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

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

Replace the term 'WAeUP' by SIRP which is a WAeUP product.

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