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

Last change on this file since 6927 was 6927, checked in by Henrik Bettermann, 13 years ago

Clearance ACs (and also upcoming school fee ACs) might have been purchased online. Thus we need an AC owner attribute which has to be checked before clearance is being started.

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