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

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

Support accessing the history for an access code via the access code itself.

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