Index: /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.py
===================================================================
--- /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.py	(revision 5117)
+++ /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.py	(revision 5118)
@@ -4,4 +4,6 @@
 import grok
 import os
+from BTrees.OIBTree import OIBTree
+from datetime import datetime
 from random import SystemRandom as random
 from waeup.sirp.interfaces import IWAeUPSIRPPluggable
@@ -13,13 +15,12 @@
     grok.name('waeup.manageACBatches')
 
-class AccessCode(grok.Model):
+class AccessCode(grok.Context):
     grok.implements(IAccessCode)
 
-    def __init__(self, batch_serial, random_num, cost,
-                 invalidation_date=None, student_id=None):
+    def __init__(self, batch_serial, random_num, invalidation_date=None,
+                 student_id=None):
         self.batch_serial = batch_serial
         self.random_num = random_num
-        self.cost = cost
-        self.invalidation_date = invalidation_date
+        self._invalidation_date = invalidation_date
         self.student_id = student_id
 
@@ -45,5 +46,17 @@
         return self.batch.num
 
-class AccessCodeBatch(grok.Container):
+    @property
+    def cost(self):
+        if self.batch is None:
+            return None
+        return self.batch.cost
+
+    @property
+    def invalidation_date(self):
+        # We define this as a property to make it unwritable.
+        # This attribute should be set by the surrounding batch only.
+        return self._invalidation_date
+    
+class AccessCodeBatch(grok.Model):
     """A batch of access codes.
     """
@@ -59,35 +72,38 @@
         self.entry_num = entry_num
         self.num = num
-
-    def createEntries(self):
+        self.invalidated_num = 0
+        self._entries = list()
+        self._acids = OIBTree()
+        self._createEntries()
+
+    def _createEntries(self):
         """Create the entries for this batch.
         """
-        rands = self.getNewRandomNum(num=self.entry_num)
-        for num in range(self.entry_num):
-            ac = AccessCode(num, rands[num], self.cost)
-            self[str(num)] = ac
+        for num, pin in enumerate(self._getNewRandomNum(num=self.entry_num)):
+            ac = AccessCode(num, pin)
+            ac.__parent__ = self
+            self._entries.append(ac)
+            self._acids.update({ac.representation: num})
+            self._p_changed = True # XXX: most probably not needed.
         return
-        
-    def getNewRandomNum(self, num=1):
+
+    def _getNewRandomNum(self, num=1):
         """Create a set of ``num`` random numbers of 10 digits each.
 
         The number is returned as string.
         """
-        results = {}
-        while len(results) < num:
+        curr = 1
+        while curr <= num:
             pin = ''
             for x in range(10):
                 pin += str(random().randint(0, 9))
