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

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

More docs.

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