source: main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/accesscode.py @ 9263

Last change on this file since 9263 was 9263, checked in by Henrik Bettermann, 12 years ago

Add batch processor for ac batches.

  • Property svn:keywords set to Id
File size: 21.5 KB
Line 
1## $Id: accesscode.py 9263 2012-10-01 06:55:04Z 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"""Components to handle access codes.
19
20Access codes (aka PINs) in waeup sites are organized in batches. That
21means a certain accesscode must be part of a batch. As a site (or
22university) can hold an arbitrary number of batches, we also provide a
23batch container. Each university has one batch container that holds
24all access code batches of which each one can hold several thousands
25of access codes.
26"""
27import csv
28import grok
29import os
30from datetime import datetime
31from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
32from random import SystemRandom as random
33from zope.component import getUtility
34from zope.component.interfaces import IFactory
35from waeup.kofa.interfaces import IKofaUtils, IKofaPluggable, IObjectHistory
36from waeup.kofa.utils.helpers import now
37from waeup.kofa.utils.logger import Logger
38from waeup.kofa.accesscodes.interfaces import (
39    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
40    )
41from waeup.kofa.accesscodes.workflow import DISABLED, USED, ac_states_dict
42
43class AccessCode(grok.Model):
44    """An access code (aka PIN).
45
46    Implements
47    :class:`waeup.kofa.accesscodes.interfaces.IAccessCode`. :class:`AccessCode`
48    instances are normally part of an :class:`AccessCodeBatch` so
49    their representation (or code) is built with the containing batch
50    involved.
51
52    `batch_serial`
53       the serial number of the new :class:`AccessCode` inside its batch.
54
55    `random_num`
56       a 10-digit number representing the main part of the code.
57
58    :class:`AccessCode` instances normally have a representation (or
59    code) like
60
61      ``APP-XXX-YYYYYYYYYY``
62
63    where ``APP`` is the prefix of the containing batch, ``XXX`` is
64    the batch number and ``YYYYYYYYYY`` is the real code. The complete
65    PIN is portal-wide unique.
66
67    Access code instances are far more than simple strings. They have
68    a state, a history (so that all changes can be tracked) and a
69    cost (given as a float number).
70
71    The state of an access code is something like 'used', 'disabled',
72    etc. and determined by the workflow defined in
73    :mod:`waeup.kofa.accesscodes.workflow`. This also means that
74    instead of setting the status of an access code directly (you
75    can't do that easily, and yes, that's intentionally), you have to
76    trigger a transition (that might fail, if the transition is not
77    allowed in terms of logic or permissions). See
78    :mod:`waeup.kofa.accesscodes.workflow` for details.
79
80    """
81    grok.implements(IAccessCode)
82
83    def __init__(self, batch_serial, random_num):
84        super(AccessCode, self).__init__()
85        self.batch_serial = batch_serial
86        self.random_num = random_num
87        self.owner = None
88        self.cost = None
89        IWorkflowInfo(self).fireTransition('init')
90
91    @property
92    def representation(self):
93        """A string representation of the :class:`AccessCode`.
94
95        It has format ``APP-XXX-YYYYYYYYYY`` as described above.
96        """
97        return '%s-%s-%s' % (
98            self.batch_prefix, self.batch_num, self.random_num)
99
100    @property
101    def batch(self):
102        """The batch this :class:`AccessCode` is contained.
103        """
104        return getattr(self, '__parent__', None)
105
106    @property
107    def batch_prefix(self):
108        """The prefix of the batch this :class:`AccessCode` belongs to.
109        """
110        if self.batch is None:
111            return ''
112        return self.batch.prefix
113
114    @property
115    def batch_num(self):
116        """The number of the batch this :class:`AccessCode` belongs to. A
117        read-only attribute.
118        """
119        if self.batch is None:
120            return ''
121        return self.batch.num
122
123    #@property
124    #def cost(self):
125    #    """A float representing the price or ``None``. A read-only attribute.
126    #    """
127    #    if self.batch is None:
128    #        return None
129    #    return self.batch.cost
130
131    @property
132    def state(self):
133        """The workflow state. A read-only attribute.
134        """
135        return IWorkflowState(self).getState()
136
137    @property
138    def translated_state(self):
139        """The translated workflow state. A read-only attribute.
140        """
141        return ac_states_dict[self.state]
142
143    @property
144    def history(self):
145        """A :class:`waeup.kofa.objecthistory.ObjectHistory` instance.
146        """
147        history = IObjectHistory(self)
148        return '||'.join(history.messages)
149
150class AccessCodeBatch(grok.Container):
151    """A batch of access codes.
152    """
153    grok.implements(IAccessCodeBatch)
154
155    def __init__(self, creation_date=None, creator=None, batch_prefix=None,
156                 cost=None, entry_num=None, num=None):
157        super(AccessCodeBatch, self).__init__()
158        self.creation_date = creation_date
159        self.creator = creator
160        try:
161            self.prefix = batch_prefix.upper()
162        except AttributeError:
163            self.prefix = None
164        self.cost = cost
165        self.entry_num = entry_num
166        self.num = num
167        self.used_num = 0
168        self.disabled_num = 0
169
170    def _createEntries(self):
171        """Create the entries for this batch.
172        """
173        for num, pin in enumerate(self.getNewRandomNum(num=self.entry_num)):
174            self.addAccessCode(num, pin, self.cost)
175        return
176
177    def getNewRandomNum(self, num=1):
178        """Create a set of ``num`` random numbers of 10 digits each.
179
180        The number is returned as string.
181        """
182        curr = 1
183        while curr <= num:
184            pin = ''
185            for x in range(10):
186                pin += str(random().randint(0, 9))
187            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
188                curr += 1
189                yield pin
190            # PIN already in use
191
192    def _getStoragePath(self):
193        """Get the directory, where we store all batch-related CSV files.
194        """
195        site = grok.getSite()
196        storagepath = site['datacenter'].storage
197        ac_storage = os.path.join(storagepath, 'accesscodes')
198        if not os.path.exists(ac_storage):
199            os.mkdir(ac_storage)
200        return ac_storage
201
202    def getAccessCode(self, ac_id):
203        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
204        """
205        return self[ac_id]
206
207    def addAccessCode(self, num, pin, cost=0.0, owner=None):
208        """Add an access-code.
209        """
210        ac = AccessCode(num, pin)
211        if owner:
212            ac.owner = owner
213        ac.cost = cost
214        ac.__parent__ = self
215        self[ac.representation] = ac
216        return
217
218    def createCSVLogFile(self):
219        """Create a CSV file with data in batch.
220
221        Data will not contain invalidation date nor student ids.  File
222        will be created in ``accesscodes`` subdir of data center
223        storage path.
224
225        Returns name of created file.
226        """
227        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
228        ac_storage = self._getStoragePath()
229        csv_path = os.path.join(
230            ac_storage, '%s-%s-%s-%s.csv' % (
231                self.prefix, self.num, date, self.creator)
232            )
233        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
234        writer.writerow(['serial', 'ac', 'cost'])
235        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
236
237        for value in sorted(self.values(),
238                            cmp=lambda x, y: cmp(
239                x.batch_serial, y.batch_serial)):
240            writer.writerow(
241                [str(value.batch_serial), str(value.representation)]
242                )
243        site = grok.getSite()
244        logger = site.logger
245        logger.info(
246            "Created batch %s-%s" % (self.prefix, self.num))
247        logger.info(
248            "Written batch CSV to %s" % csv_path)
249        return os.path.basename(csv_path)
250
251    def archive(self):
252        """Create a CSV file for archive.
253        """
254        ac_storage = self._getStoragePath()
255        tz = getUtility(IKofaUtils).tzinfo
256        dt_now = now(tz)
257        timestamp = dt_now.strftime('%Y_%m_%d_%H_%M_%S')
258        csv_path = os.path.join(
259            ac_storage, '%s-%s_archive-%s-%s.csv' % (
260                self.prefix, self.num, timestamp, self.creator)
261            )
262        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
263        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history',
264            'cost','owner'])
265        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
266                         str(self.entry_num)])
267        for value in sorted(
268            self.values(),
269            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
270            ):
271            writer.writerow([
272                    self.prefix, value.batch_serial, value.representation,
273                    value.state, value.history, value.cost, value.owner
274                    ])
275        return os.path.basename(csv_path)
276
277@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
278def handle_batch_added(batch, event):
279    # A (maybe dirty?) workaround to make batch containers work
280    # without self-maintained acids: as batches should contain their
281    # set of data immediately after creation, but we cannot add
282    # subobjects as long as the batch was not added already to the
283    # ZODB, we trigger the item creation for the time after the batch
284    # was added to the ZODB.
285    batch._createEntries()
286    return
287
288
289class AccessCodeBatchContainer(grok.Container, Logger):
290    grok.implements(IAccessCodeBatchContainer)
291
292    def _getStoragePath(self):
293        """Get the directory, where batch import files are stored.
294
295        If the path does not exist yet, it is created. The path is
296        normally ``accesscodes/imports`` below the datacenter storage
297        path (see :data:`waeup.kofa.accesscodes.Datacenter.storage`).
298        """
299        site = grok.getSite()
300        storagepath = site['datacenter'].storage
301        ac_storage = os.path.join(storagepath, 'accesscodes')
302        import_path = os.path.join(ac_storage, 'imports')
303        for path in [ac_storage, import_path]:
304            if not os.path.exists(path):
305                os.mkdir(path)
306                site.logger.info('created path %s' % path)
307        return import_path
308
309    def addBatch(self, batch):
310        """Add an already created `batch`.
311        """
312        batch.num = self.getNum(batch.prefix)
313        key = "%s-%s" % (batch.prefix, batch.num)
314        self[key] = batch
315        self._p_changed = True
316        return
317
318    def addBatchByImport(self, batch, batch_id):
319        """Add an already created `batch` by import
320        with defined id.
321        """
322        self[batch_id] = batch
323        return
324
325    def createBatch(self, creation_date, creator, prefix, cost,
326                    entry_num):
327        """Create and add a batch.
328        """
329        batch_num = self.getNum(prefix)
330        batch = AccessCodeBatch(creation_date, creator, prefix,
331                                cost, entry_num, num=batch_num)
332        self.addBatch(batch)
333        return batch
334
335    def getNum(self, prefix):
336        """Get next unused num for given prefix.
337        """
338        # School fee, clearance and hostel application batches start with 0.
339        # These batches are being emptily created during initialization of the
340        # university instance.
341        if prefix in ('CLR', 'SFE', 'HOS'):
342            num = 0
343        else:
344            num = 1
345        while self.get('%s-%s' % (prefix, num), None) is not None:
346            num += 1
347        return num
348
349    def getImportFiles(self):
350        """Return a generator with basenames of available import files.
351        """
352        path = self._getStoragePath()
353        for filename in sorted(os.listdir(path)):
354            yield filename
355
356    # This is temporary reimport solution. Access codes will be imported
357    # with state initialized no matter if they have been used before.
358    def reimport(self, filename, creator=u'UNKNOWN'):
359        """Reimport a batch given in CSV file.
360
361        CSV file must be of format as generated by createCSVLogFile
362        method.
363        """
364        path = os.path.join(self._getStoragePath(), filename)
365        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
366        entry = reader.next()
367        cost = float(entry['serial'])
368        num = int(entry['ac'])
369        prefix = entry['prefix']
370        batch_name = '%s-%s' % (prefix, num)
371        if batch_name in self.keys():
372            raise KeyError('Batch already exists: %s' % batch_name)
373        batch = AccessCodeBatch(
374            datetime.utcnow(), creator, prefix, cost, 0, num=num)
375        num_entries = 0
376        self[batch_name] = batch
377        for row in reader:
378            pin = row['ac']
379            serial = int(row['serial'])
380            try:
381                cost = float(row['cost'])
382            except ValueError:
383                cost = 0.0
384            rand_num = pin.rsplit('-', 1)[-1]
385            batch.addAccessCode(serial, rand_num, cost)
386            num_entries += 1
387        batch.entry_num = num_entries
388        batch.createCSVLogFile()
389        return
390
391    def getAccessCode(self, ac_id):
392        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
393        """
394        for batchname in self.keys():
395            batch = self[batchname]
396            try:
397                return batch.getAccessCode(ac_id)
398            except KeyError:
399                continue
400        return None
401
402    def disable(self, ac_id, comment=None):
403        """Disable the AC with ID ``ac_id``.
404
405        ``user_id`` is the user ID of the user triggering the
406        process. Already disabled ACs are left untouched.
407        """
408        ac = self.getAccessCode(ac_id)
409        if ac is None:
410            return
411        disable_accesscode(ac_id, comment)
412        return
413
414    def enable(self, ac_id, comment=None):
415        """(Re-)enable the AC with ID ``ac_id``.
416
417        This leaves the given AC in state ``unused``. Already enabled
418        ACs are left untouched.
419        """
420        ac = self.getAccessCode(ac_id)
421        if ac is None:
422            return
423        reenable_accesscode(ac_id, comment)
424        return
425
426    logger_name = 'waeup.kofa.${sitename}.accesscodes'
427    logger_filename = 'accesscodes.log'
428
429    def logger_info(self, ob_class, comment=None):
430        """Get the logger's info method.
431        """
432        self.logger.info('%s - %s' % (
433                ob_class, comment))
434        return
435
436class AccessCodePlugin(grok.GlobalUtility):
437    grok.name('accesscodes')
438    grok.implements(IKofaPluggable)
439
440    def setup(self, site, name, logger):
441        basecontainer = AccessCodeBatchContainer()
442        site['accesscodes'] = basecontainer
443        logger.info('Installed container for access code batches.')
444        # Create empty school fee, clearance and hostel application AC
445        # batches during initialization of university instance.
446        cost = 0.0
447        creator = 'system'
448        entry_num = 0
449        creation_date = datetime.utcnow()
450        basecontainer.createBatch(creation_date, creator,
451            'SFE', cost, entry_num)
452        basecontainer.createBatch(creation_date, creator,
453            'CLR', cost, entry_num)
454        basecontainer.createBatch(creation_date, creator,
455            'HOS', cost, entry_num)
456        logger.info('Installed empty SFE, CLR and HOS access code batches.')
457        return
458
459    def update(self, site, name, logger):
460        site_name = getattr(site, '__name__', '<Unnamed Site>')
461        if not 'accesscodes' in site.keys():
462            logger.info('Updating site at %s. Installing access codes.' % (
463                    site,))
464            self.setup(site, name, logger)
465        else:
466            logger.info(
467                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
468                    site_name, ))
469        return
470
471class AccessCodeBatchFactory(grok.GlobalUtility):
472    """A factory for accesscodebatches.
473
474    We need this factory for the accesscodebatchprocessor.
475    """
476    grok.implements(IFactory)
477    grok.name(u'waeup.AccessCodeBatch')
478    title = u"Create a new accesscode batch.",
479    description = u"This factory instantiates new accesscode batch instances."
480
481    def __call__(self, *args, **kw):
482        return AccessCodeBatch(*args, **kw)
483
484    def getInterfaces(self):
485        return implementedBy(AccessCodeBatch)
486
487def get_access_code(access_code):
488    """Get an access code instance.
489
490    An access code here is a string like ``PUDE-1-1234567890``.
491
492    Returns ``None`` if the given code cannot be found.
493
494    This is a convenicence function that is faster than looking up a
495    batch container for the approriate access code.
496    """
497    site = grok.getSite()
498    if not isinstance(access_code, basestring):
499        return None
500    try:
501        batch_id, ac_id = access_code.rsplit('-', 1)
502    except:
503        return None
504    batch = site['accesscodes'].get(batch_id, None)
505    if batch is None:
506        return None
507    try:
508        code = batch.getAccessCode(access_code)
509    except KeyError:
510        return None
511    return code
512
513def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
514    """Fire workflow transition for access code.
515
516    The access code instance is looked up via `access_code` (a string
517    like ``APP-1-12345678``).
518
519    `arg` tells what kind of transition to trigger. This will be a
520    transition id like ``'use'`` or ``'init'``, or some transition
521    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
522
523    If `toward` is ``False`` (the default) you have to pass a
524    transition id as `arg`, otherwise you must give a transition
525    target.
526
527    If `comment` is specified (default is ``None``) the given string
528    will be passed along as transition comment. It will appear in the
529    history of the changed access code. You can use this to place
530    remarks like for which object the access code was used or similar.
531
532    If `owner` is specified, the owner attribute of the access code is checked.
533    If the owner is different :func:`fire_transition` fails and returns False.
534
535    :func:`fire_transition` might raise exceptions depending on the
536    reason why the requested transition cannot be performed.
537
538    The following exceptions can occur during processing:
539
540    :exc:`KeyError`:
541      signals not existent access code, batch or site.
542
543    :exc:`ValueError`:
544      signals illegal format of `access_code` string. The regular format is
545      ``APP-N-XXXXXXXX``.
546
547    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
548      the transition requested cannot be performed because the workflow
549      rules forbid it.
550
551    :exc:`Unauthorized`:
552      the current user is not allowed to perform the requested transition.
553
554    """
555    try:
556        batch_id, ac_id = access_code.rsplit('-', 1)
557    except ValueError:
558        raise ValueError(
559            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
560                access_code,))
561    try:
562        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
563    except TypeError:
564        raise KeyError(
565            'No site available for looking up accesscodes')
566    if owner:
567        ac_owner = getattr(ac, 'owner', None)
568        if ac_owner and ac_owner != owner:
569            return False
570    info = IWorkflowInfo(ac)
571    if toward:
572        info.fireTransitionToward(arg, comment=comment)
573    else:
574        info.fireTransition(arg, comment=comment)
575    return True
576
577def invalidate_accesscode(access_code, comment=None, owner=None):
578    """Invalidate AccessCode denoted by string ``access_code``.
579
580    Fires an appropriate transition to perform the task.
581
582    `comment` is a string that will appear in the access code
583    history.
584
585    See :func:`fire_transition` for possible exceptions and their
586    meanings.
587    """
588    try:
589        return fire_transition(access_code, 'use', comment=comment, owner=owner)
590    except:
591        return False
592
593def disable_accesscode(access_code, comment=None):
594    """Disable AccessCode denoted by string ``access_code``.
595
596    Fires an appropriate transition to perform the task.
597
598    `comment` is a string that will appear in the access code
599    history.
600
601    See :func:`fire_transition` for possible exceptions and their
602    meanings.
603    """
604    return fire_transition(
605        access_code, DISABLED, toward=True, comment=comment)
606
607def reenable_accesscode(access_code, comment=None):
608    """Reenable AccessCode denoted by string ``access_code``.
609
610    Fires an appropriate transition to perform the task.
611
612    `comment` is a string that will appear in the access code
613    history.
614
615    See :func:`fire_transition` for possible exceptions and their
616    meanings.
617    """
618    return fire_transition(access_code, 'reenable', comment=comment)
619
620def create_accesscode(batch_prefix, batch_num, cost, owner):
621    """
622    """
623    batch_id = '%s-%s' % (batch_prefix, batch_num)
624    try:
625        batch = grok.getSite()['accesscodes'][batch_id]
626    except KeyError:
627        return None, u'No AC batch available.'
628    rand_num = list(batch.getNewRandomNum())[0]
629    num = len(batch) + 1
630    batch.addAccessCode(num, rand_num, cost, owner)
631    batch.entry_num += 1
632    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
633    return pin, None
Note: See TracBrowser for help on using the repository browser.