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

Last change on this file since 7181 was 7181, checked in by Henrik Bettermann, 13 years ago

StudentsOfficers? are not allowed to view the accommodation and payments containers of students. We therefore use the dedicated permissions waeup.handleAccommodation and waeup.payStudent.

Add role waeup.ACManager with permission waeup.manageACBatches which has already been uses in accesscodes.

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