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

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

Add method for transferring students when the student has changed the course of study.

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