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

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

Add importers for previous study course data.

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