Reorganize build process
[mspang/pyceo.git] / pylib / csc / adm / members.py
index 405ec29..bdf5bac 100644 (file)
@@ -1,4 +1,3 @@
-# $Id: members.py 44 2006-12-31 07:09:27Z mspang $
 """
 CSC Member Management
 
@@ -10,44 +9,33 @@ Transactions are used in each method that modifies the database.
 Future changes to the members database that need to be atomic
 must also be moved into this module.
 """
+import re, subprocess, ldap
+from csc.common import conf
+from csc.common.excep import InvalidArgument
+from csc.backends import ldapi
 
-import re
-from csc.adm import terms
-from csc.backends import db
-from csc.common.conf import read_config
 
+### Configuration ###
 
-
-
-### Configuration
-
-CONFIG_FILE = '/etc/csc/members.cf'
+CONFIG_FILE = '/etc/csc/accounts.cf'
 
 cfg = {}
 
-
-def load_configuration():
+def configure():
     """Load Members Configuration"""
 
-    # configuration already loaded?
-    if len(cfg) > 0:
-        return
-
-    # read in the file
-    cfg_tmp = read_config(CONFIG_FILE)
-
-    if not cfg_tmp:
-        raise MemberException("unable to read configuration file: %s"
-                % CONFIG_FILE)
+    string_fields = [ 'username_regex', 'shells_file', 'server_url',
+            'users_base', 'groups_base', 'sasl_mech', 'sasl_realm',
+            'admin_bind_keytab', 'admin_bind_userid', 'realm',
+            'admin_principal', 'admin_keytab' ]
+    numeric_fields = [ 'min_password_length' ]
 
-    # check that essential fields are completed
-    mandatory_fields = [ 'server', 'database', 'user', 'password' ]
+    # read configuration file
+    cfg_tmp = conf.read(CONFIG_FILE)
 
-    for field in mandatory_fields:
-        if not field in cfg_tmp:
-            raise MemberException("missing configuratino option: %s" % field)
-        if not cfg_tmp[field]:
-            raise MemberException("null configuration option: %s" %field)
+    # verify configuration
+    conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
+    conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
 
     # update the current configuration with the loaded values
     cfg.update(cfg_tmp)
@@ -56,299 +44,303 @@ def load_configuration():
 
 ### Exceptions ###
 
-class MemberException(Exception):
-    """Exception class for member-related errors."""
+ConfigurationException = conf.ConfigurationException
+LDAPException = ldapi.LDAPException
 
-class DuplicateStudentID(MemberException):
-    """Exception class for student ID conflicts."""
-    pass
-
-class InvalidStudentID(MemberException):
-    """Exception class for malformed student IDs."""
-    pass
+class MemberException(Exception):
+    """Base exception class for member-related errors."""
 
 class InvalidTerm(MemberException):
     """Exception class for malformed terms."""
-    pass
+    def __init__(self, term):
+        self.term = term
+    def __str__(self):
+        return "Term is invalid: %s" % self.term
 
 class NoSuchMember(MemberException):
     """Exception class for nonexistent members."""
-    pass
+    def __init__(self, memberid):
+        self.memberid = memberid
+    def __str__(self):
+        return "Member not found: %d" % self.memberid
 
+class ChildFailed(MemberException):
+    def __init__(self, program, status, output):
+        self.program, self.status, self.output = program, status, output
+    def __str__(self):
+        msg = '%s failed with status %d' % (self.program, self.status)
+        if self.output:
+            msg += ': %s' % self.output
+        return msg
 
 
 ### Connection Management ###
 
-# global database connection
-connection = db.DBConnection()
-
+# global directory connection
+ldap_connection = ldapi.LDAPConnection()
 
 def connect():
-    """Connect to PostgreSQL."""
-    
-    load_configuration()
-    
-    connection.connect(cfg['server'], cfg['database'])
-       
+    """Connect to LDAP."""
+
+    configure()
+
+    ldap_connection.connect_sasl(cfg['server_url'], cfg['sasl_mech'],
+        cfg['sasl_realm'], cfg['users_base'], cfg['groups_base'])
 
 def disconnect():
-    """Disconnect from PostgreSQL."""
-    
-    connection.disconnect()
+    """Disconnect from LDAP."""
+
+    ldap_connection.disconnect()
 
 
 def connected():
     """Determine whether the connection has been established."""
 
-    return connection.connected()
+    return ldap_connection.connected()
+
 
 
-### Member Table ###
+### Members ###
 
