Complete group and position management
authorDavid Bartley <dtbartle@csclub.uwaterloo.ca>
Thu, 15 Nov 2007 10:28:58 +0000 (05:28 -0500)
committerDavid Bartley <dtbartle@csclub.uwaterloo.ca>
Thu, 15 Nov 2007 10:28:58 +0000 (05:28 -0500)
misc/csc.schema
pylib/csc/adm/members.py
pylib/csc/apps/urwid/groups.py [new file with mode: 0644]
pylib/csc/apps/urwid/ldapfilter.py
pylib/csc/apps/urwid/main.py
pylib/csc/apps/urwid/newmember.py
pylib/csc/apps/urwid/positions.py [new file with mode: 0644]
pylib/csc/backends/ldapi.py

index 21f9d83..13c24e2 100644 (file)
@@ -12,11 +12,20 @@ attributetype ( 1.3.6.1.4.1.27934.1.1.3 NAME 'studentid'
     EQUALITY caseIgnoreIA5Match
     SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{8} SINGLE-VALUE )
 
+attributetype ( 1.3.6.1.4.1.27934.1.1.4 NAME 'position'
+    EQUALITY caseIgnoreIA5Match
+    SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{32} )
+
 objectclass ( 1.3.6.1.4.1.27934.1.2.1 NAME 'member'
     SUP top AUXILIARY
     MUST ( cn $ uid )
-    MAY ( studentid $ program $ term $ description ) )
+    MAY ( studentid $ program $ term $ description $ position ) )
 
 objectclass ( 1.3.6.1.4.1.27934.1.2.2 NAME 'club'
     SUP top AUXILIARY
     MUST ( cn $ uid ) )
+
+objectclass ( 1.3.6.1.4.1.27934.1.2.3 NAME 'group'
+    SUP top STRUCTURAL
+    MUST ( cn )
+    MAY ( uniqueMember ) )
index da3a958..4da25f4 100644 (file)
@@ -9,7 +9,7 @@ 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
+import re, ldap
 from csc.adm import terms
 from csc.backends import ldapi
 from csc.common import conf
@@ -220,6 +220,72 @@ def list_group(group):
         return {}
 
 
