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

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

Fix _getStoragePath to create not-existing subdirs.

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