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

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

StudentOnlinePaymentProcessor?: Raise DuplicationError? if payment
(in state paid) with same p_session and p_category exists.

See ticket Uniben #970.

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