Reorganize build process
[mspang/pyceo.git] / pylib / csc / adm / members.py
index a9c3f62..bdf5bac 100644 (file)
@@ -9,31 +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
-from csc.adm import terms
-from csc.backends import db, ldapi
+import re, subprocess, ldap
 from csc.common import conf
 from csc.common.excep import InvalidArgument
+from csc.backends import ldapi
 
 
 ### Configuration ###
 
-CONFIG_FILE = '/etc/csc/members.cf'
+CONFIG_FILE = '/etc/csc/accounts.cf'
 
 cfg = {}
 
-def load_configuration():
+def configure():
     """Load Members Configuration"""
 
-    string_fields = [ 'studentid_regex', 'realname_regex', 'server',
-            'database', 'user', 'password', 'server_url', 'users_base',
-            'groups_base', 'admin_bind_dn', 'admin_bind_pw' ]
+    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' ]
 
     # read configuration file
     cfg_tmp = conf.read(CONFIG_FILE)
 
     # 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)
@@ -42,26 +44,12 @@ def load_configuration():
 
 ### Exceptions ###
 
-DBException = db.DBException
 ConfigurationException = conf.ConfigurationException
+LDAPException = ldapi.LDAPException
 
 class MemberException(Exception):
     """Base exception class for member-related errors."""
 
-class DuplicateStudentID(MemberException):
-    """Exception class for student ID conflicts."""
-    def __init__(self, studentid):
-        self.studentid = studentid
-    def __str__(self):
-        return "Student ID already exists in the database: %s" % self.studentid
-
-class InvalidStudentID(MemberException):
-    """Exception class for malformed student IDs."""
-    def __init__(self, studentid):
-        self.studentid = studentid
-    def __str__(self):
-        return "Student ID is invalid: %s" % self.studentid
-
 class InvalidTerm(MemberException):
     """Exception class for malformed terms."""
     def __init__(self, term):
@@ -69,13 +57,6 @@ class InvalidTerm(MemberException):
     def __str__(self):
         return "Term is invalid: %s" % self.term
 
-class InvalidRealName(MemberException):
-    """Exception class for invalid real names."""
-    def __init__(self, name):
-        self.name = name
-    def __str__(self):
-        return "Name is invalid: %s" % self.name
-
 class NoSuchMember(MemberException):
     """Exception class for nonexistent members."""
     def __init__(self, memberid):
@@ -83,313 +64,283 @@ class NoSuchMember(MemberException):
     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
-db_connection = db.DBConnection()
-
 # global directory connection
 ldap_connection = ldapi.LDAPConnection()
 
 def connect():
-    """Connect to PostgreSQL."""
+    """Connect to LDAP."""
 
-    load_configuration()
-    db_connection.connect(cfg['server'], cfg['database'])
-    ldap_connection.connect(cfg['server_url'], cfg['admin_bind_dn'], cfg['admin_bind_pw'], cfg['users_base'], cfg['groups_base'])
+    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."""
+    """Disconnect from LDAP."""
 
-    db_connection.disconnect()
     ldap_connection.disconnect()
 
 
 def connected():
-    """Determine whether the db_connection has been established."""
+    """Determine whether the connection has been established."""
 
-    return db_connection.connected() and ldap_connection.connected()
+    return ldap_connection.connected()
 
 
 
-### Member Table ###
+### Members ###
 
-def new(uid, realname, studentid=None, program=None, mtype='user'):
+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:
-        uid       - the initial user id
-        realname  - the full real name of the member
-        studentid - the student id number of the member
-        program   - the program of study of the member
-        mtype     - a string describing the type of member ('user', 'club')
-
-    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
-        InvalidRealName    - if the real name is malformed
-
-    Example: new("Michael Spang", program="CS") -> 3349
-    """
+        InvalidArgument - on bad account attributes provided
 
-    # blank attributes should be NULL
-    if studentid == '': studentid = None
-    if program == '': program = None
-    if uid == '': uid = None
-    if mtype == '': mtype = None
+    Returns: the uid number of the new account
 
