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

Last change on this file since 6495 was 6495, checked in by uli, 14 years ago

Some more docs.

File size: 15.3 KB
RevLine 
[5068]1"""Components to handle access codes.
[6495]2
3Access codes (aka PINs) in waeup sites are organized in batches. That
4means a certain accesscode must be part of a batch. As a site (or
5university) can hold an arbitrary number of batches, we also provide a
6batch container. Each university has one batch container that holds
7all access code batches of which each one can hold several thousands
8of access codes.
[5068]9"""
[5110]10import csv
[5068]11import grok
[5110]12import os
[5118]13from datetime import datetime
[6432]14from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
[5068]15from random import SystemRandom as random
[6423]16from waeup.sirp.interfaces import IWAeUPSIRPPluggable, IObjectHistory
[5079]17from waeup.sirp.accesscodes.interfaces import (
18    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
19    )
[6413]20from waeup.sirp.accesscodes.workflow import DISABLED, USED
[5068]21
[5102]22class ManageACBatches(grok.Permission):
23    grok.name('waeup.manageACBatches')
24
[6359]25class AccessCode(grok.Model):
[6495]26    """An access code (aka PIN).
27    """
[5068]28    grok.implements(IAccessCode)
29
[6386]30    def __init__(self, batch_serial, random_num):
[5068]31        self.batch_serial = batch_serial
32        self.random_num = random_num
[6359]33        IWorkflowInfo(self).fireTransition('init')
[5068]34
35    @property
36    def representation(self):
37        return '%s-%s-%s' % (
38            self.batch_prefix, self.batch_num, self.random_num)
39
[5079]40    @property
41    def batch(self):
42        return getattr(self, '__parent__', None)
[5086]43
[5079]44    @property
45    def batch_prefix(self):
46        if self.batch is None:
47            return ''
48        return self.batch.prefix
[5086]49
[5079]50    @property
51    def batch_num(self):
52        if self.batch is None:
53            return ''
54        return self.batch.num
[5068]55
[5118]56    @property
57    def cost(self):
58        if self.batch is None:
59            return None
60        return self.batch.cost
61
[6413]62    @property
[6470]63    def state(self):
[6450]64        return IWorkflowState(self).getState()
65
66    @property
[6423]67    def history(self):
[6495]68        """A :class:`waeup.sirp.objecthistory.ObjectHistory` instance.
69        """
[6423]70        history = IObjectHistory(self)
[6453]71        return '||'.join(history.messages)
[6423]72
[6417]73class AccessCodeBatch(grok.Container):
[5068]74    """A batch of access codes.
75    """
76    grok.implements(IAccessCodeBatch)
77
[5086]78    def __init__(self, creation_date, creator, batch_prefix, cost,
79                 entry_num, num=None):
[5079]80        super(AccessCodeBatch, self).__init__()
[5068]81        self.creation_date = creation_date
82        self.creator = creator
[5116]83        self.prefix = batch_prefix.upper()
[5068]84        self.cost = cost
85        self.entry_num = entry_num
[5086]86        self.num = num
[6425]87        self.used_num = 0
[5149]88        self.disabled_num = 0
[5079]89
[5118]90    def _createEntries(self):
[5079]91        """Create the entries for this batch.
92        """
[5118]93        for num, pin in enumerate(self._getNewRandomNum(num=self.entry_num)):
[5121]94            self.addAccessCode(num, pin)
[5112]95        return
[5118]96
97    def _getNewRandomNum(self, num=1):
[5112]98        """Create a set of ``num`` random numbers of 10 digits each.
[5086]99
[5068]100        The number is returned as string.
101        """
[5118]102        curr = 1
103        while curr <= num:
[5112]104            pin = ''
[5068]105            for x in range(10):
[5112]106                pin += str(random().randint(0, 9))
[6417]107            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
[5127]108                curr += 1
109                yield pin
110            # PIN already in use
[5073]111
[5118]112    def _getStoragePath(self):
113        """Get the directory, where we store all batch-related CSV files.
114        """
115        site = grok.getSite()
116        storagepath = site['datacenter'].storage
117        ac_storage = os.path.join(storagepath, 'accesscodes')
118        if not os.path.exists(ac_storage):
119            os.mkdir(ac_storage)
120        return ac_storage
121
122    def getAccessCode(self, ac_id):
123        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
124        """
[6417]125        return self[ac_id]
[5118]126
[5121]127    def addAccessCode(self, num, pin):
128        """Add an access-code.
129        """
130        ac = AccessCode(num, pin)
131        ac.__parent__ = self
[6417]132        self[ac.representation] = ac
[5121]133        return
134
[5110]135    def createCSVLogFile(self):
136        """Create a CSV file with data in batch.
137
138        Data will not contain invalidation date nor student ids.  File
139        will be created in ``accesscodes`` subdir of data center
140        storage path.
141
142        Returns name of created file.
143        """
144        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
[5118]145        ac_storage = self._getStoragePath()
[5110]146        csv_path = os.path.join(
147            ac_storage, '%s-%s-%s-%s.csv' % (
148                self.prefix, self.num, date, self.creator)
149            )
150        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
151        writer.writerow(['serial', 'ac', 'cost'])
152        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
[5118]153
[6424]154        for value in sorted(self.values(),
155                            cmp=lambda x, y: cmp(
156                x.batch_serial, y.batch_serial)):
[5110]157            writer.writerow(
158                [str(value.batch_serial), str(value.representation)]
159                )
[5118]160        site = grok.getSite()
[5112]161        logger = site.logger
162        logger.info(
163            "Created batch %s-%s" % (self.prefix, self.num))
164        logger.info(
165            "Written batch CSV to %s" % csv_path)
[5110]166        return os.path.basename(csv_path)
167
[5118]168    def archive(self):
169        """Create a CSV file for archive.
170        """
171        ac_storage = self._getStoragePath()
172        now = datetime.now()
173        timestamp = now.strftime('%Y_%m_%d_%H_%M_%S')
174        csv_path = os.path.join(
175            ac_storage, '%s-%s_archive-%s-%s.csv' % (
176                self.prefix, self.num, timestamp, self.creator)
177            )
178        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
[6470]179        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history'])
[5118]180        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
181                         str(self.entry_num)])
[6424]182        for value in sorted(
183            self.values(),
184            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
185            ):
[5118]186            writer.writerow([
187                    self.prefix, value.batch_serial, value.representation,
[6470]188                    value.state, value.history
[5118]189                    ])
190        return os.path.basename(csv_path)
191
[5149]192    def search(self, searchterm, searchtype):
193        if searchtype == 'serial':
194            if len(self._entries) < searchterm + 1:
195                return []
196            return [self._entries[searchterm]]
197        if searchtype == 'pin':
198            try:
199                entry = self.getAccessCode(searchterm)
200                return [entry]
201            except KeyError:
202                return []
203
[6417]204@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
[6418]205def handle_batch_added(batch, event):
[6417]206    # A (maybe dirty?) workaround to make batch containers work
207    # without self-maintained acids: as batches should contain their
208    # set of data immediately after creation, but we cannot add
209    # subobjects as long as the batch was not added already to the
210    # ZODB, we trigger the item creation for the time after the batch
211    # was added to the ZODB.
212    batch._createEntries()
213    return
214
215
[5079]216class AccessCodeBatchContainer(grok.Container):
217    grok.implements(IAccessCodeBatchContainer)
[5073]218
[5132]219    def _getStoragePath(self):
220        """Get the directory, where batch import files are stored.
221        """
222        site = grok.getSite()
223        storagepath = site['datacenter'].storage
224        ac_storage = os.path.join(storagepath, 'accesscodes')
225        import_path = os.path.join(ac_storage, 'imports')
226        if not os.path.exists(import_path):
227            os.mkdir(import_path)
228        return import_path
229
[5079]230    def addBatch(self, batch):
[5086]231        """Add a batch.
232        """
233        batch.num = self.getNum(batch.prefix)
234        key = "%s-%s" % (batch.prefix, batch.num)
[5079]235        self[key] = batch
[5086]236        self._p_changed = True
[5079]237
[6417]238    def createBatch(self, creation_date, creator, prefix, cost,
[5127]239                    entry_num):
240        """Create and add a batch.
241        """
[6417]242        batch_num = self.getNum(prefix)
243        batch = AccessCodeBatch(creation_date, creator, prefix,
[5127]244                                cost, entry_num, num=batch_num)
245        self.addBatch(batch)
246        return batch
[6124]247
[5086]248    def getNum(self, prefix):
249        """Get next unused num for given prefix.
250        """
251        num = 1
[5116]252        while self.get('%s-%s' % (prefix, num), None) is not None:
[5086]253            num += 1
254        return num
[5095]255
[5132]256    def getImportFiles(self):
257        """Return a generator with basenames of available import files.
258        """
259        path = self._getStoragePath()
260        for filename in sorted(os.listdir(path)):
261            yield filename
[6124]262
[6449]263    # This is temporary reimport solution. Access codes will be imported
[6470]264    # with state initialized no matter if they have been used before.
[5132]265    def reimport(self, filename, creator=u'UNKNOWN'):
266        """Reimport a batch given in CSV file.
267
268        CSV file must be of format as generated by createCSVLogFile
269        method.
270        """
271        path = os.path.join(self._getStoragePath(), filename)
272        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
273        entry = reader.next()
[6449]274        cost = float(entry['serial'])
[5132]275        num = int(entry['ac'])
[6449]276        prefix = entry['prefix']
277        batch_name = '%s-%s' % (prefix, num)
[5132]278        if batch_name in self.keys():
279            raise KeyError('Batch already exists: %s' % batch_name)
280        batch = AccessCodeBatch(
[6449]281            datetime.now(), creator, prefix, cost, 0, num=num)
[5132]282        num_entries = 0
[6417]283        self[batch_name] = batch
[5132]284        for row in reader:
285            pin = row['ac']
286            serial = int(row['serial'])
287            rand_num = pin.rsplit('-', 1)[-1]
288            batch.addAccessCode(serial, rand_num)
289            num_entries += 1
290        batch.entry_num = num_entries
[6417]291
[5132]292        batch.createCSVLogFile()
293        return
[5149]294
[5153]295    def getAccessCode(self, ac_id):
296        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
297        """
298        for batchname in self.keys():
299            batch = self[batchname]
300            try:
301                return batch.getAccessCode(ac_id)
302            except KeyError:
303                continue
304        return None
[6124]305
[6458]306    def disable(self, ac_id, comment=None):
[5153]307        """Disable the AC with ID ``ac_id``.
308
309        ``user_id`` is the user ID of the user triggering the
310        process. Already disabled ACs are left untouched.
311        """
312        ac = self.getAccessCode(ac_id)
313        if ac is None:
314            return
[6458]315        disable_accesscode(ac_id, comment)
[5153]316        return
317
[6458]318    def enable(self, ac_id, comment=None):
[5153]319        """(Re-)enable the AC with ID ``ac_id``.
320
321        This leaves the given AC in state ``unused``. Already enabled
322        ACs are left untouched.
323        """
324        ac = self.getAccessCode(ac_id)
325        if ac is None:
326            return
[6458]327        reenable_accesscode(ac_id, comment)
[5153]328        return
329
[5073]330class AccessCodePlugin(grok.GlobalUtility):
331    grok.name('accesscodes')
332    grok.implements(IWAeUPSIRPPluggable)
333
334    def setup(self, site, name, logger):
[5079]335        site['accesscodes'] = AccessCodeBatchContainer()
336        logger.info('Installed container for access code batches.')
337        return
[5073]338
339    def update(self, site, name, logger):
[5107]340        if not 'accesscodes' in site.keys():
341            logger.info('Updating site at %s. Installing access codes.' % (
342                    site,))
343            self.setup(site, name, logger)
344        else:
345            logger.info(
346                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
347                    site, ))
348        return
[5447]349
350def get_access_code(access_code):
351    """Get an access code instance.
352
353    An access code here is a string like ``PUDE-1-1234567890``.
[6124]354
[5447]355    Returns ``None`` if the given code cannot be found.
356
357    This is a convenicence function that is faster than looking up a
358    batch container for the approriate access code.
359    """
360    site = grok.getSite()
361    if not isinstance(access_code, basestring):
362        return None
363    try:
364        batch_id, ac_id = access_code.rsplit('-', 1)
365    except:
366        return None
[5467]367    batch = site['accesscodes'].get(batch_id, None)
368    if batch is None:
[5447]369        return None
370    try:
371        code = batch.getAccessCode(access_code)
372    except KeyError:
373        return None
374    return code
[6359]375
[6420]376def fire_transition(access_code, arg, toward=False, comment=None):
[6408]377    """Fire workflow transition for access code.
378
379    The access code instance is looked up via `access_code` (a string
380    like ``APP-1-12345678``).
381
382    `arg` tells what kind of transition to trigger. This will be a
383    transition id like ``'use'`` or ``'init'``, or some transition
[6493]384    target like :data:`waeup.sirp.accesscodes.workflow.INITIALIZED`.
[6408]385
386    If `toward` is ``False`` (the default) you have to pass a
387    transition id as `arg`, otherwise you must give a transition
388    target.
389
[6420]390    If `comment` is specified (default is ``None``) the given string
391    will be passed along as transition comment. It will appear in the
392    history of the changed access code. You can use this to place
393    remarks like for which object the access code was used or similar.
394
[6408]395    :func:`fire_transition` might raise exceptions depending on the
396    reason why the requested transition cannot be performed.
397
398    The following exceptions can occur during processing:
399
400    :exc:`KeyError`:
401      signals not existent access code, batch or site.
402
403    :exc:`ValueError`:
404      signals illegal format of `access_code` string. The regular format is
405      ``APP-N-XXXXXXXX``.
406
407    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
408      the transition requested cannot be performed because the workflow
409      rules forbid it.
410
411    :exc:`Unauthorized`:
412      the current user is not allowed to perform the requested transition.
413
414    """
[6374]415    try:
[6408]416        batch_id, ac_id = access_code.rsplit('-', 1)
417    except ValueError:
418        raise ValueError(
419            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
420                access_code,))
421    try:
422        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
423    except TypeError:
424        raise KeyError(
425            'No site available for looking up accesscodes')
426    info = IWorkflowInfo(ac)
427    if toward:
[6420]428        info.fireTransitionToward(arg, comment=comment)
[6408]429    else:
[6420]430        info.fireTransition(arg, comment=comment)
[6374]431    return True
432
[6420]433def invalidate_accesscode(access_code, comment=None):
[6374]434    """Invalidate AccessCode denoted by string ``access_code``.
435
436    Fires an appropriate transition to perform the task.
437
[6420]438    `comment` is a string that will appear in the access code
439    history.
440
[6408]441    See :func:`fire_transition` for possible exceptions and their
442    meanings.
[6374]443    """
[6420]444    return fire_transition(access_code, 'use', comment=comment)
[6359]445
[6420]446def disable_accesscode(access_code, comment=None):
[6374]447    """Disable AccessCode denoted by string ``access_code``.
448
449    Fires an appropriate transition to perform the task.
450
[6420]451    `comment` is a string that will appear in the access code
452    history.
453
[6408]454    See :func:`fire_transition` for possible exceptions and their
455    meanings.
[6374]456    """
[6420]457    return fire_transition(
458        access_code, DISABLED, toward=True, comment=comment)
[6359]459
[6420]460def reenable_accesscode(access_code, comment=None):
[6374]461    """Reenable AccessCode denoted by string ``access_code``.
462
463    Fires an appropriate transition to perform the task.
464
[6420]465    `comment` is a string that will appear in the access code
466    history.
467
[6408]468    See :func:`fire_transition` for possible exceptions and their
469    meanings.
[6374]470    """
[6420]471    return fire_transition(access_code, 'reenable', comment=comment)
Note: See TracBrowser for help on using the repository browser.