source: main/waeup.kofa/branches/henrik-transcript-workflow/src/waeup/kofa/students/student.py @ 15148

Last change on this file since 15148 was 15140, checked in by Henrik Bettermann, 6 years ago

Implement transcript validation workflow. More tests will follow.

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