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

Last change on this file since 8466 was 8452, checked in by uli, 13 years ago

Finetune media file distributions for students a bit: make sure that for lower numbers 10,000 numbers share a path.

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