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

Last change on this file since 6439 was 6439, checked in by uli, 13 years ago

Update access code archiving.

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