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

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

Export entry_mode correctly.

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