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

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

Also previous_verdict and entry_session must be set.

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