source: main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscode.py @ 7733

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

Translate accesscode workflow.

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