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

Last change on this file since 11026 was 10266, checked in by Henrik Bettermann, 11 years ago

Define correct dictionaries. level_dict must include probation levels.

Add buttons for navigating to transcripts.

Add property attribute 'transcript_enabled' which eases defining conditions in custom packages.

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