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

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

Copy entry_session when transferring students. The entry session always remains the same.

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