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

Last change on this file since 9260 was 9260, checked in by Henrik Bettermann, 12 years ago

Archive also the owner of an accesscode.

  • Property svn:keywords set to Id
File size: 20.6 KB
Line 
1## $Id: accesscode.py 9260 2012-09-30 20:28:32Z 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',
260            'cost','owner'])
261        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
262                         str(self.entry_num)])
263        for value in sorted(
264            self.values(),
265            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
266            ):
267            writer.writerow([
268                    self.prefix, value.batch_serial, value.representation,
269                    value.state, value.history, value.cost, value.owner
270                    ])
271        return os.path.basename(csv_path)
272
273@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
274def handle_batch_added(batch, event):
275    # A (maybe dirty?) workaround to make batch containers work
276    # without self-maintained acids: as batches should contain their
277    # set of data immediately after creation, but we cannot add
278    # subobjects as long as the batch was not added already to the
279    # ZODB, we trigger the item creation for the time after the batch
280    # was added to the ZODB.
281    batch._createEntries()
282    return
283
284
285class AccessCodeBatchContainer(grok.Container, Logger):
286    grok.implements(IAccessCodeBatchContainer)
287
288    def _getStoragePath(self):
289        """Get the directory, where batch import files are stored.
290
291        If the path does not exist yet, it is created. The path is
292        normally ``accesscodes/imports`` below the datacenter storage
293        path (see :data:`waeup.kofa.accesscodes.Datacenter.storage`).
294        """
295        site = grok.getSite()
296        storagepath = site['datacenter'].storage
297        ac_storage = os.path.join(storagepath, 'accesscodes')
298        import_path = os.path.join(ac_storage, 'imports')
299        for path in [ac_storage, import_path]:
300            if not os.path.exists(path):
301                os.mkdir(path)
302                site.logger.info('created path %s' % path)
303        return import_path
304
305    def addBatch(self, batch):
306        """Add an already created `batch`.
307        """
308        batch.num = self.getNum(batch.prefix)
309        key = "%s-%s" % (batch.prefix, batch.num)
310        self[key] = batch
311        self._p_changed = True
312
313    def createBatch(self, creation_date, creator, prefix, cost,
314                    entry_num):
315        """Create and add a batch.
316        """
317        batch_num = self.getNum(prefix)
318        batch = AccessCodeBatch(creation_date, creator, prefix,
319                                cost, entry_num, num=batch_num)
320        self.addBatch(batch)
321        return batch
322
323    def getNum(self, prefix):
324        """Get next unused num for given prefix.
325        """
326        # School fee, clearance and hostel application batches start with 0.
327        # These batches are being emptily created during initialization of the
328        # university instance.
329        if prefix in ('CLR', 'SFE', 'HOS'):
330            num = 0
331        else:
332            num = 1
333        while self.get('%s-%s' % (prefix, num), None) is not None:
334            num += 1
335        return num
336
337    def getImportFiles(self):
338        """Return a generator with basenames of available import files.
339        """
340        path = self._getStoragePath()
341        for filename in sorted(os.listdir(path)):
342            yield filename
343
344    # This is temporary reimport solution. Access codes will be imported
345    # with state initialized no matter if they have been used before.
346    def reimport(self, filename, creator=u'UNKNOWN'):
347        """Reimport a batch given in CSV file.
348
349        CSV file must be of format as generated by createCSVLogFile
350        method.
351        """
352        path = os.path.join(self._getStoragePath(), filename)
353        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
354        entry = reader.next()
355        cost = float(entry['serial'])
356        num = int(entry['ac'])
357        prefix = entry['prefix']
358        batch_name = '%s-%s' % (prefix, num)
359        if batch_name in self.keys():
360            raise KeyError('Batch already exists: %s' % batch_name)
361        batch = AccessCodeBatch(
362            datetime.utcnow(), creator, prefix, cost, 0, num=num)
363        num_entries = 0
364        self[batch_name] = batch
365        for row in reader:
366            pin = row['ac']
367            serial = int(row['serial'])
368            try:
369                cost = float(row['cost'])
370            except ValueError:
371                cost = 0.0
372            rand_num = pin.rsplit('-', 1)[-1]
373            batch.addAccessCode(serial, rand_num, cost)
374            num_entries += 1
375        batch.entry_num = num_entries
376        batch.createCSVLogFile()
377        return
378
379    def getAccessCode(self, ac_id):
380        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
381        """
382        for batchname in self.keys():
383            batch = self[batchname]
384            try:
385                return batch.getAccessCode(ac_id)
386            except KeyError:
387                continue
388        return None
389
390    def disable(self, ac_id, comment=None):
391        """Disable the AC with ID ``ac_id``.
392
393        ``user_id`` is the user ID of the user triggering the
394        process. Already disabled ACs are left untouched.
395        """
396        ac = self.getAccessCode(ac_id)
397        if ac is None:
398            return
399        disable_accesscode(ac_id, comment)
400        return
401
402    def enable(self, ac_id, comment=None):
403        """(Re-)enable the AC with ID ``ac_id``.
404
405        This leaves the given AC in state ``unused``. Already enabled
406        ACs are left untouched.
407        """
408        ac = self.getAccessCode(ac_id)
409        if ac is None:
410            return
411        reenable_accesscode(ac_id, comment)
412        return
413
414    logger_name = 'waeup.kofa.${sitename}.accesscodes'
415    logger_filename = 'accesscodes.log'
416
417    def logger_info(self, ob_class, comment=None):
418        """Get the logger's info method.
419        """
420        self.logger.info('%s - %s' % (
421                ob_class, comment))
422        return
423
424class AccessCodePlugin(grok.GlobalUtility):
425    grok.name('accesscodes')
426    grok.implements(IKofaPluggable)
427
428    def setup(self, site, name, logger):
429        basecontainer = AccessCodeBatchContainer()
430        site['accesscodes'] = basecontainer
431        logger.info('Installed container for access code batches.')
432        # Create empty school fee, clearance and hostel application AC
433        # batches during initialization of university instance.
434        cost = 0.0
435        creator = 'system'
436        entry_num = 0
437        creation_date = datetime.utcnow()
438        basecontainer.createBatch(creation_date, creator,
439            'SFE', cost, entry_num)
440        basecontainer.createBatch(creation_date, creator,
441            'CLR', cost, entry_num)
442        basecontainer.createBatch(creation_date, creator,
443            'HOS', cost, entry_num)
444        logger.info('Installed empty SFE, CLR and HOS access code batches.')
445        return
446
447    def update(self, site, name, logger):
448        site_name = getattr(site, '__name__', '<Unnamed Site>')
449        if not 'accesscodes' in site.keys():
450            logger.info('Updating site at %s. Installing access codes.' % (
451                    site,))
452            self.setup(site, name, logger)
453        else:
454            logger.info(
455                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
456                    site_name, ))
457        return
458
459def get_access_code(access_code):
460    """Get an access code instance.
461
462    An access code here is a string like ``PUDE-1-1234567890``.
463
464    Returns ``None`` if the given code cannot be found.
465
466    This is a convenicence function that is faster than looking up a
467    batch container for the approriate access code.
468    """
469    site = grok.getSite()
470    if not isinstance(access_code, basestring):
471        return None
472    try:
473        batch_id, ac_id = access_code.rsplit('-', 1)
474    except:
475        return None
476    batch = site['accesscodes'].get(batch_id, None)
477    if batch is None:
478        return None
479    try:
480        code = batch.getAccessCode(access_code)
481    except KeyError:
482        return None
483    return code
484
485def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
486    """Fire workflow transition for access code.
487
488    The access code instance is looked up via `access_code` (a string
489    like ``APP-1-12345678``).
490
491    `arg` tells what kind of transition to trigger. This will be a
492    transition id like ``'use'`` or ``'init'``, or some transition
493    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
494
495    If `toward` is ``False`` (the default) you have to pass a
496    transition id as `arg`, otherwise you must give a transition
497    target.
498
499    If `comment` is specified (default is ``None``) the given string
500    will be passed along as transition comment. It will appear in the
501    history of the changed access code. You can use this to place
502    remarks like for which object the access code was used or similar.
503
504    If `owner` is specified, the owner attribute of the access code is checked.
505    If the owner is different :func:`fire_transition` fails and returns False.
506
507    :func:`fire_transition` might raise exceptions depending on the
508    reason why the requested transition cannot be performed.
509
510    The following exceptions can occur during processing:
511
512    :exc:`KeyError`:
513      signals not existent access code, batch or site.
514
515    :exc:`ValueError`:
516      signals illegal format of `access_code` string. The regular format is
517      ``APP-N-XXXXXXXX``.
518
519    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
520      the transition requested cannot be performed because the workflow
521      rules forbid it.
522
523    :exc:`Unauthorized`:
524      the current user is not allowed to perform the requested transition.
525
526    """
527    try:
528        batch_id, ac_id = access_code.rsplit('-', 1)
529    except ValueError:
530        raise ValueError(
531            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
532                access_code,))
533    try:
534        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
535    except TypeError:
536        raise KeyError(
537            'No site available for looking up accesscodes')
538    if owner:
539        ac_owner = getattr(ac, 'owner', None)
540        if ac_owner and ac_owner != owner:
541            return False
542    info = IWorkflowInfo(ac)
543    if toward:
544        info.fireTransitionToward(arg, comment=comment)
545    else:
546        info.fireTransition(arg, comment=comment)
547    return True
548
549def invalidate_accesscode(access_code, comment=None, owner=None):
550    """Invalidate AccessCode denoted by string ``access_code``.
551
552    Fires an appropriate transition to perform the task.
553
554    `comment` is a string that will appear in the access code
555    history.
556
557    See :func:`fire_transition` for possible exceptions and their
558    meanings.
559    """
560    try:
561        return fire_transition(access_code, 'use', comment=comment, owner=owner)
562    except:
563        return False
564
565def disable_accesscode(access_code, comment=None):
566    """Disable AccessCode denoted by string ``access_code``.
567
568    Fires an appropriate transition to perform the task.
569
570    `comment` is a string that will appear in the access code
571    history.
572
573    See :func:`fire_transition` for possible exceptions and their
574    meanings.
575    """
576    return fire_transition(
577        access_code, DISABLED, toward=True, comment=comment)
578
579def reenable_accesscode(access_code, comment=None):
580    """Reenable AccessCode denoted by string ``access_code``.
581
582    Fires an appropriate transition to perform the task.
583
584    `comment` is a string that will appear in the access code
585    history.
586
587    See :func:`fire_transition` for possible exceptions and their
588    meanings.
589    """
590    return fire_transition(access_code, 'reenable', comment=comment)
591
592def create_accesscode(batch_prefix, batch_num, cost, owner):
593    """
594    """
595    batch_id = '%s-%s' % (batch_prefix, batch_num)
596    try:
597        batch = grok.getSite()['accesscodes'][batch_id]
598    except KeyError:
599        return None, u'No AC batch available.'
600    rand_num = list(batch.getNewRandomNum())[0]
601    num = len(batch) + 1
602    batch.addAccessCode(num, rand_num, cost, owner)
603    batch.entry_num += 1
604    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
605    return pin, None
Note: See TracBrowser for help on using the repository browser.