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

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

Activate personal data expiration checker.

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