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

Last change on this file since 17313 was 16713, checked in by Henrik Bettermann, 3 years ago

Catch TypeError?

  • Property svn:keywords set to Id
File size: 23.0 KB
Line 
1## $Id: student.py 16713 2021-11-15 12:47:49Z 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            newcourse.entry_mode = 'transfer'
309            newcourse.current_session = current_session
310            newcourse.current_level = current_level
311            newcourse.current_verdict = current_verdict
312            newcourse.previous_verdict = previous_verdict
313        except ConstraintNotSatisfied:
314            return -1
315        oldcourse = self['studycourse']
316        if getattr(oldcourse, 'entry_session', None) is None or\
317            getattr(oldcourse, 'certificate', None) is None:
318            return -2
319        newcourse.entry_session = oldcourse.entry_session
320        # Students can be transferred only two times.
321        if 'studycourse_1' in self.keys():
322            if 'studycourse_2' in self.keys():
323                return -3
324            self['studycourse_2'] = oldcourse
325        else:
326            self['studycourse_1'] = oldcourse
327        del self['studycourse']
328        self['studycourse'] = newcourse
329        self.__parent__.logger.info(
330            '%s - transferred from %s to %s' % (
331            self.student_id,
332            oldcourse.certificate.code,
333            newcourse.certificate.code))
334        history = IObjectHistory(self)
335        history.addMessage('Transferred from %s to %s' % (
336            oldcourse.certificate.code, newcourse.certificate.code))
337        return
338
339    def revert_transfer(self):
340        """ Revert previous transfer.
341
342        """
343        if not self.has_key('studycourse_1'):
344            return -1
345        del self['studycourse']
346        if 'studycourse_2' in self.keys():
347            studycourse = self['studycourse_2']
348            self['studycourse'] = studycourse
349            del self['studycourse_2']
350        else:
351            studycourse = self['studycourse_1']
352            self['studycourse'] = studycourse
353            del self['studycourse_1']
354        self.__parent__.logger.info(
355            '%s - transfer reverted' % self.student_id)
356        history = IObjectHistory(self)
357        history.addMessage('Transfer reverted')
358        return
359
360# Set all attributes of Student required in IStudent as field
361# properties. Doing this, we do not have to set initial attributes
362# ourselves and as a bonus we get free validation when an attribute is
363# set.
364Student = attrs_to_fields(Student)
365
366class StudentFactory(grok.GlobalUtility):
367    """A factory for students.
368    """
369    grok.implements(IFactory)
370    grok.name(u'waeup.Student')
371    title = u"Create a new student.",
372    description = u"This factory instantiates new student instances."
373
374    def __call__(self, *args, **kw):
375        return Student()
376
377    def getInterfaces(self):
378        return implementedBy(Student)
379
380@grok.subscribe(IStudent, grok.IObjectAddedEvent)
381def handle_student_added(student, event):
382    """If a student is added all subcontainers are automatically added
383    and the transition create is fired. The latter produces a logging
384    message.
385    """
386    studycourse = createObject(u'waeup.StudentStudyCourse')
387    student['studycourse'] = studycourse
388    payments = StudentPaymentsContainer()
389    student['payments'] = payments
390    accommodation = StudentAccommodation()
391    student['accommodation'] = accommodation
392    # Assign global student role for new student
393    account = IUserAccount(student)
394    account.roles = ['waeup.Student']
395    # Assign local StudentRecordOwner role
396    role_manager = IPrincipalRoleManager(student)
397    role_manager.assignRoleToPrincipal(
398        'waeup.local.StudentRecordOwner', student.student_id)
399    if student.state is None:
400        IWorkflowInfo(student).fireTransition('create')
401    return
402
403def path_from_studid(student_id):
404    """Convert a student_id into a predictable relative folder path.
405
406    Used for storing files.
407
408    Returns the name of folder in which files for a particular student
409    should be stored. This is a relative path, relative to any general
410    students folder with 5 zero-padded digits (except when student_id
411    is overlong).
412
413    We normally map 1,000 different student ids into one single
414    path. For instance ``K1000000`` will give ``01000/K1000000``,
415    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
416    result in ``1234/K12345678``.
417
418    For lower numbers < 10**6 we return the same path for up to 10,000
419    student_ids. So for instance ``KM123456`` will result in
420    ``00120/KM123456`` (there will be no path starting with
421    ``00123``).
422
423    Works also with overlong number: here the leading zeros will be
424    missing but ``K123456789`` will give reliably
425    ``12345/K123456789`` as expected.
426    """
427    # remove all non numeric characters and turn this into an int.
428    num = int(RE_STUDID_NON_NUM.sub('', student_id))
429    if num < 10**6:
430        # store max. of 10000 studs per folder and correct num for 5 digits
431        num = num / 10000 * 10
432    else:
433        # store max. of 1000 studs per folder
434        num = num / 1000
435    # format folder name to have 5 zero-padded digits
436    folder_name = u'%05d' % num
437    folder_name = os.path.join(folder_name, student_id)
438    return folder_name
439
440def move_student_files(student, del_dir):
441    """Move files belonging to `student` to `del_dir`.
442
443    `del_dir` is expected to be the path to the site-wide directory
444    for storing backup data.
445
446    The current files of the student are removed after backup.
447
448    If the student has no associated files stored, nothing is done.
449    """
450    stud_id = student.student_id
451
452    src = getUtility(IExtFileStore).root
453    src = os.path.join(src, 'students', path_from_studid(stud_id))
454
455    dst = os.path.join(
456        del_dir, 'media', 'students', path_from_studid(stud_id))
457
458    if not os.path.isdir(src):
459        # Do not copy if no files were stored.
460        return
461    if not os.path.exists(dst):
462        os.makedirs(dst, 0755)
463    copy_filesystem_tree(src, dst)
464    shutil.rmtree(src)
465    return
466
467def update_student_deletion_csvs(student, del_dir):
468    """Update deletion CSV files with data from student.
469
470    `del_dir` is expected to be the path to the site-wide directory
471    for storing backup data.
472
473    Each exporter available for students (and their many subobjects)
474    is called in order to export CSV data of the given student to csv
475    files in the site-wide backup directory for object data (see
476    DataCenter).
477
478    Each exported row is appended a column giving the deletion date
479    (column `del_date`) as a UTC timestamp.
480    """
481
482    STUDENT_BACKUP_EXPORTER_NAMES = getUtility(
483        IStudentsUtils).STUDENT_BACKUP_EXPORTER_NAMES
484
485    for name in STUDENT_BACKUP_EXPORTER_NAMES:
486        exporter = getUtility(ICSVStudentExporter, name=name)
487        csv_data = exporter.export_student(student)
488        csv_data = csv_data.split('\r\n')
489
490        # append a deletion timestamp on each data row
491        timestamp = str(now().replace(microsecond=0)) # store UTC timestamp
492        for num, row in enumerate(csv_data[1:-1]):
493            csv_data[num+1] = csv_data[num+1] + ',' + timestamp
494        csv_path = os.path.join(del_dir, '%s.csv' % name)
495
496        # write data to CSV file
497        if not os.path.exists(csv_path):
498            # create new CSV file (including header line)
499            csv_data[0] = csv_data[0] + ',del_date'
500            open(csv_path, 'wb').write('\r\n'.join(csv_data))
501        else:
502            # append existing CSV file (omitting headerline)
503            open(csv_path, 'a').write('\r\n'.join(csv_data[1:]))
504    return
505
506@grok.subscribe(IStudent, grok.IObjectRemovedEvent)
507def handle_student_removed(student, event):
508    """If a student is removed a message is logged and data is put
509       into a backup location.
510
511    The data of the removed student is appended to CSV files in local
512    datacenter and any existing external files (passport images, etc.)
513    are copied over to this location as well.
514
515    Documents in the file storage refering to the given student are
516    removed afterwards (if they exist). Please make no assumptions
517    about how the deletion takes place. Files might be deleted
518    individually (leaving the students file directory intact) or the
519    whole student directory might be deleted completely.
520
521    All CSV rows created/appended contain a timestamp with the
522    datetime of removal in an additional `del_date` column.
523
524    XXX: blocking of used student_ids yet not implemented.
525    """
526    comment = 'Student record removed'
527    target = student.student_id
528    try:
529        site = grok.getSite()
530        site['students'].logger.info('%s - %s' % (
531            target, comment))
532    except KeyError:
533        # If we delete an entire university instance there won't be
534        # a students subcontainer
535        return
536
537    del_dir = site['datacenter'].deleted_path
538
539    if student.state == GRADUATED:
540        del_dir = site['datacenter'].graduated_path
541
542    # save files of the student
543    move_student_files(student, del_dir)
544
545    # update CSV files
546    update_student_deletion_csvs(student, del_dir)
547
548    # remove global role
549    role_manager = IPrincipalRoleManager(grok.getSite())
550    role_manager.unsetRoleForPrincipal('waeup.Student', student.student_id)
551    return
552
553#: The file id marker for student files
554STUDENT_FILE_STORE_NAME = 'file-student'
555
556class StudentFileNameChooser(grok.Adapter):
557    """A file id chooser for :class:`Student` objects.
558
559    `context` is an :class:`Student` instance.
560
561    The :class:`StudentImageNameChooser` can build/check file ids for
562    :class:`Student` objects suitable for use with
563    :class:`ExtFileStore` instances. The delivered file_id contains
564    the file id marker for :class:`Student` object and the student id
565    of the context student.
566
567    This chooser is registered as an adapter providing
568    :class:`waeup.kofa.interfaces.IFileStoreNameChooser`.
569
570    File store name choosers like this one are only convenience
571    components to ease the task of creating file ids for student
572    objects. You are nevertheless encouraged to use them instead of
573    manually setting up filenames for students.
574
575    .. seealso:: :mod:`waeup.kofa.imagestorage`
576
577    """
578    grok.context(IStudent)
579    grok.implements(IFileStoreNameChooser)
580
581    def checkName(self, name=None, attr=None):
582        """Check whether the given name is a valid file id for the context.
583
584        Returns ``True`` only if `name` equals the result of
585        :meth:`chooseName`.
586
587        """
588        return name == self.chooseName()
589
590    def chooseName(self, attr, name=None):
591        """Get a valid file id for student context.
592
593        *Example:*
594
595        For a student with student id ``'A123456'`` and
596        with attr ``'nice_image.jpeg'`` stored in
597        the students container this chooser would create:
598
599          ``'__file-student__students/A/A123456/nice_image_A123456.jpeg'``
600
601        meaning that the nice image of this applicant would be
602        stored in the site-wide file storage in path:
603
604          ``students/A/A123456/nice_image_A123456.jpeg``
605
606        """
607        basename, ext = os.path.splitext(attr)
608        stud_id = self.context.student_id
609        marked_filename = '__%s__%s/%s_%s%s' % (
610            STUDENT_FILE_STORE_NAME, path_from_studid(stud_id), basename,
611            stud_id, ext)
612        return marked_filename
613
614
615class StudentFileStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
616    """Student specific file handling.
617
618    This handler knows in which path in a filestore to store student
619    files and how to turn this kind of data into some (browsable)
620    file object.
621
622    It is called from the global file storage, when it wants to
623    get/store a file with a file id starting with
624    ``__file-student__`` (the marker string for student files).
625
626    Like each other file store handler it does not handle the files
627    really (this is done by the global file store) but only computes
628    paths and things like this.
629    """
630    grok.implements(IFileStoreHandler)
631    grok.name(STUDENT_FILE_STORE_NAME)
632
633    def pathFromFileID(self, store, root, file_id):
634        """All student files are put in directory ``students``.
635        """
636        marker, filename, basename, ext = store.extractMarker(file_id)
637        sub_root = os.path.join(root, 'students')
638        return super(StudentFileStoreHandler, self).pathFromFileID(
639            store, sub_root, basename)
640
641    def createFile(self, store, root, filename, file_id, file):
642        """Create a browsable file-like object.
643        """
644        # call super method to ensure that any old files with
645        # different filename extension are deleted.
646        file, path, file_obj =  super(
647            StudentFileStoreHandler, self).createFile(
648            store, root,  filename, file_id, file)
649        return file, path, KofaImageFile(
650            file_obj.filename, file_obj.data)
Note: See TracBrowser for help on using the repository browser.