-    # check the student id format
-    if studentid is not None and not re.match(cfg['studentid_regex'], str(studentid)):
-        raise InvalidStudentID(studentid)
-
-    # check real name format (UNIX account real names must not contain [,:=])
-    if not re.match(cfg['realname_regex'], realname):
-        raise InvalidRealName(realname)
-
-    # check for duplicate student id
-    member = db_connection.select_member_by_studentid(studentid) or \
-            ldap_connection.member_search_studentid(studentid)
-    if member:
-        raise DuplicateStudentID(studentid)
-
-    # check for duplicate userid
-    member = db_connection.select_member_by_userid(uid) or \
-            ldap_connection.user_lookup(uid)
-    if member:
-        raise InvalidArgument("uid", uid, "duplicate uid")
-
-    # add the member to the database
-    memberid = db_connection.insert_member(realname, studentid, program, userid=uid)
-
-    # add the member to the directory
-    ldap_connection.member_add(uid, realname, studentid, program)
+    See: create()
+    """
 
-    # register them for this term in the database
-    db_connection.insert_term(memberid, terms.current())
+    # 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 in the directory
-    member = ldap_connection.member_lookup(uid)
-    member['term'] = [ terms.current() ]
-    ldap_connection.user_modify(uid, member)
+    # 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 database transaction
-    db_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.
+    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 db_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 db_connection.select_member_by_userid(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 db_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 = db_connection.select_members_by_term(term)
-
-    return memberlist.values()
+    members = group_members(group)
+    ret = {}
+    if members:
+        for member in members:
+            info = get(member)
+            if info:
+                ret[member] = info
+    return ret
 
 
-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 positions and who holds them
 
-    Returns: a list of member dictionaries
-
-    Example: list_name('Spang'): -> [
-                 { 'memberid': 3349, ... },
-                 { 'memberid': ... },
+    Example: list_positions(): -> {
+                 'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
                  ...
              ]
     """
 
-    # retrieve a list of memberids matching name
-    memberlist = db_connection.select_members_by_name(name)
-
-    return memberlist.values()
-
-
-def list_all():
-    """
-    Builds a list of all members.
-    
-    Returns: a list of member dictionaries
-    """
-
-    # retrieve a list of members
-    memberlist = db_connection.select_all_members()
-
-    return memberlist.values()
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
 
+    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
 
-def delete(memberid):
+def set_position(position, members):
     """
-    Erase all records of a member.
+    Sets a position
 
-    Note: real members are never removed from the database
-
-    Returns: attributes and terms of the member in a tuple
-
-    Exceptions:
-        NoSuchMember - if the member id does not exist
+    Parameters:
+        position - the position to set
+        members - an array of members that hold the position
 
-    Example: delete(0) -> ({ 'memberid': 0, name: 'Calum T. Dalek' ...}, ['s1993'])
+    Example: set_position('president', ['dtbartle'])
     """
 
-    # save member data
-    member = db_connection.select_member_by_id(memberid)
-
-    # bail if not found
-    if not member:
-        raise NoSuchMember(memberid)
-
-    term_list = db_connection.select_terms(memberid)
-
-    # remove data from the db
-    db_connection.delete_term_all(memberid)
-    db_connection.delete_member(memberid)
-    db_connection.commit()
-
-    # remove data from the directory
-    if member and member['userid']:
-        uid = member['userid']
-        ldap_connection.user_delete(uid)
-
-    return (member, term_list)
-
-
-def update(member):
+    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
+
+    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.
-
+    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. None is NULL.
+        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 
-
-    Example: update( {'memberid': 3349, userid: 'mspang'} )
-    """
+        InvalidArgument - on bad account attributes provided
 
-    if member.has_key('studentid') and member['studentid'] is not None:
+    Returns: the uid number of the new account
 
-        studentid = member['studentid']
-        
-        # check the student id format
-        if studentid is not None and not re.match(cfg['studentid_regex'], str(studentid)):
-            raise InvalidStudentID(studentid)
-
-        # check for duplicate student id
-        dupmember = db_connection.select_member_by_studentid(studentid)
-        if dupmember:
-            raise DuplicateStudentID(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
-    if not get(memberid):
-        raise NoSuchMember(memberid)
+    See: create()
+    """
 
-    # do the update
-    db_connection.update_member(member)
+    # 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']))
+    
+    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
-    db_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:
@@ -400,16 +351,23 @@ def register(memberid, term_list):
     Example: register(3349, ["w2007", "s2007"])
     """
 
+    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 = None
-    db_member = get(memberid)
-    if db_member['userid']:
-        uid = db_member['userid']
-        ldap_member = ldap_connection.member_lookup(uid)
-        if ldap_member and 'term' not in ldap_member:
-            ldap_member['term'] = []
+    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:
 
@@ -417,164 +375,46 @@ def register(memberid, term_list):
         if not re.match('^[wsf][0-9]{4}$', term):
             raise InvalidTerm(term)
 
-        # add term to database
-        db_connection.insert_term(memberid, term)
-
-        # add the term to the directory
-        if ldap_member:
-            ldap_member['term'].append(term)
-
-    if ldap_member:
-        ldap_connection.user_modify(uid, ldap_member)
+        # add the term to the entry
+        if not term in ldap_member['term']:
+            new_member['term'].append(term)
 
-    db_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 db_connection.select_term(memberid, term) is not None
+    member = ldap_connection.member_lookup(userid)
+    return 'term' in member and term in member['term']
 
 
-def member_terms(memberid):
-    """
-    Retrieves a list of terms a member is
-    registered for.
-
-    Parameters:
-        memberid - the member id number
+def group_members(group):
 
