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

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

For FCEOkene we also need current_level as a property of student.

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