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

Last change on this file since 10571 was 10448, checked in by Henrik Bettermann, 11 years ago

Add TSC access code batch.

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