-def new(realname, studentid=None, program=None):
+def create_member(username, password, name, program):
     """
-    Registers a new CSC member. The member is added
-    to the members table and registered for the current
-    term.
+    Creates a UNIX user account with options tailored to CSC members.
 
     Parameters:
-        realname  - the full real name of the member
-        studentid - the student id number of the member
-        program   - the program of study of the member
-
-    Returns: the memberid of the new member
+        username - the desired UNIX username
+        password - the desired UNIX password
+        name     - the member's real name
+        program  - the member's program of study
 
     Exceptions:
-        DuplicateStudentID - if the student id already exists in the database
-        InvalidStudentID   - if the student id is malformed
-
-    Example: new("Michael Spang", program="CS") -> 3349
-    """
-
-    # blank attributes should be NULL
-    if studentid == '': studentid = None
-    if program == '': program = None
+        InvalidArgument - on bad account attributes provided
 
-    # check the student id format
-    regex = '^[0-9]{8}$'
-    if studentid != None and not re.match(regex, str(studentid)):
-        raise InvalidStudentID("student id is invalid: %s" % studentid)
+    Returns: the uid number of the new account
 
-    # check for duplicate student id
-    member = connection.select_member_by_studentid(studentid)
-    if member:
-        raise DuplicateStudentID("student id exists in database: %s" % studentid)
+    See: create()
+    """
 
-    # add the member
-    memberid = connection.insert_member(realname, studentid, program)
+    # check username format
+    if not username or not re.match(cfg['username_regex'], username):
+        raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
 
-    # register them for this term
-    connection.insert_term(memberid, terms.current())
+    # check password length
+    if not password or len(password) < cfg['min_password_length']:
+        raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
 
-    # commit the transaction
-    connection.commit()
+    args = [ "/usr/bin/addmember", "--stdin", username, name, program ]
+    addmember = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    out, err = addmember.communicate(password)
+    status = addmember.wait()
 
-    return memberid
+    if status:
+        raise ChildFailed("addmember", status, out+err)
 
 
-def get(memberid):
+def get(userid):
     """
-    Look up attributes of a member by memberid.
-
-    Parameters:
-        memberid - the member id number
+    Look up attributes of a member by userid.
 
     Returns: a dictionary of attributes
 
