source: main/waeup.kofa/trunk/src/waeup/kofa/accesscodes/accesscode.py @ 10009

Last change on this file since 10009 was 9265, checked in by Henrik Bettermann, 12 years ago

Add AccessCodeProcessor?.

  • Property svn:keywords set to Id
File size: 22.3 KB
RevLine 
[7195]1## $Id: accesscode.py 9265 2012-10-01 21:18:48Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
[5068]18"""Components to handle access codes.
[6495]19
20Access codes (aka PINs) in waeup sites are organized in batches. That
21means a certain accesscode must be part of a batch. As a site (or
22university) can hold an arbitrary number of batches, we also provide a
23batch container. Each university has one batch container that holds
24all access code batches of which each one can hold several thousands
25of access codes.
[5068]26"""
[5110]27import csv
[5068]28import grok
[5110]29import os
[5118]30from datetime import datetime
[6432]31from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
[5068]32from random import SystemRandom as random
[8182]33from zope.component import getUtility
[9263]34from zope.component.interfaces import IFactory
[8182]35from waeup.kofa.interfaces import IKofaUtils, IKofaPluggable, IObjectHistory
[8186]36from waeup.kofa.utils.helpers import now
[7811]37from waeup.kofa.utils.logger import Logger
38from waeup.kofa.accesscodes.interfaces import (
[5079]39    IAccessCode, IAccessCodeBatch, IAccessCodeBatchContainer
40    )
[7811]41from waeup.kofa.accesscodes.workflow import DISABLED, USED, ac_states_dict
[5068]42
[6359]43class AccessCode(grok.Model):
[6495]44    """An access code (aka PIN).
[6499]45
46    Implements
[7811]47    :class:`waeup.kofa.accesscodes.interfaces.IAccessCode`. :class:`AccessCode`
[6499]48    instances are normally part of an :class:`AccessCodeBatch` so
49    their representation (or code) is built with the containing batch
50    involved.
51
52    `batch_serial`
53       the serial number of the new :class:`AccessCode` inside its batch.
54
55    `random_num`
56       a 10-digit number representing the main part of the code.
57
58    :class:`AccessCode` instances normally have a representation (or
59    code) like
60
61      ``APP-XXX-YYYYYYYYYY``
62
63    where ``APP`` is the prefix of the containing batch, ``XXX`` is
64    the batch number and ``YYYYYYYYYY`` is the real code. The complete
65    PIN is portal-wide unique.
66
67    Access code instances are far more than simple strings. They have
68    a state, a history (so that all changes can be tracked) and a
69    cost (given as a float number).
70
71    The state of an access code is something like 'used', 'disabled',
72    etc. and determined by the workflow defined in
[7811]73    :mod:`waeup.kofa.accesscodes.workflow`. This also means that
[6499]74    instead of setting the status of an access code directly (you
75    can't do that easily, and yes, that's intentionally), you have to
76    trigger a transition (that might fail, if the transition is not
77    allowed in terms of logic or permissions). See
[7811]78    :mod:`waeup.kofa.accesscodes.workflow` for details.
[6499]79
[6495]80    """
[5068]81    grok.implements(IAccessCode)
82
[9265]83    def __init__(self, batch_serial=None, random_num=None):
[6623]84        super(AccessCode, self).__init__()
[5068]85        self.batch_serial = batch_serial
86        self.random_num = random_num
[6927]87        self.owner = None
[8321]88        self.cost = None
[6359]89        IWorkflowInfo(self).fireTransition('init')
[5068]90
91    @property
92    def representation(self):
[6499]93        """A string representation of the :class:`AccessCode`.
94
95        It has format ``APP-XXX-YYYYYYYYYY`` as described above.
96        """
[5068]97        return '%s-%s-%s' % (
98            self.batch_prefix, self.batch_num, self.random_num)
99
[5079]100    @property
101    def batch(self):
[6499]102        """The batch this :class:`AccessCode` is contained.
103        """
[5079]104        return getattr(self, '__parent__', None)
[5086]105
[5079]106    @property
107    def batch_prefix(self):
[6499]108        """The prefix of the batch this :class:`AccessCode` belongs to.
109        """
[5079]110        if self.batch is None:
111            return ''
112        return self.batch.prefix
[5086]113
[5079]114    @property
115    def batch_num(self):
[6499]116        """The number of the batch this :class:`AccessCode` belongs to. A
117        read-only attribute.
118        """
[5079]119        if self.batch is None:
120            return ''
121        return self.batch.num
[5068]122
[8321]123    #@property
124    #def cost(self):
125    #    """A float representing the price or ``None``. A read-only attribute.
126    #    """
127    #    if self.batch is None:
128    #        return None
129    #    return self.batch.cost
[5118]130
[6413]131    @property
[6470]132    def state(self):
[6499]133        """The workflow state. A read-only attribute.
134        """
[6450]135        return IWorkflowState(self).getState()
136
137    @property
[7689]138    def translated_state(self):
139        """The translated workflow state. A read-only attribute.
140        """
141        return ac_states_dict[self.state]
142
143    @property
[6423]144    def history(self):
[7811]145        """A :class:`waeup.kofa.objecthistory.ObjectHistory` instance.
[6495]146        """
[6423]147        history = IObjectHistory(self)
[6453]148        return '||'.join(history.messages)
[6423]149
[6417]150class AccessCodeBatch(grok.Container):
[5068]151    """A batch of access codes.
152    """
153    grok.implements(IAccessCodeBatch)
154
[9263]155    def __init__(self, creation_date=None, creator=None, batch_prefix=None,
[9265]156                 cost=None, entry_num=0, num=None):
[5079]157        super(AccessCodeBatch, self).__init__()
[5068]158        self.creation_date = creation_date
159        self.creator = creator
[9263]160        try:
161            self.prefix = batch_prefix.upper()
162        except AttributeError:
163            self.prefix = None
[5068]164        self.cost = cost
165        self.entry_num = entry_num
[5086]166        self.num = num
[6425]167        self.used_num = 0
[5149]168        self.disabled_num = 0
[5079]169
[5118]170    def _createEntries(self):
[5079]171        """Create the entries for this batch.
172        """
[6936]173        for num, pin in enumerate(self.getNewRandomNum(num=self.entry_num)):
[8321]174            self.addAccessCode(num, pin, self.cost)
[5112]175        return
[5118]176
[6936]177    def getNewRandomNum(self, num=1):
[5112]178        """Create a set of ``num`` random numbers of 10 digits each.
[5086]179
[5068]180        The number is returned as string.
181        """
[5118]182        curr = 1
183        while curr <= num:
[5112]184            pin = ''
[5068]185            for x in range(10):
[5112]186                pin += str(random().randint(0, 9))
[6417]187            if not '%s-%s-%s' % (self.prefix, self.num, pin) in self.keys():
[5127]188                curr += 1
189                yield pin
190            # PIN already in use
[5073]191
[5118]192    def _getStoragePath(self):
193        """Get the directory, where we store all batch-related CSV files.
194        """
195        site = grok.getSite()
196        storagepath = site['datacenter'].storage
197        ac_storage = os.path.join(storagepath, 'accesscodes')
198        if not os.path.exists(ac_storage):
199            os.mkdir(ac_storage)
200        return ac_storage
201
202    def getAccessCode(self, ac_id):
203        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
204        """
[6417]205        return self[ac_id]
[5118]206
[8321]207    def addAccessCode(self, num, pin, cost=0.0, owner=None):
[5121]208        """Add an access-code.
209        """
210        ac = AccessCode(num, pin)
[6936]211        if owner:
212            ac.owner = owner
[8321]213        ac.cost = cost
[5121]214        ac.__parent__ = self
[6417]215        self[ac.representation] = ac
[5121]216        return
217
[5110]218    def createCSVLogFile(self):
219        """Create a CSV file with data in batch.
220
221        Data will not contain invalidation date nor student ids.  File
222        will be created in ``accesscodes`` subdir of data center
223        storage path.
224
225        Returns name of created file.
226        """
227        date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
[5118]228        ac_storage = self._getStoragePath()
[5110]229        csv_path = os.path.join(
230            ac_storage, '%s-%s-%s-%s.csv' % (
231                self.prefix, self.num, date, self.creator)
232            )
233        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
234        writer.writerow(['serial', 'ac', 'cost'])
235        writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
[5118]236
[6424]237        for value in sorted(self.values(),
238                            cmp=lambda x, y: cmp(
239                x.batch_serial, y.batch_serial)):
[5110]240            writer.writerow(
241                [str(value.batch_serial), str(value.representation)]
242                )
[5118]243        site = grok.getSite()
[5112]244        logger = site.logger
245        logger.info(
246            "Created batch %s-%s" % (self.prefix, self.num))
247        logger.info(
248            "Written batch CSV to %s" % csv_path)
[5110]249        return os.path.basename(csv_path)
250
[5118]251    def archive(self):
252        """Create a CSV file for archive.
253        """
254        ac_storage = self._getStoragePath()
[8183]255        tz = getUtility(IKofaUtils).tzinfo
[8186]256        dt_now = now(tz)
257        timestamp = dt_now.strftime('%Y_%m_%d_%H_%M_%S')
[5118]258        csv_path = os.path.join(
259            ac_storage, '%s-%s_archive-%s-%s.csv' % (
260                self.prefix, self.num, timestamp, self.creator)
261            )
262        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
[9260]263        writer.writerow(['prefix', 'serial', 'ac', 'state', 'history',
264            'cost','owner'])
[5118]265        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
266                         str(self.entry_num)])
[6424]267        for value in sorted(
268            self.values(),
269            cmp = lambda x, y: cmp(x.batch_serial, y.batch_serial)
270            ):
[5118]271            writer.writerow([
272                    self.prefix, value.batch_serial, value.representation,
[9260]273                    value.state, value.history, value.cost, value.owner
[5118]274                    ])
275        return os.path.basename(csv_path)
276
[6417]277@grok.subscribe(IAccessCodeBatch, grok.IObjectAddedEvent)
[6418]278def handle_batch_added(batch, event):
[6417]279    # A (maybe dirty?) workaround to make batch containers work
280    # without self-maintained acids: as batches should contain their
281    # set of data immediately after creation, but we cannot add
282    # subobjects as long as the batch was not added already to the
283    # ZODB, we trigger the item creation for the time after the batch
284    # was added to the ZODB.
285    batch._createEntries()
286    return
287
288
[7747]289class AccessCodeBatchContainer(grok.Container, Logger):
[5079]290    grok.implements(IAccessCodeBatchContainer)
[5073]291
[5132]292    def _getStoragePath(self):
293        """Get the directory, where batch import files are stored.
[6542]294
295        If the path does not exist yet, it is created. The path is
296        normally ``accesscodes/imports`` below the datacenter storage
[7811]297        path (see :data:`waeup.kofa.accesscodes.Datacenter.storage`).
[5132]298        """
299        site = grok.getSite()
300        storagepath = site['datacenter'].storage
301        ac_storage = os.path.join(storagepath, 'accesscodes')
302        import_path = os.path.join(ac_storage, 'imports')
[6542]303        for path in [ac_storage, import_path]:
304            if not os.path.exists(path):
305                os.mkdir(path)
306                site.logger.info('created path %s' % path)
[5132]307        return import_path
308
[5079]309    def addBatch(self, batch):
[6542]310        """Add an already created `batch`.
[5086]311        """
312        batch.num = self.getNum(batch.prefix)
313        key = "%s-%s" % (batch.prefix, batch.num)
[5079]314        self[key] = batch
[5086]315        self._p_changed = True
[9263]316        return
[5079]317
[9263]318    def addBatchByImport(self, batch, batch_id):
[9265]319        """Add an already created `batch` by import with defined id.
320
321        We want to create a batch without access codes. Since num_entry
322        access codes are automatically added by handle_batch_added when
323        the batch is added to the ZODB, we have to temporarily set entry_num
324        to zero when adding the batch persistently.
[9263]325        """
[9265]326        orig_entry_num = batch.entry_num
327        batch.entry_num = 0
[9263]328        self[batch_id] = batch
[9265]329        self._p_changed = True
330        batch.entry_num = orig_entry_num
[9263]331        return
332
[6417]333    def createBatch(self, creation_date, creator, prefix, cost,
[5127]334                    entry_num):
335        """Create and add a batch.
336        """
[6417]337        batch_num = self.getNum(prefix)
338        batch = AccessCodeBatch(creation_date, creator, prefix,
[5127]339                                cost, entry_num, num=batch_num)
340        self.addBatch(batch)
341        return batch
[6124]342
[5086]343    def getNum(self, prefix):
344        """Get next unused num for given prefix.
345        """
[6932]346        # School fee, clearance and hostel application batches start with 0.
347        # These batches are being emptily created during initialization of the
348        # university instance.
349        if prefix in ('CLR', 'SFE', 'HOS'):
350            num = 0
351        else:
352            num = 1
[5116]353        while self.get('%s-%s' % (prefix, num), None) is not None:
[5086]354            num += 1
355        return num
[5095]356
[5132]357    def getImportFiles(self):
358        """Return a generator with basenames of available import files.
359        """
360        path = self._getStoragePath()
361        for filename in sorted(os.listdir(path)):
362            yield filename
[6124]363
[6449]364    # This is temporary reimport solution. Access codes will be imported
[6470]365    # with state initialized no matter if they have been used before.
[5132]366    def reimport(self, filename, creator=u'UNKNOWN'):
367        """Reimport a batch given in CSV file.
368
369        CSV file must be of format as generated by createCSVLogFile
370        method.
371        """
372        path = os.path.join(self._getStoragePath(), filename)
373        reader = csv.DictReader(open(path, 'rb'), quoting=csv.QUOTE_ALL)
374        entry = reader.next()
[6449]375        cost = float(entry['serial'])
[5132]376        num = int(entry['ac'])
[6449]377        prefix = entry['prefix']
378        batch_name = '%s-%s' % (prefix, num)
[5132]379        if batch_name in self.keys():
380            raise KeyError('Batch already exists: %s' % batch_name)
381        batch = AccessCodeBatch(
[8194]382            datetime.utcnow(), creator, prefix, cost, 0, num=num)
[5132]383        num_entries = 0
[6417]384        self[batch_name] = batch
[5132]385        for row in reader:
386            pin = row['ac']
387            serial = int(row['serial'])
[8321]388            try:
389                cost = float(row['cost'])
390            except ValueError:
391                cost = 0.0
[5132]392            rand_num = pin.rsplit('-', 1)[-1]
[8321]393            batch.addAccessCode(serial, rand_num, cost)
[5132]394            num_entries += 1
395        batch.entry_num = num_entries
396        batch.createCSVLogFile()
397        return
[5149]398
[5153]399    def getAccessCode(self, ac_id):
400        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
401        """
402        for batchname in self.keys():
403            batch = self[batchname]
404            try:
405                return batch.getAccessCode(ac_id)
406            except KeyError:
407                continue
408        return None
[6124]409
[6458]410    def disable(self, ac_id, comment=None):
[5153]411        """Disable the AC with ID ``ac_id``.
412
413        ``user_id`` is the user ID of the user triggering the
414        process. Already disabled ACs are left untouched.
415        """
416        ac = self.getAccessCode(ac_id)
417        if ac is None:
418            return
[6458]419        disable_accesscode(ac_id, comment)
[5153]420        return
421
[6458]422    def enable(self, ac_id, comment=None):
[5153]423        """(Re-)enable the AC with ID ``ac_id``.
424
425        This leaves the given AC in state ``unused``. Already enabled
426        ACs are left untouched.
427        """
428        ac = self.getAccessCode(ac_id)
429        if ac is None:
430            return
[6458]431        reenable_accesscode(ac_id, comment)
[5153]432        return
433
[7811]434    logger_name = 'waeup.kofa.${sitename}.accesscodes'
[7747]435    logger_filename = 'accesscodes.log'
436
437    def logger_info(self, ob_class, comment=None):
438        """Get the logger's info method.
439        """
440        self.logger.info('%s - %s' % (
441                ob_class, comment))
442        return
443
[5073]444class AccessCodePlugin(grok.GlobalUtility):
445    grok.name('accesscodes')
[7819]446    grok.implements(IKofaPluggable)
[5073]447
448    def setup(self, site, name, logger):
[6932]449        basecontainer = AccessCodeBatchContainer()
450        site['accesscodes'] = basecontainer
[5079]451        logger.info('Installed container for access code batches.')
[6933]452        # Create empty school fee, clearance and hostel application AC
453        # batches during initialization of university instance.
[6932]454        cost = 0.0
455        creator = 'system'
456        entry_num = 0
[8194]457        creation_date = datetime.utcnow()
[6932]458        basecontainer.createBatch(creation_date, creator,
[6933]459            'SFE', cost, entry_num)
[6932]460        basecontainer.createBatch(creation_date, creator,
[6933]461            'CLR', cost, entry_num)
[6932]462        basecontainer.createBatch(creation_date, creator,
[6933]463            'HOS', cost, entry_num)
464        logger.info('Installed empty SFE, CLR and HOS access code batches.')
[5079]465        return
[5073]466
467    def update(self, site, name, logger):
[8389]468        site_name = getattr(site, '__name__', '<Unnamed Site>')
[5107]469        if not 'accesscodes' in site.keys():
470            logger.info('Updating site at %s. Installing access codes.' % (
471                    site,))
472            self.setup(site, name, logger)
473        else:
474            logger.info(
475                'AccessCodePlugin: Updating site at %s: Nothing to do.' % (
[8389]476                    site_name, ))
[5107]477        return
[5447]478
[9263]479class AccessCodeBatchFactory(grok.GlobalUtility):
480    """A factory for accesscodebatches.
481
482    We need this factory for the accesscodebatchprocessor.
483    """
484    grok.implements(IFactory)
485    grok.name(u'waeup.AccessCodeBatch')
486    title = u"Create a new accesscode batch.",
487    description = u"This factory instantiates new accesscode batch instances."
488
489    def __call__(self, *args, **kw):
[9265]490        return AccessCodeBatch()
[9263]491
492    def getInterfaces(self):
493        return implementedBy(AccessCodeBatch)
494
[9265]495class AccessCodeFactory(grok.GlobalUtility):
496    """A factory for accesscodes.
497
498    We need this factory for the accesscodeprocessor.
499    """
500    grok.implements(IFactory)
501    grok.name(u'waeup.AccessCode')
502    title = u"Create a new accesscode.",
503    description = u"This factory instantiates new accesscode instances."
504
505    def __call__(self, *args, **kw):
506        return AccessCode(*args, **kw)
507
508    def getInterfaces(self):
509        return implementedBy(AccessCode)
510
[5447]511def get_access_code(access_code):
512    """Get an access code instance.
513
514    An access code here is a string like ``PUDE-1-1234567890``.
[6124]515
[5447]516    Returns ``None`` if the given code cannot be found.
517
518    This is a convenicence function that is faster than looking up a
519    batch container for the approriate access code.
520    """
521    site = grok.getSite()
522    if not isinstance(access_code, basestring):
523        return None
524    try:
525        batch_id, ac_id = access_code.rsplit('-', 1)
526    except:
527        return None
[5467]528    batch = site['accesscodes'].get(batch_id, None)
529    if batch is None:
[5447]530        return None
531    try:
532        code = batch.getAccessCode(access_code)
533    except KeyError:
534        return None
535    return code
[6359]536
[6927]537def fire_transition(access_code, arg, toward=False, comment=None, owner=None):
[6408]538    """Fire workflow transition for access code.
539
540    The access code instance is looked up via `access_code` (a string
541    like ``APP-1-12345678``).
542
543    `arg` tells what kind of transition to trigger. This will be a
544    transition id like ``'use'`` or ``'init'``, or some transition
[7811]545    target like :data:`waeup.kofa.accesscodes.workflow.INITIALIZED`.
[6408]546
547    If `toward` is ``False`` (the default) you have to pass a
548    transition id as `arg`, otherwise you must give a transition
549    target.
550
[6420]551    If `comment` is specified (default is ``None``) the given string
552    will be passed along as transition comment. It will appear in the
553    history of the changed access code. You can use this to place
554    remarks like for which object the access code was used or similar.
555
[6927]556    If `owner` is specified, the owner attribute of the access code is checked.
557    If the owner is different :func:`fire_transition` fails and returns False.
558
[6408]559    :func:`fire_transition` might raise exceptions depending on the
560    reason why the requested transition cannot be performed.
561
562    The following exceptions can occur during processing:
563
564    :exc:`KeyError`:
565      signals not existent access code, batch or site.
566
567    :exc:`ValueError`:
568      signals illegal format of `access_code` string. The regular format is
569      ``APP-N-XXXXXXXX``.
570
571    :exc:`hurry.workflow.interfaces.InvalidTransitionError`:
572      the transition requested cannot be performed because the workflow
573      rules forbid it.
574
575    :exc:`Unauthorized`:
576      the current user is not allowed to perform the requested transition.
577
578    """
[6374]579    try:
[6408]580        batch_id, ac_id = access_code.rsplit('-', 1)
581    except ValueError:
[6588]582        raise ValueError(
583            'Invalid access code format: %s (use: APP-N-XXXXXXXX)' % (
584                access_code,))
[6408]585    try:
586        ac = grok.getSite()['accesscodes'][batch_id].getAccessCode(access_code)
[6588]587    except TypeError:
588        raise KeyError(
589            'No site available for looking up accesscodes')
[6927]590    if owner:
591        ac_owner = getattr(ac, 'owner', None)
592        if ac_owner and ac_owner != owner:
593            return False
[6408]594    info = IWorkflowInfo(ac)
595    if toward:
[6420]596        info.fireTransitionToward(arg, comment=comment)
[6408]597    else:
[6420]598        info.fireTransition(arg, comment=comment)
[6374]599    return True
600
[6927]601def invalidate_accesscode(access_code, comment=None, owner=None):
[6374]602    """Invalidate AccessCode denoted by string ``access_code``.
603
604    Fires an appropriate transition to perform the task.
605
[6420]606    `comment` is a string that will appear in the access code
607    history.
608
[6408]609    See :func:`fire_transition` for possible exceptions and their
610    meanings.
[6374]611    """
[6588]612    try:
[6927]613        return fire_transition(access_code, 'use', comment=comment, owner=owner)
[6588]614    except:
615        return False
[6359]616
[6420]617def disable_accesscode(access_code, comment=None):
[6374]618    """Disable AccessCode denoted by string ``access_code``.
619
620    Fires an appropriate transition to perform the task.
621
[6420]622    `comment` is a string that will appear in the access code
623    history.
624
[6408]625    See :func:`fire_transition` for possible exceptions and their
626    meanings.
[6374]627    """
[6420]628    return fire_transition(
629        access_code, DISABLED, toward=True, comment=comment)
[6359]630
[6420]631def reenable_accesscode(access_code, comment=None):
[6374]632    """Reenable AccessCode denoted by string ``access_code``.
633
634    Fires an appropriate transition to perform the task.
635
[6420]636    `comment` is a string that will appear in the access code
637    history.
638
[6408]639    See :func:`fire_transition` for possible exceptions and their
640    meanings.
[6374]641    """
[6420]642    return fire_transition(access_code, 'reenable', comment=comment)
[6936]643
[8322]644def create_accesscode(batch_prefix, batch_num, cost, owner):
[6936]645    """
646    """
647    batch_id = '%s-%s' % (batch_prefix, batch_num)
648    try:
649        batch = grok.getSite()['accesscodes'][batch_id]
650    except KeyError:
[6939]651        return None, u'No AC batch available.'
[6936]652    rand_num = list(batch.getNewRandomNum())[0]
653    num = len(batch) + 1
[8322]654    batch.addAccessCode(num, rand_num, cost, owner)
[6945]655    batch.entry_num += 1
[6936]656    pin = u'%s-%s-%s' % (batch_prefix,batch_num,rand_num)
[7811]657    return pin, None
Note: See TracBrowser for help on using the repository browser.