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

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

Add StudentTransferFormPage?.

Do not show any button on old studycourse (studycourse_1 or studycourse_2) pages.

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