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

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

Slim down main.log a bit.

  • Property svn:keywords set to Id
File size: 20.6 KB
RevLine 
[7195]1## $Id: accesscode.py 8389 2012-05-09 08:52:17Z 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
[8182]33from zope.component import getUtility
34from waeup.kofa.interfaces import IKofaUtils, IKofaPluggable, IObjectHistory
[8186]35from waeup.kofa.utils.helpers import now
[7811]36from waeup.kofa.utils.logger import Logger
37from waeup.kofa.accesscodes.interfaces import (
[5079]38    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
39    )
[7811]40from waeup.kofa.accesscodes.workflow import DISABLED, USED, ac_states_dict
[5068]41
[6359]42class AccessCode(grok.Model):
[6495]43    """An access code (aka PIN).
[6499]44
45    Implements
[7811]46    :class:`waeup.kofa.accesscodes.interfaces.IAccessCode`. :class:`AccessCode`
[6499]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
[7811]72    :mod:`waeup.kofa.accesscodes.workflow`. This also means that
[6499]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
[7811]77    :mod:`waeup.kofa.accesscodes.workflow` for details.
[6499]78
[6495]79    """
[5068]80    grok.implements(IAccessCode)
81
[6386]82    def __init__(self, batch_serial, random_num):
[6623]83        super(AccessCode, self).__init__()
[5068]84        self.batch_serial = batch_serial
85        self.random_num = random_num
[6927]86        self.owner = None
[8321]87        self.cost = None
[6359]88        IWorkflowInfo(self).fireTransition('init')
[5068]89
90    @property
91    def representation(self):
[6499]92        """A string representation of the :class:`AccessCode`.
93
94        It has format ``APP-XXX-YYYYYYYYYY`` as described above.
95        """
[5068]96        return '%s-%s-%s' % (
97            self.batch_prefix, self.batch_num, self.random_num)
98
[5079]99    @property
100    def batch(self):
[6499]101        """The batch this :class:`AccessCode` is contained.
102        """
[5079]103        return getattr(self, '__parent__', None)
[5086]104
[5079]105    @property
106    def batch_prefix(self):
[6499]107        """The prefix of the batch this :class:`AccessCode` belongs to.
108        """
[5079]109        if self.batch is None:
110            return ''
111        return self.batch.prefix
[5086]112
[5079]113    @property
114    def batch_num(self):
[6499]115        """The number of the batch this :class:`AccessCode` belongs to. A
116        read-only attribute.
117        """
[5079]118        if self.batch is None:
119            return ''
120        return self.batch.num
[5068]121
[8321]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
[5118]129
[6413]130    @property
[6470]131    def state(self):
[6499]132        """The workflow state. A read-only attribute.
133        """
[6450]134        return IWorkflowState(self).getState()
135
136    @property
[7689]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
[6423]143    def history(self):
[7811]144        """A :class:`waeup.kofa.objecthistory.ObjectHistory` instance.
[6495]145        """
[6423]146        history = IObjectHistory(self)
[6453]147        return '||'.join(history.messages)
[6423]148
[6417]149class AccessCodeBatch(grok.Container):
[5068]150    """A batch of access codes.
151    """
152    grok.implements(IAccessCodeBatch)
153
[5086]154    def __init__(self, creation_date, creator, batch_prefix, cost,
155                 entry_num, num=None):
[5079]156        super(AccessCodeBatch, self).__init__()
[5068]157        self.creation_date = creation_date
158        self.creator = creator
[5116]159        self.prefix = batch_prefix.upper()
[5068]160        self.cost = cost
161        self.entry_num = entry_num
[5086]162        self.num = num
[6425]163        self.used_num = 0
[5149]164        self.disabled_num = 0
[5079]165
[5118]166    def _createEntries(self):
[5079]167        """Create the entries for this batch.
168        """
[6936]169        for num, pin in enumerate(self.getNewRandomNum(num=self.entry_num)):
[8321]170            self.addAccessCode(num, pin, self.cost)
[5112]171        return
[5118]172
[6936]173    def getNewRandomNum(self, num=1):
[5112]174        """Create a set of ``num`` random numbers of 10 digits each.
[5086]175
[5068]176        The number is returned as string.
177        """
[5118]178        curr = 1
179        while curr <= num:
[5112]180            pin = ''
[5068]181            for x in range(10):
[5112]182                pin += str(random().randint(0, 9))
[6417]183            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
[5127]184                curr += 1
185                yield pin
186            # PIN already in use
[5073]187
[5118]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        """
[6417]201        return self[ac_id]
[5118]202
[8321]203    def addAccessCode(self, num, pin, cost=0.0, owner=None):
[5121]204        """Add an access-code.
205        """
206        ac = AccessCode(num, pin)
[6936]207        if owner:
208            ac.owner = owner
[8321]209        ac.cost = cost
[5121]210        ac.__parent__ = self
[6417]211        self[ac.representation] = ac
[5121]212        return
213
[5110]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')
[5118]224        ac_storage = self._getStoragePath()
[5110]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])
[5118]232
[6424]233        for value in sorted(self.values(),
234                            cmp=lambda x, y: cmp(
235                x.batch_serial, y.batch_serial)):
[5110]236            writer.writerow(
237                [str(value.batch_serial), str(value.representation)]
238                )
[5118]239        site = grok.getSite()
[5112]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)
[5110]245        return os.path.basename(csv_path)
246
[5118]247    def archive(self):
248        """Create a CSV file for archive.
249        """
250        ac_storage = self._getStoragePath()
[8183]251        tz = getUtility(IKofaUtils).tzinfo
[8186]252        dt_now = now(tz)
253        timestamp = dt_now.strftime('%Y_%m_%d_%H_%M_%S')
[5118]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)
[8321]259        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history', 'cost'])
[5118]260        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
261                         str(self.entry_num)])
[6424]262        for value in sorted(
263            self.values(),
264            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
265            ):
[5118]266            writer.writerow([
267                    self.prefix, value.batch_serial, value.representation,
[8321]268                    value.state, value.history, value.cost
[5118]269                    ])
270        return os.path.basename(csv_path)
271
[6417]272@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
[6418]273def handle_batch_added(batch, event):
[6417]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
[7747]284class AccessCodeBatchContainer(grok.Container, Logger):
[5079]285    grok.implements(IAccessCodeBatchContainer)
[5073]286
[5132]287    def _getStoragePath(self):
288        """Get the directory, where batch import files are stored.
[6542]289
290        If the path does not exist yet, it is created. The path is
291        normally ``accesscodes/imports`` below the datacenter storage
[7811]292        path (see :data:`waeup.kofa.accesscodes.Datacenter.storage`).
[5132]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')
[6542]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)
[5132]302        return import_path
303
[5079]304    def addBatch(self, batch):
[6542]305        """Add an already created `batch`.
[5086]306        """
307        batch.num = self.getNum(batch.prefix)
308        key = "%s-%s" % (batch.prefix, batch.num)
[5079]309        self[key] = batch
[5086]310        self._p_changed = True
[5079]311
[6417]312    def createBatch(self, creation_date, creator, prefix, cost,
[5127]313                    entry_num):
314        """Create and add a batch.
315        """
[6417]316        batch_num = self.getNum(prefix)
317        batch = AccessCodeBatch(creation_date, creator, prefix,
[5127]318                                cost, entry_num, num=batch_num)
319        self.addBatch(batch)
320        return batch
[6124]321
[5086]322    def getNum(self, prefix):
323        """Get next unused num for given prefix.
324        """
[6932]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
[5116]332        while self.get('%s-%s' % (prefix, num), None) is not None:
[5086]333            num += 1
334        return num
[5095]335
[5132]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
[6124]342
[6449]343    # This is temporary reimport solution. Access codes will be imported
[6470]344    # with state initialized no matter if they have been used before.
[5132]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()
[6449]354        cost = float(entry['serial'])
[5132]355        num = int(entry['ac'])
[6449]356        prefix = entry['prefix']
357        batch_name = '%s-%s' % (prefix, num)
[5132]358        if batch_name in self.keys():
359            raise KeyError('Batch already exists: %s' % batch_name)
360        batch = AccessCodeBatch(
[8194]361            datetime.utcnow(), creator, prefix, cost, 0, num=num)
[5132]362        num_entries = 0
[6417]363        self[batch_name] = batch
[5132]364        for row in reader:
365            pin = row['ac']
366            serial = int(row['serial'])
[8321]367            try:
368                cost = float(row['cost'])
369            except ValueError:
370                cost = 0.0
[5132]371            rand_num = pin.rsplit('-', 1)[-1]
[8321]372            batch.addAccessCode(serial, rand_num, cost)
[5132]373            num_entries += 1
374        batch.entry_num = num_entries
375        batch.createCSVLogFile()
376        return
[5149]377
[5153]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
[6124]388
[6458]389    def disable(self, ac_id, comment=None):
[5153]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
[6458]398        disable_accesscode(ac_id, comment)
[5153]399        return
400
[6458]401    def enable(self, ac_id, comment=None):
[5153]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
[6458]410        reenable_accesscode(ac_id, comment)
[5153]411        return
412
[7811]413    logger_name = 'waeup.kofa.${sitename}.accesscodes'
[7747]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
[5073]423class AccessCodePlugin(grok.GlobalUtility):
424    grok.name('accesscodes')
[7819]425    grok.implements(IKofaPluggable)
[5073]426
427    def setup(self, site, name, logger):
[6932]428        basecontainer = AccessCodeBatchContainer()
429        site['accesscodes'] = basecontainer
[5079]430        logger.info('Installed container for access code batches.')
[6933]431        # Create empty school fee, clearance and hostel application AC
432        # batches during initialization of university instance.
[6932]433        cost = 0.0
434        creator = 'system'
435        entry_num = 0
[8194]436        creation_date = datetime.utcnow()
[6932]437        basecontainer.createBatch(creation_date, creator,
[6933]438            'SFE', cost, entry_num)
[6932]439        basecontainer.createBatch(creation_date, creator,
[6933]440            'CLR', cost, entry_num)
[6932]441        basecontainer.createBatch(creation_date, creator,
[6933]442            'HOS', cost, entry_num)
443        logger.info('Installed empty SFE, CLR and HOS access code batches.')
[5079]444        return
[5073]445
446    def update(self, site, name, logger):
[8389]447        site_name = getattr(site, '__name__', '<Unnamed Site>')
[5107]448        if not 'accesscodes' in site.keys():
449            logger.info('Updating site at %s. Installing access codes.' % (
450                    site,))
451            self.setup(site, name, logger)
452        else:
453            logger.info(
454                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
[8389]455                    site_name, ))
[5107]456        return
[5447]457
458def get_access_code(access_code):
459    """Get an access code instance.
460
461    An access code here is a string like ``PUDE-1-1234567890``.
[6124]462
[5447]463    Returns ``None`` if the given code cannot be found.
464
465    This is a convenicence function that is faster than looking up a
466    batch container for the approriate access code.
467    """
468    site = grok.getSite()
469    if not isinstance(access_code, basestring):
470        return None
471    try:
472        batch_id, ac_id = access_code.rsplit('-', 1)
473    except:
474        return None
[5467]475    batch = site['accesscodes'].get(batch_id, None)
476    if batch is None:
[5447]477        return None
478    try:
479        code = batch.getAccessCode(access_code)
480    except KeyError:
481        return None
482    return code
[6359]483
[6927]484def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
[6408]485    """Fire workflow transition for access code.
486
487    The access code instance is looked up via `access_code` (a string
488    like ``APP-1-12345678``).
489
490    `arg` tells what kind of transition to trigger. This will be a
491    transition id like ``'use'`` or ``'init'``, or some transition
[7811]492    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
[6408]493
494    If `toward` is ``False`` (the default) you have to pass a
495    transition id as `arg`, otherwise you must give a transition
496    target.
497
[6420]498    If `comment` is specified (default is ``None``) the given string
499    will be passed along as transition comment. It will appear in the
500    history of the changed access code. You can use this to place
501    remarks like for which object the access code was used or similar.
502
[6927]503    If `owner` is specified, the owner attribute of the access code is checked.
504    If the owner is different :func:`fire_transition` fails and returns False.
505
[6408]506    :func:`fire_transition` might raise exceptions depending on the
507    reason why the requested transition cannot be performed.
508
509    The following exceptions can occur during processing:
510
511    :exc:`KeyError`:
512      signals not existent access code, batch or site.
513
514    :exc:`ValueError`:
515      signals illegal format of `access_code` string. The regular format is
516      ``APP-N-XXXXXXXX``.
517
518    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
519      the transition requested cannot be performed because the workflow
520      rules forbid it.
521
522    :exc:`Unauthorized`:
523      the current user is not allowed to perform the requested transition.
524
525    """
[6374]526    try:
[6408]527        batch_id, ac_id = access_code.rsplit('-', 1)
528    except ValueError:
[6588]529        raise ValueError(
530            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
531                access_code,))
[6408]532    try:
533        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
[6588]534    except TypeError:
535        raise KeyError(
536            'No site available for looking up accesscodes')
[6927]537    if owner:
538        ac_owner = getattr(ac, 'owner', None)
539        if ac_owner and ac_owner != owner:
540            return False
[6408]541    info = IWorkflowInfo(ac)
542    if toward:
[6420]543        info.fireTransitionToward(arg, comment=comment)
[6408]544    else:
[6420]545        info.fireTransition(arg, comment=comment)
[6374]546    return True
547
[6927]548def invalidate_accesscode(access_code, comment=None, owner=None):
[6374]549    """Invalidate AccessCode denoted by string ``access_code``.
550
551    Fires an appropriate transition to perform the task.
552
[6420]553    `comment` is a string that will appear in the access code
554    history.
555
[6408]556    See :func:`fire_transition` for possible exceptions and their
557    meanings.
[6374]558    """
[6588]559    try:
[6927]560        return fire_transition(access_code, 'use', comment=comment, owner=owner)
[6588]561    except:
562        return False
[6359]563
[6420]564def disable_accesscode(access_code, comment=None):
[6374]565    """Disable AccessCode denoted by string ``access_code``.
566
567    Fires an appropriate transition to perform the task.
568
[6420]569    `comment` is a string that will appear in the access code
570    history.
571
[6408]572    See :func:`fire_transition` for possible exceptions and their
573    meanings.
[6374]574    """
[6420]575    return fire_transition(
576        access_code, DISABLED, toward=True, comment=comment)
[6359]577
[6420]578def reenable_accesscode(access_code, comment=None):
[6374]579    """Reenable AccessCode denoted by string ``access_code``.
580
581    Fires an appropriate transition to perform the task.
582
[6420]583    `comment` is a string that will appear in the access code
584    history.
585
[6408]586    See :func:`fire_transition` for possible exceptions and their
587    meanings.
[6374]588    """
[6420]589    return fire_transition(access_code, 'reenable', comment=comment)
[6936]590
[8322]591def create_accesscode(batch_prefix, batch_num, cost, owner):
[6936]592    """
593    """
594    batch_id = '%s-%s' % (batch_prefix, batch_num)
595    try:
596        batch = grok.getSite()['accesscodes'][batch_id]
597    except KeyError:
[6939]598        return None, u'No AC batch available.'
[6936]599    rand_num = list(batch.getNewRandomNum())[0]
600    num = len(batch) + 1
[8322]601    batch.addAccessCode(num, rand_num, cost, owner)
[6945]602    batch.entry_num += 1
[6936]603    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
[7811]604    return pin, None
Note: See TracBrowser for help on using the repository browser.