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

Last change on this file since 12880 was 12873, checked in by Henrik Bettermann, 10 years ago

Convert level into a schema field to be consistent with the documentation.

  • Property svn:keywords set to Id
File size: 37.0 KB
Line 
1## $Id: batching.py 12873 2015-04-23 19:27:29Z 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 datetime import datetime
30from zope.i18n import translate
31from zope.interface import Interface
32from zope.schema import getFields
33from zope.component import queryUtility, getUtility, createObject
34from zope.event import notify
35from zope.catalog.interfaces import ICatalog
36from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
37from waeup.kofa.interfaces import (
38    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
39    IObjectHistory, VALIDATED, REGISTERED, IGNORE_MARKER)
40from waeup.kofa.interfaces import IKofaUtils, DuplicationError
41from waeup.kofa.interfaces import MessageFactory as _
42from waeup.kofa.students.interfaces import (
43    IStudent, IStudentStudyCourse, IStudentStudyCourseTransfer,
44    IStudentUpdateByRegNo, IStudentUpdateByMatricNo,
45    IStudentStudyLevel, ICourseTicketImport,
46    IStudentOnlinePayment, IStudentVerdictUpdate)
47from waeup.kofa.students.workflow import  (
48    IMPORTABLE_STATES, IMPORTABLE_TRANSITIONS,
49    FORBIDDEN_POSTGRAD_TRANS, FORBIDDEN_POSTGRAD_STATES)
50from waeup.kofa.utils.batching import BatchProcessor
51
52class StudentProcessor(BatchProcessor):
53    """The Student Processor imports student base data.
54
55    In create mode no locator is required. If no `student_id` is given,
56    the portal automatically assigns a new student id.
57
58    In update or remove mode the processor uses
59    either the `student_id`, `reg_number` or `matric_number` to localize the
60    student object, exactly in this order. If `student_id` is given and an
61    object can be found, `reg_number` and `matric_number` will be overwritten
62    by the values provided in the import file. If `student_id` is missing,
63    `reg_number` is used to localize the object and only `matric_number`
64    will be overwritten. `matric_number` is used as locator only if both
65    `student_id` and `reg_number` are missing. `student_id` can't be changed
66    by the batch processor.
67
68    There are two ways to change the registration state of the student,
69    an unsafe and a safe way. The safe way makes use of workflow transitions.
70    Transitions are only possible between allowed workflow states. Only
71    transitions ensure that the registration workflow is maintained.
72
73    **Always prefer the safe way!**
74    """
75    grok.implements(IBatchProcessor)
76    grok.provides(IBatchProcessor)
77    grok.context(Interface)
78    util_name = 'studentprocessor'
79    grok.name(util_name)
80
81    name = _('Student Processor')
82    iface = IStudent
83    iface_byregnumber = IStudentUpdateByRegNo
84    iface_bymatricnumber = IStudentUpdateByMatricNo
85
86    factory_name = 'waeup.Student'
87
88    @property
89    def available_fields(self):
90        fields = getFields(self.iface)
91        return sorted(list(set(
92            ['student_id','reg_number','matric_number',
93            'password', 'state', 'transition'] + fields.keys())))
94
95    def checkHeaders(self, headerfields, mode='create'):
96        if 'state' in headerfields and 'transition' in headerfields:
97            raise FatalCSVError(
98                "State and transition can't be imported at the same time!")
99        if not 'reg_number' in headerfields and not 'student_id' \
100            in headerfields and not 'matric_number' in headerfields:
101            raise FatalCSVError(
102                "Need at least columns student_id or reg_number " +
103                "or matric_number for import!")
104        if mode == 'create':
105            for field in self.required_fields:
106                if not field in headerfields:
107                    raise FatalCSVError(
108                        "Need at least columns %s for import!" %
109                        ', '.join(["'%s'" % x for x in self.required_fields]))
110        # Check for fields to be ignored...
111        not_ignored_fields = [x for x in headerfields
112                              if not x.startswith('--')]
113        if len(set(not_ignored_fields)) < len(not_ignored_fields):
114            raise FatalCSVError(
115                "Double headers: each column name may only appear once.")
116        return True
117
118    def parentsExist(self, row, site):
119        return 'students' in site.keys()
120
121    def getLocator(self, row):
122        if row.get('student_id',None) not in (None, IGNORE_MARKER):
123            return 'student_id'
124        elif row.get('reg_number',None) not in (None, IGNORE_MARKER):
125            return 'reg_number'
126        elif row.get('matric_number',None) not in (None, IGNORE_MARKER):
127            return 'matric_number'
128        else:
129            return None
130
131    # The entry never exists in create mode.
132    def entryExists(self, row, site):
133        return self.getEntry(row, site) is not None
134
135    def getParent(self, row, site):
136        return site['students']
137
138    def getEntry(self, row, site):
139        if not 'students' in site.keys():
140            return None
141        if self.getLocator(row) == 'student_id':
142            if row['student_id'] in site['students']:
143                student = site['students'][row['student_id']]
144                return student
145        elif self.getLocator(row) == 'reg_number':
146            reg_number = row['reg_number']
147            cat = queryUtility(ICatalog, name='students_catalog')
148            results = list(
149                cat.searchResults(reg_number=(reg_number, reg_number)))
150            if results:
151                return results[0]
152        elif self.getLocator(row) == 'matric_number':
153            matric_number = row['matric_number']
154            cat = queryUtility(ICatalog, name='students_catalog')
155            results = list(
156                cat.searchResults(matric_number=(matric_number, matric_number)))
157            if results:
158                return results[0]
159        return None
160
161    def addEntry(self, obj, row, site):
162        parent = self.getParent(row, site)
163        parent.addStudent(obj)
164        # Reset _curr_stud_id if student_id has been imported
165        if self.getLocator(row) == 'student_id':
166            parent._curr_stud_id -= 1
167        # We have to log this if state is provided. If not,
168        # logging is done by the event handler handle_student_added
169        if 'state' in row:
170            parent.logger.info('%s - Student record created' % obj.student_id)
171        history = IObjectHistory(obj)
172        history.addMessage(_('Student record created'))
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        # XXX: Take DELETION_MARKER into consideration
217        if 'password' in row:
218            passwd = row.get('password', IGNORE_MARKER)
219            if passwd not in ('', IGNORE_MARKER):
220                if passwd.startswith('{SSHA}'):
221                    # already encrypted password
222                    obj.password = passwd
223                else:
224                    # not yet encrypted password
225                    IUserAccount(obj).setPassword(passwd)
226                items_changed += ('%s=%s, ' % ('password', passwd))
227            row.pop('password')
228
229        # Update registration state
230        if 'state' in row:
231            state = row.get('state', IGNORE_MARKER)
232            if state not in (IGNORE_MARKER, ''):
233                value = row['state']
234                IWorkflowState(obj).setState(value)
235                msg = _("State '${a}' set", mapping = {'a':value})
236                history = IObjectHistory(obj)
237                history.addMessage(msg)
238                items_changed += ('%s=%s, ' % ('state', state))
239            row.pop('state')
240
241        if 'transition' in row:
242            transition = row.get('transition', IGNORE_MARKER)
243            if transition not in (IGNORE_MARKER, ''):
244                value = row['transition']
245                IWorkflowInfo(obj).fireTransition(value)
246                items_changed += ('%s=%s, ' % ('transition', transition))
247            row.pop('transition')
248
249        # apply other values...
250        items_changed += super(StudentProcessor, self).updateEntry(
251            obj, row, site, filename)
252
253        # Log actions...
254        parent = self.getParent(row, site)
255        if hasattr(obj,'student_id'):
256            # Update mode: the student exists and we can get the student_id.
257            # Create mode: the record contains the student_id
258            parent.logger.info(
259                '%s - %s - %s - updated: %s'
260                % (self.name, filename, obj.student_id, items_changed))
261        else:
262            # Create mode: the student does not yet exist
263            # XXX: It seems that this never happens because student_id
264            # is always set.
265            parent.logger.info(
266                '%s - %s - %s - imported: %s'
267                % (self.name, filename, obj.student_id, items_changed))
268        return items_changed
269
270    def getMapping(self, path, headerfields, mode):
271        """Get a mapping from CSV file headerfields to actually used fieldnames.
272        """
273        result = dict()
274        reader = csv.reader(open(path, 'rb'))
275        raw_header = reader.next()
276        for num, field in enumerate(headerfields):
277            if field not in ['student_id', 'reg_number', 'matric_number'
278                             ] and mode == 'remove':
279                continue
280            if field == u'--IGNORE--':
281                # Skip ignored columns in failed and finished data files.
282                continue
283            result[raw_header[num]] = field
284        return result
285
286    def checkConversion(self, row, mode='create'):
287        """Validates all values in row.
288        """
289        iface = self.iface
290        if mode in ['update', 'remove']:
291            if self.getLocator(row) == 'reg_number':
292                iface = self.iface_byregnumber
293            elif self.getLocator(row) == 'matric_number':
294                iface = self.iface_bymatricnumber
295        converter = IObjectConverter(iface)
296        errs, inv_errs, conv_dict =  converter.fromStringDict(
297            row, self.factory_name, mode=mode)
298        if 'transition' in row:
299            if row['transition'] not in IMPORTABLE_TRANSITIONS:
300                if row['transition'] not in (IGNORE_MARKER, ''):
301                    errs.append(('transition','not allowed'))
302        if 'state' in row:
303            if row['state'] not in IMPORTABLE_STATES:
304                if row['state'] not in (IGNORE_MARKER, ''):
305                    errs.append(('state','not allowed'))
306                else:
307                    # State is an attribute of Student and must not
308                    # be changed if empty.
309                    conv_dict['state'] = IGNORE_MARKER
310        try:
311            # Correct stud_id counter. As the IConverter for students
312            # creates student objects that are not used afterwards, we
313            # have to fix the site-wide student_id counter.
314            site = grok.getSite()
315            students = site['students']
316            students._curr_stud_id -= 1
317        except (KeyError, TypeError, AttributeError):
318                pass
319        return errs, inv_errs, conv_dict
320
321
322class StudentProcessorBase(BatchProcessor):
323    """A base for student subitem processor.
324
325    Helps reducing redundancy.
326    """
327    grok.baseclass()
328
329    # additional available fields
330    # beside 'student_id', 'reg_number' and 'matric_number'
331    additional_fields = []
332
333    # additional required fields (subset of additional_fields)
334    additional_fields_required = []
335
336    @property
337    def available_fields(self):
338        fields = ['student_id','reg_number','matric_number'
339                  ] + self.additional_fields
340        return sorted(list(set(fields + getFields(
341                self.iface).keys())))
342
343    def checkHeaders(self, headerfields, mode='ignore'):
344        if not 'reg_number' in headerfields and not 'student_id' \
345            in headerfields and not 'matric_number' in headerfields:
346            raise FatalCSVError(
347                "Need at least columns student_id " +
348                "or reg_number or matric_number for import!")
349        for name in self.additional_fields_required:
350            if not name in headerfields:
351                raise FatalCSVError(
352                    "Need %s for import!" % name)
353
354        # Check for fields to be ignored...
355        not_ignored_fields = [x for x in headerfields
356                              if not x.startswith('--')]
357        if len(set(not_ignored_fields)) < len(not_ignored_fields):
358            raise FatalCSVError(
359                "Double headers: each column name may only appear once.")
360        return True
361
362    def _getStudent(self, row, site):
363        NON_VALUES = ['', IGNORE_MARKER]
364        if not 'students' in site.keys():
365            return None
366        if row.get('student_id', '') not in NON_VALUES:
367            if row['student_id'] in site['students']:
368                student = site['students'][row['student_id']]
369                return student
370        elif row.get('reg_number', '') not in NON_VALUES:
371            reg_number = row['reg_number']
372            cat = queryUtility(ICatalog, name='students_catalog')
373            results = list(
374                cat.searchResults(reg_number=(reg_number, reg_number)))
375            if results:
376                return results[0]
377        elif row.get('matric_number', '') not in NON_VALUES:
378            matric_number = row['matric_number']
379            cat = queryUtility(ICatalog, name='students_catalog')
380            results = list(
381                cat.searchResults(matric_number=(matric_number, matric_number)))
382            if results:
383                return results[0]
384        return None
385
386    def parentsExist(self, row, site):
387        return self.getParent(row, site) is not None
388
389    def entryExists(self, row, site):
390        return self.getEntry(row, site) is not None
391
392    def checkConversion(self, row, mode='ignore'):
393        """Validates all values in row.
394        """
395        converter = IObjectConverter(self.iface)
396        errs, inv_errs, conv_dict =  converter.fromStringDict(
397            row, self.factory_name, mode=mode)
398        return errs, inv_errs, conv_dict
399
400    def getMapping(self, path, headerfields, mode):
401        """Get a mapping from CSV file headerfields to actually used fieldnames.
402        """
403        result = dict()
404        reader = csv.reader(open(path, 'rb'))
405        raw_header = reader.next()
406        for num, field in enumerate(headerfields):
407            if field not in ['student_id', 'reg_number', 'matric_number',
408                             'p_id', 'code', 'level'
409                             ] and mode == 'remove':
410                continue
411            if field == u'--IGNORE--':
412                # Skip ignored columns in failed and finished data files.
413                continue
414            result[raw_header[num]] = field
415        return result
416
417
418class StudentStudyCourseProcessor(StudentProcessorBase):
419    """The Student Study Course Processor imports data which refer
420    to the student's course of study. The study course container data
421    describe the current state of the course of study and it stores the
422    entry conditions, i.e. when the student started the course.
423
424    Most important is the `certificate` attribute which tells us which course
425    the student is studying. The terms 'study course' and 'course of study'
426    are used synonymously. The 'certificate' is the study programme described
427    in the acadmic section. The study course object stores a referrer to a
428    certificate in the acadmic section.
429
430    When importing a new certificate code, `checkConversion` does not only
431    check whether a certificate with the same code exists, it also
432    proves if `current_level` is inside the level range of the certificate.
433    For example, some study programmes start at level 200. The imported
434    current level must thus be 200 or higher.
435
436    `checkUpdateRequirements` looks up if the imported values match the
437    certificate already stored with the study course object. The imported
438    `current_level` must be in the range of the certificate already
439    stored.
440
441    .. note::
442
443      The processor does only offer an update mode. An 'empty' study course
444      object is automatically created when the student object is added. So this
445      object always exists. It can neither be added a second time nor
446      be removed.
447
448    Students can be transferred by import. A transfer is initialized if the
449    `entry_mode` value is ``transfer``. In this case `checkConversion` uses a
450    different interface for data validation and `checkUpdateRequirements`
451    ensures that a student can only be transferred twice. The student transfer
452    process is described elsewhere.
453    """
454    grok.implements(IBatchProcessor)
455    grok.provides(IBatchProcessor)
456    grok.context(Interface)
457    util_name = 'studycourseupdater'
458    grok.name(util_name)
459
460    name = _('StudentStudyCourse Processor (update only)')
461    iface = IStudentStudyCourse
462    iface_transfer = IStudentStudyCourseTransfer
463    factory_name = 'waeup.StudentStudyCourse'
464
465    def getParent(self, row, site):
466        return self._getStudent(row, site)
467
468    def getEntry(self, row, site):
469        student = self.getParent(row, site)
470        if student is None:
471            return None
472        return student.get('studycourse')
473
474    def updateEntry(self, obj, row, site, filename):
475        """Update obj to the values given in row.
476        """
477        entry_mode = row.get('entry_mode', None)
478        certificate = row.get('certificate', None)
479        current_session = row.get('current_session', None)
480        student = self.getParent(row, site)
481        if entry_mode == 'transfer':
482            # We do not expect any error here since we
483            # checked all constraints in checkConversion and
484            # in checkUpdateRequirements
485            student.transfer(
486                certificate=certificate, current_session=current_session)
487            obj = student['studycourse']
488        items_changed = super(StudentStudyCourseProcessor, self).updateEntry(
489            obj, row, site, filename)
490        student.__parent__.logger.info(
491            '%s - %s - %s - updated: %s'
492            % (self.name, filename, student.student_id, items_changed))
493        # Update the students_catalog
494        notify(grok.ObjectModifiedEvent(student))
495        return
496
497    def checkConversion(self, row, mode='ignore'):
498        """Validates all values in row.
499        """
500        # We have to use the correct interface. Transfer
501        # updates have different constraints.
502        entry_mode = row.get('entry_mode', None)
503        if entry_mode == 'transfer':
504            converter = IObjectConverter(self.iface_transfer)
505        else:
506            converter = IObjectConverter(self.iface)
507        errs, inv_errs, conv_dict =  converter.fromStringDict(
508            row, self.factory_name, mode=mode)
509
510        # We have to check if current_level is in range of certificate.
511        if 'certificate' in conv_dict and 'current_level' in conv_dict:
512            cert = conv_dict['certificate']
513            level = conv_dict['current_level']
514            if level < cert.start_level or level > cert.end_level+120:
515                errs.append(('current_level','not in range'))
516        return errs, inv_errs, conv_dict
517
518    def checkUpdateRequirements(self, obj, row, site):
519        """Checks requirements the object must fulfill when being updated.
520        Returns error messages as strings in case of requirement
521        problems.
522        """
523        certificate = getattr(obj, 'certificate', None)
524        entry_session = getattr(obj, 'entry_session', None)
525        current_level = row.get('current_level', None)
526        entry_mode = row.get('entry_mode', None)
527        # We have to ensure that the student can be transferred.
528        if entry_mode == 'transfer':
529            if certificate is None or entry_session is None:
530                return 'Former study course record incomplete.'
531            if 'studycourse_1' in obj.__parent__.keys() and \
532                'studycourse_2' in obj.__parent__.keys():
533                return 'Maximum number of transfers exceeded.'
534        if current_level:
535            if current_level == 999 and \
536                obj.__parent__.state in FORBIDDEN_POSTGRAD_STATES:
537                return 'Not a pg student.'
538            cert = row.get('certificate', None)
539            if certificate is None and cert is None:
540                return 'No certificate to check level.'
541            if certificate is not None and cert is None and (
542                current_level < certificate.start_level or \
543                current_level > certificate.end_level+120):
544                return 'current_level not in range.'
545        return None
546
547class StudentStudyLevelProcessor(StudentProcessorBase):
548    """The Student Study Level Processor imports study level data.
549    It overwrites the container attributes but not the content of the container,
550    i.e. the course tickets stored inside the container. There is nothing
551    special about this processor.
552    """
553    grok.implements(IBatchProcessor)
554    grok.provides(IBatchProcessor)
555    grok.context(Interface)
556    util_name = 'studylevelprocessor'
557    grok.name(util_name)
558
559    name = _('StudentStudyLevel Processor')
560    iface = IStudentStudyLevel
561    factory_name = 'waeup.StudentStudyLevel'
562
563    @property
564    def available_fields(self):
565        fields = super(StudentStudyLevelProcessor, self).available_fields
566        fields.remove('total_credits')
567        fields.remove('gpa')
568        return  fields
569
570    def getParent(self, row, site):
571        student = self._getStudent(row, site)
572        if student is None:
573            return None
574        return student['studycourse']
575
576    def getEntry(self, row, site):
577        studycourse = self.getParent(row, site)
578        if studycourse is None:
579            return None
580        return studycourse.get(str(row['level']))
581
582    def delEntry(self, row, site):
583        studylevel = self.getEntry(row, site)
584        parent = self.getParent(row, site)
585        if studylevel is not None:
586            student = self._getStudent(row, site)
587            student.__parent__.logger.info('%s - Level removed: %s'
588                % (student.student_id, studylevel.__name__))
589            del parent[studylevel.__name__]
590        return
591
592    def updateEntry(self, obj, row, site, filename):
593        """Update obj to the values given in row.
594        """
595        items_changed = super(StudentStudyLevelProcessor, self).updateEntry(
596            obj, row, site, filename)
597        student = self.getParent(row, site).__parent__
598        student.__parent__.logger.info(
599            '%s - %s - %s - updated: %s'
600            % (self.name, filename, student.student_id, items_changed))
601        return
602
603    def addEntry(self, obj, row, site):
604        parent = self.getParent(row, site)
605        parent[str(row['level'])] = obj
606        return
607
608class CourseTicketProcessor(StudentProcessorBase):
609    """A batch processor for ICourseTicket objects.
610    """
611    grok.implements(IBatchProcessor)
612    grok.provides(IBatchProcessor)
613    grok.context(Interface)
614    util_name = 'courseticketprocessor'
615    grok.name(util_name)
616
617    name = _('CourseTicket Processor')
618    iface = ICourseTicketImport
619    factory_name = 'waeup.CourseTicket'
620
621    additional_fields = ['level', 'code']
622    additional_fields_required = additional_fields
623
624    @property
625    def available_fields(self):
626        fields = [
627            'student_id','reg_number','matric_number',
628            'mandatory', 'score', 'carry_over', 'automatic',
629            'level_session'
630            ] + self.additional_fields
631        return sorted(fields)
632
633    def getParent(self, row, site):
634        student = self._getStudent(row, site)
635        if student is None:
636            return None
637        return student['studycourse'].get(str(row['level']))
638
639    def getEntry(self, row, site):
640        level = self.getParent(row, site)
641        if level is None:
642            return None
643        return level.get(row['code'])
644
645    def updateEntry(self, obj, row, site, filename):
646        """Update obj to the values given in row.
647        """
648        items_changed = super(CourseTicketProcessor, self).updateEntry(
649            obj, row, site, filename)
650        parent = self.getParent(row, site)
651        student = self.getParent(row, site).__parent__.__parent__
652        student.__parent__.logger.info(
653            '%s - %s - %s - %s - updated: %s'
654            % (self.name, filename, student.student_id, parent.level, items_changed))
655        return
656
657    def addEntry(self, obj, row, site):
658        parent = self.getParent(row, site)
659        catalog = getUtility(ICatalog, name='courses_catalog')
660        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
661        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
662        obj.dcode = entries[0].__parent__.__parent__.code
663        obj.title = entries[0].title
664        #if getattr(obj, 'credits', None) is None:
665        obj.credits = entries[0].credits
666        #if getattr(obj, 'passmark', None) is None:
667        obj.passmark = entries[0].passmark
668        obj.semester = entries[0].semester
669        parent[row['code']] = obj
670        return
671
672    def delEntry(self, row, site):
673        ticket = self.getEntry(row, site)
674        parent = self.getParent(row, site)
675        if ticket is not None:
676            student = self._getStudent(row, site)
677            student.__parent__.logger.info('%s - Course ticket in %s removed: %s'
678                % (student.student_id, parent.level, ticket.code))
679            del parent[ticket.code]
680        return
681
682    def checkConversion(self, row, mode='ignore'):
683        """Validates all values in row.
684        """
685        errs, inv_errs, conv_dict = super(
686            CourseTicketProcessor, self).checkConversion(row, mode=mode)
687        if mode == 'remove':
688            return errs, inv_errs, conv_dict
689        # In update and create mode we have to check if course really exists.
690        # This is not done by the converter.
691        catalog = getUtility(ICatalog, name='courses_catalog')
692        entries = catalog.searchResults(code=(row['code'],row['code']))
693        if len(entries) == 0:
694            errs.append(('code','non-existent'))
695            return errs, inv_errs, conv_dict
696        # If level_session is provided in row we have to check if
697        # the parent studylevel exists and if its level_session
698        # attribute corresponds with the expected value in row.
699        level_session = conv_dict.get('level_session', IGNORE_MARKER)
700        if level_session not in (IGNORE_MARKER, None):
701            site = grok.getSite()
702            studylevel = self.getParent(row, site)
703            if studylevel is not None:
704                if studylevel.level_session != level_session:
705                    errs.append(('level_session','does not match %s'
706                        % studylevel.level_session))
707            else:
708                errs.append(('level','does not exist'))
709        return errs, inv_errs, conv_dict
710
711class StudentOnlinePaymentProcessor(StudentProcessorBase):
712    """A batch processor for IStudentOnlinePayment objects.
713    """
714    grok.implements(IBatchProcessor)
715    grok.provides(IBatchProcessor)
716    grok.context(Interface)
717    util_name = 'paymentprocessor'
718    grok.name(util_name)
719
720    name = _('StudentOnlinePayment Processor')
721    iface = IStudentOnlinePayment
722    factory_name = 'waeup.StudentOnlinePayment'
723
724    additional_fields = ['p_id']
725
726    def checkHeaders(self, headerfields, mode='ignore'):
727        super(StudentOnlinePaymentProcessor, self).checkHeaders(headerfields)
728        if mode in ('update', 'remove') and not 'p_id' in headerfields:
729            raise FatalCSVError(
730                "Need p_id for import in update and remove modes!")
731        return True
732
733    def parentsExist(self, row, site):
734        return self.getParent(row, site) is not None
735
736    def getParent(self, row, site):
737        student = self._getStudent(row, site)
738        if student is None:
739            return None
740        return student['payments']
741
742    def getEntry(self, row, site):
743        payments = self.getParent(row, site)
744        if payments is None:
745            return None
746        p_id = row.get('p_id', None)
747        if p_id is None:
748            return None
749        # We can use the hash symbol at the end of p_id in import files
750        # to avoid annoying automatic number transformation
751        # by Excel or Calc
752        p_id = p_id.strip('#')
753        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
754            # For data migration from old SRP only
755            p_id = 'p' + p_id[7:] + '0'
756        entry = payments.get(p_id)
757        return entry
758
759    def updateEntry(self, obj, row, site, filename):
760        """Update obj to the values given in row.
761        """
762        items_changed = super(StudentOnlinePaymentProcessor, self).updateEntry(
763            obj, row, site, filename)
764        student = self.getParent(row, site).__parent__
765        student.__parent__.logger.info(
766            '%s - %s - %s - updated: %s'
767            % (self.name, filename, student.student_id, items_changed))
768        return
769
770    def samePaymentMade(self, student, category, p_session):
771        for key in student['payments'].keys():
772            ticket = student['payments'][key]
773            if ticket.p_state == 'paid' and\
774               ticket.p_category == category and \
775               ticket.p_session == p_session:
776                  return True
777        return False
778
779    def addEntry(self, obj, row, site):
780        parent = self.getParent(row, site)
781        student = parent.student
782        p_id = row['p_id'].strip('#')
783        # Requirement added on 19/02/2015: same payment must not exist.
784        #if not None in (obj.p_category, obj.p_session):
785        if self.samePaymentMade(student, obj.p_category, obj.p_session):
786            raise DuplicationError('Same payment has already been made.')
787        if len(p_id.split('-')) != 3 and not p_id.startswith('p'):
788            # For data migration from old SRP
789            obj.p_id = 'p' + p_id[7:] + '0'
790            parent[obj.p_id] = obj
791        else:
792            parent[p_id] = obj
793        return
794
795    def delEntry(self, row, site):
796        payment = self.getEntry(row, site)
797        parent = self.getParent(row, site)
798        if payment is not None:
799            student = self._getStudent(row, site)
800            student.__parent__.logger.info('%s - Payment ticket removed: %s'
801                % (student.student_id, payment.p_id))
802            del parent[payment.p_id]
803        return
804
805    def checkConversion(self, row, mode='ignore'):
806        """Validates all values in row.
807        """
808        errs, inv_errs, conv_dict = super(
809            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
810
811        # We have to check p_id.
812        p_id = row.get('p_id', None)
813        if not p_id:
814            timestamp = ("%d" % int(time()*10000))[1:]
815            p_id = "p%s" % timestamp
816            conv_dict['p_id'] = p_id
817            return errs, inv_errs, conv_dict
818        else:
819            p_id = p_id.strip('#')
820        if p_id.startswith('p'):
821            if not len(p_id) == 14:
822                errs.append(('p_id','invalid length'))
823                return errs, inv_errs, conv_dict
824        elif len(p_id.split('-')) == 3:
825            # The SRP used either pins as keys ...
826            if len(p_id.split('-')[2]) not in (9, 10):
827                errs.append(('p_id','invalid pin'))
828                return errs, inv_errs, conv_dict
829        else:
830            # ... or order_ids.
831            if not len(p_id) == 19:
832                errs.append(('p_id','invalid format'))
833                return errs, inv_errs, conv_dict
834        # Requirement added on 24/01/2015: p_id must be portal-wide unique.
835        if mode == 'create':
836            cat = getUtility(ICatalog, name='payments_catalog')
837            results = list(cat.searchResults(p_id=(p_id, p_id)))
838            if len(results) > 0:
839                sids = [payment.student.student_id for payment in results]
840                sids_string = ''
841                for id in sids:
842                    sids_string += '%s ' % id
843                errs.append(('p_id','p_id exists in %s' % sids_string))
844                return errs, inv_errs, conv_dict
845        return errs, inv_errs, conv_dict
846
847class StudentVerdictProcessor(StudentStudyCourseProcessor):
848    """A special batch processor for verdicts.
849
850    Import verdicts and perform workflow transitions.
851    """
852
853    util_name = 'verdictupdater'
854    grok.name(util_name)
855
856    name = _('Verdict Processor (special processor, update only)')
857    iface = IStudentVerdictUpdate
858    factory_name = 'waeup.StudentStudyCourse'
859
860    def checkUpdateRequirements(self, obj, row, site):
861        """Checks requirements the studycourse and the student must fulfill
862        before being updated.
863        """
864        # Check if current_levels correspond
865        if obj.current_level != row['current_level']:
866            return 'Current level does not correspond.'
867        # Check if current_sessions correspond
868        if obj.current_session != row['current_session']:
869            return 'Current session does not correspond.'
870        # Check if new verdict is provided
871        if row['current_verdict'] in (IGNORE_MARKER, ''):
872            return 'No verdict in import file.'
873        # Check if studylevel exists
874        level_string = str(obj.current_level)
875        if obj.get(level_string) is None:
876            return 'Study level object is missing.'
877        # Check if student is in state REGISTERED or VALIDATED
878        if row.get('bypass_validation'):
879            if obj.student.state not in (VALIDATED, REGISTERED):
880                return 'Student in wrong state.'
881        else:
882            if obj.student.state != VALIDATED:
883                return 'Student in wrong state.'
884        return None
885
886    def updateEntry(self, obj, row, site, filename):
887        """Update obj to the values given in row.
888        """
889        # Don't set current_session, current_level
890        vals_to_set = dict((key, val) for key, val in row.items()
891                           if key not in ('current_session','current_level'))
892        super(StudentVerdictProcessor, self).updateEntry(
893            obj, vals_to_set, site, filename)
894        parent = self.getParent(row, site)
895        # Set current_vedict in corresponding studylevel
896        level_string = str(obj.current_level)
897        obj[level_string].level_verdict = row['current_verdict']
898        # Fire transition and set studylevel attributes
899        # depending on student's state
900        if obj.__parent__.state == REGISTERED:
901            validated_by = row.get('validated_by', '')
902            if validated_by in (IGNORE_MARKER, ''):
903                portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
904                system = translate(_('System'),'waeup.kofa',
905                                  target_language=portal_language)
906                obj[level_string].validated_by = system
907            else:
908                obj[level_string].validated_by = validated_by
909            obj[level_string].validation_date = datetime.utcnow()
910            IWorkflowInfo(obj.__parent__).fireTransition('bypass_validation')
911        else:
912            IWorkflowInfo(obj.__parent__).fireTransition('return')
913        # Update the students_catalog
914        notify(grok.ObjectModifiedEvent(obj.__parent__))
915        return
Note: See TracBrowser for help on using the repository browser.