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

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

Support comments when doing accesscode transitions. These comments will appear in the respective access codes history.

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