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

Last change on this file since 10242 was 10155, checked in by Henrik Bettermann, 12 years ago

Also in AAUA we have 'special postgrad' students. These are postgrad students with regular ug workflow.

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