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

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

Support batch-local counters for used/disabled pins. This time we only set a simple attribute and remove the enable(), disable(), ... methods that have no purpose any more.

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