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

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

Store utc without tzinfo in persistent datetime objects. Localisation will be done in views only.

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