source: main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.py @ 6424

Last change on this file since 6424 was 6424, checked in by uli, 14 years ago

Respect batch_serial when writing batches to files. I wonder, however,
whether we need this serial number of an access code inside its batch
really.

File size: 16.4 KB
RevLine 
[5068]1"""Components to handle access codes.
2"""
[5110]3import csv
[5068]4import grok
[5110]5import os
[5118]6from BTrees.OIBTree import OIBTree
7from datetime import datetime
[6413]8from hurry.workflow.interfaces import (
9    IWorkflowInfo, InvalidTransitionError, IWorkflowState)
[5068]10from random import SystemRandom as random
[6423]11from waeup.sirp.interfaces import IWAeUPSIRPPluggable, IObjectHistory
[5079]12from waeup.sirp.accesscodes.interfaces import (
13    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
14    )
[6413]15from waeup.sirp.accesscodes.workflow import DISABLED, USED
[5068]16
[5102]17class ManageACBatches(grok.Permission):
18    grok.name('waeup.manageACBatches')
19
[6359]20class AccessCode(grok.Model):
[5068]21    grok.implements(IAccessCode)
22
[6386]23    def __init__(self, batch_serial, random_num):
[5068]24        self.batch_serial = batch_serial
25        self.random_num = random_num
[6359]26        IWorkflowInfo(self).fireTransition('init')
[5068]27
28    @property
29    def representation(self):
30        return '%s-%s-%s' % (
31            self.batch_prefix, self.batch_num, self.random_num)
32
[5079]33    @property
34    def batch(self):
35        return getattr(self, '__parent__', None)
[5086]36
[5079]37    @property
38    def batch_prefix(self):
39        if self.batch is None:
40            return ''
41        return self.batch.prefix
[5086]42
[5079]43    @property
44    def batch_num(self):
45        if self.batch is None:
46            return ''
47        return self.batch.num
[5068]48
[5118]49    @property
50    def cost(self):
51        if self.batch is None:
52            return None
53        return self.batch.cost
54
[6413]55    @property
56    def disabled(self):
57        return IWorkflowState(self).getState() == DISABLED
58
59    @property
60    def used(self):
61        return IWorkflowState(self).getState() == USED
62
[6423]63    @property
64    def history(self):
65        history = IObjectHistory(self)
66        return '\n'.join(history.messages)
67
[6417]68class AccessCodeBatch(grok.Container):
[5068]69    """A batch of access codes.
70    """
71    grok.implements(IAccessCodeBatch)
72
[5086]73    def __init__(self, creation_date, creator, batch_prefix, cost,
74                 entry_num, num=None):
[5079]75        super(AccessCodeBatch, self).__init__()
[5068]76        self.creation_date = creation_date
77        self.creator = creator
[5116]78        self.prefix = batch_prefix.upper()
[5068]79        self.cost = cost
80        self.entry_num = entry_num
[5086]81        self.num = num
[5118]82        self.invalidated_num = 0
[5149]83        self.disabled_num = 0
[5079]84
[5118]85    def _createEntries(self):
[5079]86        """Create the entries for this batch.
87        """
[5118]88        for num, pin in enumerate(self._getNewRandomNum(num=self.entry_num)):
[5121]89            self.addAccessCode(num, pin)
[5112]90        return
[5118]91
92    def _getNewRandomNum(self, num=1):
[5112]93        """Create a set of ``num`` random numbers of 10 digits each.
[5086]94
[5068]95        The number is returned as string.
96        """
[5118]97        curr = 1
98        while curr <= num:
[5112]99            pin = ''
[5068]100            for x in range(10):
[5112]101                pin += str(random().randint(0, 9))
[6417]102            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
[5127]103                curr += 1
104                yield pin
105            # PIN already in use
[5073]106
[5118]107    def _getStoragePath(self):
108        """Get the directory, where we store all batch-related CSV files.
109        """
110        site = grok.getSite()
111        storagepath = site['datacenter'].storage
112        ac_storage = os.path.join(storagepath, 'accesscodes')
113        if not os.path.exists(ac_storage):
114            os.mkdir(ac_storage)
115        return ac_storage
116
117    def entries(self):
118        """Get all entries of this batch as generator.
119        """
[6417]120        for x in self.values():
[5118]121            yield x
[6124]122
[5118]123    def getAccessCode(self, ac_id):
124        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
125        """
[6417]126        return self[ac_id]
[5118]127
[5121]128    def addAccessCode(self, num, pin):
129        """Add an access-code.
130        """
131        ac = AccessCode(num, pin)
132        ac.__parent__ = self
[6417]133        self[ac.representation] = ac
[5121]134        return
135
[5118]136    def invalidate(self, ac_id, student_id=None):
137        """Invalidate the AC with ID ``ac_id``.
138        """
139        self.invalidated_num += 1
140
[6359]141    def disable(self, ac_id, user_id=None):
[5149]142        """Disable the AC with ID ``ac_id``.
143
144        ``user_id`` is the user ID of the user triggering the
145        process. Already disabled ACs are left untouched.
146        """
147        num = self._acids[ac_id]
148        ac = self.getAccessCode(ac_id)
149        if ac._disabled == True:
150            return
151        self.disabled_num += 1
[6124]152
[5149]153    def enable(self, ac_id):
154        """(Re-)enable the AC with ID ``ac_id``.
155
156        This leaves the given AC in state ``unused``. Already enabled
157        ACs are left untouched.
158        """
159
[5110]160    def createCSVLogFile(self):
161        """Create a CSV file with data in batch.
162
163        Data will not contain invalidation date nor student ids.  File
164        will be created in ``accesscodes`` subdir of data center
165        storage path.
166
167        Returns name of created file.
168        """
169        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
[5118]170        ac_storage = self._getStoragePath()
[5110]171        csv_path = os.path.join(
172            ac_storage, '%s-%s-%s-%s.csv' % (
173                self.prefix, self.num, date, self.creator)
174            )
175        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
176        writer.writerow(['serial', 'ac', 'cost'])
177        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
[5118]178
[6424]179        for value in sorted(self.values(),
180                            cmp=lambda x, y: cmp(
181                x.batch_serial, y.batch_serial)):
[5110]182            writer.writerow(
183                [str(value.batch_serial), str(value.representation)]
184                )
[5118]185        site = grok.getSite()
[5112]186        logger = site.logger
187        logger.info(
188            "Created batch %s-%s" % (self.prefix, self.num))
189        logger.info(
190            "Written batch CSV to %s" % csv_path)
[5110]191        return os.path.basename(csv_path)
192
[5118]193    def archive(self):
194        """Create a CSV file for archive.
195        """
196        ac_storage = self._getStoragePath()
197        now = datetime.now()
198        timestamp = now.strftime('%Y_%m_%d_%H_%M_%S')
199        csv_path = os.path.join(
200            ac_storage, '%s-%s_archive-%s-%s.csv' % (
201                self.prefix, self.num, timestamp, self.creator)
202            )
203        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
[6386]204        writer.writerow(['prefix', 'serial', 'ac', 'date'])
[5118]205        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
206                         str(self.entry_num)])
[6424]207        for value in sorted(
208            self.values(),
209            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
210            ):
[5118]211            writer.writerow([
212                    self.prefix, value.batch_serial, value.representation,
213                    ])
214        return os.path.basename(csv_path)
215
[5149]216    def search(self, searchterm, searchtype):
217        if searchtype == 'serial':
218            if len(self._entries) < searchterm + 1:
219                return []
220            return [self._entries[searchterm]]
221        if searchtype == 'pin':
222            try:
223                entry = self.getAccessCode(searchterm)
224                return [entry]
225            except KeyError:
226                return []
227
[6417]228@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
[6418]229def handle_batch_added(batch, event):
[6417]230    # A (maybe dirty?) workaround to make batch containers work
231    # without self-maintained acids: as batches should contain their
232    # set of data immediately after creation, but we cannot add
233    # subobjects as long as the batch was not added already to the
234    # ZODB, we trigger the item creation for the time after the batch
235    # was added to the ZODB.
236    batch._createEntries()
237    return
238
239
[5079]240class AccessCodeBatchContainer(grok.Container):
241    grok.implements(IAccessCodeBatchContainer)
[5073]242
[5132]243    def _getStoragePath(self):
244        """Get the directory, where batch import files are stored.
245        """
246        site = grok.getSite()
247        storagepath = site['datacenter'].storage
248        ac_storage = os.path.join(storagepath, 'accesscodes')
249        import_path = os.path.join(ac_storage, 'imports')
250        if not os.path.exists(import_path):
251            os.mkdir(import_path)
252        return import_path
253
[5079]254    def addBatch(self, batch):
[5086]255        """Add a batch.
256        """
257        batch.num = self.getNum(batch.prefix)
258        key = "%s-%s" % (batch.prefix, batch.num)
[5079]259        self[key] = batch
[5086]260        self._p_changed = True
[5079]261
[6417]262    def createBatch(self, creation_date, creator, prefix, cost,
[5127]263                    entry_num):
264        """Create and add a batch.
265        """
[6417]266        batch_num = self.getNum(prefix)
267        batch = AccessCodeBatch(creation_date, creator, prefix,
[5127]268                                cost, entry_num, num=batch_num)
269        self.addBatch(batch)
270        return batch
[6124]271
[5086]272    def getNum(self, prefix):
273        """Get next unused num for given prefix.
274        """
275        num = 1
[5116]276        while self.get('%s-%s' % (prefix, num), None) is not None:
[5086]277            num += 1
278        return num
[5095]279
[5132]280    def getImportFiles(self):
281        """Return a generator with basenames of available import files.
282        """
283        path = self._getStoragePath()
284        for filename in sorted(os.listdir(path)):
285            yield filename
[6124]286
[5132]287    def reimport(self, filename, creator=u'UNKNOWN'):
288        """Reimport a batch given in CSV file.
289
290        CSV file must be of format as generated by createCSVLogFile
291        method.
292        """
293        path = os.path.join(self._getStoragePath(), filename)
294        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
295        entry = reader.next()
296        cost = float(entry['cost'])
297        num = int(entry['ac'])
298        batch_name = '%s-%s' % (entry['serial'], num)
299        if batch_name in self.keys():
300            raise KeyError('Batch already exists: %s' % batch_name)
[6417]301        #batch = AccessCodeBatch(
302        #    datetime.now(), creator, entry['serial'], cost, 0, num=num)
[5132]303        batch = AccessCodeBatch(
[6417]304            datetime.now(), creator, entry['serial'], cost, 0, num=0)
[5132]305        num_entries = 0
[6417]306        self[batch_name] = batch
[5132]307        for row in reader:
308            pin = row['ac']
309            serial = int(row['serial'])
310            rand_num = pin.rsplit('-', 1)[-1]
311            batch.addAccessCode(serial, rand_num)
312            num_entries += 1
313        batch.entry_num = num_entries
[6417]314
[5132]315        batch.createCSVLogFile()
316        return
[5149]317
318    def search(self, searchterm, searchtype, ):
319        """Look for access-codes that comply with the given params.
320        """
321        results = []
322        if searchtype == 'serial':
323            try:
324                searchterm = int(searchterm)
325            except:
326                return []
327        for batchname in self.keys():
328            part_result = self[batchname].search(searchterm, searchtype)
329            results.extend(part_result)
330        return results
331
[5153]332    def getAccessCode(self, ac_id):
333        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
334        """
335        for batchname in self.keys():
336            batch = self[batchname]
337            try:
338                return batch.getAccessCode(ac_id)
339            except KeyError:
340                continue
341        return None
[6124]342
[5153]343    def disable(self, ac_id, user_id):
344        """Disable the AC with ID ``ac_id``.
345
346        ``user_id`` is the user ID of the user triggering the
347        process. Already disabled ACs are left untouched.
348        """
349        ac = self.getAccessCode(ac_id)
350        if ac is None:
351            return
352        ac.__parent__.disable(ac_id, user_id)
353        return
354
355    def enable(self, ac_id):
356        """(Re-)enable the AC with ID ``ac_id``.
357
358        This leaves the given AC in state ``unused``. Already enabled
359        ACs are left untouched.
360        """
361        ac = self.getAccessCode(ac_id)
362        if ac is None:
363            return
364        ac.__parent__.enable(ac_id)
365        return
366
[6359]367    def invalidate(self, ac_id):
368        """Invalidate the AC with ID ``ac_id``.
369        """
370        ac = self.getAccessCode(ac_id)
371        if ac is None:
372            return
373        ac.__parent__.invalidate(ac_id)
374        return
[6124]375
[6359]376
[5073]377class AccessCodePlugin(grok.GlobalUtility):
378    grok.name('accesscodes')
379    grok.implements(IWAeUPSIRPPluggable)
380
381    def setup(self, site, name, logger):
[5079]382        site['accesscodes'] = AccessCodeBatchContainer()
383        logger.info('Installed container for access code batches.')
384        return
[5073]385
386    def update(self, site, name, logger):
[5107]387        if not 'accesscodes' in site.keys():
388            logger.info('Updating site at %s. Installing access codes.' % (
389                    site,))
390            self.setup(site, name, logger)
391        else:
392            logger.info(
393                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
394                    site, ))
395        return
[5447]396
397def get_access_code(access_code):
398    """Get an access code instance.
399
400    An access code here is a string like ``PUDE-1-1234567890``.
[6124]401
[5447]402    Returns ``None`` if the given code cannot be found.
403
404    This is a convenicence function that is faster than looking up a
405    batch container for the approriate access code.
406    """
407    site = grok.getSite()
408    if not isinstance(access_code, basestring):
409        return None
410    try:
411        batch_id, ac_id = access_code.rsplit('-', 1)
412    except:
413        return None
[5467]414    batch = site['accesscodes'].get(batch_id, None)
415    if batch is None:
[5447]416        return None
417    try:
418        code = batch.getAccessCode(access_code)
419    except KeyError:
420        return None
421    return code
[6359]422
[6420]423def fire_transition(access_code, arg, toward=False, comment=None):
[6408]424    """Fire workflow transition for access code.
425
426    The access code instance is looked up via `access_code` (a string
427    like ``APP-1-12345678``).
428
429    `arg` tells what kind of transition to trigger. This will be a
430    transition id like ``'use'`` or ``'init'``, or some transition
431    target like :var:`waeup.sirp.accesscodes.workflow.INITIALIZED`.
432
433    If `toward` is ``False`` (the default) you have to pass a
434    transition id as `arg`, otherwise you must give a transition
435    target.
436
[6420]437    If `comment` is specified (default is ``None``) the given string
438    will be passed along as transition comment. It will appear in the
439    history of the changed access code. You can use this to place
440    remarks like for which object the access code was used or similar.
441
[6408]442    :func:`fire_transition` might raise exceptions depending on the
443    reason why the requested transition cannot be performed.
444
445    The following exceptions can occur during processing:
446
447    :exc:`KeyError`:
448      signals not existent access code, batch or site.
449
450    :exc:`ValueError`:
451      signals illegal format of `access_code` string. The regular format is
452      ``APP-N-XXXXXXXX``.
453
454    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
455      the transition requested cannot be performed because the workflow
456      rules forbid it.
457
458    :exc:`Unauthorized`:
459      the current user is not allowed to perform the requested transition.
460
461    """
[6374]462    try:
[6408]463        batch_id, ac_id = access_code.rsplit('-', 1)
464    except ValueError:
465        raise ValueError(
466            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
467                access_code,))
468    try:
469        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
470    except TypeError:
471        raise KeyError(
472            'No site available for looking up accesscodes')
473    info = IWorkflowInfo(ac)
474    if toward:
[6420]475        info.fireTransitionToward(arg, comment=comment)
[6408]476    else:
[6420]477        info.fireTransition(arg, comment=comment)
[6374]478    return True
479
[6420]480def invalidate_accesscode(access_code, comment=None):
[6374]481    """Invalidate AccessCode denoted by string ``access_code``.
482
483    Fires an appropriate transition to perform the task.
484
[6420]485    `comment` is a string that will appear in the access code
486    history.
487
[6408]488    See :func:`fire_transition` for possible exceptions and their
489    meanings.
[6374]490    """
[6420]491    return fire_transition(access_code, 'use', comment=comment)
[6359]492
[6420]493def disable_accesscode(access_code, comment=None):
[6374]494    """Disable AccessCode denoted by string ``access_code``.
495
496    Fires an appropriate transition to perform the task.
497
[6420]498    `comment` is a string that will appear in the access code
499    history.
500
[6408]501    See :func:`fire_transition` for possible exceptions and their
502    meanings.
[6374]503    """
[6420]504    return fire_transition(
505        access_code, DISABLED, toward=True, comment=comment)
[6359]506
[6420]507def reenable_accesscode(access_code, comment=None):
[6374]508    """Reenable AccessCode denoted by string ``access_code``.
509
510    Fires an appropriate transition to perform the task.
511
[6420]512    `comment` is a string that will appear in the access code
513    history.
514
[6408]515    See :func:`fire_transition` for possible exceptions and their
516    meanings.
[6374]517    """
[6420]518    return fire_transition(access_code, 'reenable', comment=comment)
Note: See TracBrowser for help on using the repository browser.