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

Last change on this file since 6574 was 6553, checked in by uli, 13 years ago

Remove the search method from AccessCodeBatch?. If I rememember
correctly, it is not used and makes not much sense anymore, as we
switched to catalog-based access code lookup.

File size: 17.1 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@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
243def handle_batch_added(batch, event):
244    # A (maybe dirty?) workaround to make batch containers work
245    # without self-maintained acids: as batches should contain their
246    # set of data immediately after creation, but we cannot add
247    # subobjects as long as the batch was not added already to the
248    # ZODB, we trigger the item creation for the time after the batch
249    # was added to the ZODB.
250    batch._createEntries()
251    return
252
253
254class AccessCodeBatchContainer(grok.Container):
255    grok.implements(IAccessCodeBatchContainer)
256
257    def _getStoragePath(self):
258        """Get the directory, where batch import files are stored.
259
260        If the path does not exist yet, it is created. The path is
261        normally ``accesscodes/imports`` below the datacenter storage
262        path (see :data:`waeup.sirp.accesscodes.Datacenter.storage`).
263        """
264        site = grok.getSite()
265        storagepath = site['datacenter'].storage
266        ac_storage = os.path.join(storagepath, 'accesscodes')
267        import_path = os.path.join(ac_storage, 'imports')
268        for path in [ac_storage, import_path]:
269            if not os.path.exists(path):
270                os.mkdir(path)
271                site.logger.info('created path %s' % path)
272        return import_path
273
274    def addBatch(self, batch):
275        """Add an already created `batch`.
276        """
277        batch.num = self.getNum(batch.prefix)
278        key = "%s-%s" % (batch.prefix, batch.num)
279        self[key] = batch
280        self._p_changed = True
281
282    def createBatch(self, creation_date, creator, prefix, cost,
283                    entry_num):
284        """Create and add a batch.
285        """
286        batch_num = self.getNum(prefix)
287        batch = AccessCodeBatch(creation_date, creator, prefix,
288                                cost, entry_num, num=batch_num)
289        self.addBatch(batch)
290        return batch
291
292    def getNum(self, prefix):
293        """Get next unused num for given prefix.
294        """
295        num = 1
296        while self.get('%s-%s' % (prefix, num), None) is not None:
297            num += 1
298        return num
299
300    def getImportFiles(self):
301        """Return a generator with basenames of available import files.
302        """
303        path = self._getStoragePath()
304        for filename in sorted(os.listdir(path)):
305            yield filename
306
307    # This is temporary reimport solution. Access codes will be imported
308    # with state initialized no matter if they have been used before.
309    def reimport(self, filename, creator=u'UNKNOWN'):
310        """Reimport a batch given in CSV file.
311
312        CSV file must be of format as generated by createCSVLogFile
313        method.
314        """
315        path = os.path.join(self._getStoragePath(), filename)
316        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
317        entry = reader.next()
318        cost = float(entry['serial'])
319        num = int(entry['ac'])
320        prefix = entry['prefix']
321        batch_name = '%s-%s' % (prefix, num)
322        if batch_name in self.keys():
323            raise KeyError('Batch already exists: %s' % batch_name)
324        batch = AccessCodeBatch(
325            datetime.now(), creator, prefix, cost, 0, num=num)
326        num_entries = 0
327        self[batch_name] = batch
328        for row in reader:
329            pin = row['ac']
330            serial = int(row['serial'])
331            rand_num = pin.rsplit('-', 1)[-1]
332            batch.addAccessCode(serial, rand_num)
333            num_entries += 1
334        batch.entry_num = num_entries
335
336        batch.createCSVLogFile()
337        return
338
339    def getAccessCode(self, ac_id):
340        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
341        """
342        for batchname in self.keys():
343            batch = self[batchname]
344            try:
345                return batch.getAccessCode(ac_id)
346            except KeyError:
347                continue
348        return None
349
350    def disable(self, ac_id, comment=None):
351        """Disable the AC with ID ``ac_id``.
352
353        ``user_id`` is the user ID of the user triggering the
354        process. Already disabled ACs are left untouched.
355        """
356        ac = self.getAccessCode(ac_id)
357        if ac is None:
358            return
359        disable_accesscode(ac_id, comment)
360        return
361
362    def enable(self, ac_id, comment=None):
363        """(Re-)enable the AC with ID ``ac_id``.
364
365        This leaves the given AC in state ``unused``. Already enabled
366        ACs are left untouched.
367        """
368        ac = self.getAccessCode(ac_id)
369        if ac is None:
370            return
371        reenable_accesscode(ac_id, comment)
372        return
373
374class AccessCodePlugin(grok.GlobalUtility):
375    grok.name('accesscodes')
376    grok.implements(IWAeUPSIRPPluggable)
377
378    def setup(self, site, name, logger):
379        site['accesscodes'] = AccessCodeBatchContainer()
380        logger.info('Installed container for access code batches.')
381        return
382
383    def update(self, site, name, logger):
384        if not 'accesscodes' in site.keys():
385            logger.info('Updating site at %s. Installing access codes.' % (
386                    site,))
387            self.setup(site, name, logger)
388        else:
389            logger.info(
390                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
391                    site, ))
392        return
393
394def get_access_code(access_code):
395    """Get an access code instance.
396
397    An access code here is a string like ``PUDE-1-1234567890``.
398
399    Returns ``None`` if the given code cannot be found.
400
401    This is a convenicence function that is faster than looking up a
402    batch container for the approriate access code.
403    """
404    site = grok.getSite()
405    if not isinstance(access_code, basestring):
406        return None
407    try:
408        batch_id, ac_id = access_code.rsplit('-', 1)
409    except:
410        return None
411    batch = site['accesscodes'].get(batch_id, None)
412    if batch is None:
413        return None
414    try:
415        code = batch.getAccessCode(access_code)
416    except KeyError:
417        return None
418    return code
419
420def fire_transition(access_code, arg, toward=False, comment=None):
421    """Fire workflow transition for access code.
422
423    The access code instance is looked up via `access_code` (a string
424    like ``APP-1-12345678``).
425
426    `arg` tells what kind of transition to trigger. This will be a
427    transition id like ``'use'`` or ``'init'``, or some transition
428    target like :data:`waeup.sirp.accesscodes.workflow.INITIALIZED`.
429
430    If `toward` is ``False`` (the default) you have to pass a
431    transition id as `arg`, otherwise you must give a transition
432    target.
433
434    If `comment` is specified (default is ``None``) the given string
435    will be passed along as transition comment. It will appear in the
436    history of the changed access code. You can use this to place
437    remarks like for which object the access code was used or similar.
438
439    :func:`fire_transition` might raise exceptions depending on the
440    reason why the requested transition cannot be performed.
441
442    The following exceptions can occur during processing:
443
444    :exc:`KeyError`:
445      signals not existent access code, batch or site.
446
447    :exc:`ValueError`:
448      signals illegal format of `access_code` string. The regular format is
449      ``APP-N-XXXXXXXX``.
450
451    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
452      the transition requested cannot be performed because the workflow
453      rules forbid it.
454
455    :exc:`Unauthorized`:
456      the current user is not allowed to perform the requested transition.
457
458    """
459    try:
460        batch_id, ac_id = access_code.rsplit('-', 1)
461    except ValueError:
462        raise ValueError(
463            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
464                access_code,))
465    try:
466        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
467    except TypeError:
468        raise KeyError(
469            'No site available for looking up accesscodes')
470    info = IWorkflowInfo(ac)
471    if toward:
472        info.fireTransitionToward(arg, comment=comment)
473    else:
474        info.fireTransition(arg, comment=comment)
475    return True
476
477def invalidate_accesscode(access_code, comment=None):
478    """Invalidate AccessCode denoted by string ``access_code``.
479
480    Fires an appropriate transition to perform the task.
481
482    `comment` is a string that will appear in the access code
483    history.
484
485    See :func:`fire_transition` for possible exceptions and their
486    meanings.
487    """
488    return fire_transition(access_code, 'use', comment=comment)
489
490def disable_accesscode(access_code, comment=None):
491    """Disable AccessCode denoted by string ``access_code``.
492
493    Fires an appropriate transition to perform the task.
494
495    `comment` is a string that will appear in the access code
496    history.
497
498    See :func:`fire_transition` for possible exceptions and their
499    meanings.
500    """
501    return fire_transition(
502        access_code, DISABLED, toward=True, comment=comment)
503
504def reenable_accesscode(access_code, comment=None):
505    """Reenable AccessCode denoted by string ``access_code``.
506
507    Fires an appropriate transition to perform the task.
508
509    `comment` is a string that will appear in the access code
510    history.
511
512    See :func:`fire_transition` for possible exceptions and their
513    meanings.
514    """
515    return fire_transition(access_code, 'reenable', comment=comment)
Note: See TracBrowser for help on using the repository browser.