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

Last change on this file since 8472 was 8472, checked in by Henrik Bettermann, 13 years ago

Add property is_postgrad.

Add invariant constraint to ICertificate.

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