source: main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/accesscode.py @ 8321

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

We have to store the cost in AccessCode? not only in AccessCodeBatches?. We need this for access code slips in students.

  • Property svn:keywords set to Id
File size: 20.5 KB
Line 
1## $Id: accesscode.py 8321 2012-05-02 06:24:42Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""Components to handle access codes.
19
20Access codes (aka PINs) in waeup sites are organized in batches. That
21means a certain accesscode must be part of a batch. As a site (or
22university) can hold an arbitrary number of batches, we also provide a
23batch container. Each university has one batch container that holds
24all access code batches of which each one can hold several thousands
25of access codes.
26"""
27import csv
28import grok
29import os
30from datetime import datetime
31from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
32from random import SystemRandom as random
33from zope.component import getUtility
34from waeup.kofa.interfaces import IKofaUtils, IKofaPluggable, IObjectHistory
35from waeup.kofa.utils.helpers import now
36from waeup.kofa.utils.logger import Logger
37from waeup.kofa.accesscodes.interfaces import (
38    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
39    )
40from waeup.kofa.accesscodes.workflow import DISABLED, USED, ac_states_dict
41
42class AccessCode(grok.Model):
43    """An access code (aka PIN).
44
45    Implements
46    :class:`waeup.kofa.accesscodes.interfaces.IAccessCode`. :class:`AccessCode`
47    instances are normally part of an :class:`AccessCodeBatch` so
48    their representation (or code) is built with the containing batch
49    involved.
50
51    `batch_serial`
52       the serial number of the new :class:`AccessCode` inside its batch.
53
54    `random_num`
55       a 10-digit number representing the main part of the code.
56
57    :class:`AccessCode` instances normally have a representation (or
58    code) like
59
60      ``APP-XXX-YYYYYYYYYY``
61
62    where ``APP`` is the prefix of the containing batch, ``XXX`` is
63    the batch number and ``YYYYYYYYYY`` is the real code. The complete
64    PIN is portal-wide unique.
65
66    Access code instances are far more than simple strings. They have
67    a state, a history (so that all changes can be tracked) and a
68    cost (given as a float number).
69
70    The state of an access code is something like 'used', 'disabled',
71    etc. and determined by the workflow defined in
72    :mod:`waeup.kofa.accesscodes.workflow`. This also means that
73    instead of setting the status of an access code directly (you
74    can't do that easily, and yes, that's intentionally), you have to
75    trigger a transition (that might fail, if the transition is not
76    allowed in terms of logic or permissions). See
77    :mod:`waeup.kofa.accesscodes.workflow` for details.
78
79    """
80    grok.implements(IAccessCode)
81
82    def __init__(self, batch_serial, random_num):
83        super(AccessCode, self).__init__()
84        self.batch_serial = batch_serial
85        self.random_num = random_num
86        self.owner = None
87        self.cost = None
88        IWorkflowInfo(self).fireTransition('init')
89
90    @property
91    def representation(self):
92        """A string representation of the :class:`AccessCode`.
93
94        It has format ``APP-XXX-YYYYYYYYYY`` as described above.
95        """
96        return '%s-%s-%s' % (
97            self.batch_prefix, self.batch_num, self.random_num)
98
99    @property
100    def batch(self):
101        """The batch this :class:`AccessCode` is contained.
102        """
103        return getattr(self, '__parent__', None)
104
105    @property
106    def batch_prefix(self):
107        """The prefix of the batch this :class:`AccessCode` belongs to.
108        """
109        if self.batch is None:
110            return ''
111        return self.batch.prefix
112
113    @property
114    def batch_num(self):
115        """The number of the batch this :class:`AccessCode` belongs to. A
116        read-only attribute.
117        """
118        if self.batch is None:
119            return ''
120        return self.batch.num
121
122    #@property
123    #def cost(self):
124    #    """A float representing the price or ``None``. A read-only attribute.
125    #    """
126    #    if self.batch is None:
127    #        return None
128    #    return self.batch.cost
129
130    @property
131    def state(self):
132        """The workflow state. A read-only attribute.
133        """
134        return IWorkflowState(self).getState()
135
136    @property
137    def translated_state(self):
138        """The translated workflow state. A read-only attribute.
139        """
140        return ac_states_dict[self.state]
141
142    @property
143    def history(self):
144        """A :class:`waeup.kofa.objecthistory.ObjectHistory` instance.
145        """
146        history = IObjectHistory(self)
147        return '||'.join(history.messages)
148
149class AccessCodeBatch(grok.Container):
150    """A batch of access codes.
151    """
152    grok.implements(IAccessCodeBatch)
153
154    def __init__(self, creation_date, creator, batch_prefix, cost,
155                 entry_num, num=None):
156        super(AccessCodeBatch, self).__init__()
157        self.creation_date = creation_date
158        self.creator = creator
159        self.prefix = batch_prefix.upper()
160        self.cost = cost
161        self.entry_num = entry_num
162        self.num = num
163        self.used_num = 0
164        self.disabled_num = 0
165
166    def _createEntries(self):
167        """Create the entries for this batch.
168        """
169        for num, pin in enumerate(self.getNewRandomNum(num=self.entry_num)):
170            self.addAccessCode(num, pin, self.cost)
171        return
172
173    def getNewRandomNum(self, num=1):
174        """Create a set of ``num`` random numbers of 10 digits each.
175
176        The number is returned as string.
177        """
178        curr = 1
179        while curr <= num:
180            pin = ''
181            for x in range(10):
182                pin += str(random().randint(0, 9))
183            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
184                curr += 1
185                yield pin
186            # PIN already in use
187
188    def _getStoragePath(self):
189        """Get the directory, where we store all batch-related CSV files.
190        """
191        site = grok.getSite()
192        storagepath = site['datacenter'].storage
193        ac_storage = os.path.join(storagepath, 'accesscodes')
194        if not os.path.exists(ac_storage):
195            os.mkdir(ac_storage)
196        return ac_storage
197
198    def getAccessCode(self, ac_id):
199        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
200        """
201        return self[ac_id]
202
203    def addAccessCode(self, num, pin, cost=0.0, owner=None):
204        """Add an access-code.
205        """
206        ac = AccessCode(num, pin)
207        if owner:
208            ac.owner = owner
209        ac.cost = cost
210        ac.__parent__ = self
211        self[ac.representation] = ac
212        return
213
214    def createCSVLogFile(self):
215        """Create a CSV file with data in batch.
216
217        Data will not contain invalidation date nor student ids.  File
218        will be created in ``accesscodes`` subdir of data center
219        storage path.
220
221        Returns name of created file.
222        """
223        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
224        ac_storage = self._getStoragePath()
225        csv_path = os.path.join(
226            ac_storage, '%s-%s-%s-%s.csv' % (
227                self.prefix, self.num, date, self.creator)
228            )
229        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
230        writer.writerow(['serial', 'ac', 'cost'])
231        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
232
233        for value in sorted(self.values(),
234                            cmp=lambda x, y: cmp(
235                x.batch_serial, y.batch_serial)):
236            writer.writerow(
237                [str(value.batch_serial), str(value.representation)]
238                )
239        site = grok.getSite()
240        logger = site.logger
241        logger.info(
242            "Created batch %s-%s" % (self.prefix, self.num))
243        logger.info(
244            "Written batch CSV to %s" % csv_path)
245        return os.path.basename(csv_path)
246
247    def archive(self):
248        """Create a CSV file for archive.
249        """
250        ac_storage = self._getStoragePath()
251        tz = getUtility(IKofaUtils).tzinfo
252        dt_now = now(tz)
253        timestamp = dt_now.strftime('%Y_%m_%d_%H_%M_%S')
254        csv_path = os.path.join(
255            ac_storage, '%s-%s_archive-%s-%s.csv' % (
256                self.prefix, self.num, timestamp, self.creator)
257            )
258        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
259        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history', 'cost'])
260        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
261                         str(self.entry_num)])
262        for value in sorted(
263            self.values(),
264            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
265            ):
266            writer.writerow([
267                    self.prefix, value.batch_serial, value.representation,
268                    value.state, value.history, value.cost
269                    ])
270        return os.path.basename(csv_path)
271
272@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
273def handle_batch_added(batch, event):
274    # A (maybe dirty?) workaround to make batch containers work
275    # without self-maintained acids: as batches should contain their
276    # set of data immediately after creation, but we cannot add
277    # subobjects as long as the batch was not added already to the
278    # ZODB, we trigger the item creation for the time after the batch
279    # was added to the ZODB.
280    batch._createEntries()
281    return
282
283
284class AccessCodeBatchContainer(grok.Container, Logger):
285    grok.implements(IAccessCodeBatchContainer)
286
287    def _getStoragePath(self):
288        """Get the directory, where batch import files are stored.
289
290        If the path does not exist yet, it is created. The path is
291        normally ``accesscodes/imports`` below the datacenter storage
292        path (see :data:`waeup.kofa.accesscodes.Datacenter.storage`).
293        """
294        site = grok.getSite()
295        storagepath = site['datacenter'].storage
296        ac_storage = os.path.join(storagepath, 'accesscodes')
297        import_path = os.path.join(ac_storage, 'imports')
298        for path in [ac_storage, import_path]:
299            if not os.path.exists(path):
300                os.mkdir(path)
301                site.logger.info('created path %s' % path)
302        return import_path
303
304    def addBatch(self, batch):
305        """Add an already created `batch`.
306        """
307        batch.num = self.getNum(batch.prefix)
308        key = "%s-%s" % (batch.prefix, batch.num)
309        self[key] = batch
310        self._p_changed = True
311
312    def createBatch(self, creation_date, creator, prefix, cost,
313                    entry_num):
314        """Create and add a batch.
315        """
316        batch_num = self.getNum(prefix)
317        batch = AccessCodeBatch(creation_date, creator, prefix,
318                                cost, entry_num, num=batch_num)
319        self.addBatch(batch)
320        return batch
321
322    def getNum(self, prefix):
323        """Get next unused num for given prefix.
324        """
325        # School fee, clearance and hostel application batches start with 0.
326        # These batches are being emptily created during initialization of the
327        # university instance.
328        if prefix in ('CLR', 'SFE', 'HOS'):
329            num = 0
330        else:
331            num = 1
332        while self.get('%s-%s' % (prefix, num), None) is not None:
333            num += 1
334        return num
335
336    def getImportFiles(self):
337        """Return a generator with basenames of available import files.
338        """
339        path = self._getStoragePath()
340        for filename in sorted(os.listdir(path)):
341            yield filename
342
343    # This is temporary reimport solution. Access codes will be imported
344    # with state initialized no matter if they have been used before.
345    def reimport(self, filename, creator=u'UNKNOWN'):
346        """Reimport a batch given in CSV file.
347
348        CSV file must be of format as generated by createCSVLogFile
349        method.
350        """
351        path = os.path.join(self._getStoragePath(), filename)
352        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
353        entry = reader.next()
354        cost = float(entry['serial'])
355        num = int(entry['ac'])
356        prefix = entry['prefix']
357        batch_name = '%s-%s' % (prefix, num)
358        if batch_name in self.keys():
359            raise KeyError('Batch already exists: %s' % batch_name)
360        batch = AccessCodeBatch(
361            datetime.utcnow(), creator, prefix, cost, 0, num=num)
362        num_entries = 0
363        self[batch_name] = batch
364        for row in reader:
365            pin = row['ac']
366            serial = int(row['serial'])
367            try:
368                cost = float(row['cost'])
369            except ValueError:
370                cost = 0.0
371            rand_num = pin.rsplit('-', 1)[-1]
372            batch.addAccessCode(serial, rand_num, cost)
373            num_entries += 1
374        batch.entry_num = num_entries
375        batch.createCSVLogFile()
376        return
377
378    def getAccessCode(self, ac_id):
379        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
380        """
381        for batchname in self.keys():
382            batch = self[batchname]
383            try:
384                return batch.getAccessCode(ac_id)
385            except KeyError:
386                continue
387        return None
388
389    def disable(self, ac_id, comment=None):
390        """Disable the AC with ID ``ac_id``.
391
392        ``user_id`` is the user ID of the user triggering the
393        process. Already disabled ACs are left untouched.
394        """
395        ac = self.getAccessCode(ac_id)
396        if ac is None:
397            return
398        disable_accesscode(ac_id, comment)
399        return
400
401    def enable(self, ac_id, comment=None):
402        """(Re-)enable the AC with ID ``ac_id``.
403
404        This leaves the given AC in state ``unused``. Already enabled
405        ACs are left untouched.
406        """
407        ac = self.getAccessCode(ac_id)
408        if ac is None:
409            return
410        reenable_accesscode(ac_id, comment)
411        return
412
413    logger_name = 'waeup.kofa.${sitename}.accesscodes'
414    logger_filename = 'accesscodes.log'
415
416    def logger_info(self, ob_class, comment=None):
417        """Get the logger's info method.
418        """
419        self.logger.info('%s - %s' % (
420                ob_class, comment))
421        return
422
423class AccessCodePlugin(grok.GlobalUtility):
424    grok.name('accesscodes')
425    grok.implements(IKofaPluggable)
426
427    def setup(self, site, name, logger):
428        basecontainer = AccessCodeBatchContainer()
429        site['accesscodes'] = basecontainer
430        logger.info('Installed container for access code batches.')
431        # Create empty school fee, clearance and hostel application AC
432        # batches during initialization of university instance.
433        cost = 0.0
434        creator = 'system'
435        entry_num = 0
436        creation_date = datetime.utcnow()
437        basecontainer.createBatch(creation_date, creator,
438            'SFE', cost, entry_num)
439        basecontainer.createBatch(creation_date, creator,
440            'CLR', cost, entry_num)
441        basecontainer.createBatch(creation_date, creator,
442            'HOS', cost, entry_num)
443        logger.info('Installed empty SFE, CLR and HOS access code batches.')
444        return
445
446    def update(self, site, name, logger):
447        if not 'accesscodes' in site.keys():
448            logger.info('Updating site at %s. Installing access codes.' % (
449                    site,))
450            self.setup(site, name, logger)
451        else:
452            logger.info(
453                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
454                    site, ))
455        return
456
457def get_access_code(access_code):
458    """Get an access code instance.
459
460    An access code here is a string like ``PUDE-1-1234567890``.
461
462    Returns ``None`` if the given code cannot be found.
463
464    This is a convenicence function that is faster than looking up a
465    batch container for the approriate access code.
466    """
467    site = grok.getSite()
468    if not isinstance(access_code, basestring):
469        return None
470    try:
471        batch_id, ac_id = access_code.rsplit('-', 1)
472    except:
473        return None
474    batch = site['accesscodes'].get(batch_id, None)
475    if batch is None:
476        return None
477    try:
478        code = batch.getAccessCode(access_code)
479    except KeyError:
480        return None
481    return code
482
483def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
484    """Fire workflow transition for access code.
485
486    The access code instance is looked up via `access_code` (a string
487    like ``APP-1-12345678``).
488
489    `arg` tells what kind of transition to trigger. This will be a
490    transition id like ``'use'`` or ``'init'``, or some transition
491    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
492
493    If `toward` is ``False`` (the default) you have to pass a
494    transition id as `arg`, otherwise you must give a transition
495    target.
496
497    If `comment` is specified (default is ``None``) the given string
498    will be passed along as transition comment. It will appear in the
499    history of the changed access code. You can use this to place
500    remarks like for which object the access code was used or similar.
501
502    If `owner` is specified, the owner attribute of the access code is checked.
503    If the owner is different :func:`fire_transition` fails and returns False.
504
505    :func:`fire_transition` might raise exceptions depending on the
506    reason why the requested transition cannot be performed.
507
508    The following exceptions can occur during processing:
509
510    :exc:`KeyError`:
511      signals not existent access code, batch or site.
512
513    :exc:`ValueError`:
514      signals illegal format of `access_code` string. The regular format is
515      ``APP-N-XXXXXXXX``.
516
517    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
518      the transition requested cannot be performed because the workflow
519      rules forbid it.
520
521    :exc:`Unauthorized`:
522      the current user is not allowed to perform the requested transition.
523
524    """
525    try:
526        batch_id, ac_id = access_code.rsplit('-', 1)
527    except ValueError:
528        raise ValueError(
529            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
530                access_code,))
531    try:
532        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
533    except TypeError:
534        raise KeyError(
535            'No site available for looking up accesscodes')
536    if owner:
537        ac_owner = getattr(ac, 'owner', None)
538        if ac_owner and ac_owner != owner:
539            return False
540    info = IWorkflowInfo(ac)
541    if toward:
542        info.fireTransitionToward(arg, comment=comment)
543    else:
544        info.fireTransition(arg, comment=comment)
545    return True
546
547def invalidate_accesscode(access_code, comment=None, owner=None):
548    """Invalidate AccessCode denoted by string ``access_code``.
549
550    Fires an appropriate transition to perform the task.
551
552    `comment` is a string that will appear in the access code
553    history.
554
555    See :func:`fire_transition` for possible exceptions and their
556    meanings.
557    """
558    try:
559        return fire_transition(access_code, 'use', comment=comment, owner=owner)
560    except:
561        return False
562
563def disable_accesscode(access_code, comment=None):
564    """Disable AccessCode denoted by string ``access_code``.
565
566    Fires an appropriate transition to perform the task.
567
568    `comment` is a string that will appear in the access code
569    history.
570
571    See :func:`fire_transition` for possible exceptions and their
572    meanings.
573    """
574    return fire_transition(
575        access_code, DISABLED, toward=True, comment=comment)
576
577def reenable_accesscode(access_code, comment=None):
578    """Reenable AccessCode denoted by string ``access_code``.
579
580    Fires an appropriate transition to perform the task.
581
582    `comment` is a string that will appear in the access code
583    history.
584
585    See :func:`fire_transition` for possible exceptions and their
586    meanings.
587    """
588    return fire_transition(access_code, 'reenable', comment=comment)
589
590def create_accesscode(batch_prefix, batch_num, owner):
591    """
592    """
593    batch_id = '%s-%s' % (batch_prefix, batch_num)
594    try:
595        batch = grok.getSite()['accesscodes'][batch_id]
596    except KeyError:
597        return None, u'No AC batch available.'
598    rand_num = list(batch.getNewRandomNum())[0]
599    num = len(batch) + 1
600    batch.addAccessCode(num, rand_num, owner)
601    batch.entry_num += 1
602    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
603    return pin, None
Note: See TracBrowser for help on using the repository browser.