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

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

Add logger for accesscodes.

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