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

Last change on this file since 17833 was 17769, checked in by Henrik Bettermann, 8 months ago

After import with entry_mode 'transfer' we must ensure that after export and reimport the student is not transferred again.

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