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

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

Be more verbose.

File size: 18.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        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        # School fee, clearance and hostel application batches start with 0.
298        # These batches are being emptily created during initialization of the
299        # university instance.
300        if prefix in ('CLR', 'SFE', 'HOS'):
301            num = 0
302        else:
303            num = 1
304        while self.get('%s-%s' % (prefix, num), None) is not None:
305            num += 1
306        return num
307
308    def getImportFiles(self):
309        """Return a generator with basenames of available import files.
310        """
311        path = self._getStoragePath()
312        for filename in sorted(os.listdir(path)):
313            yield filename
314
315    # This is temporary reimport solution. Access codes will be imported
316    # with state initialized no matter if they have been used before.
317    def reimport(self, filename, creator=u'UNKNOWN'):
318        """Reimport a batch given in CSV file.
319
320        CSV file must be of format as generated by createCSVLogFile
321        method.
322        """
323        path = os.path.join(self._getStoragePath(), filename)
324        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
325        entry = reader.next()
326        cost = float(entry['serial'])
327        num = int(entry['ac'])
328        prefix = entry['prefix']
329        batch_name = '%s-%s' % (prefix, num)
330        if batch_name in self.keys():
331            raise KeyError('Batch already exists: %s' % batch_name)
332        batch = AccessCodeBatch(
333            datetime.now(), creator, prefix, cost, 0, num=num)
334        num_entries = 0
335        self[batch_name] = batch
336        for row in reader:
337            pin = row['ac']
338            serial = int(row['serial'])
339            rand_num = pin.rsplit('-', 1)[-1]
340            batch.addAccessCode(serial, rand_num)
341            num_entries += 1
342        batch.entry_num = num_entries
343
344        batch.createCSVLogFile()
345        return
346
347    def getAccessCode(self, ac_id):
348        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
349        """
350        for batchname in self.keys():
351            batch = self[batchname]
352            try:
353                return batch.getAccessCode(ac_id)
354            except KeyError:
355                continue
356        return None
357
358    def disable(self, ac_id, comment=None):
359        """Disable the AC with ID ``ac_id``.
360
361        ``user_id`` is the user ID of the user triggering the
362        process. Already disabled ACs are left untouched.
363        """
364        ac = self.getAccessCode(ac_id)
365        if ac is None:
366            return
367        disable_accesscode(ac_id, comment)
368        return
369
370    def enable(self, ac_id, comment=None):
371        """(Re-)enable the AC with ID ``ac_id``.
372
373        This leaves the given AC in state ``unused``. Already enabled
374        ACs are left untouched.
375        """
376        ac = self.getAccessCode(ac_id)
377        if ac is None:
378            return
379        reenable_accesscode(ac_id, comment)
380        return
381
382class AccessCodePlugin(grok.GlobalUtility):
383    grok.name('accesscodes')
384    grok.implements(IWAeUPSIRPPluggable)
385
386    def setup(self, site, name, logger):
387        basecontainer = AccessCodeBatchContainer()
388        site['accesscodes'] = basecontainer
389        logger.info('Installed container for access code batches.')
390        # Create empty school fee, clearance and hostel application AC
391        # batches during initialization of university instance.
392        cost = 0.0
393        creator = 'system'
394        entry_num = 0
395        creation_date = datetime.now()
396        basecontainer.createBatch(creation_date, creator,
397            'SFE', cost, entry_num)
398        basecontainer.createBatch(creation_date, creator,
399            'CLR', cost, entry_num)
400        basecontainer.createBatch(creation_date, creator,
401            'HOS', cost, entry_num)
402        logger.info('Installed empty SFE, CLR and HOS access code batches.')
403        return
404
405    def update(self, site, name, logger):
406        if not 'accesscodes' in site.keys():
407            logger.info('Updating site at %s. Installing access codes.' % (
408                    site,))
409            self.setup(site, name, logger)
410        else:
411            logger.info(
412                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
413                    site, ))
414        return
415
416def get_access_code(access_code):
417    """Get an access code instance.
418
419    An access code here is a string like ``PUDE-1-1234567890``.
420
421    Returns ``None`` if the given code cannot be found.
422
423    This is a convenicence function that is faster than looking up a
424    batch container for the approriate access code.
425    """
426    site = grok.getSite()
427    if not isinstance(access_code, basestring):
428        return None
429    try:
430        batch_id, ac_id = access_code.rsplit('-', 1)
431    except:
432        return None
433    batch = site['accesscodes'].get(batch_id, None)
434    if batch is None:
435        return None
436    try:
437        code = batch.getAccessCode(access_code)
438    except KeyError:
439        return None
440    return code
441
442def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
443    """Fire workflow transition for access code.
444
445    The access code instance is looked up via `access_code` (a string
446    like ``APP-1-12345678``).
447
448    `arg` tells what kind of transition to trigger. This will be a
449    transition id like ``'use'`` or ``'init'``, or some transition
450    target like :data:`waeup.sirp.accesscodes.workflow.INITIALIZED`.
451
452    If `toward` is ``False`` (the default) you have to pass a
453    transition id as `arg`, otherwise you must give a transition
454    target.
455
456    If `comment` is specified (default is ``None``) the given string
457    will be passed along as transition comment. It will appear in the
458    history of the changed access code. You can use this to place
459    remarks like for which object the access code was used or similar.
460
461    If `owner` is specified, the owner attribute of the access code is checked.
462    If the owner is different :func:`fire_transition` fails and returns False.
463
464    :func:`fire_transition` might raise exceptions depending on the
465    reason why the requested transition cannot be performed.
466
467    The following exceptions can occur during processing:
468
469    :exc:`KeyError`:
470      signals not existent access code, batch or site.
471
472    :exc:`ValueError`:
473      signals illegal format of `access_code` string. The regular format is
474      ``APP-N-XXXXXXXX``.
475
476    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
477      the transition requested cannot be performed because the workflow
478      rules forbid it.
479
480    :exc:`Unauthorized`:
481      the current user is not allowed to perform the requested transition.
482
483    """
484    try:
485        batch_id, ac_id = access_code.rsplit('-', 1)
486    except ValueError:
487        raise ValueError(
488            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
489                access_code,))
490    try:
491        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
492    except TypeError:
493        raise KeyError(
494            'No site available for looking up accesscodes')
495    if owner:
496        ac_owner = getattr(ac, 'owner', None)
497        if ac_owner and ac_owner != owner:
498            return False
499    info = IWorkflowInfo(ac)
500    if toward:
501        info.fireTransitionToward(arg, comment=comment)
502    else:
503        info.fireTransition(arg, comment=comment)
504    return True
505
506def invalidate_accesscode(access_code, comment=None, owner=None):
507    """Invalidate AccessCode denoted by string ``access_code``.
508
509    Fires an appropriate transition to perform the task.
510
511    `comment` is a string that will appear in the access code
512    history.
513
514    See :func:`fire_transition` for possible exceptions and their
515    meanings.
516    """
517    try:
518        return fire_transition(access_code, 'use', comment=comment, owner=owner)
519    except:
520        return False
521
522def disable_accesscode(access_code, comment=None):
523    """Disable AccessCode denoted by string ``access_code``.
524
525    Fires an appropriate transition to perform the task.
526
527    `comment` is a string that will appear in the access code
528    history.
529
530    See :func:`fire_transition` for possible exceptions and their
531    meanings.
532    """
533    return fire_transition(
534        access_code, DISABLED, toward=True, comment=comment)
535
536def reenable_accesscode(access_code, comment=None):
537    """Reenable AccessCode denoted by string ``access_code``.
538
539    Fires an appropriate transition to perform the task.
540
541    `comment` is a string that will appear in the access code
542    history.
543
544    See :func:`fire_transition` for possible exceptions and their
545    meanings.
546    """
547    return fire_transition(access_code, 'reenable', comment=comment)
Note: See TracBrowser for help on using the repository browser.