source: main/waeup.kofa/branches/uli-async-update/src/waeup/kofa/students/student.py @ 9169

Last change on this file since 9169 was 9169, checked in by uli, 12 years ago

Merge changes from trunk, r8786-HEAD

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