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

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

Add method to revert a previously made transfer.

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