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

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

Do it right and add test.

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