source: main/waeup.kofa/trunk/src/waeup/kofa/applicants/batching.py @ 16217

Last change on this file since 16217 was 16202, checked in by Henrik Bettermann, 4 years ago

Do it right.

  • Property svn:keywords set to Id
File size: 18.8 KB
RevLine 
[7192]1## $Id: batching.py 16202 2020-08-17 14:37:00Z henrik $
[5321]2##
[7192]3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
[5321]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.
[7192]8##
[5321]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.
[7192]13##
[5321]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 for applicants.
19"""
[10028]20import unicodecsv as csv # XXX: csv ops should move to dedicated module.
[5321]21import grok
[13873]22from time import time
[7271]23from zope.schema import getFields
[5321]24from zope.interface import Interface
[13872]25from zope.component import queryUtility, getUtility
[8290]26from hurry.workflow.interfaces import IWorkflowState
[7268]27from zope.catalog.interfaces import ICatalog
[7811]28from waeup.kofa.interfaces import (
[15628]29    IBatchProcessor, IObjectConverter, FatalCSVError,
30    IGNORE_MARKER, DELETION_MARKER,
[13872]31    IObjectHistory, IUserAccount, DuplicationError)
[8290]32from waeup.kofa.interfaces import MessageFactory as _
[13872]33from waeup.kofa.payments.interfaces import IPayer
[7811]34from waeup.kofa.utils.batching import BatchProcessor
35from waeup.kofa.applicants.interfaces import (
[13872]36    IApplicantsContainer, IApplicant, IApplicantUpdateByRegNo,
37    IApplicantOnlinePayment)
[8336]38from waeup.kofa.applicants.workflow import  IMPORTABLE_STATES, CREATED
[5321]39
[7933]40class ApplicantsContainerProcessor(BatchProcessor):
[12869]41    """The Applicants Container Processor imports containers for applicants.
42    It does not import their content. There is nothing special about this
43    processor.
[5321]44    """
[5474]45    grok.implements(IBatchProcessor)
[5321]46    grok.provides(IBatchProcessor)
47    grok.context(Interface)
[8987]48    util_name = 'applicantscontainerprocessor'
[5321]49    grok.name(util_name)
50
[11891]51    name = _('ApplicantsContainer Processor')
[5475]52    mode = u'create'
[6251]53    iface = IApplicantsContainer
[5321]54
[6251]55    location_fields = ['code',]
[6282]56    factory_name = 'waeup.ApplicantsContainer'
[5321]57
58    def parentsExist(self, row, site):
[6251]59        return 'applicants' in site.keys()
[5321]60
61    def entryExists(self, row, site):
[6251]62        return row['code'] in site['applicants'].keys()
[5321]63
64    def getParent(self, row, site):
[6251]65        return site['applicants']
[5321]66
67    def getEntry(self, row, site):
68        if not self.entryExists(row, site):
69            return None
70        parent = self.getParent(row, site)
[6251]71        return parent.get(row['code'])
[5321]72
73    def addEntry(self, obj, row, site):
74        parent = self.getParent(row, site)
[6251]75        parent[row['code']] = obj
[5321]76        return
77
78    def delEntry(self, row, site):
79        parent = self.getParent(row, site)
[6251]80        del parent[row['code']]
[5321]81        return
[7262]82
[7933]83class ApplicantProcessor(BatchProcessor):
[12872]84    """The Applicant Processor imports application data (applicants).
[8331]85
[12869]86    In create mode `container_code` is required. If `application_number` is
87    given, an applicant with this number is created in the designated container.
88    If `application_number` is not given, a random `application_number` is
89    assigned. `applicant_id` is being determined by the system and can't be
90    imported.
[8331]91
[12869]92    In update or remove mode `container_code` and `application_number` columns
[12870]93    must not exist. The applicant object is solely localized by searching
94    the applicants catalog for `reg_number` or `applicant_id` .
[7262]95    """
96    grok.implements(IBatchProcessor)
97    grok.provides(IBatchProcessor)
98    grok.context(Interface)
[7933]99    util_name = 'applicantprocessor'
[7262]100    grok.name(util_name)
[11891]101    name = _('Applicant Processor')
[7262]102    iface = IApplicant
[8581]103    iface_byregnumber = IApplicantUpdateByRegNo
[7262]104    factory_name = 'waeup.Applicant'
105
106    mode = None
107
108    @property
[7268]109    def available_fields(self):
110        return sorted(list(set(
[8331]111            ['application_number',
[8290]112            'container_code','state','password'] + getFields(
[7268]113                self.iface).keys())))
[7262]114
[7268]115    def checkHeaders(self, headerfields, mode='create'):
[8331]116        cond1 = 'container_code' in headerfields
117        cond2 = 'application_number' in headerfields
118        cond3 = 'applicant_id' in headerfields
119        cond4 = 'reg_number' in headerfields
[7268]120        if mode == 'create':
[8331]121            if not cond1:
122                raise FatalCSVError(
123                    "Need at least container_code column!")
124            if cond3:
125                raise FatalCSVError(
126                    "applicant_id can't be imported in create mode!")
[7268]127            for field in self.required_fields:
128                if not field in headerfields:
129                    raise FatalCSVError(
130                        "Need at least columns %s for import!" %
131                        ', '.join(["'%s'" % x for x in self.required_fields]))
[8331]132        if mode in ('update', 'remove'):
133            if not cond3 and not cond4:
134                raise FatalCSVError(
135                    "Need at least column reg_number or applicant_id!")
136            if cond1 or cond2:
137                raise FatalCSVError(
138                    "container_code or application_number can't be imported " +
139                    "in update or remove mode!")
[7268]140        # Check for fields to be ignored...
141        not_ignored_fields = [x for x in headerfields
142                              if not x.startswith('--')]
143        if len(set(not_ignored_fields)) < len(not_ignored_fields):
144            raise FatalCSVError(
145                "Double headers: each column name may only appear once.")
146        return True
[7262]147
[7268]148    def getLocator(self, row):
[8331]149        if row.get('container_code', None) not in (IGNORE_MARKER, None):
[9250]150            # create mode
[8331]151            return 'container_code'
152        elif row.get('applicant_id', None) not in (IGNORE_MARKER, None):
[9250]153            # update or remove mode
[8331]154            return 'applicant_id'
[8236]155        elif row.get('reg_number', None) not in (IGNORE_MARKER, None):
[9250]156            # update or remove mode
[7270]157            return 'reg_number'
[7268]158        else:
159            return None
[7262]160
161    def getParent(self, row, site):
[8615]162        result = None
[8331]163        if self.getLocator(row) == 'container_code':
[8615]164            result = site['applicants'].get(row['container_code'], None)
165        elif self.getLocator(row) == 'reg_number':
[8331]166            reg_number = row['reg_number']
167            cat = queryUtility(ICatalog, name='applicants_catalog')
168            results = list(
169                cat.searchResults(reg_number=(reg_number, reg_number)))
170            if results:
[8615]171                result = results[0].__parent__
172        elif self.getLocator(row) == 'applicant_id':
[8331]173            applicant_id = row['applicant_id']
174            cat = queryUtility(ICatalog, name='applicants_catalog')
175            results = list(
176                cat.searchResults(applicant_id=(applicant_id, applicant_id)))
177            if results:
[8615]178                result = results[0].__parent__
179        return result
[7262]180
[7268]181    def parentsExist(self, row, site):
182        return self.getParent(row, site) is not None
183
[7262]184    def getEntry(self, row, site):
[8331]185        if self.getLocator(row) == 'container_code':
186            if row.get('application_number', None) not in (IGNORE_MARKER, None):
187                if not self.parentsExist(row, site):
188                    return None
189                parent = self.getParent(row, site)
190                return parent.get(row['application_number'])
[7264]191            return None
[8331]192        if self.getLocator(row) == 'applicant_id':
193            applicant_id = row['applicant_id']
194            cat = queryUtility(ICatalog, name='applicants_catalog')
195            results = list(
196                cat.searchResults(applicant_id=(applicant_id, applicant_id)))
197            if results:
198                return results[0]
199        if self.getLocator(row) == 'reg_number':
[7270]200            reg_number = row['reg_number']
[7268]201            cat = queryUtility(ICatalog, name='applicants_catalog')
202            results = list(
[7270]203                cat.searchResults(reg_number=(reg_number, reg_number)))
[7268]204            if results:
205                return results[0]
206        return None
[7262]207
[7268]208    def entryExists(self, row, site):
209        return self.getEntry(row, site) is not None
210
[7262]211    def addEntry(self, obj, row, site):
212        parent = self.getParent(row, site)
213        parent.addApplicant(obj)
[8334]214        #parent.__parent__.logger.info(
215        #    'Applicant imported: %s' % obj.applicant_id)
[8290]216        history = IObjectHistory(obj)
[8334]217        history.addMessage(_('Application record imported'))
[7262]218        return
219
220    def delEntry(self, row, site):
[7268]221        applicant = self.getEntry(row, site)
222        if applicant is not None:
[8331]223            parent = applicant.__parent__
[7268]224            del parent[applicant.application_number]
[8334]225            #parent.__parent__.logger.info(
226            #    'Applicant removed: %s' % applicant.applicant_id)
[7262]227        pass
[7268]228
[9706]229    def updateEntry(self, obj, row, site, filename):
[8290]230        """Update obj to the values given in row.
231        """
232        items_changed = ''
233        # Remove application_number from row if empty
[9701]234        if 'application_number' in row and row['application_number'] in (
[8290]235            None, IGNORE_MARKER):
236            row.pop('application_number')
237
238        # Update applicant_id fom application_number and container code
239        # if application_number is given
[9701]240        if 'application_number' in row:
[8290]241            obj.applicant_id = u'%s_%s' % (
242                row['container_code'], row['application_number'])
[8334]243            items_changed += ('%s=%s, ' % ('applicant_id', obj.applicant_id))
[8290]244            row.pop('application_number')
245
[15628]246        # Update
[9701]247        if 'password' in row:
[8334]248            passwd = row.get('password', IGNORE_MARKER)
249            if passwd not in ('', IGNORE_MARKER):
[8348]250                if passwd.startswith('{SSHA}'):
251                    # already encrypted password
252                    obj.password = passwd
[15628]253                elif passwd == DELETION_MARKER:
254                    obj.password = None
[8348]255                else:
256                    # not yet encrypted password
257                    IUserAccount(obj).setPassword(passwd)
[8334]258                items_changed += ('%s=%s, ' % ('password', passwd))
[8290]259            row.pop('password')
260
261        # Update registration state
[9701]262        if 'state' in row:
[8334]263            state = row.get('state', IGNORE_MARKER)
264            if state not in (IGNORE_MARKER, ''):
265                IWorkflowState(obj).setState(state)
266                msg = _("State '${a}' set", mapping = {'a':state})
267                history = IObjectHistory(obj)
268                history.addMessage(msg)
269                items_changed += ('%s=%s, ' % ('state', state))
[8290]270            row.pop('state')
271
272        # apply other values...
[8334]273        items_changed += super(ApplicantProcessor, self).updateEntry(
[9706]274            obj, row, site, filename)
[8290]275
276        # Log actions...
277        parent = self.getParent(row, site)
[8334]278        if self.getLocator(row) == 'container_code':
[8290]279            parent.__parent__.logger.info(
[9706]280                '%s - %s - imported: %s' % (self.name, filename, items_changed))
[8290]281        else:
[8334]282            parent.__parent__.logger.info(
[9706]283                '%s - %s - updated: %s' % (self.name, filename, items_changed))
[8290]284        return items_changed
285
[7268]286    def getMapping(self, path, headerfields, mode):
287        """Get a mapping from CSV file headerfields to actually used fieldnames.
288        """
289        result = dict()
290        reader = csv.reader(open(path, 'rb'))
291        raw_header = reader.next()
292        for num, field in enumerate(headerfields):
[8331]293            if field not in ['applicant_id', 'reg_number'] and mode == 'remove':
[7268]294                continue
295            if field == u'--IGNORE--':
296                # Skip ignored columns in failed and finished data files.
297                continue
298            result[raw_header[num]] = field
299        return result
300
301    def checkConversion(self, row, mode='create'):
302        """Validates all values in row.
303        """
[8331]304        iface = self.iface
305        if self.getLocator(row) == 'reg_number' or mode == 'remove':
[8581]306            iface = self.iface_byregnumber
[7268]307        converter = IObjectConverter(iface)
308        errs, inv_errs, conv_dict =  converter.fromStringDict(
[8223]309            row, self.factory_name, mode=mode)
[8615]310        cert = conv_dict.get('course1', None)
311        if cert is not None and (mode in ('create', 'update')):
[8617]312            # course1 application category must match container's.
[12869]313            site = grok.getSite()
314            parent = self.getParent(row, site)
[10613]315            if parent is None:
316                errs.append(('container', 'not found'))
317            elif cert.application_category != parent.application_category:
[8615]318                errs.append(('course1', 'wrong application category'))
[9701]319        if 'state' in row and \
[8290]320            not row['state'] in IMPORTABLE_STATES:
321            if row['state'] not in (IGNORE_MARKER, ''):
322                errs.append(('state','not allowed'))
323            else:
324                # state is an attribute of Applicant and must not
325                # be changed if empty
326                conv_dict['state'] = IGNORE_MARKER
[8331]327        application_number = row.get('application_number', None)
[8290]328        if application_number in (IGNORE_MARKER, ''):
329                conv_dict['application_number'] = IGNORE_MARKER
[7268]330        return errs, inv_errs, conv_dict
[8336]331
332    def checkUpdateRequirements(self, obj, row, site):
333        """Checks requirements the object must fulfill when being updated.
334
335        This method is not used in case of deleting or adding objects.
336
337        Returns error messages as strings in case of requirement
338        problems.
339        """
340        if obj.state == CREATED:
341            return 'Applicant is blocked.'
342        return None
[13872]343
344class ApplicantOnlinePaymentProcessor(BatchProcessor):
345    """The Applicant Online Payment Processor imports applicant payment tickets.
346    The tickets are located in the applicant container.
347
348    The `checkConversion` method checks the format of the payment identifier.
349    In create mode it does also ensures that same p_id does not exist
350    elsewhere. It must be portal-wide unique.
351
352    When adding a payment ticket, the `addEntry` method checks if a
353    payment has already been made. If so, a `DuplicationError` is raised.
354    """
355    grok.implements(IBatchProcessor)
356    grok.provides(IBatchProcessor)
357    grok.context(Interface)
358    util_name = 'applicantpaymentprocessor'
359    grok.name(util_name)
360
361    name = _('ApplicantOnlinePayment Processor')
362    iface = IApplicantOnlinePayment
363    factory_name = 'waeup.ApplicantOnlinePayment'
364
[14804]365    location_fields = ['applicant_id',]
[13872]366
367    @property
368    def available_fields(self):
369        af = sorted(list(set(
[14804]370            self.location_fields + getFields(self.iface).keys())) +
371            ['p_id',])
[13872]372        af.remove('display_item')
373        return af
374
[14804]375    def checkHeaders(self, headerfields, mode='ignore'):
376        super(ApplicantOnlinePaymentProcessor, self).checkHeaders(headerfields)
377        if mode in ('update', 'remove') and not 'p_id' in headerfields:
378            raise FatalCSVError(
379                "Need p_id for import in update and remove modes!")
380        return True
381
[13872]382    def parentsExist(self, row, site):
383        return self.getParent(row, site) is not None
384
385    def getParent(self, row, site):
386        applicant_id = row['applicant_id']
387        cat = queryUtility(ICatalog, name='applicants_catalog')
388        results = list(
389            cat.searchResults(applicant_id=(applicant_id, applicant_id)))
390        if results:
391            return results[0]
392        return None
393
394    def getEntry(self, row, site):
395        applicant = self.getParent(row, site)
396        if applicant is None:
397            return None
398        p_id = row.get('p_id', None)
399        if p_id in (None, IGNORE_MARKER):
400            return None
401        # We can use the hash symbol at the end of p_id in import files
402        # to avoid annoying automatic number transformation
403        # by Excel or Calc
404        p_id = p_id.strip('#')
405        entry = applicant.get(p_id)
406        return entry
407
408    def entryExists(self, row, site):
409        return self.getEntry(row, site) is not None
410
411    def updateEntry(self, obj, row, site, filename):
412        """Update obj to the values given in row.
413        """
414        items_changed = super(ApplicantOnlinePaymentProcessor, self).updateEntry(
415            obj, row, site, filename)
416        applicant = self.getParent(row, site)
417        applicant.__parent__.__parent__.logger.info(
418            '%s - %s - %s - updated: %s'
419            % (self.name, filename, applicant.applicant_id, items_changed))
420        return
421
422    def samePaymentMade(self, applicant, category):
423        for key in applicant.keys():
424            ticket = applicant[key]
425            if ticket.p_state == 'paid' and\
[16202]426                ticket.p_category == category:
[16200]427                return True
[13872]428        return False
429
430    def addEntry(self, obj, row, site):
431        applicant = self.getParent(row, site)
432        p_id = row['p_id'].strip('#')
[16202]433        if obj.p_item != 'Balance' and self.samePaymentMade(
434            applicant, obj.p_category):
[13872]435            applicant.__parent__.__parent__.logger.info(
436                '%s - %s - previous update cancelled'
437                % (self.name, applicant.applicant_id))
438            raise DuplicationError('Payment has already been made.')
439        applicant[p_id] = obj
440        return
441
442    def delEntry(self, row, site):
443        payment = self.getEntry(row, site)
444        applicant = self.getParent(row, site)
445        if payment is not None:
446            applicant.__parent__.__parent__.logger.info('%s - Payment ticket removed: %s'
447                % (applicant.applicant_id, payment.p_id))
448            del applicant[payment.p_id]
449        return
450
451    def checkConversion(self, row, mode='ignore'):
452        """Validates all values in row.
453        """
454        errs, inv_errs, conv_dict = super(
455            ApplicantOnlinePaymentProcessor, self).checkConversion(row, mode=mode)
456        # We have to check p_id.
457        p_id = row.get('p_id', None)
458        if mode == 'create' and p_id in (None, IGNORE_MARKER):
459            timestamp = ("%d" % int(time()*10000))[1:]
460            p_id = "p%s" % timestamp
461            conv_dict['p_id'] = p_id
462            return errs, inv_errs, conv_dict
463        elif p_id in (None, IGNORE_MARKER):
464            errs.append(('p_id','missing'))
465            return errs, inv_errs, conv_dict
466        else:
467            p_id = p_id.strip('#')
468            if not len(p_id) == 14:
469                errs.append(('p_id','invalid length'))
470                return errs, inv_errs, conv_dict
471        if mode == 'create':
472            cat = getUtility(ICatalog, name='payments_catalog')
473            results = list(cat.searchResults(p_id=(p_id, p_id)))
474            if len(results) > 0:
475                sids = [IPayer(payment).id for payment in results]
476                sids_string = ''
477                for id in sids:
478                    sids_string += '%s ' % id
479                errs.append(('p_id','p_id exists in %s' % sids_string))
480                return errs, inv_errs, conv_dict
481        return errs, inv_errs, conv_dict
Note: See TracBrowser for help on using the repository browser.