+def list_positions():
+    """
+    Build a list of positions
+
+    Returns: a list of positions and who holds them
+
+    Example: list_positions(): -> {
+                 'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
+                 ...
+             ]
+    """
+
+    ceo_ldap = ldap_connection.ldap
+    user_base = ldap_connection.user_base
+    escape = ldap_connection.escape
+
+    if not ldap_connection.connected(): ldap_connection.connect()
+
+    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 set_position(position, members):
+    """
+    Sets a position
+
+    Parameters:
+        position - the position to set
+        members - an array of members that hold the position
+
+    Example: set_position('president', ['dtbartle'])
+    """
+
+    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 type in ['del', 'add']:
+        for userid in mods[type]:
+            dn = 'uid=%s,%s' % (escape(userid), user_base)
+            entry1 = {'position' : [position]}
+            entry2 = {} #{'position' : []}
+            entry = ()
+            if type == 'del':
+                entry = (entry1, entry2)
+            elif type == 'add':
+                entry = (entry2, entry1)
+            mlist = ldap_connection.make_modlist(entry[0], entry[1])
+            ceo_ldap.modify_s(dn, mlist)
+
 def delete(userid):
     """
     Erase all records of a member.
@@ -248,6 +314,27 @@ def delete(userid):
     return member
 
 
+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)
+
 
 ### Term Table ###
 
diff --git a/pylib/csc/apps/urwid/groups.py b/pylib/csc/apps/urwid/groups.py
new file mode 100644 (file)
index 0000000..80ee9df
--- /dev/null
@@ -0,0 +1,64 @@
+import urwid
+from csc.apps.urwid.widgets import *
+from csc.apps.urwid.window import *
+
+from csc.adm import accounts, members
+from csc.common.excep import InvalidArgument
+
+class ChangeMember(WizardPanel):
+    def __init__(self, state, data):
+        state['data'] = data
+        WizardPanel.__init__(self, state)
+    def init_widgets(self):
+        self.userid = WordEdit("Username: ")
+
+        data = self.state['data']
+        self.widgets = [
+            urwid.Text( "%s %s Member" % (data['type'], data['name']) ),
+            urwid.Divider(),
+            self.userid,
+        ]
+    def check(self):
+        self.state['userid'] = self.userid.get_edit_text()
+        if self.state['userid']:
+            if not members.connected(): members.connect()
+            self.state['member'] = members.get(self.userid.get_edit_text())
+        if not self.state['member']:
+            set_status("Member not found")
+            self.focus_widget(self.userid)
+            return True
+        clear_status()
+
+class EndPage(WizardPanel):
+    def init_widgets(self):
+        self.headtext = urwid.Text("")
+        self.midtext = urwid.Text("")
+        self.widgets = [
+            self.headtext,
+            urwid.Divider(),
+            self.midtext,
+        ]
+    def focusable(self):
+        return False
+    def check(self):
+        pop_window()
+    def activate(self):
+        data = self.state['data']
+        type = data['type'].lower()
+        failed = []
+        for group in data['groups']:
+            try:
+                members.change_group_member(type, group, self.state['userid'])
+            except:
+                failed.append(group)
+        if len(failed) == 0:
+            self.headtext.set_text("%s succeeded" % data['type'])
+            self.midtext.set_text("Congratulations, the group modification "
+                "has succeeded.")
+        else:
+            self.headtext.set_text("%s partially succeeded" % data['type'])
+            self.midtext.set_text("Failed to %s member to %s for the "
+                "following groups: %s. This may indicate an attempt to add a "
+                "duplicate group member or to delete a non-present group "
+                "member." % (data['type'].lower(), data['name'],
+                ', '.join(failed)))
index 46d425e..6aee341 100644 (file)
@@ -21,7 +21,8 @@ class LdapFilter:
                 search = self.escape(self.widget.get_edit_text(self))
                 filter = '(%s=%s)' % (attr, search)
                 try:
-                    matches = self.ldap.search_s(self.base, ldap.SCOPE_SUBTREE, filter)
+                    matches = self.ldap.search_s(self.base,
+                        ldap.SCOPE_SUBTREE, filter)
                     if len(matches) > 0:
                         (_, attrs) = matches[0]
                         for (k, v) in self.map.items():
index ee9bf7c..8ea09aa 100644 (file)
@@ -7,6 +7,8 @@ import csc.apps.urwid.newmember as newmember
 import csc.apps.urwid.renew as renew
 import csc.apps.urwid.info as info
 import csc.apps.urwid.search as search
+import csc.apps.urwid.positions as positions
+import csc.apps.urwid.groups as groups
 
 from csc.adm import accounts, members, terms
 from csc.common.excep import InvalidArgument
@@ -49,12 +51,14 @@ def program_name():
 
 office_data = {
     "name" : "Office Staff",
-    "group" : "office"
+    "group" : "office",
+    "groups" : [ "office", "cdrom", "audio", "video", "www" ],
 }
 
 syscom_data = {
     "name" : "Systems Committee",
-    "group" : "syscom"
+    "group" : "syscom",
+    "groups" : [ "office", "staff", "adm", "src" ],
 }
 
 def menu_items(items):
@@ -67,6 +71,7 @@ def main_menu():
         ("Create Club Account", new_club, None),
         ("Display Member", display_member, None),
         ("Search", search_members, None),
+        ("Manage Positions", manage_positions, None),
         ("Manage Office Staff", group_members, office_data),
         ("Manage Systems Committee", group_members, syscom_data),
         ("Exit", raise_abort, None),
@@ -100,7 +105,7 @@ def new_club(*args, **kwargs):
         newmember.ClubIntroPage,
         newmember.ClubInfoPage,
         (newmember.EndPage, "club"),
-    ], (60,15))
+    ], (60, 15))
 
 def renew_member(*args, **kwargs):
     push_wizard("Renew Membership", [
@@ -137,10 +142,23 @@ def search_term(data):
 def search_group(data):
     push_wizard("By Group", [ search.GroupPage ])
 
+def manage_positions(data):
+    push_wizard("Manage Positions", [
+        positions.IntroPage,
+        positions.InfoPage,
+        positions.EndPage,
+    ], (50, 15))
+
 def group_members(data):
+    add_data = data.copy()
+    add_data['type'] = 'Add'
+    remove_data = data.copy()
+    remove_data['type'] = 'Remove'
     menu = [
-        ("Add %s member" % data["name"].lower(), add_group_member, data),
-        ("Remove %s member" % data["name"].lower(), remove_group_member, data),
+        ("Add %s member" % data["name"].lower(),
+            change_group_member, add_data),
+        ("Remove %s member" % data["name"].lower(),
+            change_group_member, remove_data),
         ("List %s members" % data["name"].lower(), list_group_members, data),
         ("Back", raise_back, None),
     ]
@@ -148,11 +166,11 @@ def group_members(data):
     listbox = urwid.ListBox( menu_items( menu ) )
     push_window(listbox, "Manage %s" % data["name"])
 
-def add_group_member(data):
-    pass
-
-def remove_group_member(data):
-    pass
+def change_group_member(data):
+    push_wizard("%s %s Member" % (data["type"], data["name"]), [
+        (groups.ChangeMember, data),
+        groups.EndPage,
+    ])
 
 def list_group_members(data):
     if not members.connected(): members.connect()
index 48f3210..c0f3ad1 100644 (file)
@@ -58,7 +58,7 @@ class InfoPage(WizardPanel):
         self.state['name'] = self.name.get_edit_text()
         self.state['program'] = self.program.get_edit_text()
 
-        if len( self.state['userid'] ) < 4:
+        if len( self.state['userid'] ) < 3:
             self.focus_widget( self.userid )
             set_status("Username is too short")
             return True
@@ -188,5 +188,7 @@ class EndPage(WizardPanel):
         else:
             self.headtext.set_text("User Added")
             self.midtext.set_text("Congratulations, %s has been added "
-                "successfully. Please run 'addhomedir %s'."
+                "successfully. Please run 'addhomedir %s'. "
+                "You should also rebuild the website in order to update the "
+                "memberlist."
                 % (self.state['userid'], self.state['userid']))
diff --git a/pylib/csc/apps/urwid/positions.py b/pylib/csc/apps/urwid/positions.py
new file mode 100644 (file)
index 0000000..489be5a
--- /dev/null
@@ -0,0 +1,83 @@
+import urwid
+from csc.apps.urwid.widgets import *
+from csc.apps.urwid.window import *
+
+from csc.adm import accounts, members
+from csc.common.excep import InvalidArgument
+
+position_data = [
+    ('president',       'President'),
+    ('vice-president',  'Vice-president'),
+    ('treasurer',       'Treasurer'),
+    ('secretary',       'Secretary'),
+    ('sysadmin',        'System Administrator'),
+    ('librarian',       'Librarian'),
+    ('imapd',           'Imapd'),
+    ('webmaster',       'Web Master'),
+]
+
+class IntroPage(WizardPanel):
+    def init_widgets(self):
+        self.widgets = [
+            urwid.Text( "Managing Positions" ),
+            urwid.Divider(),
+            urwid.Text( "Enter a username for each position. If a position is "
+                        "held by multiple people, enter a comma-separated "
+                        "list of usernames. If a position is held by nobody "
+                        "leave the username blank." ),
+        ]
+    def focusable(self):
+        return False
+
+class InfoPage(WizardPanel):
+    def init_widgets(self):
+        if not members.connected(): members.connect()
+        self.widgets = [
+            urwid.Text( "Positions" ),
+            urwid.Divider(),
+        ]
+        positions = members.list_positions()
+        self.position_widgets = {}
+        for (position, text) in position_data:
+            widget = WordEdit("%s: " % text)
+            if position in positions:
+                widget.set_edit_text(','.join(positions[position].keys()))
+            else:
+                widget.set_edit_text('')
+            self.position_widgets[position] = widget
+            self.widgets.append(widget)
+
+    def parse(self, entry):
+        if len(entry) == 0:
+            return []
+        return entry.split(',')
+
+    def check(self):
+        self.state['positions'] = {}
+        for (position, widget) in self.position_widgets.iteritems():
+            self.state['positions'][position] = \
+                self.parse(widget.get_edit_text())
+        clear_status()
+
+class EndPage(WizardPanel):
+    def init_widgets(self):
+        old = members.list_positions()
+        self.headtext = urwid.Text("")
+        self.midtext = urwid.Text("")
+
+        self.widgets = [
+            self.headtext,
+            urwid.Divider(),
+            self.midtext,
+        ]
+    def focusable(self):
+        return False
+    def activate(self):
+        for (position, info) in self.state['positions'].iteritems():
+            members.set_position(position, info)
+        self.headtext.set_text("Positions Updated")
+        self.midtext.set_text("Congratulations, positions have been updated. "
+            "You should rebuild the website in order to update the Positions "
+            "page.")
+    def check(self):
+        pop_window()
index 002cfbe..3da2314 100644 (file)
@@ -641,6 +641,23 @@ class LDAPConnection(object):
         return gids
 
 
+    def make_modlist(self, old, new):
+        keys = set(old.keys()).union(set(new))
+        mlist = []
+        for key in keys:
+            if key in old and not key in new:
+                mlist.append((ldap.MOD_DELETE, key, list(set(old[key]))))
+            elif key in new and not key in old:
+                mlist.append((ldap.MOD_ADD, key, list(set(new[key]))))
+            else:
+                to_add = list(set(new[key]) - set(old[key]))
+                if len(to_add) > 0:
+                    mlist.append((ldap.MOD_ADD, key, to_add))
+                to_del = list(set(old[key]) - set(new[key]))
+                if len(to_del) > 0:
+                    mlist.append((ldap.MOD_DELETE, key, to_del))
+        return mlist
+
 
 ### Tests ###