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

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

Do it right and add test.

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