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

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

In Okene we also need the current verdict of the student.

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