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

Last change on this file since 14418 was 14169, checked in by Henrik Bettermann, 8 years ago

Add report purge button.

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