-    Returns: list of term strings
-
-    Example: registered(0) -> 's1993'
+    """
+    Returns a list of group members
     """
 
-    terms_list = db_connection.select_terms(memberid)
-    terms_list.sort(terms.compare)
-    return terms_list
-
-
-
-### Tests ###
-
-if __name__ == '__main__':
-
-    from csc.common.test import *
-
-    # t=test m=member s=student u=updated
-    tmname = 'Test Member'
-    tmuid = 'testmember'
-    tmprogram = 'Metaphysics'
-    tmsid = '00000000'
-    tm2name = 'Test Member 2'
-    tm2uid = 'testmember2'
-    tm2sid = '00000001'
-    tm2uname = 'Test Member II'
-    tm2usid = '00000002'
-    tm2uprogram = 'Pseudoscience'
-
-    tmdict = {'name': tmname, 'userid': tmuid, 'program': tmprogram, 'type': 'user', 'studentid': tmsid }
-    tm2dict = {'name': tm2name, 'userid': tm2uid, 'program': None, 'type': 'user', 'studentid': tm2sid }
-    tm2udict = {'name': tm2uname, 'userid': tm2uid, 'program': tm2uprogram, 'type': 'user', 'studentid': tm2usid }
-
-    thisterm = terms.current()
-    nextterm = terms.next(thisterm)
-
-    test(connect)
-    connect()
-    success()
-
-    test(connected)
-    assert_equal(True, connected())
-    success()
-
-    dmid = get_studentid(tmsid)
-    if dmid: delete(dmid['memberid'])
-    dmid = get_studentid(tm2sid)
-    if dmid: delete(dmid['memberid'])
-    dmid = get_studentid(tm2usid)
-    if dmid: delete(dmid['memberid'])
-
-    test(new)
-    tmid = new(tmuid, tmname, tmsid, tmprogram)
-    tm2id = new(tm2uid, tm2name, tm2sid)
-    success()
-
-    tmdict['memberid'] = tmid
-    tm2dict['memberid'] = tm2id
-    tm2udict['memberid'] = tm2id
-
-    test(registered)
-    assert_equal(True, registered(tmid, thisterm))
-    assert_equal(True, registered(tm2id, thisterm))
-    assert_equal(False, registered(tmid, nextterm))
-    success()
-
-    test(get)
-    assert_equal(tmdict, get(tmid))
-    assert_equal(tm2dict, get(tm2id))
-    success()
-
-    test(list_name)
-    assert_equal(True, tmid in [ x['memberid'] for x in list_name(tmname) ])
-    assert_equal(True, tm2id in [ x['memberid'] for x in list_name(tm2name) ])
-    success()
-
-    test(list_all)
-    allmembers = list_all()
-    assert_equal(True, tmid in [ x['memberid'] for x in allmembers ])
-    assert_equal(True, tm2id in [ x['memberid'] for x in allmembers ])
-    success()
-
-    test(register)
-    register(tmid, nextterm)
-    assert_equal(True, registered(tmid, nextterm))
-    success()
-
-    test(member_terms)
-    assert_equal([thisterm, nextterm], member_terms(tmid))
-    assert_equal([thisterm], member_terms(tm2id))
-    success()
-
-    test(list_term)
-    assert_equal(True, tmid in [ x['memberid'] for x in list_term(thisterm) ])
-    assert_equal(True, tmid in [ x['memberid'] for x in list_term(nextterm) ])
-    assert_equal(True, tm2id in [ x['memberid'] for x in list_term(thisterm) ])
-    assert_equal(False, tm2id in [ x['memberid'] for x in list_term(nextterm) ])
-    success()
-
-    test(update)
-    update(tm2udict)
-    assert_equal(tm2udict, get(tm2id))
-    success()
-
-    test(get_userid)
-    assert_equal(tm2udict, get_userid(tm2uid))
-    success()
-
-    test(get_studentid)
-    assert_equal(tm2udict, get_studentid(tm2usid))
-    assert_equal(tmdict, get_studentid(tmsid))
-    success()
-
-    test(delete)
-    delete(tmid)
-    delete(tm2id)
-    success()
-
-    test(disconnect)
-    disconnect()
-    assert_equal(False, connected())
-    disconnect()
-    success()
+    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 []