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

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

Add unlock_score field to CourseTicketProcessor and extend checkUpdateRequirements.

  • Property svn:keywords set to Id
File size: 43.0 KB
RevLine 
[7191]1## $Id: batching.py 16000 2020-02-11 12:15:26Z 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
[9690]572    @property
573    def available_fields(self):
574        fields = super(StudentStudyLevelProcessor, self).available_fields
575        fields.remove('total_credits')
[10479]576        fields.remove('gpa')
[9690]577        return  fields
578
[7536]579    def getParent(self, row, site):
[8232]580        student = self._getStudent(row, site)
581        if student is None:
[7536]582            return None
[8232]583        return student['studycourse']
[7536]584
585    def getEntry(self, row, site):
586        studycourse = self.getParent(row, site)
587        if studycourse is None:
588            return None
[14337]589        try:
590            entry = studycourse.get(str(row['level']))
591        except KeyError:
592            return None
593        return entry
[7536]594
[9301]595    def delEntry(self, row, site):
596        studylevel = self.getEntry(row, site)
597        parent = self.getParent(row, site)
598        if studylevel is not None:
599            student = self._getStudent(row, site)
600            student.__parent__.logger.info('%s - Level removed: %s'
601                % (student.student_id, studylevel.__name__))
602            del parent[studylevel.__name__]
603        return
604
[9706]605    def updateEntry(self, obj, row, site, filename):
[8626]606        """Update obj to the values given in row.
607        """
608        items_changed = super(StudentStudyLevelProcessor, self).updateEntry(
[9706]609            obj, row, site, filename)
[8626]610        student = self.getParent(row, site).__parent__
611        student.__parent__.logger.info(
[9706]612            '%s - %s - %s - updated: %s'
613            % (self.name, filename, student.student_id, items_changed))
[8626]614        return
615
[7536]616    def addEntry(self, obj, row, site):
[15065]617        if IGNORE_MARKER == str(row['level']):
[15066]618            raise FatalCSVError("level: Invalid value")
[7536]619        parent = self.getParent(row, site)
[12873]620        parent[str(row['level'])] = obj
[7536]621        return
622
[15163]623    def checkUpdateRequirements(self, obj, row, site):
624        """
625        """
626        if obj.student.studycourse_locked:
627            return 'Studylevel is locked.'
628        return None
629
[8232]630class CourseTicketProcessor(StudentProcessorBase):
[12882]631    """The Course Ticket Processor imports course tickets, the subobjects
632    of student study levels (= course lists).
633
634    An imported course ticket contains a copy of the original course data.
635    During import only a few attributes can be set/overwritten.
636
637    Like all other student data importers, this processor also requires
638    either `student_id`, `reg_number` or `matric_number` to find the student.
[16000]639    Then it needs `level` and `code` to localize the course ticket.
[12882]640
641    `checkConversion` first searches the courses catalog for the imported
642    `code` and ensures that a course with such a code really exists
643    in the academic section. It furthermore checks if `level_session` in
644    the row corresponds with the session of the parent student
645    study level object. It fails if one of the conditions is not met.
646
647    In create mode `fcode`, `dcode`, `title`, `credits`, `passmark` and
648    `semester` are taken from the course found in the academic section.
[14189]649    `fcode` and `dcode` can nevermore be changed, neither via the user interface
[14654]650    nor by import. Other values can be overwritten by import.
[15203]651
[16000]652    `ticket_session` is an additional field which can be used to store the
[15203]653    session of the course when it was taken. Usually this information is
[15446]654    redundant because the parent study level object already contains this
655    information, except for the study level zero container which can be used to
[15203]656    store 'orphaned' course tickets.
[16000]657
658    `checkUpdateRequirements` ensures that the `score` attribute can't
659    be accidentally overwritten by import in update mode. The `score`
660    attribute can be unlocked by setting the boolean field
661    `unlock_score` = 1.
[7548]662    """
663    grok.implements(IBatchProcessor)
664    grok.provides(IBatchProcessor)
665    grok.context(Interface)
[7933]666    util_name = 'courseticketprocessor'
[7548]667    grok.name(util_name)
668
[11891]669    name = _('CourseTicket Processor')
[9420]670    iface = ICourseTicketImport
[7548]671    factory_name = 'waeup.CourseTicket'
672
[9316]673    additional_fields = ['level', 'code']
[12869]674    additional_fields_required = additional_fields
[7548]675
[9420]676    @property
677    def available_fields(self):
678        fields = [
679            'student_id','reg_number','matric_number',
680            'mandatory', 'score', 'carry_over', 'automatic',
[14642]681            'outstanding', 'course_category', 'level_session',
[16000]682            'title', 'credits', 'passmark', 'semester', 'ticket_session',
683            'unlock_score'
[9420]684            ] + self.additional_fields
685        return sorted(fields)
686
[7548]687    def getParent(self, row, site):
[8232]688        student = self._getStudent(row, site)
689        if student is None:
[7548]690            return None
[12873]691        return student['studycourse'].get(str(row['level']))
[7548]692
693    def getEntry(self, row, site):
694        level = self.getParent(row, site)
695        if level is None:
696            return None
697        return level.get(row['code'])
698
[9706]699    def updateEntry(self, obj, row, site, filename):
[8626]700        """Update obj to the values given in row.
701        """
702        items_changed = super(CourseTicketProcessor, self).updateEntry(
[9706]703            obj, row, site, filename)
[8888]704        parent = self.getParent(row, site)
[8626]705        student = self.getParent(row, site).__parent__.__parent__
706        student.__parent__.logger.info(
[9706]707            '%s - %s - %s - %s - updated: %s'
708            % (self.name, filename, student.student_id, parent.level, items_changed))
[8626]709        return
710
[7548]711    def addEntry(self, obj, row, site):
712        parent = self.getParent(row, site)
713        catalog = getUtility(ICatalog, name='courses_catalog')
714        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
715        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
716        obj.dcode = entries[0].__parent__.__parent__.code
[14189]717        if getattr(obj, 'title', None) is None:
718            obj.title = entries[0].title
719        if getattr(obj, 'credits', None) is None:
720            obj.credits = entries[0].credits
721        if getattr(obj, 'passmark', None) is None:
722            obj.passmark = entries[0].passmark
723        if getattr(obj, 'semester', None) is None:
724            obj.semester = entries[0].semester
[7548]725        parent[row['code']] = obj
726        return
727
[8888]728    def delEntry(self, row, site):
729        ticket = self.getEntry(row, site)
730        parent = self.getParent(row, site)
731        if ticket is not None:
732            student = self._getStudent(row, site)
733            student.__parent__.logger.info('%s - Course ticket in %s removed: %s'
734                % (student.student_id, parent.level, ticket.code))
735            del parent[ticket.code]
736        return
737
[15163]738    def checkUpdateRequirements(self, obj, row, site):
739        """
740        """
741        if obj.student.studycourse_locked:
742            return 'Studycourse is locked.'
[16000]743        if row.get('score',None) and obj.score and not row.get(
744            'unlock_score',None):
745            return 'Score attribute is locked.'
[15163]746        return None
747
[7548]748    def checkConversion(self, row, mode='ignore'):
749        """Validates all values in row.
750        """
[8232]751        errs, inv_errs, conv_dict = super(
752            CourseTicketProcessor, self).checkConversion(row, mode=mode)
[9420]753        if mode == 'remove':
754            return errs, inv_errs, conv_dict
755        # In update and create mode we have to check if course really exists.
[7548]756        # This is not done by the converter.
757        catalog = getUtility(ICatalog, name='courses_catalog')
758        entries = catalog.searchResults(code=(row['code'],row['code']))
759        if len(entries) == 0:
760            errs.append(('code','non-existent'))
761            return errs, inv_errs, conv_dict
[9420]762        # If level_session is provided in row we have to check if
763        # the parent studylevel exists and if its level_session
764        # attribute corresponds with the expected value in row.
[9421]765        level_session = conv_dict.get('level_session', IGNORE_MARKER)
766        if level_session not in (IGNORE_MARKER, None):
[9420]767            site = grok.getSite()
768            studylevel = self.getParent(row, site)
769            if studylevel is not None:
770                if studylevel.level_session != level_session:
771                    errs.append(('level_session','does not match %s'
772                        % studylevel.level_session))
773            else:
[13001]774                errs.append(('level object','does not exist'))
[7623]775        return errs, inv_errs, conv_dict
776
[8232]777class StudentOnlinePaymentProcessor(StudentProcessorBase):
[13871]778    """The Student Online Payment Processor imports student payment tickets.
779    The tickets are located in the ``payments`` subfolder of the student
[12882]780    container. The only additional locator is `p_id`, the object id.
781
782    The `checkConversion` method checks the format of the payment identifier.
[14804]783    In create mode it does also ensures that same `p_id` does not exist
[12882]784    elsewhere. It must be portal-wide unique.
785
786    When adding a payment ticket, the `addEntry` method checks if the same
787    payment has already been made. It compares `p_category` and `p_session`
788    in the row with the corresponding attributes  of existing payment
789    tickets in state ``paid``. If they match, a `DuplicationError` is raised.
[7623]790    """
791    grok.implements(IBatchProcessor)
792    grok.provides(IBatchProcessor)
793    grok.context(Interface)
[7933]794    util_name = 'paymentprocessor'
[7623]795    grok.name(util_name)
796
[11891]797    name = _('StudentOnlinePayment Processor')
[8174]798    iface = IStudentOnlinePayment
[7623]799    factory_name = 'waeup.StudentOnlinePayment'
800
[8232]801    additional_fields = ['p_id']
[7623]802
[13871]803    @property
804    def available_fields(self):
805        af = super(
806            StudentOnlinePaymentProcessor, self).available_fields
807        af.remove('display_item')
808        return af
809
[8884]810    def checkHeaders(self, headerfields, mode='ignore'):
811        super(StudentOnlinePaymentProcessor, self).checkHeaders(headerfields)
[8885]812        if mode in ('update', 'remove') and not 'p_id' in headerfields:
[8884]813            raise FatalCSVError(
[8885]814                "Need p_id for import in update and remove modes!")
[8884]815        return True
816
[8232]817    def parentsExist(self, row, site):
818        return self.getParent(row, site) is not None
[7623]819
820    def getParent(self, row, site):
[8232]821        student = self._getStudent(row, site)
822        if student is None:
[7623]823            return None
[8232]824        return student['payments']
[7623]825
826    def getEntry(self, row, site):
827        payments = self.getParent(row, site)
828        if payments is None:
829            return None
[8884]830        p_id = row.get('p_id', None)
[12996]831        if p_id in (None, IGNORE_MARKER):
[8884]832            return None
[7626]833        # We can use the hash symbol at the end of p_id in import files
834        # to avoid annoying automatic number transformation
835        # by Excel or Calc
[8884]836        p_id = p_id.strip('#')
[9467]837        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
[8884]838            # For data migration from old SRP only
839            p_id = 'p' + p_id[7:] + '0'
840        entry = payments.get(p_id)
[7623]841        return entry
842
[9706]843    def updateEntry(self, obj, row, site, filename):
[8626]844        """Update obj to the values given in row.
845        """
846        items_changed = super(StudentOnlinePaymentProcessor, self).updateEntry(
[9706]847            obj, row, site, filename)
[8626]848        student = self.getParent(row, site).__parent__
849        student.__parent__.logger.info(
[9706]850            '%s - %s - %s - updated: %s'
851            % (self.name, filename, student.student_id, items_changed))
[8626]852        return
853
[12623]854    def samePaymentMade(self, student, category, p_session):
855        for key in student['payments'].keys():
856            ticket = student['payments'][key]
857            if ticket.p_state == 'paid' and\
858               ticket.p_category == category and \
[14053]859               ticket.p_session == p_session and \
860               ticket.p_item != 'Balance':
[12623]861                  return True
862        return False
863
[7623]864    def addEntry(self, obj, row, site):
865        parent = self.getParent(row, site)
[12623]866        student = parent.student
[7626]867        p_id = row['p_id'].strip('#')
[12623]868        # Requirement added on 19/02/2015: same payment must not exist.
[14213]869        if obj.p_item != 'Balance' and self.samePaymentMade(
870            student, obj.p_category, obj.p_session):
[13872]871            student.__parent__.logger.info(
872                '%s - %s - previous update cancelled'
873                % (self.name, student.student_id))
[12623]874            raise DuplicationError('Same payment has already been made.')
[9467]875        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
[7623]876            # For data migration from old SRP
[8884]877            obj.p_id = 'p' + p_id[7:] + '0'
[7623]878            parent[obj.p_id] = obj
879        else:
[7626]880            parent[p_id] = obj
[7623]881        return
882
[8885]883    def delEntry(self, row, site):
884        payment = self.getEntry(row, site)
885        parent = self.getParent(row, site)
886        if payment is not None:
887            student = self._getStudent(row, site)
[8886]888            student.__parent__.logger.info('%s - Payment ticket removed: %s'
[8885]889                % (student.student_id, payment.p_id))
890            del parent[payment.p_id]
[8886]891        return
[8885]892
[7623]893    def checkConversion(self, row, mode='ignore'):
894        """Validates all values in row.
895        """
[8232]896        errs, inv_errs, conv_dict = super(
897            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
898
[7623]899        # We have to check p_id.
[8884]900        p_id = row.get('p_id', None)
[12996]901        if mode == 'create' and p_id in (None, IGNORE_MARKER):
[8884]902            timestamp = ("%d" % int(time()*10000))[1:]
903            p_id = "p%s" % timestamp
904            conv_dict['p_id'] = p_id
905            return errs, inv_errs, conv_dict
[12996]906        elif p_id in (None, IGNORE_MARKER):
907            errs.append(('p_id','missing'))
908            return errs, inv_errs, conv_dict
[8884]909        else:
910            p_id = p_id.strip('#')
[12996]911            if p_id.startswith('p'):
912                if not len(p_id) == 14:
913                    errs.append(('p_id','invalid length'))
914                    return errs, inv_errs, conv_dict
915            elif len(p_id.split('-')) == 3:
916                # The SRP used either pins as keys ...
917                if len(p_id.split('-')[2]) not in (9, 10):
918                    errs.append(('p_id','invalid pin'))
919                    return errs, inv_errs, conv_dict
920            else:
921                # ... or order_ids.
922                if not len(p_id) == 19:
923                    errs.append(('p_id','invalid format'))
924                    return errs, inv_errs, conv_dict
[12513]925        # Requirement added on 24/01/2015: p_id must be portal-wide unique.
926        if mode == 'create':
927            cat = getUtility(ICatalog, name='payments_catalog')
928            results = list(cat.searchResults(p_id=(p_id, p_id)))
929            if len(results) > 0:
[13871]930                sids = [IPayer(payment).id for payment in results]
[12513]931                sids_string = ''
932                for id in sids:
933                    sids_string += '%s ' % id
934                errs.append(('p_id','p_id exists in %s' % sids_string))
935                return errs, inv_errs, conv_dict
[7623]936        return errs, inv_errs, conv_dict
[7951]937
938class StudentVerdictProcessor(StudentStudyCourseProcessor):
[12882]939    """The Student Verdict Processor inherits from the Student Study
940    Course Processor. It's a pure updater. Import step 2 raises a warning
941    message if a datacenter manager tries to select another mode.
942    But it does more than only overwriting study course attributes.
[7951]943
[12882]944    The Student Verdict Processor is the only processor which cannot be
945    used for restoring data. Purpose is to announce a verdict at the end of
946    each academic session. The processor does not only import a verdict,
947    it also conditions the student data so that the student can pay for the
948    next session and proceed to the next study level.
949
950    The `checkUpdateRequirements` method ensures that the imported data
951    really correspond to the actual state of the student.
952    `current_level` and `current_session` in the row must be on par
953    with the attributes of the study course object. Thus, the processor
954    does not use these values to overwrite the attributes of the study course
955    but to control that the verdict is really meant for the current session of
956    the student. The verdict is only imported if a corresponding study level
957    object exists and the student is in the right registration state,
958    either ``courses validated`` or ``courses registered``. Course registration
959    can be bypassed by setting `bypass_validation` to ``True``.
960
961    The `updateEntry` method does not only update the current verdict of
962    the student study course, it also updates the matching student study
963    level object. It saves the current verdict as `level_verdict` and sets
964    the `validated_by` and `validation_date` attributes, whereas `validated_by`
965    is taken from the row of the import file and `validation_date` is set to the
966    actual UTC datetime. Finally, the student is moved to state ``returning``.
[7951]967    """
968
969    util_name = 'verdictupdater'
970    grok.name(util_name)
971
[11891]972    name = _('Verdict Processor (special processor, update only)')
[7951]973    iface = IStudentVerdictUpdate
974    factory_name = 'waeup.StudentStudyCourse'
975
[14901]976    additional_fields_required = [
977        'current_level', 'current_session', 'current_verdict']
978
[7951]979    def checkUpdateRequirements(self, obj, row, site):
980        """Checks requirements the studycourse and the student must fulfill
981        before being updated.
982        """
983        # Check if current_levels correspond
984        if obj.current_level != row['current_level']:
985            return 'Current level does not correspond.'
986        # Check if current_sessions correspond
987        if obj.current_session != row['current_session']:
988            return 'Current session does not correspond.'
[9282]989        # Check if new verdict is provided
990        if row['current_verdict'] in (IGNORE_MARKER, ''):
[9293]991            return 'No verdict in import file.'
[9631]992        # Check if studylevel exists
[9293]993        level_string = str(obj.current_level)
994        if obj.get(level_string) is None:
995            return 'Study level object is missing.'
[9284]996        # Check if student is in state REGISTERED or VALIDATED
[9296]997        if row.get('bypass_validation'):
[9284]998            if obj.student.state not in (VALIDATED, REGISTERED):
999                return 'Student in wrong state.'
1000        else:
1001            if obj.student.state != VALIDATED:
1002                return 'Student in wrong state.'
[7951]1003        return None
1004
[9706]1005    def updateEntry(self, obj, row, site, filename):
[7951]1006        """Update obj to the values given in row.
1007        """
[8221]1008        # Don't set current_session, current_level
1009        vals_to_set = dict((key, val) for key, val in row.items()
1010                           if key not in ('current_session','current_level'))
[9706]1011        super(StudentVerdictProcessor, self).updateEntry(
1012            obj, vals_to_set, site, filename)
[7951]1013        parent = self.getParent(row, site)
[12882]1014        # Set current_verdict in corresponding studylevel
[9293]1015        level_string = str(obj.current_level)
1016        obj[level_string].level_verdict = row['current_verdict']
[9296]1017        # Fire transition and set studylevel attributes
1018        # depending on student's state
[9284]1019        if obj.__parent__.state == REGISTERED:
[9296]1020            validated_by = row.get('validated_by', '')
1021            if validated_by in (IGNORE_MARKER, ''):
1022                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
1023                system = translate(_('System'),'waeup.kofa',
1024                                  target_language=portal_language)
1025                obj[level_string].validated_by = system
1026            else:
1027                obj[level_string].validated_by = validated_by
1028            obj[level_string].validation_date = datetime.utcnow()
[9284]1029            IWorkflowInfo(obj.__parent__).fireTransition('bypass_validation')
1030        else:
1031            IWorkflowInfo(obj.__parent__).fireTransition('return')
[7951]1032        # Update the students_catalog
1033        notify(grok.ObjectModifiedEvent(obj.__parent__))
1034        return
Note: See TracBrowser for help on using the repository browser.