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

Last change on this file since 11952 was 11604, checked in by Henrik Bettermann, 10 years ago

is_fresh property method added to Student class.

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