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

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

Dedicated officers should be able to login as student with a temporary password set by the system. This is the first part of its implementation.

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