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

Last change on this file since 13872 was 13872, checked in by Henrik Bettermann, 8 years ago

Add ApplicantOnlinePaymentProcessor?.

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