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

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

Rename is_fresh method. Actually we need to filter students who have not yet paid school fee.

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