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

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

Show entry_sesssion in export. Show only level_session to ease post-processing with Excel.

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