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

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

Improve interfaces for documentation.

  • Property svn:keywords set to Id
File size: 20.3 KB
Line 
1## $Id: student.py 13080 2015-06-20 07:55:18Z 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    def transfer(self, certificate, current_session=None,
242        current_level=None, current_verdict=None, previous_verdict=None):
243        """ Creates a new studycourse and backups the old one.
244        """
245        newcourse = createObject(u'waeup.StudentStudyCourse')
246        try:
247            newcourse.certificate = certificate
248            newcourse.entry_mode = 'transfer'
249            newcourse.current_session = current_session
250            newcourse.current_level = current_level
251            newcourse.current_verdict = current_verdict
252            newcourse.previous_verdict = previous_verdict
253        except ConstraintNotSatisfied:
254            return -1
255        oldcourse = self['studycourse']
256        if getattr(oldcourse, 'entry_session', None) is None or\
257            getattr(oldcourse, 'certificate', None) is None:
258            return -2
259        newcourse.entry_session = oldcourse.entry_session
260        # Students can be transferred only two times.
261        if 'studycourse_1' in self.keys():
262            if 'studycourse_2' in self.keys():
263                return -3
264            self['studycourse_2'] = oldcourse
265        else:
266            self['studycourse_1'] = oldcourse
267        del self['studycourse']
268        self['studycourse'] = newcourse
269        self.__parent__.logger.info(
270            '%s - transferred from %s to %s' % (
271            self.student_id,
272            oldcourse.certificate.code,
273            newcourse.certificate.code))
274        history = IObjectHistory(self)
275        history.addMessage('Transferred from %s to %s' % (
276            oldcourse.certificate.code, newcourse.certificate.code))
277        return
278
279    def revert_transfer(self):
280        """ Revert previous transfer.
281
282        """
283        if not self.has_key('studycourse_1'):
284            return -1
285        del self['studycourse']
286        if 'studycourse_2' in self.keys():
287            studycourse = self['studycourse_2']
288            self['studycourse'] = studycourse
289            del self['studycourse_2']
290        else:
291            studycourse = self['studycourse_1']
292            self['studycourse'] = studycourse
293            del self['studycourse_1']
294        self.__parent__.logger.info(
295            '%s - transfer reverted' % self.student_id)
296        history = IObjectHistory(self)
297        history.addMessage('Transfer reverted')
298        return
299
300# Set all attributes of Student required in IStudent as field
301# properties. Doing this, we do not have to set initial attributes
302# ourselves and as a bonus we get free validation when an attribute is
303# set.
304Student = attrs_to_fields(Student)
305
306class StudentFactory(grok.GlobalUtility):
307    """A factory for students.
308    """
309    grok.implements(IFactory)
310    grok.name(u'waeup.Student')
311    title = u"Create a new student.",
312    description = u"This factory instantiates new student instances."
313
314    def __call__(self, *args, **kw):
315        return Student()
316
317    def getInterfaces(self):
318        return implementedBy(Student)
319
320@grok.subscribe(IStudent, grok.IObjectAddedEvent)
321def handle_student_added(student, event):
322    """If a student is added all subcontainers are automatically added
323    and the transition create is fired. The latter produces a logging
324    message.
325    """
326    if student.state == CLEARANCE:
327        student.clearance_locked = False
328    else:
329        student.clearance_locked = True
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    return
489
490#: The file id marker for student files
491STUDENT_FILE_STORE_NAME = 'file-student'
492
493class StudentFileNameChooser(grok.Adapter):
494    """A file id chooser for :class:`Student` objects.
495
496    `context` is an :class:`Student` instance.
497
498    The :class:`StudentImageNameChooser` can build/check file ids for
499    :class:`Student` objects suitable for use with
500    :class:`ExtFileStore` instances. The delivered file_id contains
501    the file id marker for :class:`Student` object and the student id
502    of the context student.
503
504    This chooser is registered as an adapter providing
505    :class:`waeup.kofa.interfaces.IFileStoreNameChooser`.
506
507    File store name choosers like this one are only convenience
508    components to ease the task of creating file ids for student
509    objects. You are nevertheless encouraged to use them instead of
510    manually setting up filenames for students.
511
512    .. seealso:: :mod:`waeup.kofa.imagestorage`
513
514    """
515    grok.context(IStudent)
516    grok.implements(IFileStoreNameChooser)
517
518    def checkName(self, name=None, attr=None):
519        """Check whether the given name is a valid file id for the context.
520
521        Returns ``True`` only if `name` equals the result of
522        :meth:`chooseName`.
523
524        """
525        return name == self.chooseName()
526
527    def chooseName(self, attr, name=None):
528        """Get a valid file id for student context.
529
530        *Example:*
531
532        For a student with student id ``'A123456'`` and
533        with attr ``'nice_image.jpeg'`` stored in
534        the students container this chooser would create:
535
536          ``'__file-student__students/A/A123456/nice_image_A123456.jpeg'``
537
538        meaning that the nice image of this applicant would be
539        stored in the site-wide file storage in path:
540
541          ``students/A/A123456/nice_image_A123456.jpeg``
542
543        """
544        basename, ext = os.path.splitext(attr)
545        stud_id = self.context.student_id
546        marked_filename = '__%s__%s/%s_%s%s' % (
547            STUDENT_FILE_STORE_NAME, path_from_studid(stud_id), basename,
548            stud_id, ext)
549        return marked_filename
550
551
552class StudentFileStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
553    """Student specific file handling.
554
555    This handler knows in which path in a filestore to store student
556    files and how to turn this kind of data into some (browsable)
557    file object.
558
559    It is called from the global file storage, when it wants to
560    get/store a file with a file id starting with
561    ``__file-student__`` (the marker string for student files).
562
563    Like each other file store handler it does not handle the files
564    really (this is done by the global file store) but only computes
565    paths and things like this.
566    """
567    grok.implements(IFileStoreHandler)
568    grok.name(STUDENT_FILE_STORE_NAME)
569
570    def pathFromFileID(self, store, root, file_id):
571        """All student files are put in directory ``students``.
572        """
573        marker, filename, basename, ext = store.extractMarker(file_id)
574        sub_root = os.path.join(root, 'students')
575        return super(StudentFileStoreHandler, self).pathFromFileID(
576            store, sub_root, basename)
577
578    def createFile(self, store, root, filename, file_id, file):
579        """Create a browsable file-like object.
580        """
581        # call super method to ensure that any old files with
582        # different filename extension are deleted.
583        file, path, file_obj =  super(
584            StudentFileStoreHandler, self).createFile(
585            store, root,  filename, file_id, file)
586        return file, path, KofaImageFile(
587            file_obj.filename, file_obj.data)
Note: See TracBrowser for help on using the repository browser.