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

Last change on this file since 6451 was 6450, checked in by Henrik Bettermann, 14 years ago

Implement search page for access codes (work in progress).

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