source: main/waeup.kofa/branches/henrik-transcript-workflow/src/waeup/kofa/accesscodes/accesscode.py @ 17664

Last change on this file since 17664 was 13304, checked in by Henrik Bettermann, 9 years ago

Second line is used for footer if nummber of lines > 1

  • Property svn:keywords set to Id
File size: 22.5 KB
Line 
1## $Id: accesscode.py 13304 2015-10-08 14:40:37Z 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        return
481
482class AccessCodeBatchFactory(grok.GlobalUtility):
483    """A factory for accesscodebatches.
484
485    We need this factory for the accesscodebatchprocessor.
486    """
487    grok.implements(IFactory)
488    grok.name(u'waeup.AccessCodeBatch')
489    title = u"Create a new accesscode batch.",
490    description = u"This factory instantiates new accesscode batch instances."
491
492    def __call__(self, *args, **kw):
493        return AccessCodeBatch()
494
495    def getInterfaces(self):
496        return implementedBy(AccessCodeBatch)
497
498class AccessCodeFactory(grok.GlobalUtility):
499    """A factory for accesscodes.
500
501    We need this factory for the accesscodeprocessor.
502    """
503    grok.implements(IFactory)
504    grok.name(u'waeup.AccessCode')
505    title = u"Create a new accesscode.",
506    description = u"This factory instantiates new accesscode instances."
507
508    def __call__(self, *args, **kw):
509        return AccessCode(*args, **kw)
510
511    def getInterfaces(self):
512        return implementedBy(AccessCode)
513
514def get_access_code(access_code):
515    """Get an access code instance.
516
517    An access code here is a string like ``PUDE-1-1234567890``.
518
519    Returns ``None`` if the given code cannot be found.
520
521    This is a convenicence function that is faster than looking up a
522    batch container for the approriate access code.
523    """
524    site = grok.getSite()
525    if not isinstance(access_code, basestring):
526        return None
527    try:
528        batch_id, ac_id = access_code.rsplit('-', 1)
529    except:
530        return None
531    batch = site['accesscodes'].get(batch_id, None)
532    if batch is None:
533        return None
534    try:
535        code = batch.getAccessCode(access_code)
536    except KeyError:
537        return None
538    return code
539
540def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
541    """Fire workflow transition for access code.
542
543    The access code instance is looked up via `access_code` (a string
544    like ``APP-1-12345678``).
545
546    `arg` tells what kind of transition to trigger. This will be a
547    transition id like ``'use'`` or ``'init'``, or some transition
548    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
549
550    If `toward` is ``False`` (the default) you have to pass a
551    transition id as `arg`, otherwise you must give a transition
552    target.
553
554    If `comment` is specified (default is ``None``) the given string
555    will be passed along as transition comment. It will appear in the
556    history of the changed access code. You can use this to place
557    remarks like for which object the access code was used or similar.
558
559    If `owner` is specified, the owner attribute of the access code is checked.
560    If the access code has an owner but is different, :func:`fire_transition`
561    fails and returns False.
562
563    :func:`fire_transition` might raise exceptions depending on the
564    reason why the requested transition cannot be performed.
565
566    The following exceptions can occur during processing:
567
568    :exc:`KeyError`:
569      signals not existent access code, batch or site.
570
571    :exc:`ValueError`:
572      signals illegal format of `access_code` string. The regular format is
573      ``APP-N-XXXXXXXX``.
574
575    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
576      the transition requested cannot be performed because the workflow
577      rules forbid it.
578
579    :exc:`Unauthorized`:
580      the current user is not allowed to perform the requested transition.
581
582    """
583    try:
584        batch_id, ac_id = access_code.rsplit('-', 1)
585    except ValueError:
586        raise ValueError(
587            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
588                access_code,))
589    try:
590        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
591    except TypeError:
592        raise KeyError(
593            'No site available for looking up accesscodes')
594    if owner:
595        ac_owner = getattr(ac, 'owner', None)
596        if ac_owner and ac_owner != owner:
597            return False
598    info = IWorkflowInfo(ac)
599    if toward:
600        info.fireTransitionToward(arg, comment=comment)
601    else:
602        info.fireTransition(arg, comment=comment)
603    return True
604
605def invalidate_accesscode(access_code, comment=None, owner=None):
606    """Invalidate AccessCode denoted by string ``access_code``.
607
608    Fires an appropriate transition to perform the task.
609
610    `comment` is a string that will appear in the access code
611    history.
612
613    See :func:`fire_transition` for possible exceptions and their
614    meanings.
615    """
616    try:
617        return fire_transition(access_code, 'use', comment=comment, owner=owner)
618    except:
619        return False
620
621def disable_accesscode(access_code, comment=None):
622    """Disable AccessCode denoted by string ``access_code``.
623
624    Fires an appropriate transition to perform the task.
625
626    `comment` is a string that will appear in the access code
627    history.
628
629    See :func:`fire_transition` for possible exceptions and their
630    meanings.
631    """
632    return fire_transition(
633        access_code, DISABLED, toward=True, comment=comment)
634
635def reenable_accesscode(access_code, comment=None):
636    """Reenable AccessCode denoted by string ``access_code``.
637
638    Fires an appropriate transition to perform the task.
639
640    `comment` is a string that will appear in the access code
641    history.
642
643    See :func:`fire_transition` for possible exceptions and their
644    meanings.
645    """
646    return fire_transition(access_code, 'reenable', comment=comment)
647
648def create_accesscode(batch_prefix, batch_num, cost, owner):
649    """
650    """
651    batch_id = '%s-%s' % (batch_prefix, batch_num)
652    try:
653        batch = grok.getSite()['accesscodes'][batch_id]
654    except KeyError:
655        return None, u'No AC batch available.'
656    rand_num = list(batch.getNewRandomNum())[0]
657    num = len(batch) + 1
658    batch.addAccessCode(num, rand_num, cost, owner)
659    batch.entry_num += 1
660    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
661    return pin, None
Note: See TracBrowser for help on using the repository browser.