source: main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py @ 16293

Last change on this file since 16293 was 16079, checked in by Henrik Bettermann, 5 years ago

After such a long time: 'level' is a required field also in update mode.

  • Property svn:keywords set to Id
File size: 43.8 KB
RevLine 
[7191]1## $Id: batching.py 16079 2020-05-04 05:46:50Z 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##
[7433]18"""Batch processing components for student objects.
[6821]19
20Batch processors eat CSV files to add, update or remove large numbers
21of certain kinds of objects at once.
22
[7261]23Here we define the processors for students specific objects like
24students, studycourses, payment tickets and accommodation tickets.
[6821]25"""
26import grok
[10028]27import unicodecsv as csv # XXX: csv ops should move to dedicated module.
[8884]28from time import time
[9295]29from datetime import datetime
[9296]30from zope.i18n import translate
[6821]31from zope.interface import Interface
[6825]32from zope.schema import getFields
[9960]33from zope.component import queryUtility, getUtility, createObject
[7429]34from zope.event import notify
[6825]35from zope.catalog.interfaces import ICatalog
[7951]36from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
[7811]37from waeup.kofa.interfaces import (
[7522]38    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
[15628]39    IObjectHistory, VALIDATED, REGISTERED, IGNORE_MARKER, DELETION_MARKER)
[12623]40from waeup.kofa.interfaces import IKofaUtils, DuplicationError
[7959]41from waeup.kofa.interfaces import MessageFactory as _
[7811]42from waeup.kofa.students.interfaces import (
[9960]43    IStudent, IStudentStudyCourse, IStudentStudyCourseTransfer,
[7536]44    IStudentUpdateByRegNo, IStudentUpdateByMatricNo,
[9420]45    IStudentStudyLevel, ICourseTicketImport,
[8174]46    IStudentOnlinePayment, IStudentVerdictUpdate)
[13871]47from waeup.kofa.payments.interfaces import IPayer
[8309]48from waeup.kofa.students.workflow import  (
[9028]49    IMPORTABLE_STATES, IMPORTABLE_TRANSITIONS,
50    FORBIDDEN_POSTGRAD_TRANS, FORBIDDEN_POSTGRAD_STATES)
[7811]51from waeup.kofa.utils.batching import BatchProcessor
[6821]52
53class StudentProcessor(BatchProcessor):
[12872]54    """The Student Processor imports student base data.
55
56    In create mode no locator is required. If no `student_id` is given,
57    the portal automatically assigns a new student id.
58
59    In update or remove mode the processor uses
60    either the `student_id`, `reg_number` or `matric_number` to localize the
61    student object, exactly in this order. If `student_id` is given and an
62    object can be found, `reg_number` and `matric_number` will be overwritten
63    by the values provided in the import file. If `student_id` is missing,
64    `reg_number` is used to localize the object and only `matric_number`
65    will be overwritten. `matric_number` is used as locator only if both
66    `student_id` and `reg_number` are missing. `student_id` can't be changed
67    by the batch processor.
68
[13024]69    There are two ways to change the workflow state of the student,
[12872]70    an unsafe and a safe way. The safe way makes use of workflow transitions.
71    Transitions are only possible between allowed workflow states. Only
72    transitions ensure that the registration workflow is maintained.
73
74    **Always prefer the safe way!**
[6821]75    """
76    grok.implements(IBatchProcessor)
77    grok.provides(IBatchProcessor)
78    grok.context(Interface)
[7933]79    util_name = 'studentprocessor'
[6821]80    grok.name(util_name)
81
[11891]82    name = _('Student Processor')
[6821]83    iface = IStudent
[8581]84    iface_byregnumber = IStudentUpdateByRegNo
85    iface_bymatricnumber = IStudentUpdateByMatricNo
[6821]86
87    factory_name = 'waeup.Student'
88
89    @property
[6849]90    def available_fields(self):
[8176]91        fields = getFields(self.iface)
[6849]92        return sorted(list(set(
[7513]93            ['student_id','reg_number','matric_number',
[8309]94            'password', 'state', 'transition'] + fields.keys())))
[6821]95
[6849]96    def checkHeaders(self, headerfields, mode='create'):
[8309]97        if 'state' in headerfields and 'transition' in headerfields:
98            raise FatalCSVError(
[12180]99                "State and transition can't be imported at the same time!")
[6854]100        if not 'reg_number' in headerfields and not 'student_id' \
101            in headerfields and not 'matric_number' in headerfields:
[6849]102            raise FatalCSVError(
[6854]103                "Need at least columns student_id or reg_number " +
104                "or matric_number for import!")
[6849]105        if mode == 'create':
106            for field in self.required_fields:
107                if not field in headerfields:
108                    raise FatalCSVError(
109                        "Need at least columns %s for import!" %
110                        ', '.join(["'%s'" % x for x in self.required_fields]))
111        # Check for fields to be ignored...
112        not_ignored_fields = [x for x in headerfields
113                              if not x.startswith('--')]
114        if len(set(not_ignored_fields)) < len(not_ignored_fields):
115            raise FatalCSVError(
116                "Double headers: each column name may only appear once.")
117        return True
118
[6821]119    def parentsExist(self, row, site):
120        return 'students' in site.keys()
121
[6849]122    def getLocator(self, row):
[8232]123        if row.get('student_id',None) not in (None, IGNORE_MARKER):
[6849]124            return 'student_id'
[8232]125        elif row.get('reg_number',None) not in (None, IGNORE_MARKER):
[6849]126            return 'reg_number'
[8232]127        elif row.get('matric_number',None) not in (None, IGNORE_MARKER):
[6849]128            return 'matric_number'
129        else:
130            return None
131
[6821]132    # The entry never exists in create mode.
133    def entryExists(self, row, site):
[7267]134        return self.getEntry(row, site) is not None
135
136    def getParent(self, row, site):
137        return site['students']
138
139    def getEntry(self, row, site):
[6846]140        if not 'students' in site.keys():
[6849]141            return None
142        if self.getLocator(row) == 'student_id':
[6846]143            if row['student_id'] in site['students']:
144                student = site['students'][row['student_id']]
145                return student
[6849]146        elif self.getLocator(row) == 'reg_number':
[6846]147            reg_number = row['reg_number']
148            cat = queryUtility(ICatalog, name='students_catalog')
149            results = list(
150                cat.searchResults(reg_number=(reg_number, reg_number)))
151            if results:
152                return results[0]
[6849]153        elif self.getLocator(row) == 'matric_number':
[6846]154            matric_number = row['matric_number']
155            cat = queryUtility(ICatalog, name='students_catalog')
156            results = list(
157                cat.searchResults(matric_number=(matric_number, matric_number)))
158            if results:
159                return results[0]
[6849]160        return None
[6821]161
162    def addEntry(self, obj, row, site):
163        parent = self.getParent(row, site)
164        parent.addStudent(obj)
[8491]165        # Reset _curr_stud_id if student_id has been imported
166        if self.getLocator(row) == 'student_id':
167            parent._curr_stud_id -= 1
[8287]168        # We have to log this if state is provided. If not,
[7959]169        # logging is done by the event handler handle_student_added
[9701]170        if 'state' in row:
[7959]171            parent.logger.info('%s - Student record created' % obj.student_id)
[6821]172        return
173
174    def delEntry(self, row, site):
[7267]175        student = self.getEntry(row, site)
[7263]176        if student is not None:
[6846]177            parent = self.getParent(row, site)
[7656]178            parent.logger.info('%s - Student removed' % student.student_id)
[6846]179            del parent[student.student_id]
[6821]180        pass
[6825]181
[8309]182    def checkUpdateRequirements(self, obj, row, site):
183        """Checks requirements the object must fulfill when being updated.
184
185        This method is not used in case of deleting or adding objects.
186
187        Returns error messages as strings in case of requirement
188        problems.
189        """
190        transition = row.get('transition', IGNORE_MARKER)
191        if transition not in (IGNORE_MARKER, ''):
192            allowed_transitions = IWorkflowInfo(obj).getManualTransitionIds()
193            if transition not in allowed_transitions:
194                return 'Transition not allowed.'
[9028]195            if transition in FORBIDDEN_POSTGRAD_TRANS and \
196                obj.is_postgrad:
197                return 'Transition not allowed (pg student).'
198        state = row.get('state', IGNORE_MARKER)
199        if state not in (IGNORE_MARKER, ''):
200            if state in FORBIDDEN_POSTGRAD_STATES and \
201                obj.is_postgrad:
202                return 'State not allowed (pg student).'
[8309]203        return None
204
[9706]205    def updateEntry(self, obj, row, site, filename):
[7497]206        """Update obj to the values given in row.
207        """
[8221]208        items_changed = ''
209
[7643]210        # Remove student_id from row if empty
[9701]211        if 'student_id' in row and row['student_id'] in (None, IGNORE_MARKER):
[7643]212            row.pop('student_id')
[8221]213
214        # Update password
[9701]215        if 'password' in row:
[8498]216            passwd = row.get('password', IGNORE_MARKER)
217            if passwd not in ('', IGNORE_MARKER):
218                if passwd.startswith('{SSHA}'):
219                    # already encrypted password
220                    obj.password = passwd
[15628]221                elif passwd == DELETION_MARKER:
222                    obj.password = None
[8498]223                else:
224                    # not yet encrypted password
225                    IUserAccount(obj).setPassword(passwd)
226                items_changed += ('%s=%s, ' % ('password', passwd))
[8221]227            row.pop('password')
228
229        # Update registration state
[9701]230        if 'state' in row:
[8498]231            state = row.get('state', IGNORE_MARKER)
232            if state not in (IGNORE_MARKER, ''):
233                value = row['state']
234                IWorkflowState(obj).setState(value)
235                msg = _("State '${a}' set", mapping = {'a':value})
236                history = IObjectHistory(obj)
237                history.addMessage(msg)
238                items_changed += ('%s=%s, ' % ('state', state))
[8287]239            row.pop('state')
[8498]240
[9701]241        if 'transition' in row:
[8498]242            transition = row.get('transition', IGNORE_MARKER)
243            if transition not in (IGNORE_MARKER, ''):
244                value = row['transition']
245                IWorkflowInfo(obj).fireTransition(value)
246                items_changed += ('%s=%s, ' % ('transition', transition))
[8309]247            row.pop('transition')
[8221]248
249        # apply other values...
[8498]250        items_changed += super(StudentProcessor, self).updateEntry(
[9706]251            obj, row, site, filename)
[8221]252
253        # Log actions...
[7656]254        parent = self.getParent(row, site)
255        if hasattr(obj,'student_id'):
[8995]256            # Update mode: the student exists and we can get the student_id.
257            # Create mode: the record contains the student_id
[7656]258            parent.logger.info(
[9706]259                '%s - %s - %s - updated: %s'
260                % (self.name, filename, obj.student_id, items_changed))
[7656]261        else:
262            # Create mode: the student does not yet exist
[9706]263            # XXX: It seems that this never happens because student_id
264            # is always set.
265            parent.logger.info(
266                '%s - %s - %s - imported: %s'
267                % (self.name, filename, obj.student_id, items_changed))
[15418]268        notify(grok.ObjectModifiedEvent(obj))
[8221]269        return items_changed
[7497]270
[6849]271    def getMapping(self, path, headerfields, mode):
272        """Get a mapping from CSV file headerfields to actually used fieldnames.
273        """
274        result = dict()
275        reader = csv.reader(open(path, 'rb'))
276        raw_header = reader.next()
277        for num, field in enumerate(headerfields):
[8221]278            if field not in ['student_id', 'reg_number', 'matric_number'
279                             ] and mode == 'remove':
[6849]280                continue
281            if field == u'--IGNORE--':
282                # Skip ignored columns in failed and finished data files.
283                continue
284            result[raw_header[num]] = field
285        return result
286
287    def checkConversion(self, row, mode='create'):
288        """Validates all values in row.
289        """
[7643]290        iface = self.iface
[6849]291        if mode in ['update', 'remove']:
292            if self.getLocator(row) == 'reg_number':
[8581]293                iface = self.iface_byregnumber
[6849]294            elif self.getLocator(row) == 'matric_number':
[8581]295                iface = self.iface_bymatricnumber
[6849]296        converter = IObjectConverter(iface)
297        errs, inv_errs, conv_dict =  converter.fromStringDict(
[8214]298            row, self.factory_name, mode=mode)
[12883]299        # We cannot import both state and transition.
300        if 'transition' in row and 'state' in row:
301            if row['transition'] not in (IGNORE_MARKER, '') and \
302                row['state'] not in (IGNORE_MARKER, ''):
303                errs.append(('workflow','not allowed'))
304                return errs, inv_errs, conv_dict
[9701]305        if 'transition' in row:
[9028]306            if row['transition'] not in IMPORTABLE_TRANSITIONS:
307                if row['transition'] not in (IGNORE_MARKER, ''):
308                    errs.append(('transition','not allowed'))
[9701]309        if 'state' in row:
[9028]310            if row['state'] not in IMPORTABLE_STATES:
311                if row['state'] not in (IGNORE_MARKER, ''):
312                    errs.append(('state','not allowed'))
313                else:
314                    # State is an attribute of Student and must not
315                    # be changed if empty.
316                    conv_dict['state'] = IGNORE_MARKER
[8490]317        try:
318            # Correct stud_id counter. As the IConverter for students
319            # creates student objects that are not used afterwards, we
320            # have to fix the site-wide student_id counter.
321            site = grok.getSite()
322            students = site['students']
323            students._curr_stud_id -= 1
324        except (KeyError, TypeError, AttributeError):
325                pass
[6849]326        return errs, inv_errs, conv_dict
327
[8232]328
329class StudentProcessorBase(BatchProcessor):
330    """A base for student subitem processor.
331
332    Helps reducing redundancy.
[6825]333    """
[8232]334    grok.baseclass()
[6825]335
[9420]336    # additional available fields
[8884]337    # beside 'student_id', 'reg_number' and 'matric_number'
[8232]338    additional_fields = []
[6825]339
[12869]340    # additional required fields (subset of additional_fields)
341    additional_fields_required = []
[6849]342
[6825]343    @property
344    def available_fields(self):
[8232]345        fields = ['student_id','reg_number','matric_number'
346                  ] + self.additional_fields
347        return sorted(list(set(fields + getFields(
[6843]348                self.iface).keys())))
[6825]349
[6837]350    def checkHeaders(self, headerfields, mode='ignore'):
[6854]351        if not 'reg_number' in headerfields and not 'student_id' \
352            in headerfields and not 'matric_number' in headerfields:
[6825]353            raise FatalCSVError(
[6854]354                "Need at least columns student_id " +
355                "or reg_number or matric_number for import!")
[12869]356        for name in self.additional_fields_required:
[8232]357            if not name in headerfields:
358                raise FatalCSVError(
359                    "Need %s for import!" % name)
360
[6834]361        # Check for fields to be ignored...
[6825]362        not_ignored_fields = [x for x in headerfields
363                              if not x.startswith('--')]
364        if len(set(not_ignored_fields)) < len(not_ignored_fields):
365            raise FatalCSVError(
366                "Double headers: each column name may only appear once.")
367        return True
368
[8232]369    def _getStudent(self, row, site):
[8225]370        NON_VALUES = ['', IGNORE_MARKER]
[6846]371        if not 'students' in site.keys():
[6849]372            return None
[8225]373        if row.get('student_id', '') not in NON_VALUES:
[6825]374            if row['student_id'] in site['students']:
375                student = site['students'][row['student_id']]
376                return student
[8225]377        elif row.get('reg_number', '') not in NON_VALUES:
[6825]378            reg_number = row['reg_number']
379            cat = queryUtility(ICatalog, name='students_catalog')
380            results = list(
381                cat.searchResults(reg_number=(reg_number, reg_number)))
382            if results:
383                return results[0]
[8225]384        elif row.get('matric_number', '') not in NON_VALUES:
[6843]385            matric_number = row['matric_number']
386            cat = queryUtility(ICatalog, name='students_catalog')
387            results = list(
388                cat.searchResults(matric_number=(matric_number, matric_number)))
389            if results:
390                return results[0]
[6849]391        return None
[6825]392
[7267]393    def parentsExist(self, row, site):
394        return self.getParent(row, site) is not None
395
[6825]396    def entryExists(self, row, site):
[7534]397        return self.getEntry(row, site) is not None
[6825]398
[8232]399    def checkConversion(self, row, mode='ignore'):
400        """Validates all values in row.
401        """
402        converter = IObjectConverter(self.iface)
403        errs, inv_errs, conv_dict =  converter.fromStringDict(
404            row, self.factory_name, mode=mode)
405        return errs, inv_errs, conv_dict
406
[8885]407    def getMapping(self, path, headerfields, mode):
408        """Get a mapping from CSV file headerfields to actually used fieldnames.
409        """
410        result = dict()
411        reader = csv.reader(open(path, 'rb'))
412        raw_header = reader.next()
413        for num, field in enumerate(headerfields):
[8888]414            if field not in ['student_id', 'reg_number', 'matric_number',
415                             'p_id', 'code', 'level'
[8885]416                             ] and mode == 'remove':
417                continue
418            if field == u'--IGNORE--':
419                # Skip ignored columns in failed and finished data files.
420                continue
421            result[raw_header[num]] = field
422        return result
[8232]423
[8885]424
[8232]425class StudentStudyCourseProcessor(StudentProcessorBase):
[12872]426    """The Student Study Course Processor imports data which refer
427    to the student's course of study. The study course container data
428    describe the current state of the course of study and it stores the
429    entry conditions, i.e. when the student started the course.
430
431    Most important is the `certificate` attribute which tells us which course
432    the student is studying. The terms 'study course' and 'course of study'
433    are used synonymously. The 'certificate' is the study programme described
434    in the acadmic section. The study course object stores a referrer to a
435    certificate in the acadmic section.
436
437    When importing a new certificate code, `checkConversion` does not only
438    check whether a certificate with the same code exists, it also
439    proves if `current_level` is inside the level range of the certificate.
440    For example, some study programmes start at level 200. The imported
441    current level must thus be 200 or higher.
442
443    `checkUpdateRequirements` looks up if the imported values match the
444    certificate already stored with the study course object. The imported
445    `current_level` must be in the range of the certificate already
446    stored.
447
448    .. note::
449
450      The processor does only offer an update mode. An 'empty' study course
451      object is automatically created when the student object is added. So this
452      object always exists. It can neither be added a second time nor
453      be removed.
454
455    Students can be transferred by import. A transfer is initialized if the
456    `entry_mode` value is ``transfer``. In this case `checkConversion` uses a
457    different interface for data validation and `checkUpdateRequirements`
458    ensures that a student can only be transferred twice. The student transfer
459    process is described elsewhere.
[8232]460    """
461    grok.implements(IBatchProcessor)
462    grok.provides(IBatchProcessor)
463    grok.context(Interface)
464    util_name = 'studycourseupdater'
465    grok.name(util_name)
466
[11891]467    name = _('StudentStudyCourse Processor (update only)')
[8232]468    iface = IStudentStudyCourse
[9960]469    iface_transfer = IStudentStudyCourseTransfer
[8232]470    factory_name = 'waeup.StudentStudyCourse'
471
472    def getParent(self, row, site):
473        return self._getStudent(row, site)
474
[6825]475    def getEntry(self, row, site):
[7534]476        student = self.getParent(row, site)
[7536]477        if student is None:
[6825]478            return None
479        return student.get('studycourse')
[7429]480
[9706]481    def updateEntry(self, obj, row, site, filename):
[7429]482        """Update obj to the values given in row.
483        """
[9960]484        entry_mode = row.get('entry_mode', None)
485        certificate = row.get('certificate', None)
[9962]486        current_session = row.get('current_session', None)
487        student = self.getParent(row, site)
[9960]488        if entry_mode == 'transfer':
[9962]489            # We do not expect any error here since we
490            # checked all constraints in checkConversion and
491            # in checkUpdateRequirements
492            student.transfer(
493                certificate=certificate, current_session=current_session)
[9960]494            obj = student['studycourse']
[8221]495        items_changed = super(StudentStudyCourseProcessor, self).updateEntry(
[9706]496            obj, row, site, filename)
[9962]497        student.__parent__.logger.info(
[9706]498            '%s - %s - %s - updated: %s'
[9962]499            % (self.name, filename, student.student_id, items_changed))
[7429]500        # Update the students_catalog
[9962]501        notify(grok.ObjectModifiedEvent(student))
[7429]502        return
503
[7532]504    def checkConversion(self, row, mode='ignore'):
505        """Validates all values in row.
506        """
[9960]507        # We have to use the correct interface. Transfer
508        # updates have different constraints.
509        entry_mode = row.get('entry_mode', None)
510        if entry_mode == 'transfer':
511            converter = IObjectConverter(self.iface_transfer)
512        else:
513            converter = IObjectConverter(self.iface)
514        errs, inv_errs, conv_dict =  converter.fromStringDict(
515            row, self.factory_name, mode=mode)
516
[7532]517        # We have to check if current_level is in range of certificate.
[9701]518        if 'certificate' in conv_dict and 'current_level' in conv_dict:
[8940]519            cert = conv_dict['certificate']
520            level = conv_dict['current_level']
521            if level < cert.start_level or level > cert.end_level+120:
522                errs.append(('current_level','not in range'))
[7532]523        return errs, inv_errs, conv_dict
524
[9028]525    def checkUpdateRequirements(self, obj, row, site):
526        """Checks requirements the object must fulfill when being updated.
527        Returns error messages as strings in case of requirement
528        problems.
529        """
[15163]530        if obj.student.studycourse_locked:
531            return 'Studycourse is locked.'
[9960]532        certificate = getattr(obj, 'certificate', None)
533        entry_session = getattr(obj, 'entry_session', None)
[9028]534        current_level = row.get('current_level', None)
[9960]535        entry_mode = row.get('entry_mode', None)
536        # We have to ensure that the student can be transferred.
537        if entry_mode == 'transfer':
538            if certificate is None or entry_session is None:
539                return 'Former study course record incomplete.'
540            if 'studycourse_1' in obj.__parent__.keys() and \
541                'studycourse_2' in obj.__parent__.keys():
542                return 'Maximum number of transfers exceeded.'
[9735]543        if current_level:
544            if current_level == 999 and \
545                obj.__parent__.state in FORBIDDEN_POSTGRAD_STATES:
546                return 'Not a pg student.'
547            cert = row.get('certificate', None)
548            if certificate is None and cert is None:
549                return 'No certificate to check level.'
[9960]550            if certificate is not None and cert is None and (
[9735]551                current_level < certificate.start_level or \
552                current_level > certificate.end_level+120):
553                return 'current_level not in range.'
[9028]554        return None
555
[8232]556class StudentStudyLevelProcessor(StudentProcessorBase):
[12872]557    """The Student Study Level Processor imports study level data.
558    It overwrites the container attributes but not the content of the container,
559    i.e. the course tickets stored inside the container. There is nothing
560    special about this processor.
[7536]561    """
562    grok.implements(IBatchProcessor)
563    grok.provides(IBatchProcessor)
564    grok.context(Interface)
[7933]565    util_name = 'studylevelprocessor'
[7536]566    grok.name(util_name)
567
[11891]568    name = _('StudentStudyLevel Processor')
[7536]569    iface = IStudentStudyLevel
570    factory_name = 'waeup.StudentStudyLevel'
571
[16079]572    additional_fields_required = ['level']
573
[9690]574    @property
575    def available_fields(self):
576        fields = super(StudentStudyLevelProcessor, self).available_fields
577        fields.remove('total_credits')
[10479]578        fields.remove('gpa')
[9690]579        return  fields
580
[7536]581    def getParent(self, row, site):
[8232]582        student = self._getStudent(row, site)
583        if student is None:
[7536]584            return None
[8232]585        return student['studycourse']
[7536]586
587    def getEntry(self, row, site):
588        studycourse = self.getParent(row, site)
589        if studycourse is None:
590            return None
[14337]591        try:
592            entry = studycourse.get(str(row['level']))
593        except KeyError:
594            return None
595        return entry
[7536]596
[9301]597    def delEntry(self, row, site):
598        studylevel = self.getEntry(row, site)
599        parent = self.getParent(row, site)
600        if studylevel is not None:
601            student = self._getStudent(row, site)
602            student.__parent__.logger.info('%s - Level removed: %s'
603                % (student.student_id, studylevel.__name__))
604            del parent[studylevel.__name__]
605        return
606
[9706]607    def updateEntry(self, obj, row, site, filename):
[8626]608        """Update obj to the values given in row.
609        """
610        items_changed = super(StudentStudyLevelProcessor, self).updateEntry(
[9706]611            obj, row, site, filename)
[8626]612        student = self.getParent(row, site).__parent__
613        student.__parent__.logger.info(
[9706]614            '%s - %s - %s - updated: %s'
615            % (self.name, filename, student.student_id, items_changed))
[8626]616        return
617
[7536]618    def addEntry(self, obj, row, site):
[15065]619        if IGNORE_MARKER == str(row['level']):
[15066]620            raise FatalCSVError("level: Invalid value")
[7536]621        parent = self.getParent(row, site)
[12873]622        parent[str(row['level'])] = obj
[7536]623        return
624
[16012]625    def checkCreateRequirements(self, parent, row, site):
626        """
627        """
628        if parent.student.studycourse_locked:
629            return 'Studycourse is locked.'
630        return None
631
[15163]632    def checkUpdateRequirements(self, obj, row, site):
633        """
634        """
635        if obj.student.studycourse_locked:
636            return 'Studylevel is locked.'
637        return None
638
[16012]639    def checkRemoveRequirements(self, obj, row, site):
640        """
641        """
642        if obj.student.studycourse_locked:
643            return 'Studycourse is locked.'
644        return None
645
[8232]646class CourseTicketProcessor(StudentProcessorBase):
[12882]647    """The Course Ticket Processor imports course tickets, the subobjects
648    of student study levels (= course lists).
649
650    An imported course ticket contains a copy of the original course data.
651    During import only a few attributes can be set/overwritten.
652
653    Like all other student data importers, this processor also requires
654    either `student_id`, `reg_number` or `matric_number` to find the student.
[16000]655    Then it needs `level` and `code` to localize the course ticket.
[12882]656
657    `checkConversion` first searches the courses catalog for the imported
658    `code` and ensures that a course with such a code really exists
659    in the academic section. It furthermore checks if `level_session` in
660    the row corresponds with the session of the parent student
661    study level object. It fails if one of the conditions is not met.
662
663    In create mode `fcode`, `dcode`, `title`, `credits`, `passmark` and
664    `semester` are taken from the course found in the academic section.
[14189]665    `fcode` and `dcode` can nevermore be changed, neither via the user interface
[14654]666    nor by import. Other values can be overwritten by import.
[15203]667
[16000]668    `ticket_session` is an additional field which can be used to store the
[15203]669    session of the course when it was taken. Usually this information is
[15446]670    redundant because the parent study level object already contains this
671    information, except for the study level zero container which can be used to
[15203]672    store 'orphaned' course tickets.
[16000]673
674    `checkUpdateRequirements` ensures that the `score` attribute can't
675    be accidentally overwritten by import in update mode. The `score`
676    attribute can be unlocked by setting the boolean field
677    `unlock_score` = 1.
[7548]678    """
679    grok.implements(IBatchProcessor)
680    grok.provides(IBatchProcessor)
681    grok.context(Interface)
[7933]682    util_name = 'courseticketprocessor'
[7548]683    grok.name(util_name)
684
[11891]685    name = _('CourseTicket Processor')
[9420]686    iface = ICourseTicketImport
[7548]687    factory_name = 'waeup.CourseTicket'
688
[9316]689    additional_fields = ['level', 'code']
[12869]690    additional_fields_required = additional_fields
[7548]691
[9420]692    @property
693    def available_fields(self):
694        fields = [
695            'student_id','reg_number','matric_number',
696            'mandatory', 'score', 'carry_over', 'automatic',
[14642]697            'outstanding', 'course_category', 'level_session',
[16000]698            'title', 'credits', 'passmark', 'semester', 'ticket_session',
699            'unlock_score'
[9420]700            ] + self.additional_fields
701        return sorted(fields)
702
[7548]703    def getParent(self, row, site):
[8232]704        student = self._getStudent(row, site)
705        if student is None:
[7548]706            return None
[12873]707        return student['studycourse'].get(str(row['level']))
[7548]708
709    def getEntry(self, row, site):
710        level = self.getParent(row, site)
711        if level is None:
712            return None
713        return level.get(row['code'])
714
[9706]715    def updateEntry(self, obj, row, site, filename):
[8626]716        """Update obj to the values given in row.
717        """
718        items_changed = super(CourseTicketProcessor, self).updateEntry(
[9706]719            obj, row, site, filename)
[8888]720        parent = self.getParent(row, site)
[8626]721        student = self.getParent(row, site).__parent__.__parent__
722        student.__parent__.logger.info(
[9706]723            '%s - %s - %s - %s - updated: %s'
724            % (self.name, filename, student.student_id, parent.level, items_changed))
[8626]725        return
726
[7548]727    def addEntry(self, obj, row, site):
728        parent = self.getParent(row, site)
729        catalog = getUtility(ICatalog, name='courses_catalog')
730        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
731        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
732        obj.dcode = entries[0].__parent__.__parent__.code
[14189]733        if getattr(obj, 'title', None) is None:
734            obj.title = entries[0].title
735        if getattr(obj, 'credits', None) is None:
736            obj.credits = entries[0].credits
737        if getattr(obj, 'passmark', None) is None:
738            obj.passmark = entries[0].passmark
739        if getattr(obj, 'semester', None) is None:
740            obj.semester = entries[0].semester
[7548]741        parent[row['code']] = obj
742        return
743
[8888]744    def delEntry(self, row, site):
745        ticket = self.getEntry(row, site)
746        parent = self.getParent(row, site)
747        if ticket is not None:
748            student = self._getStudent(row, site)
749            student.__parent__.logger.info('%s - Course ticket in %s removed: %s'
750                % (student.student_id, parent.level, ticket.code))
751            del parent[ticket.code]
752        return
753
[16012]754    def checkCreateRequirements(self, parent, row, site):
755        """
756        """
757        if parent.student.studycourse_locked:
758            return 'Studycourse is locked.'
759        return None
760
[15163]761    def checkUpdateRequirements(self, obj, row, site):
762        """
763        """
764        if obj.student.studycourse_locked:
765            return 'Studycourse is locked.'
[16000]766        if row.get('score',None) and obj.score and not row.get(
767            'unlock_score',None):
768            return 'Score attribute is locked.'
[15163]769        return None
770
[16012]771    def checkRemoveRequirements(self, obj, row, site):
772        """
773        """
774        if obj.student.studycourse_locked:
775            return 'Studycourse is locked.'
776        return None
777
[7548]778    def checkConversion(self, row, mode='ignore'):
779        """Validates all values in row.
780        """
[8232]781        errs, inv_errs, conv_dict = super(
782            CourseTicketProcessor, self).checkConversion(row, mode=mode)
[9420]783        if mode == 'remove':
784            return errs, inv_errs, conv_dict
785        # In update and create mode we have to check if course really exists.
[7548]786        # This is not done by the converter.
787        catalog = getUtility(ICatalog, name='courses_catalog')
788        entries = catalog.searchResults(code=(row['code'],row['code']))
789        if len(entries) == 0:
790            errs.append(('code','non-existent'))
791            return errs, inv_errs, conv_dict
[9420]792        # If level_session is provided in row we have to check if
793        # the parent studylevel exists and if its level_session
794        # attribute corresponds with the expected value in row.
[9421]795        level_session = conv_dict.get('level_session', IGNORE_MARKER)
796        if level_session not in (IGNORE_MARKER, None):
[9420]797            site = grok.getSite()
798            studylevel = self.getParent(row, site)
799            if studylevel is not None:
800                if studylevel.level_session != level_session:
801                    errs.append(('level_session','does not match %s'
802                        % studylevel.level_session))
803            else:
[13001]804                errs.append(('level object','does not exist'))
[7623]805        return errs, inv_errs, conv_dict
806
[8232]807class StudentOnlinePaymentProcessor(StudentProcessorBase):
[13871]808    """The Student Online Payment Processor imports student payment tickets.
809    The tickets are located in the ``payments`` subfolder of the student
[12882]810    container. The only additional locator is `p_id`, the object id.
811
812    The `checkConversion` method checks the format of the payment identifier.
[14804]813    In create mode it does also ensures that same `p_id` does not exist
[12882]814    elsewhere. It must be portal-wide unique.
815
816    When adding a payment ticket, the `addEntry` method checks if the same
817    payment has already been made. It compares `p_category` and `p_session`
818    in the row with the corresponding attributes  of existing payment
819    tickets in state ``paid``. If they match, a `DuplicationError` is raised.
[7623]820    """
821    grok.implements(IBatchProcessor)
822    grok.provides(IBatchProcessor)
823    grok.context(Interface)
[7933]824    util_name = 'paymentprocessor'
[7623]825    grok.name(util_name)
826
[11891]827    name = _('StudentOnlinePayment Processor')
[8174]828    iface = IStudentOnlinePayment
[7623]829    factory_name = 'waeup.StudentOnlinePayment'
830
[8232]831    additional_fields = ['p_id']
[7623]832
[13871]833    @property
834    def available_fields(self):
835        af = super(
836            StudentOnlinePaymentProcessor, self).available_fields
837        af.remove('display_item')
838        return af
839
[8884]840    def checkHeaders(self, headerfields, mode='ignore'):
841        super(StudentOnlinePaymentProcessor, self).checkHeaders(headerfields)
[8885]842        if mode in ('update', 'remove') and not 'p_id' in headerfields:
[8884]843            raise FatalCSVError(
[8885]844                "Need p_id for import in update and remove modes!")
[8884]845        return True
846
[8232]847    def parentsExist(self, row, site):
848        return self.getParent(row, site) is not None
[7623]849
850    def getParent(self, row, site):
[8232]851        student = self._getStudent(row, site)
852        if student is None:
[7623]853            return None
[8232]854        return student['payments']
[7623]855
856    def getEntry(self, row, site):
857        payments = self.getParent(row, site)
858        if payments is None:
859            return None
[8884]860        p_id = row.get('p_id', None)
[12996]861        if p_id in (None, IGNORE_MARKER):
[8884]862            return None
[7626]863        # We can use the hash symbol at the end of p_id in import files
864        # to avoid annoying automatic number transformation
865        # by Excel or Calc
[8884]866        p_id = p_id.strip('#')
[9467]867        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
[8884]868            # For data migration from old SRP only
869            p_id = 'p' + p_id[7:] + '0'
870        entry = payments.get(p_id)
[7623]871        return entry
872
[9706]873    def updateEntry(self, obj, row, site, filename):
[8626]874        """Update obj to the values given in row.
875        """
876        items_changed = super(StudentOnlinePaymentProcessor, self).updateEntry(
[9706]877            obj, row, site, filename)
[8626]878        student = self.getParent(row, site).__parent__
879        student.__parent__.logger.info(
[9706]880            '%s - %s - %s - updated: %s'
881            % (self.name, filename, student.student_id, items_changed))
[8626]882        return
883
[12623]884    def samePaymentMade(self, student, category, p_session):
885        for key in student['payments'].keys():
886            ticket = student['payments'][key]
887            if ticket.p_state == 'paid' and\
888               ticket.p_category == category and \
[14053]889               ticket.p_session == p_session and \
890               ticket.p_item != 'Balance':
[12623]891                  return True
892        return False
893
[7623]894    def addEntry(self, obj, row, site):
895        parent = self.getParent(row, site)
[12623]896        student = parent.student
[7626]897        p_id = row['p_id'].strip('#')
[12623]898        # Requirement added on 19/02/2015: same payment must not exist.
[14213]899        if obj.p_item != 'Balance' and self.samePaymentMade(
900            student, obj.p_category, obj.p_session):
[13872]901            student.__parent__.logger.info(
902                '%s - %s - previous update cancelled'
903                % (self.name, student.student_id))
[12623]904            raise DuplicationError('Same payment has already been made.')
[9467]905        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
[7623]906            # For data migration from old SRP
[8884]907            obj.p_id = 'p' + p_id[7:] + '0'
[7623]908            parent[obj.p_id] = obj
909        else:
[7626]910            parent[p_id] = obj
[7623]911        return
912
[8885]913    def delEntry(self, row, site):
914        payment = self.getEntry(row, site)
915        parent = self.getParent(row, site)
916        if payment is not None:
917            student = self._getStudent(row, site)
[8886]918            student.__parent__.logger.info('%s - Payment ticket removed: %s'
[8885]919                % (student.student_id, payment.p_id))
920            del parent[payment.p_id]
[8886]921        return
[8885]922
[7623]923    def checkConversion(self, row, mode='ignore'):
924        """Validates all values in row.
925        """
[8232]926        errs, inv_errs, conv_dict = super(
927            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
928
[7623]929        # We have to check p_id.
[8884]930        p_id = row.get('p_id', None)
[12996]931        if mode == 'create' and p_id in (None, IGNORE_MARKER):
[8884]932            timestamp = ("%d" % int(time()*10000))[1:]
933            p_id = "p%s" % timestamp
934            conv_dict['p_id'] = p_id
935            return errs, inv_errs, conv_dict
[12996]936        elif p_id in (None, IGNORE_MARKER):
937            errs.append(('p_id','missing'))
938            return errs, inv_errs, conv_dict
[8884]939        else:
940            p_id = p_id.strip('#')
[12996]941            if p_id.startswith('p'):
942                if not len(p_id) == 14:
943                    errs.append(('p_id','invalid length'))
944                    return errs, inv_errs, conv_dict
945            elif len(p_id.split('-')) == 3:
946                # The SRP used either pins as keys ...
947                if len(p_id.split('-')[2]) not in (9, 10):
948                    errs.append(('p_id','invalid pin'))
949                    return errs, inv_errs, conv_dict
950            else:
951                # ... or order_ids.
952                if not len(p_id) == 19:
953                    errs.append(('p_id','invalid format'))
954                    return errs, inv_errs, conv_dict
[12513]955        # Requirement added on 24/01/2015: p_id must be portal-wide unique.
956        if mode == 'create':
957            cat = getUtility(ICatalog, name='payments_catalog')
958            results = list(cat.searchResults(p_id=(p_id, p_id)))
959            if len(results) > 0:
[13871]960                sids = [IPayer(payment).id for payment in results]
[12513]961                sids_string = ''
962                for id in sids:
963                    sids_string += '%s ' % id
964                errs.append(('p_id','p_id exists in %s' % sids_string))
965                return errs, inv_errs, conv_dict
[7623]966        return errs, inv_errs, conv_dict
[7951]967
968class StudentVerdictProcessor(StudentStudyCourseProcessor):
[12882]969    """The Student Verdict Processor inherits from the Student Study
970    Course Processor. It's a pure updater. Import step 2 raises a warning
971    message if a datacenter manager tries to select another mode.
972    But it does more than only overwriting study course attributes.
[7951]973
[12882]974    The Student Verdict Processor is the only processor which cannot be
975    used for restoring data. Purpose is to announce a verdict at the end of
976    each academic session. The processor does not only import a verdict,
977    it also conditions the student data so that the student can pay for the
978    next session and proceed to the next study level.
979
980    The `checkUpdateRequirements` method ensures that the imported data
981    really correspond to the actual state of the student.
982    `current_level` and `current_session` in the row must be on par
983    with the attributes of the study course object. Thus, the processor
984    does not use these values to overwrite the attributes of the study course
985    but to control that the verdict is really meant for the current session of
986    the student. The verdict is only imported if a corresponding study level
987    object exists and the student is in the right registration state,
988    either ``courses validated`` or ``courses registered``. Course registration
989    can be bypassed by setting `bypass_validation` to ``True``.
990
991    The `updateEntry` method does not only update the current verdict of
992    the student study course, it also updates the matching student study
993    level object. It saves the current verdict as `level_verdict` and sets
994    the `validated_by` and `validation_date` attributes, whereas `validated_by`
995    is taken from the row of the import file and `validation_date` is set to the
996    actual UTC datetime. Finally, the student is moved to state ``returning``.
[7951]997    """
998
999    util_name = 'verdictupdater'
1000    grok.name(util_name)
1001
[11891]1002    name = _('Verdict Processor (special processor, update only)')
[7951]1003    iface = IStudentVerdictUpdate
1004    factory_name = 'waeup.StudentStudyCourse'
1005
[14901]1006    additional_fields_required = [
1007        'current_level', 'current_session', 'current_verdict']
1008
[7951]1009    def checkUpdateRequirements(self, obj, row, site):
1010        """Checks requirements the studycourse and the student must fulfill
1011        before being updated.
1012        """
1013        # Check if current_levels correspond
1014        if obj.current_level != row['current_level']:
1015            return 'Current level does not correspond.'
1016        # Check if current_sessions correspond
1017        if obj.current_session != row['current_session']:
1018            return 'Current session does not correspond.'
[9282]1019        # Check if new verdict is provided
1020        if row['current_verdict'] in (IGNORE_MARKER, ''):
[9293]1021            return 'No verdict in import file.'
[9631]1022        # Check if studylevel exists
[9293]1023        level_string = str(obj.current_level)
1024        if obj.get(level_string) is None:
1025            return 'Study level object is missing.'
[9284]1026        # Check if student is in state REGISTERED or VALIDATED
[9296]1027        if row.get('bypass_validation'):
[9284]1028            if obj.student.state not in (VALIDATED, REGISTERED):
1029                return 'Student in wrong state.'
1030        else:
1031            if obj.student.state != VALIDATED:
1032                return 'Student in wrong state.'
[7951]1033        return None
1034
[9706]1035    def updateEntry(self, obj, row, site, filename):
[7951]1036        """Update obj to the values given in row.
1037        """
[8221]1038        # Don't set current_session, current_level
1039        vals_to_set = dict((key, val) for key, val in row.items()
1040                           if key not in ('current_session','current_level'))
[9706]1041        super(StudentVerdictProcessor, self).updateEntry(
1042            obj, vals_to_set, site, filename)
[7951]1043        parent = self.getParent(row, site)
[12882]1044        # Set current_verdict in corresponding studylevel
[9293]1045        level_string = str(obj.current_level)
1046        obj[level_string].level_verdict = row['current_verdict']
[9296]1047        # Fire transition and set studylevel attributes
1048        # depending on student's state
[9284]1049        if obj.__parent__.state == REGISTERED:
[9296]1050            validated_by = row.get('validated_by', '')
1051            if validated_by in (IGNORE_MARKER, ''):
1052                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
1053                system = translate(_('System'),'waeup.kofa',
1054                                  target_language=portal_language)
1055                obj[level_string].validated_by = system
1056            else:
1057                obj[level_string].validated_by = validated_by
1058            obj[level_string].validation_date = datetime.utcnow()
[9284]1059            IWorkflowInfo(obj.__parent__).fireTransition('bypass_validation')
1060        else:
1061            IWorkflowInfo(obj.__parent__).fireTransition('return')
[7951]1062        # Update the students_catalog
1063        notify(grok.ObjectModifiedEvent(obj.__parent__))
1064        return
Note: See TracBrowser for help on using the repository browser.