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

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

Rollback several commits. Funny, nobody noticed that important tests were gone for half a week.

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