source: main/waeup.kofa/trunk/src/waeup/kofa/students/student.py @ 13026

Last change on this file since 13026 was 13026, checked in by Henrik Bettermann, 9 years ago

More docs.

  • Property svn:keywords set to Id
File size: 20.2 KB
Line 
1## $Id: student.py 13026 2015-06-02 12:15:52Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""
19Container for the various objects owned by students.
20"""
21import os
22import re
23import shutil
24import grok
25from datetime import datetime, timedelta
26from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
27from zope.password.interfaces import IPasswordManager
28from zope.component import getUtility, createObject
29from zope.component.interfaces import IFactory
30from zope.interface import implementedBy
31from zope.securitypolicy.interfaces import IPrincipalRoleManager
32from zope.schema.interfaces import ConstraintNotSatisfied
33
34from waeup.kofa.image import KofaImageFile
35from waeup.kofa.imagestorage import DefaultFileStoreHandler
36from waeup.kofa.interfaces import (
37    IObjectHistory, IUserAccount, IFileStoreNameChooser, IFileStoreHandler,
38    IKofaUtils, registration_states_vocab, IExtFileStore,
39    CREATED, ADMITTED, CLEARANCE, PAID, REGISTERED, VALIDATED, RETURNING)
40from waeup.kofa.students.accommodation import StudentAccommodation
41from waeup.kofa.students.interfaces import (
42    IStudent, IStudentNavigation, IStudentPersonalEdit, ICSVStudentExporter,
43    IStudentsUtils)
44from waeup.kofa.students.payments import StudentPaymentsContainer
45from waeup.kofa.students.utils import generate_student_id
46from waeup.kofa.utils.helpers import attrs_to_fields, now, copy_filesystem_tree
47
48RE_STUDID_NON_NUM = re.compile('[^\d]+')
49
50class Student(grok.Container):
51    """This is a student container for the various objects
52    owned by students.
53    """
54    grok.implements(IStudent, IStudentNavigation, IStudentPersonalEdit)
55    grok.provides(IStudent)
56
57    temp_password_minutes = 10
58
59    def __init__(self):
60        super(Student, self).__init__()
61        # The site doesn't exist in unit tests
62        try:
63            self.student_id = generate_student_id()
64        except TypeError:
65            self.student_id = u'Z654321'
66        self.password = None
67        self.temp_password = None
68        return
69
70    def setTempPassword(self, user, password):
71        """Set a temporary password (LDAP-compatible) SSHA encoded for
72        officers.
73        """
74        passwordmanager = getUtility(IPasswordManager, 'SSHA')
75        self.temp_password = {}
76        self.temp_password[
77            'password'] = passwordmanager.encodePassword(password)
78        self.temp_password['user'] = user
79        self.temp_password['timestamp'] = datetime.utcnow() # offset-naive datetime
80
81    def getTempPassword(self):
82        """Check if a temporary password has been set and if it
83        is not expired.
84
85        Return the temporary password if valid,
86        None otherwise. Unset the temporary password if expired.
87        """
88        temp_password_dict = getattr(self, 'temp_password', None)
89        if temp_password_dict is not None:
90            delta = timedelta(minutes=self.temp_password_minutes)
91            now = datetime.utcnow()
92            if now < temp_password_dict.get('timestamp') + delta:
93                return temp_password_dict.get('password')
94            else:
95                # Unset temporary password if expired
96                self.temp_password = None
97        return None
98
99    def writeLogMessage(self, view, message):
100        ob_class = view.__implemented__.__name__.replace('waeup.kofa.','')
101        self.__parent__.logger.info(
102            '%s - %s - %s' % (ob_class, self.__name__, message))
103        return
104
105    @property
106    def display_fullname(self):
107        middlename = getattr(self, 'middlename', None)
108        kofa_utils = getUtility(IKofaUtils)
109        return kofa_utils.fullname(self.firstname, self.lastname, middlename)
110
111    @property
112    def fullname(self):
113        middlename = getattr(self, 'middlename', None)
114        if middlename:
115            return '%s-%s-%s' % (self.firstname.lower(),
116                middlename.lower(), self.lastname.lower())
117        else:
118            return '%s-%s' % (self.firstname.lower(), self.lastname.lower())
119
120    @property
121    def state(self):
122        state = IWorkflowState(self).getState()
123        return state
124
125    @property
126    def translated_state(self):
127        state = registration_states_vocab.getTermByToken(
128            self.state).title
129        return state
130
131    @property
132    def history(self):
133        history = IObjectHistory(self)
134        return history
135
136    @property
137    def student(self):
138        return self
139
140    @property
141    def certcode(self):
142        cert = getattr(self.get('studycourse', None), 'certificate', None)
143        if cert is not None:
144            return cert.code
145        return
146
147    @property
148    def faccode(self):
149        cert = getattr(self.get('studycourse', None), 'certificate', None)
150        if cert is not None:
151            return cert.__parent__.__parent__.__parent__.code
152        return
153
154    @property
155    def depcode(self):
156        cert = getattr(self.get('studycourse', None), 'certificate', None)
157        if cert is not None:
158            return cert.__parent__.__parent__.code
159        return
160
161    @property
162    def current_session(self):
163        session = getattr(
164            self.get('studycourse', None), 'current_session', None)
165        return session
166
167    @property
168    def entry_session(self):
169        session = getattr(
170            self.get('studycourse', None), 'entry_session', None)
171        return session
172
173    @property
174    def entry_mode(self):
175        session = getattr(
176            self.get('studycourse', None), 'entry_mode', None)
177        return session
178
179    @property
180    def current_level(self):
181        level = getattr(
182            self.get('studycourse', None), 'current_level', None)
183        return level
184
185    @property
186    def current_verdict(self):
187        level = getattr(
188            self.get('studycourse', None), 'current_verdict', None)
189        return level
190
191    @property
192    def current_mode(self):
193        certificate = getattr(
194            self.get('studycourse', None), 'certificate', None)
195        if certificate is not None:
196            return certificate.study_mode
197        return None
198
199    @property
200    def is_postgrad(self):
201        is_postgrad = getattr(
202            self.get('studycourse', None), 'is_postgrad', False)
203        return is_postgrad
204
205    @property
206    def is_special_postgrad(self):
207        is_special_postgrad = getattr(
208            self.get('studycourse', None), 'is_special_postgrad', False)
209        return is_special_postgrad
210
211    @property
212    def is_fresh(self):
213        return self.current_session == self.entry_session
214
215    @property
216    def before_payment(self):
217        non_fresh_states = (PAID, REGISTERED, VALIDATED, RETURNING, )
218        if self.is_fresh and self.state not in non_fresh_states:
219            return True
220        return False
221
222    @property
223    def personal_data_expired(self):
224        if self.state in (CREATED, ADMITTED,):
225            return False
226        now = datetime.utcnow()
227        if self.personal_updated is None:
228            return True
229        days_ago = getattr(now - self.personal_updated, 'days')
230        if days_ago > 180:
231            return True
232        return False
233
234    @property
235    def transcript_enabled(self):
236        return True
237
238    def transfer(self, certificate, current_session=None,
239        current_level=None, current_verdict=None, previous_verdict=None):
240        """ Creates a new studycourse and backups the old one.
241
242        """
243        newcourse = createObject(u'waeup.StudentStudyCourse')
244        try:
245            newcourse.certificate = certificate
246            newcourse.entry_mode = 'transfer'
247            newcourse.current_session = current_session
248            newcourse.current_level = current_level
249            newcourse.current_verdict = current_verdict
250            newcourse.previous_verdict = previous_verdict
251        except ConstraintNotSatisfied:
252            return -1
253        oldcourse = self['studycourse']
254        if getattr(oldcourse, 'entry_session', None) is None or\
255            getattr(oldcourse, 'certificate', None) is None:
256            return -2
257        newcourse.entry_session = oldcourse.entry_session
258        # Students can be transferred only two times.
259        if 'studycourse_1' in self.keys():
260            if 'studycourse_2' in self.keys():
261                return -3
262            self['studycourse_2'] = oldcourse
263        else:
264            self['studycourse_1'] = oldcourse
265        del self['studycourse']
266        self['studycourse'] = newcourse
267        self.__parent__.logger.info(
268            '%s - transferred from %s to %s' % (
269            self.student_id, oldcourse.certificate.code, newcourse.certificate.code))
270        history = IObjectHistory(self)
271        history.addMessage('Transferred from %s to %s' % (
272            oldcourse.certificate.code, newcourse.certificate.code))
273        return
274
275    def revert_transfer(self):
276        """ Revert previous transfer.
277
278        """
279        if not self.has_key('studycourse_1'):
280            return -1
281        del self['studycourse']
282        if 'studycourse_2' in self.keys():
283            studycourse = self['studycourse_2']
284            self['studycourse'] = studycourse
285            del self['studycourse_2']
286        else:
287            studycourse = self['studycourse_1']
288            self['studycourse'] = studycourse
289            del self['studycourse_1']
290        self.__parent__.logger.info(
291            '%s - transfer reverted' % self.student_id)
292        history = IObjectHistory(self)
293        history.addMessage('Transfer reverted')
294        return
295
296# Set all attributes of Student required in IStudent as field
297# properties. Doing this, we do not have to set initial attributes
298# ourselves and as a bonus we get free validation when an attribute is
299# set.
300Student = attrs_to_fields(Student)
301
302class StudentFactory(grok.GlobalUtility):
303    """A factory for students.
304    """
305    grok.implements(IFactory)
306    grok.name(u'waeup.Student')
307    title = u"Create a new student.",
308    description = u"This factory instantiates new student instances."
309
310    def __call__(self, *args, **kw):
311        return Student()
312
313    def getInterfaces(self):
314        return implementedBy(Student)
315
316@grok.subscribe(IStudent, grok.IObjectAddedEvent)
317def handle_student_added(student, event):
318    """If a student is added all subcontainers are automatically added
319    and the transition create is fired. The latter produces a logging
320    message.
321    """
322    if student.state == CLEARANCE:
323        student.clearance_locked = False
324    else:
325        student.clearance_locked = True
326    studycourse = createObject(u'waeup.StudentStudyCourse')
327    student['studycourse'] = studycourse
328    payments = StudentPaymentsContainer()
329    student['payments'] = payments
330    accommodation = StudentAccommodation()
331    student['accommodation'] = accommodation
332    # Assign global student role for new student
333    account = IUserAccount(student)
334    account.roles = ['waeup.Student']
335    # Assign local StudentRecordOwner role
336    role_manager = IPrincipalRoleManager(student)
337    role_manager.assignRoleToPrincipal(
338        'waeup.local.StudentRecordOwner', student.student_id)
339    if student.state is None:
340        IWorkflowInfo(student).fireTransition('create')
341    return
342
343def path_from_studid(student_id):
344    """Convert a student_id into a predictable relative folder path.
345
346    Used for storing files.
347
348    Returns the name of folder in which files for a particular student
349    should be stored. This is a relative path, relative to any general
350    students folder with 5 zero-padded digits (except when student_id
351    is overlong).
352
353    We normally map 1,000 different student ids into one single
354    path. For instance ``K1000000`` will give ``01000/K1000000``,
355    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
356    result in ``1234/K12345678``.
357
358    For lower numbers < 10**6 we return the same path for up to 10,000
359    student_ids. So for instance ``KM123456`` will result in
360    ``00120/KM123456`` (there will be no path starting with
361    ``00123``).
362
363    Works also with overlong number: here the leading zeros will be
364    missing but ``K123456789`` will give reliably
365    ``12345/K123456789`` as expected.
366    """
367    # remove all non numeric characters and turn this into an int.
368    num = int(RE_STUDID_NON_NUM.sub('', student_id))
369    if num < 10**6:
370        # store max. of 10000 studs per folder and correct num for 5 digits
371        num = num / 10000 * 10
372    else:
373        # store max. of 1000 studs per folder
374        num = num / 1000
375    # format folder name to have 5 zero-padded digits
376    folder_name = u'%05d' % num
377    folder_name = os.path.join(folder_name, student_id)
378    return folder_name
379
380def move_student_files(student, del_dir):
381    """Move files belonging to `student` to `del_dir`.
382
383    `del_dir` is expected to be the path to the site-wide directory
384    for storing backup data.
385
386    The current files of the student are removed after backup.
387
388    If the student has no associated files stored, nothing is done.
389    """
390    stud_id = student.student_id
391
392    src = getUtility(IExtFileStore).root
393    src = os.path.join(src, 'students', path_from_studid(stud_id))
394
395    dst = os.path.join(
396        del_dir, 'media', 'students', path_from_studid(stud_id))
397
398    if not os.path.isdir(src):
399        # Do not copy if no files were stored.
400        return
401    if not os.path.exists(dst):
402        os.makedirs(dst, 0755)
403    copy_filesystem_tree(src, dst)
404    shutil.rmtree(src)
405    return
406
407def update_student_deletion_csvs(student, del_dir):
408    """Update deletion CSV files with data from student.
409
410    `del_dir` is expected to be the path to the site-wide directory
411    for storing backup data.
412
413    Each exporter available for students (and their many subobjects)
414    is called in order to export CSV data of the given student to csv
415    files in the site-wide backup directory for object data (see
416    DataCenter).
417
418    Each exported row is appended a column giving the deletion date
419    (column `del_date`) as a UTC timestamp.
420    """
421
422    STUDENT_BACKUP_EXPORTER_NAMES = getUtility(
423        IStudentsUtils).STUDENT_BACKUP_EXPORTER_NAMES
424
425    for name in STUDENT_BACKUP_EXPORTER_NAMES:
426        exporter = getUtility(ICSVStudentExporter, name=name)
427        csv_data = exporter.export_student(student)
428        csv_data = csv_data.split('\r\n')
429
430        # append a deletion timestamp on each data row
431        timestamp = str(now().replace(microsecond=0)) # store UTC timestamp
432        for num, row in enumerate(csv_data[1:-1]):
433            csv_data[num+1] = csv_data[num+1] + ',' + timestamp
434        csv_path = os.path.join(del_dir, '%s.csv' % name)
435
436        # write data to CSV file
437        if not os.path.exists(csv_path):
438            # create new CSV file (including header line)
439            csv_data[0] = csv_data[0] + ',del_date'
440            open(csv_path, 'wb').write('\r\n'.join(csv_data))
441        else:
442            # append existing CSV file (omitting headerline)
443            open(csv_path, 'a').write('\r\n'.join(csv_data[1:]))
444    return
445
446@grok.subscribe(IStudent, grok.IObjectRemovedEvent)
447def handle_student_removed(student, event):
448    """If a student is removed a message is logged and data is put
449       into a backup location.
450
451    The data of the removed student is appended to CSV files in local
452    datacenter and any existing external files (passport images, etc.)
453    are copied over to this location as well.
454
455    Documents in the file storage refering to the given student are
456    removed afterwards (if they exist). Please make no assumptions
457    about how the deletion takes place. Files might be deleted
458    individually (leaving the students file directory intact) or the
459    whole student directory might be deleted completely.
460
461    All CSV rows created/appended contain a timestamp with the
462    datetime of removal in an additional `del_date` column.
463
464    XXX: blocking of used student_ids yet not implemented.
465    """
466    comment = 'Student record removed'
467    target = student.student_id
468    try:
469        site = grok.getSite()
470        site['students'].logger.info('%s - %s' % (
471            target, comment))
472    except KeyError:
473        # If we delete an entire university instance there won't be
474        # a students subcontainer
475        return
476
477    del_dir = site['datacenter'].deleted_path
478
479    # save files of the student
480    move_student_files(student, del_dir)
481
482    # update CSV files
483    update_student_deletion_csvs(student, del_dir)
484    return
485
486#: The file id marker for student files
487STUDENT_FILE_STORE_NAME = 'file-student'
488
489class StudentFileNameChooser(grok.Adapter):
490    """A file id chooser for :class:`Student` objects.
491
492    `context` is an :class:`Student` instance.
493
494    The :class:`StudentImageNameChooser` can build/check file ids for
495    :class:`Student` objects suitable for use with
496    :class:`ExtFileStore` instances. The delivered file_id contains
497    the file id marker for :class:`Student` object and the student id
498    of the context student.
499
500    This chooser is registered as an adapter providing
501    :class:`waeup.kofa.interfaces.IFileStoreNameChooser`.
502
503    File store name choosers like this one are only convenience
504    components to ease the task of creating file ids for student
505    objects. You are nevertheless encouraged to use them instead of
506    manually setting up filenames for students.
507
508    .. seealso:: :mod:`waeup.kofa.imagestorage`
509
510    """
511    grok.context(IStudent)
512    grok.implements(IFileStoreNameChooser)
513
514    def checkName(self, name=None, attr=None):
515        """Check whether the given name is a valid file id for the context.
516
517        Returns ``True`` only if `name` equals the result of
518        :meth:`chooseName`.
519
520        """
521        return name == self.chooseName()
522
523    def chooseName(self, attr, name=None):
524        """Get a valid file id for student context.
525
526        *Example:*
527
528        For a student with student id ``'A123456'`` and
529        with attr ``'nice_image.jpeg'`` stored in
530        the students container this chooser would create:
531
532          ``'__file-student__students/A/A123456/nice_image_A123456.jpeg'``
533
534        meaning that the nice image of this applicant would be
535        stored in the site-wide file storage in path:
536
537          ``students/A/A123456/nice_image_A123456.jpeg``
538
539        """
540        basename, ext = os.path.splitext(attr)
541        stud_id = self.context.student_id
542        marked_filename = '__%s__%s/%s_%s%s' % (
543            STUDENT_FILE_STORE_NAME, path_from_studid(stud_id), basename,
544            stud_id, ext)
545        return marked_filename
546
547
548class StudentFileStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
549    """Student specific file handling.
550
551    This handler knows in which path in a filestore to store student
552    files and how to turn this kind of data into some (browsable)
553    file object.
554
555    It is called from the global file storage, when it wants to
556    get/store a file with a file id starting with
557    ``__file-student__`` (the marker string for student files).
558
559    Like each other file store handler it does not handle the files
560    really (this is done by the global file store) but only computes
561    paths and things like this.
562    """
563    grok.implements(IFileStoreHandler)
564    grok.name(STUDENT_FILE_STORE_NAME)
565
566    def pathFromFileID(self, store, root, file_id):
567        """All student files are put in directory ``students``.
568        """
569        marker, filename, basename, ext = store.extractMarker(file_id)
570        sub_root = os.path.join(root, 'students')
571        return super(StudentFileStoreHandler, self).pathFromFileID(
572            store, sub_root, basename)
573
574    def createFile(self, store, root, filename, file_id, file):
575        """Create a browsable file-like object.
576        """
577        # call super method to ensure that any old files with
578        # different filename extension are deleted.
579        file, path, file_obj =  super(
580            StudentFileStoreHandler, self).createFile(
581            store, root,  filename, file_id, file)
582        return file, path, KofaImageFile(
583            file_obj.filename, file_obj.data)
Note: See TracBrowser for help on using the repository browser.