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

Last change on this file since 6461 was 6458, checked in by Henrik Bettermann, 14 years ago

Make comment optional. The comment is not necessarily the user_id.

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