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

Last change on this file since 6533 was 6499, checked in by uli, 13 years ago

More docs.

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