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

Last change on this file since 8339 was 8309, checked in by Henrik Bettermann, 13 years ago

Implement import of student transitions.

  • Property svn:keywords set to Id
File size: 22.5 KB
RevLine 
[7191]1## $Id: batching.py 8309 2012-04-29 12:55:52Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
[7433]18"""Batch processing components for student objects.
[6821]19
20Batch processors eat CSV files to add, update or remove large numbers
21of certain kinds of objects at once.
22
[7261]23Here we define the processors for students specific objects like
24students, studycourses, payment tickets and accommodation tickets.
[6821]25"""
26import grok
[6849]27import csv
[6821]28from zope.interface import Interface
[6825]29from zope.schema import getFields
[7548]30from zope.component import queryUtility, getUtility
[7429]31from zope.event import notify
[6825]32from zope.catalog.interfaces import ICatalog
[7951]33from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
[7811]34from waeup.kofa.interfaces import (
[7522]35    IBatchProcessor, FatalCSVError, IObjectConverter, IUserAccount,
[8300]36    IObjectHistory, VALIDATED, IGNORE_MARKER)
[7959]37from waeup.kofa.interfaces import MessageFactory as _
[7811]38from waeup.kofa.students.interfaces import (
[7532]39    IStudent, IStudentStudyCourse,
[7536]40    IStudentUpdateByRegNo, IStudentUpdateByMatricNo,
[7623]41    IStudentStudyLevel, ICourseTicket,
[8174]42    IStudentOnlinePayment, IStudentVerdictUpdate)
[8309]43from waeup.kofa.students.workflow import  (
44    IMPORTABLE_STATES, IMPORTABLE_TRANSITIONS)
[7811]45from waeup.kofa.utils.batching import BatchProcessor
[6821]46
47class StudentProcessor(BatchProcessor):
48    """A batch processor for IStudent objects.
49    """
50    grok.implements(IBatchProcessor)
51    grok.provides(IBatchProcessor)
52    grok.context(Interface)
[7933]53    util_name = 'studentprocessor'
[6821]54    grok.name(util_name)
55
[7933]56    name = u'Student Processor'
[6821]57    iface = IStudent
58
[6849]59    location_fields = []
[6821]60    factory_name = 'waeup.Student'
61
62    @property
[6849]63    def available_fields(self):
[8176]64        fields = getFields(self.iface)
[6849]65        return sorted(list(set(
[7513]66            ['student_id','reg_number','matric_number',
[8309]67            'password', 'state', 'transition'] + fields.keys())))
[6821]68
[6849]69    def checkHeaders(self, headerfields, mode='create'):
[8309]70        if 'state' in headerfields and 'transition' in headerfields:
71            raise FatalCSVError(
72                "State and transition can't be  imported at the same time!")
[6854]73        if not 'reg_number' in headerfields and not 'student_id' \
74            in headerfields and not 'matric_number' in headerfields:
[6849]75            raise FatalCSVError(
[6854]76                "Need at least columns student_id or reg_number " +
77                "or matric_number for import!")
[6849]78        if mode == 'create':
79            for field in self.required_fields:
80                if not field in headerfields:
81                    raise FatalCSVError(
82                        "Need at least columns %s for import!" %
83                        ', '.join(["'%s'" % x for x in self.required_fields]))
84        # Check for fields to be ignored...
85        not_ignored_fields = [x for x in headerfields
86                              if not x.startswith('--')]
87        if len(set(not_ignored_fields)) < len(not_ignored_fields):
88            raise FatalCSVError(
89                "Double headers: each column name may only appear once.")
90        return True
91
[6821]92    def parentsExist(self, row, site):
93        return 'students' in site.keys()
94
[6849]95    def getLocator(self, row):
[8232]96        if row.get('student_id',None) not in (None, IGNORE_MARKER):
[6849]97            return 'student_id'
[8232]98        elif row.get('reg_number',None) not in (None, IGNORE_MARKER):
[6849]99            return 'reg_number'
[8232]100        elif row.get('matric_number',None) not in (None, IGNORE_MARKER):
[6849]101            return 'matric_number'
102        else:
103            return None
104
[6821]105    # The entry never exists in create mode.
106    def entryExists(self, row, site):
[7267]107        return self.getEntry(row, site) is not None
108
109    def getParent(self, row, site):
110        return site['students']
111
112    def getEntry(self, row, site):
[6846]113        if not 'students' in site.keys():
[6849]114            return None
115        if self.getLocator(row) == 'student_id':
[6846]116            if row['student_id'] in site['students']:
117                student = site['students'][row['student_id']]
118                return student
[6849]119        elif self.getLocator(row) == 'reg_number':
[6846]120            reg_number = row['reg_number']
121            cat = queryUtility(ICatalog, name='students_catalog')
122            results = list(
123                cat.searchResults(reg_number=(reg_number, reg_number)))
124            if results:
125                return results[0]
[6849]126        elif self.getLocator(row) == 'matric_number':
[6846]127            matric_number = row['matric_number']
128            cat = queryUtility(ICatalog, name='students_catalog')
129            results = list(
130                cat.searchResults(matric_number=(matric_number, matric_number)))
131            if results:
132                return results[0]
[6849]133        return None
[6821]134
135    def addEntry(self, obj, row, site):
136        parent = self.getParent(row, site)
137        parent.addStudent(obj)
[8287]138        # We have to log this if state is provided. If not,
[7959]139        # logging is done by the event handler handle_student_added
[8287]140        if row.has_key('state'):
[7959]141            parent.logger.info('%s - Student record created' % obj.student_id)
[7656]142        history = IObjectHistory(obj)
[7959]143        history.addMessage(_('Student record created'))
[6821]144        return
145
146    def delEntry(self, row, site):
[7267]147        student = self.getEntry(row, site)
[7263]148        if student is not None:
[6846]149            parent = self.getParent(row, site)
[7656]150            parent.logger.info('%s - Student removed' % student.student_id)
[6846]151            del parent[student.student_id]
[6821]152        pass
[6825]153
[8309]154    def checkUpdateRequirements(self, obj, row, site):
155        """Checks requirements the object must fulfill when being updated.
156
157        This method is not used in case of deleting or adding objects.
158
159        Returns error messages as strings in case of requirement
160        problems.
161        """
162        transition = row.get('transition', IGNORE_MARKER)
163        if transition not in (IGNORE_MARKER, ''):
164            allowed_transitions = IWorkflowInfo(obj).getManualTransitionIds()
165            if transition not in allowed_transitions:
166                return 'Transition not allowed.'
167        return None
168
[7497]169    def updateEntry(self, obj, row, site):
170        """Update obj to the values given in row.
171        """
[8221]172        items_changed = ''
173
[7643]174        # Remove student_id from row if empty
[8232]175        if row.has_key('student_id') and row['student_id'] in (
176            None, IGNORE_MARKER):
[7643]177            row.pop('student_id')
[8221]178
179        # Update password
[8232]180        passwd = row.get('password', IGNORE_MARKER)
181        if passwd not in ('', IGNORE_MARKER):
[8221]182            IUserAccount(obj).setPassword(passwd)
183            row.pop('password')
184
185        # Update registration state
[8287]186        state = row.get('state', IGNORE_MARKER)
187        if state not in (IGNORE_MARKER, ''):
188            value = row['state']
[8221]189            IWorkflowState(obj).setState(value)
190            msg = _("State '${a}' set", mapping = {'a':value})
191            history = IObjectHistory(obj)
192            history.addMessage(msg)
[8287]193            row.pop('state')
[8309]194        transition = row.get('transition', IGNORE_MARKER)
195        if transition not in (IGNORE_MARKER, ''):
196            value = row['transition']
197            IWorkflowInfo(obj).fireTransition(value)
198            row.pop('transition')
[8221]199
200        # apply other values...
201        items_changed = super(StudentProcessor, self).updateEntry(
202            obj, row, site)
203
204        # Log actions...
[7656]205        parent = self.getParent(row, site)
206        if hasattr(obj,'student_id'):
207            # Update mode: the student exists and we can get the student_id
208            parent.logger.info(
209                '%s - Student record updated: %s'
210                % (obj.student_id, items_changed))
211        else:
212            # Create mode: the student does not yet exist
213            parent.logger.info('Student data imported: %s' % items_changed)
[8221]214        return items_changed
[7497]215
[6849]216    def getMapping(self, path, headerfields, mode):
217        """Get a mapping from CSV file headerfields to actually used fieldnames.
218        """
219        result = dict()
220        reader = csv.reader(open(path, 'rb'))
221        raw_header = reader.next()
222        for num, field in enumerate(headerfields):
[8221]223            if field not in ['student_id', 'reg_number', 'matric_number'
224                             ] and mode == 'remove':
[6849]225                continue
226            if field == u'--IGNORE--':
227                # Skip ignored columns in failed and finished data files.
228                continue
229            result[raw_header[num]] = field
230        return result
231
232    def checkConversion(self, row, mode='create'):
233        """Validates all values in row.
234        """
[7643]235        iface = self.iface
[6849]236        if mode in ['update', 'remove']:
237            if self.getLocator(row) == 'reg_number':
238                iface = IStudentUpdateByRegNo
239            elif self.getLocator(row) == 'matric_number':
240                iface = IStudentUpdateByMatricNo
241        converter = IObjectConverter(iface)
242        errs, inv_errs, conv_dict =  converter.fromStringDict(
[8214]243            row, self.factory_name, mode=mode)
[8309]244        if row.has_key('transition') and \
245            not row['transition'] in IMPORTABLE_TRANSITIONS:
246            if row['transition'] not in (IGNORE_MARKER, ''):
247                errs.append(('transition','not allowed'))
[8287]248        if row.has_key('state') and \
249            not row['state'] in IMPORTABLE_STATES:
250            if row['state'] not in (IGNORE_MARKER, ''):
251                errs.append(('state','not allowed'))
252            else:
253                # state is an attribute of Student and must not
254                # be changed if empty
255                conv_dict['state'] = IGNORE_MARKER
[6849]256        return errs, inv_errs, conv_dict
257
[8232]258
259class StudentProcessorBase(BatchProcessor):
260    """A base for student subitem processor.
261
262    Helps reducing redundancy.
[6825]263    """
[8232]264    grok.baseclass()
[6825]265
[8232]266    #: required fields beside 'student_id', 'reg_number' and 'matric_number'
267    additional_fields = []
[6825]268
[8232]269    #: header fields additional required
270    additional_headers = []
[6849]271
[6825]272    @property
273    def available_fields(self):
[8232]274        fields = ['student_id','reg_number','matric_number'
275                  ] + self.additional_fields
276        return sorted(list(set(fields + getFields(
[6843]277                self.iface).keys())))
[6825]278
[6837]279    def checkHeaders(self, headerfields, mode='ignore'):
[6854]280        if not 'reg_number' in headerfields and not 'student_id' \
281            in headerfields and not 'matric_number' in headerfields:
[6825]282            raise FatalCSVError(
[6854]283                "Need at least columns student_id " +
284                "or reg_number or matric_number for import!")
[8232]285        for name in self.additional_headers:
286            if not name in headerfields:
287                raise FatalCSVError(
288                    "Need %s for import!" % name)
289
[6834]290        # Check for fields to be ignored...
[6825]291        not_ignored_fields = [x for x in headerfields
292                              if not x.startswith('--')]
293        if len(set(not_ignored_fields)) < len(not_ignored_fields):
294            raise FatalCSVError(
295                "Double headers: each column name may only appear once.")
296        return True
297
[8232]298    def _getStudent(self, row, site):
[8225]299        NON_VALUES = ['', IGNORE_MARKER]
[6846]300        if not 'students' in site.keys():
[6849]301            return None
[8225]302        if row.get('student_id', '') not in NON_VALUES:
[6825]303            if row['student_id'] in site['students']:
304                student = site['students'][row['student_id']]
305                return student
[8225]306        elif row.get('reg_number', '') not in NON_VALUES:
[6825]307            reg_number = row['reg_number']
308            cat = queryUtility(ICatalog, name='students_catalog')
309            results = list(
310                cat.searchResults(reg_number=(reg_number, reg_number)))
311            if results:
312                return results[0]
[8225]313        elif row.get('matric_number', '') not in NON_VALUES:
[6843]314            matric_number = row['matric_number']
315            cat = queryUtility(ICatalog, name='students_catalog')
316            results = list(
317                cat.searchResults(matric_number=(matric_number, matric_number)))
318            if results:
319                return results[0]
[6849]320        return None
[6825]321
[7267]322    def parentsExist(self, row, site):
[8232]323        result = self.getParent(row, site) is not None
[7267]324        return self.getParent(row, site) is not None
325
[6825]326    def entryExists(self, row, site):
[7534]327        return self.getEntry(row, site) is not None
[6825]328
[8232]329    def checkConversion(self, row, mode='ignore'):
330        """Validates all values in row.
331        """
332        converter = IObjectConverter(self.iface)
333        errs, inv_errs, conv_dict =  converter.fromStringDict(
334            row, self.factory_name, mode=mode)
335        return errs, inv_errs, conv_dict
336
337
338class StudentStudyCourseProcessor(StudentProcessorBase):
339    """A batch processor for IStudentStudyCourse objects.
340    """
341    grok.implements(IBatchProcessor)
342    grok.provides(IBatchProcessor)
343    grok.context(Interface)
344    util_name = 'studycourseupdater'
345    grok.name(util_name)
346
347    name = u'StudentStudyCourse Processor (update only)'
348    iface = IStudentStudyCourse
349    factory_name = 'waeup.StudentStudyCourse'
350
351    location_fields = []
352    additional_fields = []
353
354    def getParent(self, row, site):
355        return self._getStudent(row, site)
356
[6825]357    def getEntry(self, row, site):
[7534]358        student = self.getParent(row, site)
[7536]359        if student is None:
[6825]360            return None
361        return student.get('studycourse')
[7429]362
363    def updateEntry(self, obj, row, site):
364        """Update obj to the values given in row.
365        """
[8221]366        items_changed = super(StudentStudyCourseProcessor, self).updateEntry(
367            obj, row, site)
[7656]368        parent = self.getParent(row, site)
369        parent.__parent__.logger.info(
370            '%s - Study course updated: %s'
371            % (parent.student_id, items_changed))
[7429]372        # Update the students_catalog
373        notify(grok.ObjectModifiedEvent(obj.__parent__))
374        return
375
[7532]376    def checkConversion(self, row, mode='ignore'):
377        """Validates all values in row.
378        """
[8232]379        errs, inv_errs, conv_dict = super(
380            StudentStudyCourseProcessor, self).checkConversion(row, mode=mode)
[7532]381        # We have to check if current_level is in range of certificate.
[7534]382        if conv_dict.has_key('certificate'):
[8221]383          cert = conv_dict['certificate']
384          if conv_dict['current_level'] < cert.start_level or \
385              conv_dict['current_level'] > cert.end_level+120:
[7534]386              errs.append(('current_level','not in range'))
[7532]387        return errs, inv_errs, conv_dict
388
[8232]389class StudentStudyLevelProcessor(StudentProcessorBase):
[7536]390    """A batch processor for IStudentStudyLevel objects.
391    """
392    grok.implements(IBatchProcessor)
393    grok.provides(IBatchProcessor)
394    grok.context(Interface)
[7933]395    util_name = 'studylevelprocessor'
[7536]396    grok.name(util_name)
397
[7933]398    name = u'StudentStudyLevel Processor'
[7536]399    iface = IStudentStudyLevel
400    factory_name = 'waeup.StudentStudyLevel'
401
402    location_fields = []
[8232]403    additional_fields = ['level']
404    additional_headers = ['level']
[7536]405
406    def getParent(self, row, site):
[8232]407        student = self._getStudent(row, site)
408        if student is None:
[7536]409            return None
[8232]410        return student['studycourse']
[7536]411
412    def getEntry(self, row, site):
413        studycourse = self.getParent(row, site)
414        if studycourse is None:
415            return None
416        return studycourse.get(row['level'])
417
418    def addEntry(self, obj, row, site):
419        parent = self.getParent(row, site)
420        obj.level = int(row['level'])
421        parent[row['level']] = obj
422        return
423
424    def checkConversion(self, row, mode='ignore'):
425        """Validates all values in row.
426        """
[8232]427        errs, inv_errs, conv_dict = super(
428            StudentStudyLevelProcessor, self).checkConversion(row, mode=mode)
429
[7536]430        # We have to check if level is a valid integer.
[7548]431        # This is not done by the converter.
[7536]432        try:
433            level = int(row['level'])
[7612]434            if level not in range(0,700,10):
[7536]435                errs.append(('level','no valid integer'))
436        except ValueError:
437            errs.append(('level','no integer'))
438        return errs, inv_errs, conv_dict
[7548]439
[8232]440class CourseTicketProcessor(StudentProcessorBase):
[7548]441    """A batch processor for ICourseTicket objects.
442    """
443    grok.implements(IBatchProcessor)
444    grok.provides(IBatchProcessor)
445    grok.context(Interface)
[7933]446    util_name = 'courseticketprocessor'
[7548]447    grok.name(util_name)
448
[7933]449    name = u'CourseTicket Processor'
[7548]450    iface = ICourseTicket
451    factory_name = 'waeup.CourseTicket'
452
453    location_fields = []
[8232]454    additional_fields = ['level', 'code']
455    additional_headers = ['level', 'code']
[7548]456
457    def getParent(self, row, site):
[8232]458        student = self._getStudent(row, site)
459        if student is None:
[7548]460            return None
[8232]461        return student['studycourse'].get(row['level'])
[7548]462
463    def getEntry(self, row, site):
464        level = self.getParent(row, site)
465        if level is None:
466            return None
467        return level.get(row['code'])
468
469    def addEntry(self, obj, row, site):
470        parent = self.getParent(row, site)
471        catalog = getUtility(ICatalog, name='courses_catalog')
472        entries = list(catalog.searchResults(code=(row['code'],row['code'])))
473        obj.fcode = entries[0].__parent__.__parent__.__parent__.code
474        obj.dcode = entries[0].__parent__.__parent__.code
475        obj.title = entries[0].title
476        obj.credits = entries[0].credits
477        obj.passmark = entries[0].passmark
478        obj.semester = entries[0].semester
479        parent[row['code']] = obj
480        return
481
482    def checkConversion(self, row, mode='ignore'):
483        """Validates all values in row.
484        """
[8232]485        errs, inv_errs, conv_dict = super(
486            CourseTicketProcessor, self).checkConversion(row, mode=mode)
487
[7548]488        # We have to check if course really exists.
489        # This is not done by the converter.
490        catalog = getUtility(ICatalog, name='courses_catalog')
491        entries = catalog.searchResults(code=(row['code'],row['code']))
492        if len(entries) == 0:
493            errs.append(('code','non-existent'))
494            return errs, inv_errs, conv_dict
[7623]495        return errs, inv_errs, conv_dict
496
[8232]497class StudentOnlinePaymentProcessor(StudentProcessorBase):
[7623]498    """A batch processor for IStudentOnlinePayment objects.
499    """
500    grok.implements(IBatchProcessor)
501    grok.provides(IBatchProcessor)
502    grok.context(Interface)
[7933]503    util_name = 'paymentprocessor'
[7623]504    grok.name(util_name)
505
[7933]506    name = u'Payment Processor'
[8174]507    iface = IStudentOnlinePayment
[7623]508    factory_name = 'waeup.StudentOnlinePayment'
509
510    location_fields = []
[8232]511    additional_fields = ['p_id']
512    additional_headers = ['p_id']
[7623]513
[8232]514    def parentsExist(self, row, site):
515        result = self.getParent(row, site) is not None
516        return self.getParent(row, site) is not None
[7623]517
518    def getParent(self, row, site):
[8232]519        student = self._getStudent(row, site)
520        if student is None:
[7623]521            return None
[8232]522        return student['payments']
[7623]523
524    def getEntry(self, row, site):
525        payments = self.getParent(row, site)
526        if payments is None:
527            return None
[7626]528        # We can use the hash symbol at the end of p_id in import files
529        # to avoid annoying automatic number transformation
530        # by Excel or Calc
531        p_id = row['p_id'].strip('#')
532        if p_id.startswith('p'):
533            entry = payments.get(p_id)
[7623]534        else:
535            # For data migration from old SRP
[7626]536            entry = payments.get('p' + p_id[6:])
[7623]537        return entry
538
539    def addEntry(self, obj, row, site):
540        parent = self.getParent(row, site)
[7626]541        p_id = row['p_id'].strip('#')
542        if not p_id.startswith('p'):
[7623]543            # For data migration from old SRP
[7626]544            obj.p_id = 'p' + p_id[6:]
[7623]545            parent[obj.p_id] = obj
546        else:
[7626]547            parent[p_id] = obj
[7623]548        return
549
550    def checkConversion(self, row, mode='ignore'):
551        """Validates all values in row.
552        """
[8232]553        errs, inv_errs, conv_dict = super(
554            StudentOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
555
[7623]556        # We have to check p_id.
[7626]557        p_id = row['p_id'].strip('#')
558        if p_id.startswith('p'):
559            if not len(p_id) == 14:
[7623]560                errs.append(('p_id','invalid length'))
561                return errs, inv_errs, conv_dict
562        else:
[7626]563            if not len(p_id) == 19:
[7623]564                errs.append(('p_id','invalid length'))
565                return errs, inv_errs, conv_dict
566        return errs, inv_errs, conv_dict
[7951]567
568class StudentVerdictProcessor(StudentStudyCourseProcessor):
569    """A batch processor for verdicts.
570
571    Import verdicts and perform workflow transitions.
572    """
573
574    util_name = 'verdictupdater'
575    grok.name(util_name)
576
577    name = u'Verdict Processor (update only)'
578    iface = IStudentVerdictUpdate
579    factory_name = 'waeup.StudentStudyCourse'
580
[8232]581    additional_fields = ['current_session', 'current_level']
[7951]582
583    def checkUpdateRequirements(self, obj, row, site):
584        """Checks requirements the studycourse and the student must fulfill
585        before being updated.
586        """
587        # Check if current_levels correspond
588        if obj.current_level != row['current_level']:
589            return 'Current level does not correspond.'
590        # Check if current_sessions correspond
591        if obj.current_session != row['current_session']:
592            return 'Current session does not correspond.'
593        # Check if student is in state REGISTERED
594        if obj.getStudent().state != VALIDATED:
595            return 'Student in wrong state.'
596        return None
597
598    def updateEntry(self, obj, row, site):
599        """Update obj to the values given in row.
600        """
[8221]601        # Don't set current_session, current_level
602        vals_to_set = dict((key, val) for key, val in row.items()
603                           if key not in ('current_session','current_level'))
604        items_changed = super(StudentVerdictProcessor, self).updateEntry(
605            obj, vals_to_set, site)
[7951]606        parent = self.getParent(row, site)
607        parent.__parent__.logger.info(
608            '%s - Verdict updated: %s'
609            % (parent.student_id, items_changed))
610        # Fire transition
611        IWorkflowInfo(obj.__parent__).fireTransition('return')
612        # Update the students_catalog
613        notify(grok.ObjectModifiedEvent(obj.__parent__))
614        return
Note: See TracBrowser for help on using the repository browser.