-    Example: get(3349) -> {
-                 'memberid': 3349,
-                 'name': 'Michael Spang',
-                 'program': 'Computer Science',
+    Example: get('mspang') -> {
+                 'cn': [ 'Michael Spang' ],
+                 'program': [ 'Computer Science' ],
                  ...
              }
     """
 
-    return connection.select_member_by_id(memberid)
+    return ldap_connection.user_lookup(userid)
 
 
-def get_userid(userid):
+def list_term(term):
     """
-    Look up attributes of a member by userid.
+    Build a list of members in a term.
 
     Parameters:
-        userid - the UNIX user id
+        term - the term to match members against
 
-    Returns: a dictionary of attributes
+    Returns: a list of members
 
-    Example: get('mspang') -> {
-                 'memberid': 3349,
-                 'name': 'Michael Spang',
-                 'program': 'Computer Science',
+    Example: list_term('f2006'): -> {
+                 'mspang': { 'cn': 'Michael Spang', ... },
+                 'ctdalek': { 'cn': 'Calum T. Dalek', ... },
                  ...
              }
     """
 
-    return connection.select_member_by_account(userid)
+    return ldap_connection.member_search_term(term)
 
 
-def get_studentid(studentid):
+def list_name(name):
     """
-    Look up attributes of a member by studnetid.
+    Build a list of members with matching names.
 
     Parameters:
-        studentid - the student ID number
+        name - the name to match members against
+Returns: a list of member dictionaries
 
-    Returns: a dictionary of attributes
-    
-    Example: get(...) -> {
-                 'memberid': 3349,
-                 'name': 'Michael Spang',
-                 'program': 'Computer Science',
+    Example: list_name('Spang'): -> {
+                 'mspang': { 'cn': 'Michael Spang', ... },
                  ...
-             }
+             ]
     """
 
-    return connection.select_member_by_studentid(studentid)
+    return ldap_connection.member_search_name(name)
 
 
-def list_term(term):
+def list_group(group):
     """
-    Build a list of members in a term.
+    Build a list of members in a group.
 
     Parameters:
-        term - the term to match members against
+        group - the group to match members against
 
     Returns: a list of member dictionaries
 
-    Example: list_term('f2006'): -> [
-                 { 'memberid': 3349, ... },
-                 { 'memberid': ... }.
+    Example: list_name('syscom'): -> {
+                 'mspang': { 'cn': 'Michael Spang', ... },
                  ...
              ]
     """
 
-    # retrieve a list of memberids in term
-    memberlist = connection.select_members_by_term(term)
+    members = group_members(group)
+    ret = {}
+    if members:
+        for member in members:
+            info = get(member)
+            if info:
+                ret[member] = info
+    return ret
 
-    # convert the list of memberids to a list of dictionaries
-    memberlist = map(connection.select_member_by_id, memberlist)
 
-    return memberlist
-        
-
-def list_name(name):
+def list_positions():
     """
-    Build a list of members with matching names.
+    Build a list of positions
 
-    Parameters:
-        name - the name to match members against
-
-    Returns: a list of member dictionaries
+    Returns: a list of positions and who holds them
 
-    Example: list_name('Spang'): -> [
-                 { 'memberid': 3349, ... },
-                 { 'memberid': ... },
+    Example: list_positions(): -> {
+                 'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
                  ...
              ]
     """
 
-    # retrieve a list of memberids matching name
-    memberlist = connection.select_members_by_name(name)
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
 
-    # convert the list of memberids to a list of dictionaries
-    memberlist = map(connection.select_member_by_id, memberlist)
+    members = ceo_ldap.search_s(user_base, ldap.SCOPE_SUBTREE, '(position=*)')
+    positions = {}
+    for (_, member) in members:
+        for position in member['position']:
+            if not position in positions:
+                positions[position] = {}
+            positions[position][member['uid'][0]] = member
+    return positions
 
-    return memberlist
-
-
-def delete(memberid):
+def set_position(position, members):
     """
-    Erase all records of a member.
-
-    Note: real members are never removed
-          from the database
+    Sets a position
 
     Parameters:
-        memberid - the member id number
+        position - the position to set
+        members - an array of members that hold the position
 
-    Returns: attributes and terms of the
-             member in a tuple
-
-    Example: delete(0) -> ({ 'memberid': 0, name: 'Calum T. Dalek' ...}, ['s1993'])
+    Example: set_position('president', ['dtbartle'])
     """
 
-    # save member data
-    member = connection.select_member_by_id(memberid)
-    term_list = connection.select_terms(memberid)
-
-    # remove data from the db
-    connection.delete_term_all(memberid)
-    connection.delete_member(memberid)
-    connection.commit()
-
-    return (member, term_list)
-
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
+    escape = ldap_connection.escape
+
+    res = ceo_ldap.search_s(user_base, ldap.SCOPE_SUBTREE,
+        '(&(objectClass=member)(position=%s))' % escape(position))
+    old = set([ member['uid'][0] for (_, member) in res ])
+    new = set(members)
+    mods = {
+        'del': set(old) - set(new),
+        'add': set(new) - set(old),
+    }
+    if len(mods['del']) == 0 and len(mods['add']) == 0:
+        return
 
-def update(member):
+    for action in ['del', 'add']:
+        for userid in mods[action]:
+            dn = 'uid=%s,%s' % (escape(userid), user_base)
+            entry1 = {'position' : [position]}
+            entry2 = {} #{'position' : []}
+            entry = ()
+            if action == 'del':
+                entry = (entry1, entry2)
+            elif action == 'add':
+                entry = (entry2, entry1)
+            mlist = ldap_connection.make_modlist(entry[0], entry[1])
+            ceo_ldap.modify_s(dn, mlist)
+
+
+def change_group_member(action, group, userid):
+
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
+    group_base = ldap_connection.group_base
+    escape = ldap_connection.escape
+
+    user_dn = 'uid=%s,%s' % (escape(userid), user_base)
+    group_dn = 'cn=%s,%s' % (escape(group), group_base)
+    entry1 = {'uniqueMember' : []}
+    entry2 = {'uniqueMember' : [user_dn]}
+    entry = []
+    if action == 'add' or action == 'insert':
+        entry = (entry1, entry2)
+    elif action == 'remove' or action == 'delete':
+        entry = (entry2, entry1)
+    else:
+        raise InvalidArgument("action", action, "invalid action")
+    mlist = ldap_connection.make_modlist(entry[0], entry[1])
+    ceo_ldap.modify_s(group_dn, mlist)
+
+
+
+### Clubs ###
+
+def create_club(username, name):
     """
-    Update CSC member attributes. None is NULL.
-
+    Creates a UNIX user account with options tailored to CSC-hosted clubs.
+    
     Parameters:
-        member - a dictionary with member attributes as
-                 returned by get, possibly omitting some
-                 attributes. member['memberid'] must exist
-                 and be valid.
+        username - the desired UNIX username
+        name     - the club name
 
     Exceptions:
-        NoSuchMember       - if the member id does not exist
-        InvalidStudentID   - if the student id number is malformed
-        DuplicateStudentID - if the student id number exists 
+        InvalidArgument - on bad account attributes provided
 
-    Example: update( {'memberid': 3349, userid: 'mspang'} )
+    Returns: the uid number of the new account
+
+    See: create()
     """
 
-    if member.has_key('studentid') and member['studentid'] != None:
-
-        studentid = member['studentid']
-        
-        # check the student id format
-        regex = '^[0-9]{8}$'
-        if studentid != None and not re.match(regex, str(studentid)):
-            raise InvalidStudentID("student id is invalid: %s" % studentid)
-
-        # check for duplicate student id
-        member = connection.select_member_by_studentid(studentid)
-        if member:
-            raise DuplicateStudentID("student id exists in database: %s" %
-                    studentid)
-
-    # not specifying memberid is a bug
-    if not member.has_key('memberid'):
-        raise Exception("no member specified in call to update")
-    memberid = member['memberid']
-
-    # see if member exists
-    old_member = connection.select_member_by_id(memberid)
-    if not old_member:
-        raise NoSuchMember("memberid does not exist in database: %d" %
-                memberid)
+    # check username format
+    if not username or not re.match(cfg['username_regex'], username):
+        raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
     
-    # do the update
-    connection.update_member(member)
+    args = [ "/usr/bin/addclub", username, name ]
+    addclub = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    out, err = addclub.communicate()
+    status = addclub.wait()
 
-    # commit the transaction
-    connection.commit()
+    if status:
+        raise ChildFailed("addclub", status, out+err)
 
 
 
-### Term Table ###
+### Terms ###
 
-def register(memberid, term_list):
+def register(userid, term_list):
     """
     Registers a member for one or more terms.
 
     Parameters:
-        memberid  - the member id number
+        userid  - the member's username
         term_list - the term to register for, or a list of terms
 
     Exceptions:
@@ -359,68 +351,70 @@ def register(memberid, term_list):
     Example: register(3349, ["w2007", "s2007"])
     """
 
-    if not type(term_list) in (list, tuple):
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
+    escape = ldap_connection.escape
+    user_dn = 'uid=%s,%s' % (escape(userid), user_base)
+
+    if type(term_list) in (str, unicode):
         term_list = [ term_list ]
 
+    ldap_member = ldap_connection.member_lookup(userid)
+    if ldap_member and 'term' not in ldap_member:
+        ldap_member['term'] = []
+
+    if not ldap_member:
+        raise NoSuchMember(userid)
+
+    new_member = ldap_member.copy()
+    new_member['term'] = new_member['term'][:]
+
     for term in term_list:
-        
+
         # check term syntax
         if not re.match('^[wsf][0-9]{4}$', term):
-            raise InvalidTerm("term is invalid: %s" % term)
-    
-        # add term to database
-        connection.insert_term(memberid, term)
+            raise InvalidTerm(term)
+
+        # add the term to the entry
+        if not term in ldap_member['term']:
+            new_member['term'].append(term)
 
-    connection.commit()
+    mlist = ldap_connection.make_modlist(ldap_member, new_member)
+    ceo_ldap.modify_s(user_dn, mlist)
 
 
-def registered(memberid, term):
+def registered(userid, term):
     """
     Determines whether a member is registered
     for a term.
 
     Parameters:
-        memberid - the member id number
+        userid   - the member's username
         term     - the term to check
 
     Returns: whether the member is registered
 
-    Example: registered(3349, "f2006") -> True
+    Example: registered("mspang", "f2006") -> True
     """
 
-    return connection.select_term(memberid, term) != None
-
-
-def terms_list(memberid):
-    """
-    Retrieves a list of terms a member is
-    registered for.
+    member = ldap_connection.member_lookup(userid)
+    return 'term' in member and term in member['term']
 
-    Parameters:
-        memberid - the member id number
 
-    Returns: list of term strings
+def group_members(group):
 
-    Example: registered(0) -> 's1993'
+    """
+    Returns a list of group members
     """
 
-    return connection.select_terms(memberid)
-
-
-
-### Tests ###
-
-if __name__ == '__main__':
-
-    connect()
-    
-    
-    sid = new("Test User", "99999999", "CS")
-
-    assert registered(id, terms.current())
-    print get(sid)
-    register(sid, terms.next(terms.current()))
-    assert registered(sid, terms.next(terms.current()))
-    print terms_list(sid)
-    print get(sid)
-    print delete(sid)
+    group = ldap_connection.group_lookup(group)
+    if group:
+        if 'uniqueMember' in group:
+            r = re.compile('^uid=([^,]*)')
+            return map(lambda x: r.match(x).group(1), group['uniqueMember'])
+        elif 'memberUid' in group:
+            return group['memberUid']
+        else:
+            return []
+    else:
+        return []