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

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

Rename applicants.py, accesscodes.py and students.py modules. Their names should be singular like in the university package.

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