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

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

We need a special interface for the StudentPersonalEditFormPage?. Some fields on this form are required.

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