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

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

Some more docs.

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