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

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

Increase entry_num by 1 when AC is added.

File size: 19.0 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, owner=None):
180        """Add an access-code.
181        """
182        ac = AccessCode(num, pin)
183        if owner:
184            ac.owner = owner
185        ac.__parent__ = self
186        self[ac.representation] = ac
187        return
188
189    def createCSVLogFile(self):
190        """Create a CSV file with data in batch.
191
192        Data will not contain invalidation date nor student ids.  File
193        will be created in ``accesscodes`` subdir of data center
194        storage path.
195
196        Returns name of created file.
197        """
198        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
199        ac_storage = self._getStoragePath()
200        csv_path = os.path.join(
201            ac_storage, '%s-%s-%s-%s.csv' % (
202                self.prefix, self.num, date, self.creator)
203            )
204        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
205        writer.writerow(['serial', 'ac', 'cost'])
206        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
207
208        for value in sorted(self.values(),
209                            cmp=lambda x, y: cmp(
210                x.batch_serial, y.batch_serial)):
211            writer.writerow(
212                [str(value.batch_serial), str(value.representation)]
213                )
214        site = grok.getSite()
215        logger = site.logger
216        logger.info(
217            "Created batch %s-%s" % (self.prefix, self.num))
218        logger.info(
219            "Written batch CSV to %s" % csv_path)
220        return os.path.basename(csv_path)
221
222    def archive(self):
223        """Create a CSV file for archive.
224        """
225        ac_storage = self._getStoragePath()
226        now = datetime.now()
227        timestamp = now.strftime('%Y_%m_%d_%H_%M_%S')
228        csv_path = os.path.join(
229            ac_storage, '%s-%s_archive-%s-%s.csv' % (
230                self.prefix, self.num, timestamp, self.creator)
231            )
232        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
233        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history'])
234        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
235                         str(self.entry_num)])
236        for value in sorted(
237            self.values(),
238            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
239            ):
240            writer.writerow([
241                    self.prefix, value.batch_serial, value.representation,
242                    value.state, value.history
243                    ])
244        return os.path.basename(csv_path)
245
246@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
247def handle_batch_added(batch, event):
248    # A (maybe dirty?) workaround to make batch containers work
249    # without self-maintained acids: as batches should contain their
250    # set of data immediately after creation, but we cannot add
251    # subobjects as long as the batch was not added already to the
252    # ZODB, we trigger the item creation for the time after the batch
253    # was added to the ZODB.
254    batch._createEntries()
255    return
256
257
258class AccessCodeBatchContainer(grok.Container):
259    grok.implements(IAccessCodeBatchContainer)
260
261    def _getStoragePath(self):
262        """Get the directory, where batch import files are stored.
263
264        If the path does not exist yet, it is created. The path is
265        normally ``accesscodes/imports`` below the datacenter storage
266        path (see :data:`waeup.sirp.accesscodes.Datacenter.storage`).
267        """
268        site = grok.getSite()
269        storagepath = site['datacenter'].storage
270        ac_storage = os.path.join(storagepath, 'accesscodes')
271        import_path = os.path.join(ac_storage, 'imports')
272        for path in [ac_storage, import_path]:
273            if not os.path.exists(path):
274                os.mkdir(path)
275                site.logger.info('created path %s' % path)
276        return import_path
277
278    def addBatch(self, batch):
279        """Add an already created `batch`.
280        """
281        batch.num = self.getNum(batch.prefix)
282        key = "%s-%s" % (batch.prefix, batch.num)
283        self[key] = batch
284        self._p_changed = True
285
286    def createBatch(self, creation_date, creator, prefix, cost,
287                    entry_num):
288        """Create and add a batch.
289        """
290        batch_num = self.getNum(prefix)
291        batch = AccessCodeBatch(creation_date, creator, prefix,
292                                cost, entry_num, num=batch_num)
293        self.addBatch(batch)
294        return batch
295
296    def getNum(self, prefix):
297        """Get next unused num for given prefix.
298        """
299        # School fee, clearance and hostel application batches start with 0.
300        # These batches are being emptily created during initialization of the
301        # university instance.
302        if prefix in ('CLR', 'SFE', 'HOS'):
303            num = 0
304        else:
305            num = 1
306        while self.get('%s-%s' % (prefix, num), None) is not None:
307            num += 1
308        return num
309
310    def getImportFiles(self):
311        """Return a generator with basenames of available import files.
312        """
313        path = self._getStoragePath()
314        for filename in sorted(os.listdir(path)):
315            yield filename
316
317    # This is temporary reimport solution. Access codes will be imported
318    # with state initialized no matter if they have been used before.
319    def reimport(self, filename, creator=u'UNKNOWN'):
320        """Reimport a batch given in CSV file.
321
322        CSV file must be of format as generated by createCSVLogFile
323        method.
324        """
325        path = os.path.join(self._getStoragePath(), filename)
326        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
327        entry = reader.next()
328        cost = float(entry['serial'])
329        num = int(entry['ac'])
330        prefix = entry['prefix']
331        batch_name = '%s-%s' % (prefix, num)
332        if batch_name in self.keys():
333            raise KeyError('Batch already exists: %s' % batch_name)
334        batch = AccessCodeBatch(
335            datetime.now(), creator, prefix, cost, 0, num=num)
336        num_entries = 0
337        self[batch_name] = batch
338        for row in reader:
339            pin = row['ac']
340            serial = int(row['serial'])
341            rand_num = pin.rsplit('-', 1)[-1]
342            batch.addAccessCode(serial, rand_num)
343            num_entries += 1
344        batch.entry_num = num_entries
345
346        batch.createCSVLogFile()
347        return
348
349    def getAccessCode(self, ac_id):
350        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
351        """
352        for batchname in self.keys():
353            batch = self[batchname]
354            try:
355                return batch.getAccessCode(ac_id)
356            except KeyError:
357                continue
358        return None
359
360    def disable(self, ac_id, comment=None):
361        """Disable the AC with ID ``ac_id``.
362
363        ``user_id`` is the user ID of the user triggering the
364        process. Already disabled ACs are left untouched.
365        """
366        ac = self.getAccessCode(ac_id)
367        if ac is None:
368            return
369        disable_accesscode(ac_id, comment)
370        return
371
372    def enable(self, ac_id, comment=None):
373        """(Re-)enable the AC with ID ``ac_id``.
374
375        This leaves the given AC in state ``unused``. Already enabled
376        ACs are left untouched.
377        """
378        ac = self.getAccessCode(ac_id)
379        if ac is None:
380            return
381        reenable_accesscode(ac_id, comment)
382        return
383
384class AccessCodePlugin(grok.GlobalUtility):
385    grok.name('accesscodes')
386    grok.implements(IWAeUPSIRPPluggable)
387
388    def setup(self, site, name, logger):
389        basecontainer = AccessCodeBatchContainer()
390        site['accesscodes'] = basecontainer
391        logger.info('Installed container for access code batches.')
392        # Create empty school fee, clearance and hostel application AC
393        # batches during initialization of university instance.
394        cost = 0.0
395        creator = 'system'
396        entry_num = 0
397        creation_date = datetime.now()
398        basecontainer.createBatch(creation_date, creator,
399            'SFE', cost, entry_num)
400        basecontainer.createBatch(creation_date, creator,
401            'CLR', cost, entry_num)
402        basecontainer.createBatch(creation_date, creator,
403            'HOS', cost, entry_num)
404        logger.info('Installed empty SFE, CLR and HOS access code batches.')
405        return
406
407    def update(self, site, name, logger):
408        if not 'accesscodes' in site.keys():
409            logger.info('Updating site at %s. Installing access codes.' % (
410                    site,))
411            self.setup(site, name, logger)
412        else:
413            logger.info(
414                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
415                    site, ))
416        return
417
418def get_access_code(access_code):
419    """Get an access code instance.
420
421    An access code here is a string like ``PUDE-1-1234567890``.
422
423    Returns ``None`` if the given code cannot be found.
424
425    This is a convenicence function that is faster than looking up a
426    batch container for the approriate access code.
427    """
428    site = grok.getSite()
429    if not isinstance(access_code, basestring):
430        return None
431    try:
432        batch_id, ac_id = access_code.rsplit('-', 1)
433    except:
434        return None
435    batch = site['accesscodes'].get(batch_id, None)
436    if batch is None:
437        return None
438    try:
439        code = batch.getAccessCode(access_code)
440    except KeyError:
441        return None
442    return code
443
444def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
445    """Fire workflow transition for access code.
446
447    The access code instance is looked up via `access_code` (a string
448    like ``APP-1-12345678``).
449
450    `arg` tells what kind of transition to trigger. This will be a
451    transition id like ``'use'`` or ``'init'``, or some transition
452    target like :data:`waeup.sirp.accesscodes.workflow.INITIALIZED`.
453
454    If `toward` is ``False`` (the default) you have to pass a
455    transition id as `arg`, otherwise you must give a transition
456    target.
457
458    If `comment` is specified (default is ``None``) the given string
459    will be passed along as transition comment. It will appear in the
460    history of the changed access code. You can use this to place
461    remarks like for which object the access code was used or similar.
462
463    If `owner` is specified, the owner attribute of the access code is checked.
464    If the owner is different :func:`fire_transition` fails and returns False.
465
466    :func:`fire_transition` might raise exceptions depending on the
467    reason why the requested transition cannot be performed.
468
469    The following exceptions can occur during processing:
470
471    :exc:`KeyError`:
472      signals not existent access code, batch or site.
473
474    :exc:`ValueError`:
475      signals illegal format of `access_code` string. The regular format is
476      ``APP-N-XXXXXXXX``.
477
478    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
479      the transition requested cannot be performed because the workflow
480      rules forbid it.
481
482    :exc:`Unauthorized`:
483      the current user is not allowed to perform the requested transition.
484
485    """
486    try:
487        batch_id, ac_id = access_code.rsplit('-', 1)
488    except ValueError:
489        raise ValueError(
490            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
491                access_code,))
492    try:
493        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
494    except TypeError:
495        raise KeyError(
496            'No site available for looking up accesscodes')
497    if owner:
498        ac_owner = getattr(ac, 'owner', None)
499        if ac_owner and ac_owner != owner:
500            return False
501    info = IWorkflowInfo(ac)
502    if toward:
503        info.fireTransitionToward(arg, comment=comment)
504    else:
505        info.fireTransition(arg, comment=comment)
506    return True
507
508def invalidate_accesscode(access_code, comment=None, owner=None):
509    """Invalidate AccessCode denoted by string ``access_code``.
510
511    Fires an appropriate transition to perform the task.
512
513    `comment` is a string that will appear in the access code
514    history.
515
516    See :func:`fire_transition` for possible exceptions and their
517    meanings.
518    """
519    try:
520        return fire_transition(access_code, 'use', comment=comment, owner=owner)
521    except:
522        return False
523
524def disable_accesscode(access_code, comment=None):
525    """Disable AccessCode denoted by string ``access_code``.
526
527    Fires an appropriate transition to perform the task.
528
529    `comment` is a string that will appear in the access code
530    history.
531
532    See :func:`fire_transition` for possible exceptions and their
533    meanings.
534    """
535    return fire_transition(
536        access_code, DISABLED, toward=True, comment=comment)
537
538def reenable_accesscode(access_code, comment=None):
539    """Reenable AccessCode denoted by string ``access_code``.
540
541    Fires an appropriate transition to perform the task.
542
543    `comment` is a string that will appear in the access code
544    history.
545
546    See :func:`fire_transition` for possible exceptions and their
547    meanings.
548    """
549    return fire_transition(access_code, 'reenable', comment=comment)
550
551def create_accesscode(batch_prefix, batch_num, owner):
552    """
553    """
554    batch_id = '%s-%s' % (batch_prefix, batch_num)
555    try:
556        batch = grok.getSite()['accesscodes'][batch_id]
557    except KeyError:
558        return None, u'No AC batch available.'
559    rand_num = list(batch.getNewRandomNum())[0]
560    #import pdb; pdb.set_trace()
561    num = len(batch) + 1
562    batch.addAccessCode(num, rand_num, owner)
563    batch.entry_num += 1
564    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
565    return pin, None
Note: See TracBrowser for help on using the repository browser.