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

Last change on this file since 15607 was 15606, checked in by Henrik Bettermann, 5 years ago

Parents access implementation (part 1)

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