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

Last change on this file since 13264 was 13224, checked in by Henrik Bettermann, 9 years ago

Remove global roles after deletion of applicants and students.

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