PgSQL to LDAP transition - Phase 1: Added LDAP support for member data.
[mspang/pyceo.git] / pylib / csc / adm / members.py
1 """
2 CSC Member Management
3
4 This module contains functions for registering new members, registering
5 members for terms, searching for members, and other member-related
6 functions.
7
8 Transactions are used in each method that modifies the database. 
9 Future changes to the members database that need to be atomic
10 must also be moved into this module.
11 """
12 import re
13 from csc.adm import terms
14 from csc.backends import db, ldapi
15 from csc.common import conf
16 from csc.common.excep import InvalidArgument
17
18
19 ### Configuration ###
20
21 CONFIG_FILE = '/etc/csc/members.cf'
22
23 cfg = {}
24
25 def load_configuration():
26     """Load Members Configuration"""
27
28     string_fields = [ 'studentid_regex', 'realname_regex', 'server',
29             'database', 'user', 'password', 'server_url', 'users_base',
30             'groups_base', 'admin_bind_dn', 'admin_bind_pw' ]
31
32     # read configuration file
33     cfg_tmp = conf.read(CONFIG_FILE)
34
35     # verify configuration
36     conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
37
38     # update the current configuration with the loaded values
39     cfg.update(cfg_tmp)
40
41
42
43 ### Exceptions ###
44
45 DBException = db.DBException
46 ConfigurationException = conf.ConfigurationException
47
48 class MemberException(Exception):
49     """Base exception class for member-related errors."""
50
51 class DuplicateStudentID(MemberException):
52     """Exception class for student ID conflicts."""
53     def __init__(self, studentid):
54         self.studentid = studentid
55     def __str__(self):
56         return "Student ID already exists in the database: %s" % self.studentid
57
58 class InvalidStudentID(MemberException):
59     """Exception class for malformed student IDs."""
60     def __init__(self, studentid):
61         self.studentid = studentid
62     def __str__(self):
63         return "Student ID is invalid: %s" % self.studentid
64
65 class InvalidTerm(MemberException):
66     """Exception class for malformed terms."""
67     def __init__(self, term):
68         self.term = term
69     def __str__(self):
70         return "Term is invalid: %s" % self.term
71
72 class InvalidRealName(MemberException):
73     """Exception class for invalid real names."""
74     def __init__(self, name):
75         self.name = name
76     def __str__(self):
77         return "Name is invalid: %s" % self.name
78
79 class NoSuchMember(MemberException):
80     """Exception class for nonexistent members."""
81     def __init__(self, memberid):
82         self.memberid = memberid
83     def __str__(self):
84         return "Member not found: %d" % self.memberid
85
86
87
88 ### Connection Management ###
89
90 # global database connection
91 db_connection = db.DBConnection()
92
93 # global directory connection
94 ldap_connection = ldapi.LDAPConnection()
95
96 def connect():
97     """Connect to PostgreSQL."""
98
99     load_configuration()
100     db_connection.connect(cfg['server'], cfg['database'])
101     ldap_connection.connect(cfg['server_url'], cfg['admin_bind_dn'], cfg['admin_bind_pw'], cfg['users_base'], cfg['groups_base'])
102
103
104 def disconnect():
105     """Disconnect from PostgreSQL."""
106
107     db_connection.disconnect()
108     ldap_connection.disconnect()
109
110
111 def connected():
112     """Determine whether the db_connection has been established."""
113
114     return db_connection.connected() and ldap_connection.connected()
115
116
117
118 ### Member Table ###
119
120 def new(uid, realname, studentid=None, program=None, mtype='user'):
121     """
122     Registers a new CSC member. The member is added to the members table
123     and registered for the current term.
124
125     Parameters:
126         uid       - the initial user id
127         realname  - the full real name of the member
128         studentid - the student id number of the member
129         program   - the program of study of the member
130         mtype     - a string describing the type of member ('user', 'club')
131
132     Returns: the memberid of the new member
133
134     Exceptions:
135         DuplicateStudentID - if the student id already exists in the database
136         InvalidStudentID   - if the student id is malformed
137         InvalidRealName    - if the real name is malformed
138
139     Example: new("Michael Spang", program="CS") -> 3349
140     """
141
142     # blank attributes should be NULL
143     if studentid == '': studentid = None
144     if program == '': program = None
145     if uid == '': uid = None
146     if mtype == '': mtype = None
147
148     # check the student id format
149     if studentid is not None and not re.match(cfg['studentid_regex'], str(studentid)):
150         raise InvalidStudentID(studentid)
151
152     # check real name format (UNIX account real names must not contain [,:=])
153     if not re.match(cfg['realname_regex'], realname):
154         raise InvalidRealName(realname)
155
156     # check for duplicate student id
157     member = db_connection.select_member_by_studentid(studentid) or \
158             ldap_connection.member_search_studentid(studentid)
159     if member:
160         raise DuplicateStudentID(studentid)
161
162     # check for duplicate userid
163     member = db_connection.select_member_by_userid(uid) or \
164             ldap_connection.user_lookup(uid)
165     if member:
166         raise InvalidArgument("uid", uid, "duplicate uid")
167
168     # add the member to the database
169     memberid = db_connection.insert_member(realname, studentid, program, userid=uid)
170
171     # add the member to the directory
172     ldap_connection.member_add(uid, realname, studentid, program)
173
174     # register them for this term in the database
175     db_connection.insert_term(memberid, terms.current())
176
177     # register them for this term in the directory
178     member = ldap_connection.member_lookup(uid)
179     member['term'] = [ terms.current() ]
180     ldap_connection.user_modify(uid, member)
181
182     # commit the database transaction
183     db_connection.commit()
184
185     return memberid
186
187
188 def get(memberid):
189     """
190     Look up attributes of a member by memberid.
191
192     Returns: a dictionary of attributes
193
194     Example: get(3349) -> {
195                  'memberid': 3349,
196                  'name': 'Michael Spang',
197                  'program': 'Computer Science',
198                  ...
199              }
200     """
201
202     return db_connection.select_member_by_id(memberid)
203
204
205 def get_userid(userid):
206     """
207     Look up attributes of a member by userid.
208
209     Parameters:
210         userid - the UNIX user id
211
212     Returns: a dictionary of attributes
213
214     Example: get('mspang') -> {
215                  'memberid': 3349,
216                  'name': 'Michael Spang',
217                  'program': 'Computer Science',
218                  ...
219              }
220     """
221
222     return db_connection.select_member_by_userid(userid)
223
224
225 def get_studentid(studentid):
226     """
227     Look up attributes of a member by studnetid.
228
229     Parameters:
230         studentid - the student ID number
231
232     Returns: a dictionary of attributes
233     
234     Example: get(...) -> {
235                  'memberid': 3349,
236                  'name': 'Michael Spang',
237                  'program': 'Computer Science',
238                  ...
239              }
240     """
241
242     return db_connection.select_member_by_studentid(studentid)
243
244
245 def list_term(term):
246     """
247     Build a list of members in a term.
248
249     Parameters:
250         term - the term to match members against
251
252     Returns: a list of member dictionaries
253
254     Example: list_term('f2006'): -> [
255                  { 'memberid': 3349, ... },
256                  { 'memberid': ... }.
257                  ...
258              ]
259     """
260
261     # retrieve a list of memberids in term
262     memberlist = db_connection.select_members_by_term(term)
263
264     return memberlist.values()
265
266
267 def list_name(name):
268     """
269     Build a list of members with matching names.
270
271     Parameters:
272         name - the name to match members against
273
274     Returns: a list of member dictionaries
275
276     Example: list_name('Spang'): -> [
277                  { 'memberid': 3349, ... },
278                  { 'memberid': ... },
279                  ...
280              ]
281     """
282
283     # retrieve a list of memberids matching name
284     memberlist = db_connection.select_members_by_name(name)
285
286     return memberlist.values()
287
288
289 def list_all():
290     """
291     Builds a list of all members.
292     
293     Returns: a list of member dictionaries
294     """
295
296     # retrieve a list of members
297     memberlist = db_connection.select_all_members()
298
299     return memberlist.values()
300
301
302 def delete(memberid):
303     """
304     Erase all records of a member.
305
306     Note: real members are never removed from the database
307
308     Returns: attributes and terms of the member in a tuple
309
310     Exceptions:
311         NoSuchMember - if the member id does not exist
312
313     Example: delete(0) -> ({ 'memberid': 0, name: 'Calum T. Dalek' ...}, ['s1993'])
314     """
315
316     # save member data
317     member = db_connection.select_member_by_id(memberid)
318
319     # bail if not found
320     if not member:
321         raise NoSuchMember(memberid)
322
323     term_list = db_connection.select_terms(memberid)
324
325     # remove data from the db
326     db_connection.delete_term_all(memberid)
327     db_connection.delete_member(memberid)
328     db_connection.commit()
329
330     # remove data from the directory
331     if member and member['userid']:
332         uid = member['userid']
333         ldap_connection.user_delete(uid)
334
335     return (member, term_list)
336
337
338 def update(member):
339     """
340     Update CSC member attributes.
341
342     Parameters:
343         member - a dictionary with member attributes as returned by get,
344                  possibly omitting some attributes. member['memberid']
345                  must exist and be valid. None is NULL.
346
347     Exceptions:
348         NoSuchMember       - if the member id does not exist
349         InvalidStudentID   - if the student id number is malformed
350         DuplicateStudentID - if the student id number exists 
351
352     Example: update( {'memberid': 3349, userid: 'mspang'} )
353     """
354
355     if member.has_key('studentid') and member['studentid'] is not None:
356
357         studentid = member['studentid']
358         
359         # check the student id format
360         if studentid is not None and not re.match(cfg['studentid_regex'], str(studentid)):
361             raise InvalidStudentID(studentid)
362
363         # check for duplicate student id
364         dupmember = db_connection.select_member_by_studentid(studentid)
365         if dupmember:
366             raise DuplicateStudentID(studentid)
367
368     # not specifying memberid is a bug
369     if not member.has_key('memberid'):
370         raise Exception("no member specified in call to update")
371     memberid = member['memberid']
372
373     # see if member exists
374     if not get(memberid):
375         raise NoSuchMember(memberid)
376
377     # do the update
378     db_connection.update_member(member)
379
380     # commit the transaction
381     db_connection.commit()
382
383
384
385 ### Term Table ###
386
387 def register(memberid, term_list):
388     """
389     Registers a member for one or more terms.
390
391     Parameters:
392         memberid  - the member id number
393         term_list - the term to register for, or a list of terms
394
395     Exceptions:
396         InvalidTerm - if a term is malformed
397
398     Example: register(3349, "w2007")
399
400     Example: register(3349, ["w2007", "s2007"])
401     """
402
403     if type(term_list) in (str, unicode):
404         term_list = [ term_list ]
405
406     ldap_member = None
407     db_member = get(memberid)
408     if db_member['userid']:
409         uid = db_member['userid']
410         ldap_member = ldap_connection.member_lookup(uid)
411         if ldap_member and 'term' not in ldap_member:
412             ldap_member['term'] = []
413
414     for term in term_list:
415
416         # check term syntax
417         if not re.match('^[wsf][0-9]{4}$', term):
418             raise InvalidTerm(term)
419
420         # add term to database
421         db_connection.insert_term(memberid, term)
422
423         # add the term to the directory
424         if ldap_member:
425             ldap_member['term'].append(term)
426
427     if ldap_member:
428         ldap_connection.user_modify(uid, ldap_member)
429
430     db_connection.commit()
431
432
433 def registered(memberid, term):
434     """
435     Determines whether a member is registered
436     for a term.
437
438     Parameters:
439         memberid - the member id number
440         term     - the term to check
441
442     Returns: whether the member is registered
443
444     Example: registered(3349, "f2006") -> True
445     """
446
447     return db_connection.select_term(memberid, term) is not None
448
449
450 def member_terms(memberid):
451     """
452     Retrieves a list of terms a member is
453     registered for.
454
455     Parameters:
456         memberid - the member id number
457
458     Returns: list of term strings
459
460     Example: registered(0) -> 's1993'
461     """
462
463     terms_list = db_connection.select_terms(memberid)
464     terms_list.sort(terms.compare)
465     return terms_list
466
467
468
469 ### Tests ###
470
471 if __name__ == '__main__':
472
473     from csc.common.test import *
474
475     # t=test m=member s=student u=updated
476     tmname = 'Test Member'
477     tmuid = 'testmember'
478     tmprogram = 'Metaphysics'
479     tmsid = '00000000'
480     tm2name = 'Test Member 2'
481     tm2uid = 'testmember2'
482     tm2sid = '00000001'
483     tm2uname = 'Test Member II'
484     tm2usid = '00000002'
485     tm2uprogram = 'Pseudoscience'
486
487     tmdict = {'name': tmname, 'userid': tmuid, 'program': tmprogram, 'type': 'user', 'studentid': tmsid }
488     tm2dict = {'name': tm2name, 'userid': tm2uid, 'program': None, 'type': 'user', 'studentid': tm2sid }
489     tm2udict = {'name': tm2uname, 'userid': tm2uid, 'program': tm2uprogram, 'type': 'user', 'studentid': tm2usid }
490
491     thisterm = terms.current()
492     nextterm = terms.next(thisterm)
493
494     test(connect)
495     connect()
496     success()
497
498     test(connected)
499     assert_equal(True, connected())
500     success()
501
502     dmid = get_studentid(tmsid)
503     if dmid: delete(dmid['memberid'])
504     dmid = get_studentid(tm2sid)
505     if dmid: delete(dmid['memberid'])
506     dmid = get_studentid(tm2usid)
507     if dmid: delete(dmid['memberid'])
508
509     test(new)
510     tmid = new(tmuid, tmname, tmsid, tmprogram)
511     tm2id = new(tm2uid, tm2name, tm2sid)
512     success()
513
514     tmdict['memberid'] = tmid
515     tm2dict['memberid'] = tm2id
516     tm2udict['memberid'] = tm2id
517
518     test(registered)
519     assert_equal(True, registered(tmid, thisterm))
520     assert_equal(True, registered(tm2id, thisterm))
521     assert_equal(False, registered(tmid, nextterm))
522     success()
523
524     test(get)
525     assert_equal(tmdict, get(tmid))
526     assert_equal(tm2dict, get(tm2id))
527     success()
528
529     test(list_name)
530     assert_equal(True, tmid in [ x['memberid'] for x in list_name(tmname) ])
531     assert_equal(True, tm2id in [ x['memberid'] for x in list_name(tm2name) ])
532     success()
533
534     test(list_all)
535     allmembers = list_all()
536     assert_equal(True, tmid in [ x['memberid'] for x in allmembers ])
537     assert_equal(True, tm2id in [ x['memberid'] for x in allmembers ])
538     success()
539
540     test(register)
541     register(tmid, nextterm)
542     assert_equal(True, registered(tmid, nextterm))
543     success()
544
545     test(member_terms)
546     assert_equal([thisterm, nextterm], member_terms(tmid))
547     assert_equal([thisterm], member_terms(tm2id))
548     success()
549
550     test(list_term)
551     assert_equal(True, tmid in [ x['memberid'] for x in list_term(thisterm) ])
552     assert_equal(True, tmid in [ x['memberid'] for x in list_term(nextterm) ])
553     assert_equal(True, tm2id in [ x['memberid'] for x in list_term(thisterm) ])
554     assert_equal(False, tm2id in [ x['memberid'] for x in list_term(nextterm) ])
555     success()
556
557     test(update)
558     update(tm2udict)
559     assert_equal(tm2udict, get(tm2id))
560     success()
561
562     test(get_userid)
563     assert_equal(tm2udict, get_userid(tm2uid))
564     success()
565
566     test(get_studentid)
567     assert_equal(tm2udict, get_studentid(tm2usid))
568     assert_equal(tmdict, get_studentid(tmsid))
569     success()
570
571     test(delete)
572     delete(tmid)
573     delete(tm2id)
574     success()
575
576     test(disconnect)
577     disconnect()
578     assert_equal(False, connected())
579     disconnect()
580     success()