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

Last change on this file since 16831 was 16831, checked in by Henrik Bettermann, 3 years ago

Improve export and reimport of previous study course data.

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