-            results[pin] = True
-        return results.keys()
-
-    def createCSVLogFile(self):
-        """Create a CSV file with data in batch.
-
-        Data will not contain invalidation date nor student ids.  File
-        will be created in ``accesscodes`` subdir of data center
-        storage path.
-
-        Returns name of created file.
+            if '%s-%s-%s' % (self.prefix, self.num, pin) in self._acids:
+                # PIN already in use
+                continue
+            curr += 1
+            yield pin
+
+    def _getStoragePath(self):
+        """Get the directory, where we store all batch-related CSV files.
         """
         site = grok.getSite()
@@ -96,5 +112,37 @@
         if not os.path.exists(ac_storage):
             os.mkdir(ac_storage)
+        return ac_storage
+
+    def entries(self):
+        """Get all entries of this batch as generator.
+        """
+        for x in self._entries:
+            yield x
+            
+    def getAccessCode(self, ac_id):
+        """Get the AccessCode with ID ``ac_id`` or ``KeyError``.
+        """
+        return self._entries[self._acids[ac_id]]
+
+    def invalidate(self, ac_id, student_id=None):
+        """Invalidate the AC with ID ``ac_id``.
+        """
+        num = self._acids[ac_id]
+        ac = self.getAccessCode(ac_id)
+        ac._invalidation_date = datetime.now()
+        ac.student_id = student_id
+        self.invalidated_num += 1
+
+    def createCSVLogFile(self):
+        """Create a CSV file with data in batch.
+
+        Data will not contain invalidation date nor student ids.  File
+        will be created in ``accesscodes`` subdir of data center
+        storage path.
+
+        Returns name of created file.
+        """
         date = self.creation_date.strftime('%Y_%m_%d_%H_%M_%S')
+        ac_storage = self._getStoragePath()
         csv_path = os.path.join(
             ac_storage, '%s-%s-%s-%s.csv' % (
@@ -104,8 +152,10 @@
         writer.writerow(['serial', 'ac', 'cost'])
         writer.writerow([self.prefix, str(self.num), "%0.2f" % self.cost])
-        for key, value in self.items():
+
+        for value in self._entries:
             writer.writerow(
                 [str(value.batch_serial), str(value.representation)]
                 )
+        site = grok.getSite()
         logger = site.logger
         logger.info(
@@ -115,5 +165,32 @@
         return os.path.basename(csv_path)
 
-    
+    def archive(self):
+        """Create a CSV file for archive.
+        """
+        ac_storage = self._getStoragePath()
+        now = datetime.now()
+        timestamp = now.strftime('%Y_%m_%d_%H_%M_%S')
+        csv_path = os.path.join(
+            ac_storage, '%s-%s_archive-%s-%s.csv' % (
+                self.prefix, self.num, timestamp, self.creator)
+            )
+        writer = csv.writer(open(csv_path, 'w'), quoting=csv.QUOTE_ALL)
+        writer.writerow(['prefix', 'serial', 'ac', 'student', 'date'])
+        writer.writerow([self.prefix, '%0.2f' % self.cost, str(self.num),
+                         str(self.entry_num)])
+        for value in self._entries:
+            date = ''
+            stud_id = ''
+            if value.invalidation_date is not None:
+                date = value.invalidation_date.strftime(
+                    '%Y-%m-%d-%H-%M-%S')
+            if stud_id is not None:
+                stud_id = value.student_id
+            writer.writerow([
+                    self.prefix, value.batch_serial, value.representation,
+                    stud_id, date
+                    ])
+        return os.path.basename(csv_path)
+
 class AccessCodeBatchContainer(grok.Container):
     grok.implements(IAccessCodeBatchContainer)
@@ -125,5 +202,4 @@
         key = "%s-%s" % (batch.prefix, batch.num)
         self[key] = batch
-        batch.createEntries()
         self._p_changed = True
 
Index: /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.txt
===================================================================
--- /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.txt	(revision 5117)
+++ /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/accesscodes.txt	(revision 5118)
@@ -8,8 +8,214 @@
 .. :doctest:
 
-Access codes are created as parts of batches. We therefore have to
-create a batch first.
-
-Here we create a batch of three entries, with a cost of ``12.12`` per
+About access-codes
+==================
+
+Access codes are ids used to grant first-time access to the system for
+students.
+
+They are normally not generated by third-party components but always
+part of batches.
+
+An access-code consists of three parts::
+
+    APP-12-0123456789
+    ^^^ ^^ ^^^^^^^^^^
+     A  B  C
+
+where ``A`` tells about the purpose of the code, ``B`` gives the
+number of batch the code belongs to (1 to 3 digits), and ``C`` is a
+unique random number of 10 digits.
+
+For the generation of the random number :mod:`waeup.sirp` requires a
+'urandom' entropy provider which is available with most standard
+Unix/Linux systems. This makes the generated numbers relatively
+secure, especially when compared with recent PHP-based applications.
+
+
+AccessCode
+==========
+
+.. class:: AccessCode(batch_serial, random_num[,invalidation_date=None[, student_id=None]])
+
+   You normally shouldn't create standalone access-codes. Use
+   instances of :class:`AccessCodeBatch` instead as they generate them
+   (in masses) and care for them.
+
+   Note, that :class:`AccessCode` instances are not persistent on
+   themselves. They have to be stored inside a persistent object (like
+   :class:`AccessCodeBatch`) to be kept.
+
+   The class implements
+   :mod:`waeup.sirp.accesscodes.interfaces.IAccessCode`:
+
+    >>> from waeup.sirp.accesscodes.interfaces import IAccessCode
+    >>> from waeup.sirp.accesscodes.accesscodes import AccessCode
+    >>> from zope.interface.verify import verifyClass
+    >>> verifyClass(IAccessCode, AccessCode)
+    True
+
+   .. attribute:: representation
+
+      The 'full' id of an access-code as described above. Something
+      like ``'APP-12-0123456789'``.
+
+      Read-only attribute.
+
+   .. attribute:: batch_serial
+
+      Serial number of this access-code inside the batch.
+
+      .. note:: XXX: Do we really need this?
+
+         Subject to be dropped.
+
+   .. attribute:: student_id
+
+      A string or ``None``. Set when an access-code is
+      invalidated. ``None`` by default.
+
+   .. attribute:: batch_prefix
+
+      The prefix of the batch the access-code belongs to.
+
+      Read-only attribute.
+
+   .. attribute:: batch_num
+
+      The number of the batch the access-code belongs to.
+
+      Read-only attribute.
+
+   .. attribute:: cost
+
+      What the access-code costs. A float.
+
+      Read-only attribute.
+
+   .. attribute:: invalidation_date
+
+      Python datetime when the access code was invalidated, or
+      ``None``. ``None`` by default.
+
+      Read-only attribute. Only batches are supposed to set this value.
+
+   Access codes that are not part of a batch, will give strange
+   representations:
+
+    >>> ac = AccessCode(None, '9999999999', 12.12)
+    >>> ac.representation
+    '--<10-DIGITS>'
+
+
+AccessCodeBatch
+===============
+
+.. class:: AccessCodeBatch(creation_date, creator, batch_prefix, cost, entry_num, num)
+
+   Create a batch of access-codes.
+
+   :param creation_date: python datetime
+   :param creator: creators user id
+   :type creator: string
+   :param batch_prefix: prefix of this batch
+   :param cost: cost per access code
+   :type cost: float
+   :param entry_num: number of access codes to create
+   :param num: number of this batch
+
+   A persistent :class:`grok.Model`. It implements
+   :class:`waeup.sirp.accesscodes.interfaces.IAccessCodeBatch`.
+
+   When creating a batch, all entries (access-codes) are generated as
+   well.
+
+   .. attribute:: creation_date
+
+      The datetime when the batch was created.
+
+   .. attribute:: creator
+
+      String with user id of the user that generated the batch.
+
+   .. attribute:: cost
+
+      Float representing the costs for a single access-code. All
+      entries inside the batch share the same cost.
+
+   .. attribute:: entry_num
+
+      Number of entries (access-codes) inside the batch.
+
+   .. attribute:: invalidated_num
+
+      Number of entries that were already invalidated.
+
+   .. attribute:: prefix
+
+      Prefix of the batch. This tells about the purpose of this batch.
+
+   .. attribute:: num
+
+      Number of this batch. For a certain prefix there can exist
+      several batches, which are numbered in increasing order. The
+      number is normally computed by the
+      :class:`AccessCodeBatchContainer` in which batches are
+      stored.
+
+      .. seealso:: :class:`AccessCodeBatchContainer`
+
+   .. method:: entries()
+
+      Get all accesscodes stored in the batch.
+
+      Returns a generator over all stored entries.
+
+   .. method:: getAccessCode(acesscode_id)
+
+      Get the :class:`AccessCode` object for the given
+      ``accesscode_id``.
+
+      Certain single access codes can be accessed inside a batch by
+      their representation (i.e. something like ``'APP-12-0123456789'``.
+
+      When a code cannot be found :exc:`KeyError` is raised.
+
+   .. method:: invalidate(ac_id[, student_id=None])
+
+      Invalidate the access-code with ID ``ac_id``.
+
+      Sets also the ``student_id`` attribute of the respective
+      :class:`AccessCode` entry.
+
+   .. method:: createCSVLogFile()
+
+      Create a CSV file with data in batch.
+
+      Data will not contain invalidation date nor student ids.  File
+      will be created in ``accesscodes`` subdir of data center storage
+      path.
+
+      Returns name of created file.
+
+   .. method:: archive()
+
+      Create a CSV file for archive. Archive files contain also
+      ``student_id`` and ``invalidation_date``.
+
+      Returns name of created file.
+
+
+Examples
+--------
+
+:class:`AccessCodeBatch` implements :class:`IAccessCodeBatch`:
+
+    >>> from waeup.sirp.accesscodes.interfaces import IAccessCodeBatch
+    >>> from waeup.sirp.accesscodes.accesscodes import AccessCodeBatch
+    >>> from zope.interface.verify import verifyClass
+    >>> verifyClass(IAccessCodeBatch, AccessCodeBatch)
+    True
+
+Creating a batch of three access-codes, with a cost of ``12.12`` per
 code, the batch prefix ``APP``, batch number ``10``, creator ID
 ``Fred`` and some arbitrary creation date:
@@ -20,50 +226,52 @@
     ...   datetime.datetime(2009, 12, 23), 'Fred','APP', 12.12, 3, num=10)
 
-The entries of a batch have to be created manually. This is, because
-we cannot add persistent objects in a still not persisted container:
-
-    >>> batch.createEntries()
-
-Now we have three accesscodes stored in the batch:
-
-    >>> sorted(list(batch.items()))
-    [(u'0', <waeup.sirp...AccessCode object at 0x...), ...]
-
-As we can see, access codes entries can be found in a batch by their
-(stringified) serial number.
-
-Each accesscode has a representation:
-
-    >>> ac = batch['0']
-    >>> ac.representation
-    'APP-10-<10-DIGITS>'
-
-The main point about a batch is that it can act as a dictionary for
-the generated access codes:
-
-    >>> ac_codes = list(batch.values())
+Getting all access-codes from a batch:
+
+    >>> ac_codes = batch.entries()
+    >>> ac_codes
+    <generator object at 0x...>
+
     >>> [x.representation for x in ac_codes]
-    ['APP-10-...', 'APP-10-...', 'APP-10-...']
-
-`Accesscode` and `AccessCodeBatch` classes implement the respective
-interfaces:
-
-    >>> from waeup.sirp.accesscodes.accesscodes import (
-    ...   AccessCode, AccessCodeBatch)
-    >>> from waeup.sirp.accesscodes.interfaces import(
-    ...   IAccessCode, IAccessCodeBatch)
-    >>> from zope.interface.verify import verifyClass
-    >>> verifyClass(IAccessCode, AccessCode)
+    ['APP-10-<10-DIGITS>', 'APP-10-<10-DIGITS>', 'APP-10-<10-DIGITS>']
+
+    >>> [x for x in batch.entries()]
+    [<waeup.sirp...AccessCode object at 0x...>, ...]
+
+Getting a single entry from the batch:
+
+    >>> ac_id = list(batch.entries())[0].representation
+    >>> ac = batch.getAccessCode(ac_id)
+    >>> ac
+    <waeup.sirp...AccessCode object at 0x...>
+
+    >>> ac is list(batch.entries())[0]
     True
 
-    >>> verifyClass(IAccessCodeBatch, AccessCodeBatch)
-    True
-
-Access codes without that are not part of a batch, will give strange
-representations:
-
-    >>> ac = AccessCode(None, '9999999999', 12.12)
-    >>> ac.representation
-    '--<10-DIGITS>'
+Trying to get a single not-existent entry from a batch:
+
+    >>> batch.getAccessCode('blah')
+    Traceback (most recent call last):
+    ...
+    KeyError: 'blah'
+
+Invalidating an entry:
+
+    >>> batch.invalidated_num
+    0
+
+    >>> str(ac.invalidation_date), str(ac.student_id)
+    ('None', 'None')
+
+    >>> batch.invalidate(ac_id)
+    >>> batch.invalidated_num
+    1
+
+    >>> ac.invalidation_date , str(ac.student_id)
+    (datetime.datetime(...), 'None')
+
+    >>> batch.invalidate(ac_id, 'some_user_id')
+    >>> ac.student_id
+    'some_user_id'
+
 
 Access code plugin
Index: /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/browser_templates/batchcontainer.pt
===================================================================
--- /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/browser_templates/batchcontainer.pt	(revision 5117)
+++ /main/waeup.sirp/trunk/src/waeup/sirp/accesscodes/browser_templates/batchcontainer.pt	(revision 5118)
@@ -4,31 +4,40 @@
   The following batches are available:
 </p>
-<table>
-  <thead>
-    <tr>
-      <th>Prefix</th><th>Entries/(invalidated)</th><th>Cost</th>
-      <th>Created</th><th>Creator</th>
-    </tr>
-  </thead>
-  <tbody>
-    <tr tal:repeat="batch context/values"
-	tal:attributes="class python: repeat['batch'].odd() and 'even' or 'odd'">
-      <td>
-	<span tal:replace="batch/prefix">APP</span>
-	-
-	<span tal:replace="batch/num">10</span>
-      </td>
-      <td>
-	<span tal:replace="batch/entry_num">1012</span>
-	/
-	<span tal:replace="python: view.invalidated(batch)">512</span>
-      </td>
-      <td tal:content="batch/cost">12.12</td>
-      <td tal:content="python: batch.creation_date.ctime()">2009/12/24</td>
-      <td tal:content="batch/creator">some user</td>
-    </tr>
-    <tr tal:condition="not: context/values">
-      <td colspan="5"><b>No batches yet</b></td>
-    </tr>
-  </tbody>
-</table>
+<form method="POST">
+  <table>
+    <thead>
+      <tr>
+        <th>&nbsp;</th>
+	<th>Prefix</th><th>Entries/(invalidated)</th><th>Cost</th>
+	<th>Created</th><th>Creator</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr tal:repeat="batch context/values"
+	  tal:attributes="class python: repeat['batch'].odd() and 'even' or 'odd'">
+	<td>
+	  <input type="checkbox" name="batches" value="batch/prefix"
+		 tal:attributes="value batch/__name__" />
+	</td>
+	<td>
+	  <span tal:replace="batch/prefix">APP</span>
+	  -
+	  <span tal:replace="batch/num">10</span>
+	</td>
+	<td>
+	  <span tal:replace="batch/entry_num">1012</span>
+	  /
+	  <span tal:replace="batch/invalidated_num">512</span>
+	</td>
+	<td tal:content="batch/cost">12.12</td>
+	<td tal:content="python: batch.creation_date.ctime()">2009/12/24</td>
+	<td tal:content="batch/creator">some user</td>
+      </tr>
+      <tr tal:condition="not: context/values">
+	<td colspan="5"><b>No batches yet</b></td>
+      </tr>
+    </tbody>
+  </table>
+  <input type="submit" name="archive" value="Archive" />
+  <input type="submit" name="delete" value="Archive and Delete" />
+</form>
