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

Last change on this file since 9216 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
Line 
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##
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        site_name = getattr(site, '__name__', '<Unnamed Site>')
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.' % (
455                    site_name, ))
456        return
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``.
462
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
475    batch = site['accesscodes'].get(batch_id, None)
476    if batch is None:
477        return None
478    try:
479        code = batch.getAccessCode(access_code)
480    except KeyError:
481        return None
482    return code
483
484def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
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
492    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
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
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
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
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    """
526    try:
527        batch_id, ac_id = access_code.rsplit('-', 1)
528    except ValueError:
529        raise ValueError(
530            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
531                access_code,))
532    try:
533        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
534    except TypeError:
535        raise KeyError(
536            'No site available for looking up accesscodes')
537    if owner:
538        ac_owner = getattr(ac, 'owner', None)
539        if ac_owner and ac_owner != owner:
540            return False
541    info = IWorkflowInfo(ac)
542    if toward:
543        info.fireTransitionToward(arg, comment=comment)
544    else:
545        info.fireTransition(arg, comment=comment)
546    return True
547
548def invalidate_accesscode(access_code, comment=None, owner=None):
549    """Invalidate AccessCode denoted by string ``access_code``.
550
551    Fires an appropriate transition to perform the task.
552
553    `comment` is a string that will appear in the access code
554    history.
555
556    See :func:`fire_transition` for possible exceptions and their
557    meanings.
558    """
559    try:
560        return fire_transition(access_code, 'use', comment=comment, owner=owner)
561    except:
562        return False
563
564def disable_accesscode(access_code, comment=None):
565    """Disable AccessCode denoted by string ``access_code``.
566
567    Fires an appropriate transition to perform the task.
568
569    `comment` is a string that will appear in the access code
570    history.
571
572    See :func:`fire_transition` for possible exceptions and their
573    meanings.
574    """
575    return fire_transition(
576        access_code, DISABLED, toward=True, comment=comment)
577
578def reenable_accesscode(access_code, comment=None):
579    """Reenable AccessCode denoted by string ``access_code``.
580
581    Fires an appropriate transition to perform the task.
582
583    `comment` is a string that will appear in the access code
584    history.
585
586    See :func:`fire_transition` for possible exceptions and their
587    meanings.
588    """
589    return fire_transition(access_code, 'reenable', comment=comment)
590
591def create_accesscode(batch_prefix, batch_num, cost, owner):
592    """
593    """
594    batch_id = '%s-%s' % (batch_prefix, batch_num)
595    try:
596        batch = grok.getSite()['accesscodes'][batch_id]
597    except KeyError:
598        return None, u'No AC batch available.'
599    rand_num = list(batch.getNewRandomNum())[0]
600    num = len(batch) + 1
601    batch.addAccessCode(num, rand_num, cost, owner)
602    batch.entry_num += 1
603    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
604    return pin, None
Note: See TracBrowser for help on using the repository browser.