Index: main/waeup.kofa/trunk/CHANGES.txt
===================================================================
--- main/waeup.kofa/trunk/CHANGES.txt	(revision 15162)
+++ main/waeup.kofa/trunk/CHANGES.txt	(revision 15163)
@@ -4,4 +4,6 @@
 1.6.1.dev0 (unreleased)
 =======================
+
+* Implement new transcript processing workflow.
 
 * Always display applicant payments if they exist.
Index: main/waeup.kofa/trunk/docs/source/userdocs/datacenter/export.rst
===================================================================
--- main/waeup.kofa/trunk/docs/source/userdocs/datacenter/export.rst	(revision 15162)
+++ main/waeup.kofa/trunk/docs/source/userdocs/datacenter/export.rst	(revision 15163)
@@ -282,5 +282,4 @@
   .. autoattribute:: waeup.kofa.students.export.SessionPaymentsOverviewExporter.fields
   .. autoattribute:: waeup.kofa.students.export.SessionPaymentsOverviewExporter.title
-  .. autoattribute:: waeup.kofa.students.export.SessionPaymentsOverviewExporter.curr_year
   .. automethod:: waeup.kofa.students.export.SessionPaymentsOverviewExporter.mangle_value()
 
Index: main/waeup.kofa/trunk/docs/source/userdocs/datacenter/import_stages.rst
===================================================================
--- main/waeup.kofa/trunk/docs/source/userdocs/datacenter/import_stages.rst	(revision 15162)
+++ main/waeup.kofa/trunk/docs/source/userdocs/datacenter/import_stages.rst	(revision 15163)
@@ -8,5 +8,5 @@
    :ref:`Data Center Doctests <datacenter_txt>`
 
-The term 'data import' actually understates the range of funcnctions
+The term 'data import' actually understates the range of functions
 importers really have. As already stated, many importers do not only
 restore data once backed up by exporters or, in other words, take
Index: main/waeup.kofa/trunk/docs/source/userdocs/security.rst
===================================================================
--- main/waeup.kofa/trunk/docs/source/userdocs/security.rst	(revision 15162)
+++ main/waeup.kofa/trunk/docs/source/userdocs/security.rst	(revision 15163)
@@ -241,4 +241,10 @@
    :noindex:
 
+.. autoclass:: waeup.kofa.students.permissions.TranscriptOfficer()
+   :noindex:
+
+.. autoclass:: waeup.kofa.students.permissions.TranscriptSignee()
+   :noindex:
+
 .. _local_roles:
 
@@ -306,2 +312,8 @@
 .. autoclass:: waeup.kofa.permissions.CourseAdviser100()
    :noindex:
+
+.. autoclass:: waeup.kofa.permissions.LocalTranscriptOfficer()
+   :noindex:
+
+.. autoclass:: waeup.kofa.permissions.LocalTranscriptSignee()
+   :noindex:
Index: main/waeup.kofa/trunk/docs/source/userdocs/students/workflow.rst
===================================================================
--- main/waeup.kofa/trunk/docs/source/userdocs/students/workflow.rst	(revision 15162)
+++ main/waeup.kofa/trunk/docs/source/userdocs/students/workflow.rst	(revision 15163)
@@ -28,36 +28,37 @@
 
               a
-   created ------- admitted
-              b       |
-     +----------------+
-     |                         e
-     +------------------------------------------------------+
-     |                  c                            d      |
-   clearance started ------- clearance requested  ------- cleared
-                                                            |
-                                f                           |
-     +--------+---------------------------------------------+
-     |        |
-     | h    school fee paid -------- returning -------------+
-     |        |             \   l        |                  |
-     +--------+ g            \ _ _ _ m   | k                |
-              |                      \   |                  |
-           courses registered ------ courses validated      |
-                          |      i       |                  |
-                          |              | n                |
-                          | j            |                  |
-                          |          graduated              |
-                          |              |                  |
-                          |              | o                |
-                          |              |                  |
-                          |          transcript requested   |
-     a: admit             |                                 |
-    -a: reset1            +---------------------------------+
-     b: start_clearance
-    -b: reset2
-     c: request_clearance
-    -c: reset3 (= reject request)
-     d: clear
-    -d: N/A
+    created ------- admitted
+               b       |
+      +----------------+
+      |                         e
+      +------------------------------------------------------+
+      |                  c                            d      |
+    clearance started ------- clearance requested  ------- cleared
+                                                             |
+                                 f                           |
+      +--------+---------------------------------------------+
+      |        |
+      | h    school fee paid -------- returning -------------+
+      |        |             \   l        |                  |
+      +--------+ g            \ _ _ _ m   | k                |
+               |                      \   |                  |
+            courses registered ------ courses validated      |
+                           |      i       |                  |
+                           |              | n                |
+                           | j            |                  |
+                           |      +-- graduated              |
+                           |      |       |                  |
+                           |      |       | o                |
+                           |      |       |                  |
+                           |      |   transcript requested   |
+     a: admit              |      |       |                  |
+    -a: reset1             |    r |       | p                |
+     b: start_clearance    |      |       |                  |
+    -b: reset2             |      |   transcript validated   |
+     c: request_clearance  |      |       |                  |
+    -c: reset3             |      |       | q                |
+        (= reject request) |      |       |                  |
+     d: clear              |      +-- transcript released    |
+    -d: N/A                +---------------------------------+
      e: N/A
     -e: reset4 (= annul clearance)
@@ -81,6 +82,11 @@
     -n: N/A
      o: request_transcript
-    -o: process_transcript
-
+    -o: reset10 (= reject request)
+     p: validate_transcript
+    -p: N/A
+     q: release_transcript
+    -q: N/A
+     r: N/A
+    -r: reset11
 
 Student registration starts with the first login of the student into
@@ -131,4 +137,26 @@
 list.
 
+After graduation, former students (alumni) can apply for an official
+transcript. The transcript processing workflow (**opqr**) is a
+subset of the registration workflow which starts in state
+``graduated``. Many people are involved in transcript processing.
+Therefore, Kofa provides two additional roles, the Transcript
+Officer and Transcript Signee roles. Both are available globally and
+locally (at faculty level). First of all, the graduated student has
+to request a transcript **(o)** by filling a request form. The
+Transcript Officer will see the new request and can either reject
+the request **(-o)** or validate the request **(p)**. Before
+validation course results can be edited or added. After validation
+the entire studycourse including its content is locked. Nobody will
+be able to modify the course lists, neither through the UI nor by
+import. Transcripts can now be electronically signed by Transcript
+Signees. Once all signees have done their job, the Transcript
+Officer can release the transcript **(q)**. A pdf file is created
+and stored in the file system. The transcript pdf slip contains all
+signatures. The dynamic transcript generation is disabled. Nothing
+can be changed now, unless the entire transcript process is reset
+**(-r)** by a Students Manager. Then comments and signatures will be
+removed and the pdf file deleted. The student can start afresh.
+
 
 .. _registration_workflow_batch_processing:
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 15163)
@@ -60,5 +60,7 @@
     ISessionConfiguration, ISessionConfigurationAdd, IJobManager,
     IPasswordValidator, IContactForm, IKofaUtils, ICSVExporter,
-    academic_sessions_vocab, DOCLINK, registration_states_vocab)
+    academic_sessions_vocab, DOCLINK, registration_states_vocab,
+    TRANSREQ, TRANSVAL)
+from waeup.kofa.university.faculty import Faculty
 from waeup.kofa.university.department import Department
 from waeup.kofa.university.course import Course
@@ -375,8 +377,14 @@
                     self.redirect(self.application_url() + rel_link)
                     return
-                # Redirect lecturer after login
+                # Redirect some officers after login
                 local_roles = get_user_account(self.request).getLocalRoles()
                 if local_roles.has_key('waeup.local.Lecturer'):
                     self.redirect(self.application_url() + '/my_courses')
+                    return
+                if local_roles.has_key('waeup.local.TranscriptOfficer'):
+                    self.redirect(self.application_url() + '/my_transcript_requests')
+                    return
+                if local_roles.has_key('waeup.local.TranscriptSignee'):
+                    self.redirect(self.application_url() + '/my_transcript_signing_requests')
                     return
                 if not self.camefrom:
@@ -1023,4 +1031,56 @@
         return courses
 
+class TranscriptOfficerLandingPage(KofaPage):
+    """Display students, who requested a transcript, when
+    transcript officer logs in.
+    """
+    grok.name('my_transcript_requests')
+    grok.require('waeup.Authenticated')
+    grok.context(IUniversity)
+    grok.template('mytranscriptrequests')
+    label =_('My ToDoList')
+
+    @property
+    def getStudents(self):
+        students = ([],[])
+        cat = queryUtility(ICatalog, name='students_catalog')
+        local_roles = get_user_account(self.request).getLocalRoles()
+        for item in local_roles.get('waeup.local.TranscriptOfficer', []):
+            if isinstance(item, Faculty):
+                faccode = item.code
+                results1 = cat.searchResults(
+                    state=(TRANSREQ, TRANSREQ), faccode=(faccode, faccode))
+                for result in results1:
+                    students[0].append(StudentQueryResultItem(result, self))
+                results2 = cat.searchResults(
+                    state=(TRANSVAL, TRANSVAL), faccode=(faccode, faccode))
+                for result in results2:
+                    students[1].append(StudentQueryResultItem(result, self))
+        return students
+
+class TranscriptSigneeLandingPage(KofaPage):
+    """Display students with validated transcript requests, when
+    transcript signee logs in.
+    """
+    grok.name('my_transcript_signing_requests')
+    grok.require('waeup.Authenticated')
+    grok.context(IUniversity)
+    grok.template('mytranscriptrequests')
+    label =_('My ToDoList')
+
+    @property
+    def getStudents(self):
+        students = ([],[])
+        cat = queryUtility(ICatalog, name='students_catalog')
+        local_roles = get_user_account(self.request).getLocalRoles()
+        for item in local_roles.get('waeup.local.TranscriptSignee', []):
+            if isinstance(item, Faculty):
+                faccode = item.code
+                results = cat.searchResults(
+                    state=(TRANSVAL, TRANSVAL), faccode=(faccode, faccode))
+                for result in results:
+                    students[1].append(StudentQueryResultItem(result, self))
+        return students
+
 #
 # Search pages...
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/mytranscriptrequests.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/mytranscriptrequests.pt	(revision 15163)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/templates/mytranscriptrequests.pt	(revision 15163)
@@ -0,0 +1,61 @@
+<h3 i18n:domain="waeup.kofa" i18n:translate="" tal:condition="python:view.getStudents[0]">
+  Transcripts to be validated
+</h3>
+
+<table class="kofa-data-table dataTable" tal:condition="python:view.getStudents[0]">
+  <thead>
+  <tr>
+    <th i18n:translate="">Matric No.</th>
+    <th i18n:translate="">Student Id</th>
+    <th i18n:translate="">Fullname</th>
+    <th i18n:translate="">Faculty</th>
+    <th i18n:translate="">Department</th>
+    <th i18n:translate="">Course of Studies</th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr tal:repeat="student python:view.getStudents[0]">
+    <td tal:content="student/context/matric_number">MATRICNUMBER</td>
+    <td>
+      <a tal:attributes="href python:student.url + '/studycourse/transcript'">
+      <span tal:content="student/student_id">STUDENTID</span>
+      </a>
+    </td>
+    <td tal:content="student/context/display_fullname">FULLNAME</td>
+    <td tal:content="student/context/faccode">FACCODE</td>
+    <td tal:content="student/context/depcode">DEPCODE</td>
+    <td tal:content="student/context/certcode">CERTCODE</td>
+  </tr>
+  </tbody>
+</table>
+
+<h3 i18n:domain="waeup.kofa" i18n:translate="" tal:condition="python:view.getStudents[1]">
+  Transcripts to be signed and released
+</h3>
+
+<table class="kofa-data-table dataTable" tal:condition="python:view.getStudents[1]">
+  <thead>
+  <tr>
+    <th i18n:translate="">Matric No.</th>
+    <th i18n:translate="">Student Id</th>
+    <th i18n:translate="">Fullname</th>
+    <th i18n:translate="">Faculty</th>
+    <th i18n:translate="">Department</th>
+    <th i18n:translate="">Course of Studies</th>
+  </tr>
+  </thead>
+  <tbody>
+  <tr tal:repeat="student python:view.getStudents[1]">
+    <td tal:content="student/context/matric_number">MATRICNUMBER</td>
+    <td>
+      <a tal:attributes="href python:student.url + '/studycourse/transcript'">
+      <span tal:content="student/student_id">STUDENTID</span>
+      </a>
+    </td>
+    <td tal:content="student/context/display_fullname">FULLNAME</td>
+    <td tal:content="student/context/faccode">FACCODE</td>
+    <td tal:content="student/context/depcode">DEPCODE</td>
+    <td tal:content="student/context/certcode">CERTCODE</td>
+  </tr>
+  </tbody>
+</table>
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/tests/test_browser.py	(revision 15163)
@@ -439,5 +439,4 @@
             'nationality,officer_comment,perm_address,personal_updated,'
             'phone,reg_number,sex,student_id,suspended,suspended_comment,'
-            'transcript_comment,'
             'password,state,history,certcode,is_postgrad,current_level,'
             'current_session\r\n')
Index: main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py	(revision 15163)
@@ -365,4 +365,42 @@
         return ''
 
+class TranscriptOfficerStudents(ManageLink):
+    """A link to transcript requests.
+    """
+    grok.order(6)
+    grok.require('waeup.Authenticated')
+
+    link = u'my_transcript_requests'
+    text = _(u'My ToDoList')
+
+    def render(self):
+        user_account = get_user_account(self.request)
+        if user_account:
+            local_roles = user_account.getLocalRoles()
+            if local_roles.has_key('waeup.local.TranscriptOfficer'):
+                url = self.view.url(grok.getSite(), self.link)
+                text = translate(self.text, context=self.request)
+                return u'<li><a href="%s">%s</a></li>' % (url, text)
+        return ''
+
+class TranscriptSigneeStudents(ManageLink):
+    """A link to transcript signing requests.
+    """
+    grok.order(6)
+    grok.require('waeup.Authenticated')
+
+    link = u'my_transcript_signing_requests'
+    text = _(u'My ToDoList')
+
+    def render(self):
+        user_account = get_user_account(self.request)
+        if user_account:
+            local_roles = user_account.getLocalRoles()
+            if local_roles.has_key('waeup.local.TranscriptSignee'):
+                url = self.view.url(grok.getSite(), self.link)
+                text = translate(self.text, context=self.request)
+                return u'<li><a href="%s">%s</a></li>' % (url, text)
+        return ''
+
 class MyPreferences(ManageLink):
     """A link to personal preferences, placed in upper left box.
Index: main/waeup.kofa/trunk/src/waeup/kofa/doctests/permissions.txt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/doctests/permissions.txt	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/doctests/permissions.txt	(revision 15163)
@@ -39,8 +39,8 @@
     >>> from waeup.kofa.permissions import get_waeup_roles
     >>> len(list(get_waeup_roles()))
-    28
+    29
 
     >>> len(list(get_waeup_roles(also_local=True)))
-    49
+    52
 
 
@@ -78,4 +78,5 @@
      u'waeup.StudentsOfficer',
      u'waeup.TranscriptOfficer',
+     u'waeup.TranscriptSignee',
      u'waeup.UsersManager',
      u'waeup.WorkflowManager',
Index: main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/interfaces.py	(revision 15163)
@@ -56,5 +56,7 @@
 VALIDATED = 'courses validated'
 GRADUATED = 'graduated'
-TRANSCRIPT = 'transcript requested'
+TRANSREQ = 'transcript requested'
+TRANSVAL = 'transcript validated'
+TRANSREL = 'transcript released'
 
 
@@ -103,5 +105,7 @@
     (_('returning'), RETURNING),
     (_('graduated'), GRADUATED),
-    (_('transcript requested'), TRANSCRIPT),
+    (_('transcript requested'), TRANSREQ),
+    (_('transcript validated'), TRANSVAL),
+    (_('transcript released'), TRANSREL),
     )
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/permissions.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/permissions.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/permissions.py	(revision 15163)
@@ -155,8 +155,4 @@
 
 
-class ViewTranscript(grok.Permission):
-    grok.name('waeup.viewTranscript')
-
-
 class ManagePortalConfiguration(grok.Permission):
     """The ManagePortalConfiguration permission allows to
@@ -392,4 +388,27 @@
                      'waeup.viewAcademics',
                      'waeup.exportData')
+
+
+class LocalTranscriptOfficer(grok.Role):
+    """The LocalTranscriptOfficer role can be assigned at faculty
+    level. The role allows to view, to validate and to
+    release student transcripts at faculty level.
+    Local Transcript Officers get the TranscriptOfficer role
+    for this subset of students.
+    """
+    grok.name('waeup.local.TranscriptOfficer')
+    grok.title(u'Transcript Officer')
+    grok.permissions('waeup.viewAcademics',)
+
+
+class LocalTranscriptSignee(grok.Role):
+    """The LocalTranscriptSignee role can be assigned at faculty
+    level. The role allows to view and to sign student transcripts
+    at faculty level. Local Transcript Signees  get the TranscriptSignee role
+    for this subset of students.
+    """
+    grok.name('waeup.local.TranscriptSignee')
+    grok.title(u'Transcript Signee')
+    grok.permissions('waeup.viewAcademics',)
 
 
@@ -552,4 +571,5 @@
                      'waeup.exportData',
                      'waeup.viewTranscript',
+                     'waeup.processTranscript',
                      'waeup.viewDocuments', 'waeup.manageDocuments',
                      'waeup.managePortalConfiguration',
@@ -603,4 +623,5 @@
         'waeup.exportData',
         'waeup.viewTranscript',
+        'waeup.processTranscript',
         'waeup.viewDocuments', 'waeup.manageDocuments',
         'waeup.managePortalConfiguration', 'waeup.viewApplication',
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/batching.py	(revision 15163)
@@ -528,4 +528,6 @@
         problems.
         """
+        if obj.student.studycourse_locked:
+            return 'Studycourse is locked.'
         certificate = getattr(obj, 'certificate', None)
         entry_session = getattr(obj, 'entry_session', None)
@@ -619,4 +621,11 @@
         return
 
+    def checkUpdateRequirements(self, obj, row, site):
+        """
+        """
+        if obj.student.studycourse_locked:
+            return 'Studylevel is locked.'
+        return None
+
 class CourseTicketProcessor(StudentProcessorBase):
     """The Course Ticket Processor imports course tickets, the subobjects
@@ -714,4 +723,11 @@
             del parent[ticket.code]
         return
+
+    def checkUpdateRequirements(self, obj, row, site):
+        """
+        """
+        if obj.student.studycourse_locked:
+            return 'Studycourse is locked.'
+        return None
 
     def checkConversion(self, row, mode='ignore'):
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 15163)
@@ -68,5 +68,5 @@
     IStudentsContainer, IStudent, IUGStudentClearance, IPGStudentClearance,
     IStudentPersonal, IStudentPersonalEdit, IStudentBase, IStudentStudyCourse,
-    IStudentStudyCourseTransfer, IStudentStudyCourseTranscript,
+    IStudentStudyCourseTransfer,
     IStudentAccommodation, IStudentStudyLevel, ICourseTicket, ICourseTicketAdd,
     IStudentPaymentsContainer, IStudentOnlinePayment, IStudentPreviousPayment,
@@ -78,5 +78,5 @@
 from waeup.kofa.students.workflow import (
     ADMITTED, PAID, CLEARANCE, REQUESTED, RETURNING, CLEARED, REGISTERED,
-    VALIDATED, GRADUATED, TRANSCRIPT, FORBIDDEN_POSTGRAD_TRANS
+    VALIDATED, GRADUATED, TRANSREQ, TRANSVAL, TRANSREL, FORBIDDEN_POSTGRAD_TRANS
     )
 
@@ -264,8 +264,5 @@
         form = self.request.form
         self.hitlist = []
-        if form.get('searchtype', None) == 'suspended':
-            self.searchtype = form['searchtype']
-            self.searchterm = None
-        elif form.get('searchtype', None) == 'transcript':
+        if form.get('searchtype', None) in ('suspended', TRANSREQ, TRANSVAL):
             self.searchtype = form['searchtype']
             self.searchterm = None
@@ -309,5 +306,5 @@
         form = self.request.form
         self.hitlist = []
-        if form.get('searchtype', None) == 'suspended':
+        if form.get('searchtype', None) in ('suspended', TRANSREQ, TRANSVAL):
             self.searchtype = form['searchtype']
             self.searchterm = None
@@ -996,5 +993,6 @@
 
     def update(self):
-        if not self.context.is_current:
+        if not self.context.is_current \
+            or self.context.student.studycourse_locked:
             emit_lock_message(self)
             return
@@ -1096,7 +1094,9 @@
             self.ac_series = self.request.form.get('ac_series', None)
             self.ac_number = self.request.form.get('ac_number', None)
-        if self.context.transcript_comment is not None:
-            self.correspondence = self.context.transcript_comment.replace(
-                '\n', '<br>')
+        if getattr(
+            self.context['studycourse'], 'transcript_comment', None) is not None:
+            self.correspondence = self.context[
+                'studycourse'].transcript_comment.replace(
+                    '\n', '<br>')
         else:
             self.correspondence = ''
@@ -1128,8 +1128,9 @@
         tz = getattr(queryUtility(IKofaUtils), 'tzinfo', pytz.utc)
         today = now(tz).strftime('%d/%m/%Y %H:%M:%S %Z')
-        old_transcript_comment = self.context.transcript_comment
+        old_transcript_comment = getattr(
+            self.context['studycourse'], 'transcript_comment', None)
         if old_transcript_comment == None:
             old_transcript_comment = ''
-        self.context.transcript_comment = '''On %s %s wrote:
+        self.context['studycourse'].transcript_comment = '''On %s %s wrote:
 
 %s
@@ -1146,35 +1147,103 @@
         return
 
-class StudentTranscriptRequestProcessFormPage(KofaEditFormPage):
-    """ Page to process transcript requests
-    """
-    grok.context(IStudent)
-    grok.name('process_transcript_request')
-    grok.require('waeup.viewTranscript')
-    grok.template('transcriptprocess')
-    form_fields = grok.AutoFields(IStudentTranscript)
-    label = _('Process transcript request')
-    buttonname = _('Save comment and mark as processed')
-    pnav = 4
+class StudentTranscriptValidateView(UtilityView, grok.View):
+    """ View to validate transcript
+    """
+    grok.context(IStudentStudyCourse)
+    grok.name('validate_transcript')
+    grok.require('waeup.processTranscript')
 
     def update(self, SUBMIT=None):
-        super(StudentTranscriptRequestProcessFormPage, self).update()
-        if self.context.state != TRANSCRIPT:
+        if self.context.student.state != TRANSREQ:
             self.flash(_('Student is in wrong state.'), type="warning")
             self.redirect(self.url(self.context))
             return
-        if self.context.transcript_comment is not None:
+        # Fire transition
+        IWorkflowInfo(self.context.student).fireTransition(
+            'validate_transcript')
+        self.flash(_('Transcript validated.'))
+        self.redirect(self.url(self.context) + '/transcript')
+        return
+
+    def render(self):
+        return
+
+class StudentTranscriptSignView(UtilityView, grok.View):
+    """ View to sign transcript
+    """
+    grok.context(IStudentStudyCourse)
+    grok.name('sign_transcript')
+    grok.require('waeup.signTranscript')
+
+    def update(self, SUBMIT=None):
+        if self.context.student.state != TRANSVAL:
+            self.flash(_('Student is in wrong state.'), type="warning")
+            self.redirect(self.url(self.context))
+            return
+        prev_transcript_signees = getattr(
+            self.context, 'transcript_signees', None)
+        if prev_transcript_signees \
+            and '(%s)' % self.request.principal.id in prev_transcript_signees:
+            self.flash(_('You have already signed this transcript.'),
+                type="warning")
+            self.redirect(self.url(self.context) + '/transcript')
+            return
+        self.flash(_('Transcript signed.'))
+        ob_class = self.__implemented__.__name__.replace('waeup.kofa.','')
+        self.context.student.__parent__.logger.info(
+            '%s - %s - Transcript signed'
+            % (ob_class, self.context.student.student_id))
+        self.context.student.history.addMessage('Transcript signed')
+        tz = getattr(queryUtility(IKofaUtils), 'tzinfo', pytz.utc)
+        today = now(tz).strftime('%d/%m/%Y %H:%M:%S %Z')
+        if prev_transcript_signees == None:
+            prev_transcript_signees = ''
+        self.context.transcript_signees = (
+            u"Electronically signed by %s (%s) on %s\n%s"
+            % (self.request.principal.title, self.request.principal.id, today,
+            prev_transcript_signees))
+        self.redirect(self.url(self.context) + '/transcript')
+        return
+
+    def render(self):
+        return
+
+class StudentTranscriptReleaseFormPage(KofaEditFormPage):
+    """ Page to release transcript
+    """
+    grok.context(IStudentStudyCourse)
+    grok.name('release_transcript')
+    grok.require('waeup.processTranscript')
+    grok.template('transcriptrelease')
+    label = _('Release transcript')
+    buttonname = _('Save comment and release transcript')
+    pnav = 4
+
+    def update(self, SUBMIT=None):
+        super(StudentTranscriptReleaseFormPage, self).update()
+        if self.context.student.state != TRANSVAL:
+            self.flash(_('Student is in wrong state.'), type="warning")
+            self.redirect(self.url(self.context))
+            return
+        if getattr(self.context, 'transcript_comment', None) is not None:
             self.correspondence = self.context.transcript_comment.replace(
                 '\n', '<br>')
         else:
             self.correspondence = ''
+        if getattr(self.context, 'transcript_signees', None) is not None:
+            self.signees = self.context.transcript_signees.replace(
+                '\n', '<br><br>')
+        else:
+            self.signees = ''
         if SUBMIT is None:
             return
-        IWorkflowInfo(self.context).fireTransition('process_transcript')
-        self.flash(_('Transcript request processed.'))
+        # Fire transition
+        IWorkflowInfo(self.context.student).fireTransition('release_transcript')
+        self.flash(_('Transcript released and final transcript file saved.'))
         comment = self.request.form.get('comment', '').replace('\r', '')
         tz = getattr(queryUtility(IKofaUtils), 'tzinfo', pytz.utc)
         today = now(tz).strftime('%d/%m/%Y %H:%M:%S %Z')
-        old_transcript_comment = self.context.transcript_comment
+        old_transcript_comment = getattr(
+            self.context, 'transcript_comment', None)
         if old_transcript_comment == None:
             old_transcript_comment = ''
@@ -1187,16 +1256,15 @@
         self.context.writeLogMessage(
             self, 'comment: %s' % comment.replace('\n', '<br>'))
-        subject = _('Transcript processed')
-        args = {'subject':subject, 'body':comment}
-        self.redirect(self.url(self.context) +
-            '/contactstudent?%s' % urlencode(args))
+        # Produce transcript file
+        self.redirect(self.url(self.context) + '/transcript.pdf')
         return
 
 class StudentTranscriptRequestManageFormPage(KofaEditFormPage):
-    """ Page to manage transcript data
-    """
-    grok.context(IStudent)
+    """ Page to manage transcript data. There is no link button
+    for this page. It's an emergency page.
+    """
+    grok.context(IStudentStudyCourse)
     grok.name('manage_transcript_request')
-    grok.require('waeup.manageStudent')
+    grok.require('waeup.processTranscript')
     form_fields = grok.AutoFields(IStudentTranscript)
     label = _('Manage transcript request')
@@ -1211,5 +1279,5 @@
     """ Page to display the student's transcript.
     """
-    grok.context(IStudentStudyCourseTranscript)
+    grok.context(IStudentStudyCourse)
     grok.name('transcript')
     grok.require('waeup.viewTranscript')
@@ -1218,7 +1286,8 @@
 
     def update(self):
-        if not self.context.student.transcript_enabled:
-            self.flash(_('You are not allowed to view the transcript.'),
-                       type="warning")
+        final_slip = getUtility(IExtFileStore).getFileByContext(
+            self.context.student, attr='final_transcript')
+        if not self.context.student.transcript_enabled or final_slip:
+            self.flash(_('Forbidden!'), type="warning")
             self.redirect(self.url(self.context))
             return
@@ -1243,5 +1312,4 @@
     grok.name('transcript.pdf')
     grok.require('waeup.viewTranscript')
-    form_fields = grok.AutoFields(IStudentStudyCourseTranscript)
     prefix = 'form'
     omit_fields = (
@@ -1251,7 +1319,9 @@
 
     def update(self):
-        if not self.context.student.transcript_enabled:
-            self.flash(_('You are not allowed to download the transcript.'),
-                       type="warning")
+        final_slip = getUtility(IExtFileStore).getFileByContext(
+            self.context.student, attr='final_transcript')
+        if not self.context.student.transcript_enabled \
+            or final_slip:
+            self.flash(_('Forbidden!'), type="warning")
             self.redirect(self.url(self.context))
             return
@@ -1272,8 +1342,22 @@
 
     def _sigsInFooter(self):
+        if getattr(
+            self.context.student['studycourse'], 'transcript_signees', None):
+            return ()
         return (_('CERTIFIED TRUE COPY'),)
 
     def _signatures(self):
-        return None
+        return ()
+
+    def _digital_sigs(self):
+        if getattr(
+            self.context.student['studycourse'], 'transcript_signees', None):
+            return self.context.student['studycourse'].transcript_signees 
+        return ()
+
+    def _save_file(self):
+        if self.context.student.state == TRANSREL:
+            return True
+        return False
 
     def render(self):
@@ -1297,5 +1381,5 @@
                          ]
 
-        return students_utils.renderPDFTranscript(
+        pdfstream = students_utils.renderPDFTranscript(
             self, 'transcript.pdf',
             self.context.student, studentview,
@@ -1304,5 +1388,11 @@
             signatures=self._signatures(),
             sigs_in_footer=self._sigsInFooter(),
+            digital_sigs=self._digital_sigs(),
+            save_file=self._save_file(),
             )
+        if not pdfstream:
+            self.redirect(self.url(self.context.student))
+            return
+        return pdfstream
 
 class StudentTransferFormPage(KofaAddFormPage):
@@ -1482,5 +1572,6 @@
 
     def update(self, ADD=None, course=None):
-        if not self.context.__parent__.is_current:
+        if not self.context.__parent__.is_current \
+            or self.context.student.studycourse_locked:
             emit_lock_message(self)
             return
@@ -1554,5 +1645,5 @@
             emit_lock_message(self)
             return
-        if str(self.context.__parent__.current_level) != self.context.__name__:
+        if str(self.context.student.current_level) != self.context.__name__:
             self.flash(_('This is not the student\'s current level.'),
                        type="danger")
@@ -1650,5 +1741,6 @@
 
     def update(self):
-        if not self.context.__parent__.is_current:
+        if not self.context.__parent__.is_current \
+            or self.context.student.studycourse_locked:
             emit_lock_message(self)
             return
@@ -1702,4 +1794,12 @@
     pnav = 4
     grok.template('courseticketmanagepage')
+
+    def update(self):
+        if not self.context.__parent__.__parent__.is_current \
+            or self.context.student.studycourse_locked:
+            emit_lock_message(self)
+            return
+        super(CourseTicketManageFormPage, self).update()
+        return
 
     @property
@@ -2630,5 +2730,6 @@
 
     def update(self):
-        if not self.context.is_current:
+        if not self.context.is_current \
+            or self.context.student.studycourse_locked:
             emit_lock_message(self)
             return
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containermanagepage.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containermanagepage.pt	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containermanagepage.pt	(revision 15163)
@@ -17,6 +17,9 @@
         with deactivated account (search term omitted)
       </option>
-      <option i18n:translate="" value="transcript">
-        who requested transcript (search term omitted)
+      <option i18n:translate="" value="transcript requested">
+        in state 'transcript requested' (search term omitted)
+      </option>
+      <option i18n:translate="" value="transcript validated">
+        in state 'transcript validated' (search term omitted)
       </option>
     </select>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containerpage.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containerpage.pt	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/containerpage.pt	(revision 15163)
@@ -17,6 +17,9 @@
         with deactivated account (search term omitted)
       </option>
-      <option i18n:translate="" value="transcript">
-        who requested transcript (search term omitted)
+      <option i18n:translate="" value="transcript requested">
+        in state 'transcript requested' (search term omitted)
+      </option>
+      <option i18n:translate="" value="transcript validated">
+        in state 'transcript validated' (search term omitted)
       </option>
     </select>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcript.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcript.pt	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcript.pt	(revision 15163)
@@ -58,3 +58,5 @@
   </tbody>
 </table>
+<br />
+<div tal:content="python:getattr(context, 'transcript_signees', '')">SIGNEES</div>
 </tal:def>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptprocess.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptprocess.pt	(revision 15162)
+++ 	(revision )
@@ -1,45 +1,0 @@
-<form method="POST" i18n:domain="waeup.kofa">
-
-  <ul class="tabs nav nav-tabs" data-tabs="tabs">
-    <li class="active">
-      <a href="#tab1" data-toggle="tab">
-        <span i18n:translate="">Process request</span>
-      </a>
-    </li>
-    <li>
-      <a href="#tab2" data-toggle="tab">
-        <span i18n:translate="">View correspondence</span>
-      </a>
-    </li>
-  </ul>
-
-  <div class="tab-content">
-    <div id="tab1" class="active tab-pane">
-
-      <table class="form-table">
-        <tbody>
-          <tr>
-            <td i18n:translate="">
-            Comment:
-            </td>
-            <td>
-            <textarea cols="60" name="comment" rows="6"></textarea>
-            </td>
-          </tr>
-          <tr>
-            <td colspan="2">
-              <br />
-              <input type="submit" name="SUBMIT" class="btn btn-primary"
-                     tal:attributes="value view/buttonname" />
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
-
-    <div id="tab2" class="tab-pane">
-      <p tal:content="structure view/correspondence"></p>
-    </div>
-  </div>
-
-</form>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrelease.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrelease.pt	(revision 15163)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrelease.pt	(revision 15163)
@@ -0,0 +1,54 @@
+<form method="POST" i18n:domain="waeup.kofa">
+
+  <ul class="tabs nav nav-tabs" data-tabs="tabs">
+    <li class="active">
+      <a href="#tab1" data-toggle="tab">
+        <span i18n:translate="">Process request</span>
+      </a>
+    </li>
+    <li>
+      <a href="#tab2" data-toggle="tab">
+        <span i18n:translate="">View correspondence</span>
+      </a>
+    </li>
+    <li>
+      <a href="#tab3" data-toggle="tab">
+        <span i18n:translate="">View signatures</span>
+      </a>
+    </li>
+  </ul>
+
+  <div class="tab-content">
+    <div id="tab1" class="active tab-pane">
+
+      <table class="form-table">
+        <tbody>
+          <tr>
+            <td class="fieldname" i18n:translate="">
+            Comment:
+            </td>
+            <td>
+            <textarea cols="60" name="comment" rows="6"></textarea>
+            </td>
+          </tr>
+          <tr>
+            <td colspan="2">
+              <br />
+              <input type="submit" name="SUBMIT" class="btn btn-primary"
+                     tal:attributes="value view/buttonname" />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <div id="tab2" class="tab-pane">
+      <br /><p tal:content="structure view/correspondence"></p>
+    </div>
+
+    <div id="tab3" class="tab-pane">
+      <br /><p tal:content="structure view/signees"></p>
+    </div>
+  </div>
+
+</form>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrequest.pt
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrequest.pt	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/transcriptrequest.pt	(revision 15163)
@@ -73,5 +73,5 @@
 
     <div id="tab2" class="tab-pane">
-      <p tal:content="structure view/correspondence"></p>
+      <br /><p tal:content="structure view/correspondence"></p>
     </div>
   </div>
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/catalog.py	(revision 15163)
@@ -29,4 +29,5 @@
 from waeup.kofa.students.interfaces import IStudent, ICourseTicket
 from waeup.kofa.university.vocabularies import course_levels
+from waeup.kofa.students.workflow import (TRANSREQ, TRANSVAL)
 
 class StudentsCatalog(grok.Indexes):
@@ -88,8 +89,7 @@
                 hitlist.append(StudentQueryResultItem(student, view=view))
         return hitlist
-    elif searchtype == 'transcript':
+    elif searchtype in (TRANSREQ, TRANSVAL):
         cat = queryUtility(ICatalog, name='students_catalog')
-        results = cat.searchResults(
-            state=('transcript requested', 'transcript requested'))
+        results = cat.searchResults(state=(searchtype, searchtype))
     else:
         # Temporary solution to display all students added
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/dynamicroles.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/dynamicroles.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/dynamicroles.py	(revision 15163)
@@ -46,4 +46,6 @@
         'waeup.local.LocalStudentsManager': 'waeup.StudentsManager',
         'waeup.local.LocalWorkflowManager': 'waeup.WorkflowManager',
+        'waeup.local.TranscriptOfficer': 'waeup.TranscriptOfficer',
+        'waeup.local.TranscriptSignee': 'waeup.TranscriptSignee',
         }
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/fileviewlets.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/fileviewlets.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/fileviewlets.py	(revision 15163)
@@ -153,4 +153,11 @@
 
 
+class FinalTranscriptImage(StudentImage):
+    """Renders final transcript.
+    """
+    grok.name('final_transcript')
+    download_name = u'final_transcript'
+
+
 class BirthCertificateImage(StudentImage):
     """Renders birth certificate scan.
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/interfaces.py	(revision 15163)
@@ -219,4 +219,7 @@
     transcript_enabled = Attribute('True if transcript processing is enabled')
     clearance_locked = Attribute('True if clearance form is locked')
+    studycourse_locked = Attribute(
+        'True if nobody is allowed to change studycourse, studylecel or '
+        'course ticket data, neither through the UI nor via import')
 
     password = Attribute('Encrypted password')
@@ -371,5 +374,6 @@
 
 class IStudentTranscript(IKofaObject):
-    """Representation of student transcript data.
+    """Representation of student transcript data. Only used for
+    StudentTranscriptRequestManageFormPage.
     """
 
@@ -379,7 +383,12 @@
         )
 
+    transcript_signees = schema.Text(
+        title = _(u'Signees'),
+        required = False,
+        )
+
 
 class IStudent(IStudentBase,IUGStudentClearance,IPGStudentClearance,
-    IStudentPersonal, IStudentTranscript):
+    IStudentPersonal):
     """Representation of a student.
     """
@@ -528,22 +537,4 @@
 IStudentStudyCourseTransfer['current_level'].order = IStudentStudyCourse[
     'current_level'].order
-
-class IStudentStudyCourseTranscript(IKofaObject):
-    """An interface for student transcripts.
-    """
-    entry_mode = schema.Choice(
-        title = _(u'Entry Mode'),
-        source = StudyModeSource(),
-        required = True,
-        readonly = False,
-        )
-
-    entry_session = schema.Choice(
-        title = _(u'Entry Session'),
-        source = academic_sessions_vocab,
-        #default = datetime.now().year,
-        required = True,
-        readonly = False,
-        )
 
 class IStudentVerdictUpdate(IKofaObject):
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/permissions.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/permissions.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/permissions.py	(revision 15163)
@@ -92,4 +92,19 @@
     """
     grok.name('waeup.loginAsStudent')
+
+class ViewTranscript(grok.Permission):
+    """The ViewTranscript role is needed to view transcript pages and slips.
+    """
+    grok.name('waeup.viewTranscript')
+
+class ProcessTranscript(grok.Permission):
+    grok.name('waeup.processTranscript')
+    """The ProcessTranscript role is needed to validate and relase transcripts.
+    """
+
+class SignTranscript(grok.Permission):
+    grok.name('waeup.signTranscript')
+    """The ViewTranscript role is needed to sign transcripts.
+    """
 
 # Local role
@@ -142,10 +157,27 @@
 
 class TranscriptOfficer(grok.Role):
+    """The Transcript Officer is allowed to view, to validate and to
+    release student transcripts. The officer is not allowed to
+    manage student data.
+    """
     grok.name('waeup.TranscriptOfficer')
     grok.title(u'Transcript Officer')
     grok.permissions('waeup.viewAcademics',
                      'waeup.viewTranscript',
+                     'waeup.processTranscript',
                      'waeup.viewStudent',
                      'waeup.viewStudentsContainer',
+                     )
+
+class TranscriptSignee(grok.Role):
+    """The Transcript Signee is allowed to view and to sign student
+    transcripts.
+    """
+    grok.name('waeup.TranscriptSignee')
+    grok.title(u'Transcript Signee')
+    grok.permissions('waeup.viewAcademics',
+                     'waeup.viewTranscript',
+                     'waeup.signTranscript',
+                     'waeup.viewStudent',
                      )
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/reports/tests/test_student_statistics.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/reports/tests/test_student_statistics.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/reports/tests/test_student_statistics.py	(revision 15163)
@@ -21,5 +21,6 @@
               'clearance requested', 'cleared', 'school fee paid',
               'courses registered', 'courses validated', 'returning',
-              'graduated', 'transcript requested', 'Total')
+              'graduated', 'transcript requested', 'transcript validated', 
+              'transcript released', 'Total')
 
     def test_iface(self):
@@ -41,12 +42,12 @@
             ((u'fac1', u'Total',),
              self.states,
-             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
-              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
+             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
+              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
         self.assertEqual(
             result2,
             ((u'fac1', u'Total'),
              self.states,
-             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
+             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
         return
 
@@ -62,14 +63,14 @@
             ((u'fac1', u'fac2', u'Total'),
              self.states,
-             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
+             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
         self.assertEqual(
             result2,
             ((u'fac1', u'fac2', u'Total'),
              self.states,
-             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
+             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
         return
 
@@ -83,14 +84,14 @@
             ((u'fac1/dep1', u'fac2/dep2', u'Total'),
              self.states,
-             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
-        self.assertEqual(
-            result2,
-            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
-             self.states,
-             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
+             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
+        self.assertEqual(
+            result2,
+            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
+             self.states,
+             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
         return
 
@@ -104,14 +105,14 @@
             ((u'fac1/dep1', u'fac2/dep2', u'Total'),
              self.states,
-             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
-        self.assertEqual(
-            result2,
-            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
-             self.states,
-             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
-              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
+             ((1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1),)))
+        self.assertEqual(
+            result2,
+            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
+             self.states,
+             ((0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),)))
         return
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/student.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/student.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/student.py	(revision 15163)
@@ -38,5 +38,5 @@
     IKofaUtils, registration_states_vocab, IExtFileStore,
     CREATED, ADMITTED, CLEARANCE, PAID, REGISTERED, VALIDATED, RETURNING,
-    GRADUATED)
+    GRADUATED, TRANSVAL, TRANSREL)
 from waeup.kofa.students.accommodation import StudentAccommodation
 from waeup.kofa.students.interfaces import (
@@ -239,4 +239,8 @@
     def transcript_enabled(self):
         return True
+
+    @property
+    def studycourse_locked(self):
+        return self.state in (GRADUATED, TRANSREL, TRANSVAL)
 
     @property
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/studycourse.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/studycourse.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/studycourse.py	(revision 15163)
@@ -26,5 +26,5 @@
 from waeup.kofa.students.interfaces import (
     IStudentStudyCourse, IStudentNavigation, IStudentStudyLevel,
-    IStudentStudyCourseTranscript)
+    IStudentTranscript)
 from waeup.kofa.students.studylevel import CourseTicket
 from waeup.kofa.students.workflow import CLEARED, RETURNING, PAID
@@ -34,6 +34,5 @@
     """This is a container for study levels.
     """
-    grok.implements(IStudentStudyCourse, IStudentNavigation,
-        IStudentStudyCourseTranscript)
+    grok.implements(IStudentStudyCourse, IStudentNavigation)
     grok.provides(IStudentStudyCourse)
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_batching.py	(revision 15163)
@@ -665,4 +665,28 @@
             entry_mode='transfer'), self.app)
         self.assertTrue(err is None)
+        # In state 'transcript validated' studycourses are locked.
+        IWorkflowState(self.student).setState('transcript validated')
+        err = self.processor.checkUpdateRequirements(
+            self.student['studycourse'],
+            dict(reg_number='1', current_level=999), self.app)
+        self.assertEqual(err, 'Studycourse is locked.')
+        # In state 'graduated' studycourses are also locked.
+        IWorkflowState(self.student).setState('graduated')
+        err = self.processor.checkUpdateRequirements(
+            self.student['studycourse'],
+            dict(reg_number='1', current_level=999), self.app)
+        self.assertEqual(err, 'Studycourse is locked.')
+        # In state 'transcript released' studycourses are also locked ...
+        IWorkflowState(self.student).setState('transcript released')
+        err = self.processor.checkUpdateRequirements(
+            self.student['studycourse'],
+            dict(reg_number='1', current_level=999), self.app)
+        self.assertEqual(err, 'Studycourse is locked.')
+        # ... but not in state 'transcript requested'.
+        IWorkflowState(self.student).setState('transcript requested')
+        err = self.processor.checkUpdateRequirements(
+            self.student['studycourse'],
+            dict(reg_number='1', current_level=999), self.app)
+        self.assertTrue(err is None)
 
     def test_import(self):
@@ -826,4 +850,28 @@
         shutil.rmtree(os.path.dirname(fin_file))
 
+    def test_import_update_locked(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, STUDYLEVEL_HEADER_FIELDS,'create')
+        shutil.rmtree(os.path.dirname(fin_file))
+        # In state 'transcript validated' studylevels can't be edited
+        student = self.app['students']['X666666']
+        IWorkflowState(student).setState('transcript validated')
+        # Two more records could't be imported because course tickets
+        # of X666666 are locked
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, STUDYLEVEL_HEADER_FIELDS,'update')
+        self.assertEqual(num_warns,5)
+        content = open(fail_file).read()
+        self.assertEqual(
+            content,
+            'reg_number,level_verdict,level_session,matric_number,level,--ERRORS--\r\n'
+            '1,<IGNORE>,2008,<IGNORE>,100,Studylevel is locked.\r\n'
+            '1,<IGNORE>,2008,<IGNORE>,200,Studylevel is locked.\r\n'
+            '1,A,2008,<IGNORE>,111,level: Invalid value\r\n'
+            '1,A,2008,<IGNORE>,nonsense,level: Invalid value\r\n'
+            '1,A,2008,<IGNORE>,<IGNORE>,Cannot update: no such entry\r\n'
+            )
+        shutil.rmtree(os.path.dirname(fin_file))
+
     def test_import_remove(self):
         # We perform the same import twice,
@@ -989,4 +1037,28 @@
             'INFO - system - K1000000 - Course ticket in 100 removed: COURSE1'
             in logcontent)
+
+    def test_import_update_locked(self):
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, COURSETICKET_HEADER_FIELDS,'create')
+        shutil.rmtree(os.path.dirname(fin_file))
+        # In state 'transcript validated' course tickets can't edited
+        student = self.app['students']['X666666']
+        IWorkflowState(student).setState('transcript validated')
+        num, num_warns, fin_file, fail_file = self.processor.doImport(
+            self.csv_file, COURSETICKET_HEADER_FIELDS,'update')
+        fail_file = open(fail_file).read()
+        # Two more records could't be imported because course tickets
+        # of X666666 are locked
+        self.assertEqual(num_warns,7)
+        self.assertEqual(fail_file,
+            'reg_number,code,mandatory,level,level_session,score,matric_number,--ERRORS--\r\n'
+            '1,COURSE1,True,100,<IGNORE>,1,<IGNORE>,Studycourse is locked.\r\n'
+            '1,COURSE1,True,200,2008,1,<IGNORE>,Studycourse is locked.\r\n'
+            '1,COURSE1,<IGNORE>,nonsense,<IGNORE>,5,<IGNORE>,Cannot update: no such entry\r\n'
+            '1,NONSENSE,<IGNORE>,100,<IGNORE>,5,<IGNORE>,code: non-existent\r\n'
+            '1,COURSE1,<IGNORE>,200,2004,6,<IGNORE>,level_session: does not match 2008\r\n'
+            '1,COURSE1,<IGNORE>,300,2008,6,<IGNORE>,level object: does not exist\r\n'
+            '1,COURSE1,<IGNORE>,300,2008X,6,<IGNORE>,level_session: Invalid value\r\n')
+        shutil.rmtree(os.path.dirname(fin_file))
 
 class PaymentProcessorTest(StudentImportExportSetup):
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_browser.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_browser.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_browser.py	(revision 15163)
@@ -41,4 +41,5 @@
 from waeup.kofa.testing import FunctionalLayer, FunctionalTestCase
 from waeup.kofa.app import University
+from waeup.kofa.interfaces import IFileStoreNameChooser, IExtFileStore
 from waeup.kofa.payments.interfaces import IPayer
 from waeup.kofa.students.payments import StudentOnlinePayment
@@ -2119,12 +2120,12 @@
         print "Sample PDF transcript.pdf written to %s" % path
 
-    def test_process_transcript_request(self):
+    def test_process_transcript(self):
         IWorkflowState(self.student).setState('transcript requested')
         notify(grok.ObjectModifiedEvent(self.student))
-        self.student.transcript_comment = (
+        self.student['studycourse'].transcript_comment = (
             u'On 07/08/2013 08:59:54 UTC K1000000 wrote:\n\nComment line 1 \n'
             'Comment line2\n\nDispatch Address:\nAddress line 1 \n'
             'Address line2\n\n')
-        # Create transcript officer
+        # Create officer with both roles
         self.app['users'].addUser('mrtranscript', 'mrtranscriptsecret')
         self.app['users']['mrtranscript'].email = 'mrtranscript@foo.ng'
@@ -2132,5 +2133,7 @@
         prmglobal = IPrincipalRoleManager(self.app)
         prmglobal.assignRoleToPrincipal('waeup.TranscriptOfficer', 'mrtranscript')
-        # Login as transcript officer
+        prmglobal.assignRoleToPrincipal('waeup.StudentsManager', 'mrtranscript')
+        prmglobal.assignRoleToPrincipal('waeup.TranscriptSignee', 'mrtranscript')
+        # Login
         self.browser.open(self.login_path)
         self.browser.getControl(name="form.login").value = 'mrtranscript'
@@ -2143,11 +2146,73 @@
             '...<div>Transcript Officer</div>...',
             self.browser.contents)
-        # Officer can search for students in state 'transcripr requested'
+        # Officer can search for students in state 'transcript requested'
         self.browser.open(self.container_path)
-        self.browser.getControl(name="searchtype").value = ['transcript']
+        self.browser.getControl(name="searchtype").value = [
+            'transcript requested']
         self.browser.getControl("Find student(s)").click()
         self.assertTrue('Anna Tester' in self.browser.contents)
         self.browser.getLink("K1000000").click()
-        self.browser.getLink("Manage transcript request").click()
+        self.assertFalse('Release transcript request' in self.browser.contents)
+        # Officers can still edit studycourse, studylevel and course tickets.
+        self.browser.open(self.studycourse_path + '/manage')
+        self.assertTrue('Undergraduate Full-Time</option>'
+            in self.browser.contents)
+        self.browser.getControl(name="form.certificate").value = ['CERT1']
+        self.browser.getControl(name="form.current_session").value = ['2004']
+        self.browser.getControl(name="form.current_verdict").value = ['A']
+        self.browser.getControl(name="form.entry_mode").value = ['ug_ft']
+        self.browser.getControl("Save").click()
+        self.browser.getControl(name="form.current_level").value = ['100']
+        self.browser.getControl("Save").click()
+        self.browser.getControl(name="addlevel").value = ['100']
+        self.browser.getControl(name="level_session").value = ['2004']
+        self.browser.getControl("Add study level").click()
+        self.browser.getLink("100").click()
+        self.browser.getLink("Manage").click()
+        self.browser.getControl(name="form.level_session").value = ['2002']
+        self.browser.getControl("Save").click()
+        self.browser.getLink("COURSE1").click()
+        self.browser.getLink("Manage").click()
+        self.browser.getControl("Save").click()
+        self.assertTrue('Form has been saved' in self.browser.contents)
+        # Officer can validate the transcript
+        self.browser.open(self.studycourse_path + '/transcript')
+        self.browser.getLink("Validate transcript").click()
+        # After validation all manage forms are locked.
+        self.browser.open(self.studycourse_path + '/manage')
+        self.assertTrue('The requested form is locked' in self.browser.contents)
+        self.assertFalse('Undergraduate Full-Time</option>'
+            in self.browser.contents)
+        self.browser.open(self.studycourse_path + '/100/manage')
+        self.assertTrue('The requested form is locked' in self.browser.contents)
+        self.browser.open(self.studycourse_path + '/100/COURSE1/manage')
+        self.assertTrue('The requested form is locked' in self.browser.contents)
+
+        # Transcript can be signed if officer has the permission to sign
+        #self.browser.open(self.studycourse_path + '/transcript')
+        #self.assertFalse('Sign transcript' in self.browser.contents)
+        #prmglobal = IPrincipalRoleManager(self.app)
+        #prmglobal.assignRoleToPrincipal('waeup.TranscriptSignee', 'mrtranscript')
+
+        self.browser.open(self.studycourse_path + '/transcript')
+        self.browser.getLink("Sign transcript electronically").click()
+        # Transcript signing has been logged ...
+        logfile = os.path.join(
+            self.app['datacenter'].storage, 'logs', 'students.log')
+        logcontent = open(logfile).read()
+        self.assertTrue(
+            'mrtranscript - students.browser.StudentTranscriptSignView - '
+            'K1000000 - Transcript signed' in logcontent)
+        # ... appears in the student's history ...
+        self.browser.open(self.history_path)
+        self.assertTrue('Transcript signed by Ruth Gordon'
+            in self.browser.contents)
+        # ... and is also stored in the transcript_signee attribute.
+        self.assertTrue(
+            u'Electronically signed by Ruth Gordon (mrtranscript) on '
+            in self.student['studycourse'].transcript_signees)
+        # Officer can release the transcript
+        self.browser.open(self.studycourse_path + '/transcript')
+        self.browser.getLink("Release transcript").click()
         self.assertTrue(' UTC K1000000 wrote:<br><br>Comment line 1 <br>'
         'Comment line2<br><br>Dispatch Address:<br>Address line 1 <br>'
@@ -2155,9 +2220,9 @@
         self.browser.getControl(name="comment").value = (
             'Hello,\nYour transcript has been sent to the address provided.')
-        self.browser.getControl("Save comment and mark as processed").click()
+        self.browser.getControl("Save comment and release transcript").click()
         self.assertTrue(
             'UTC mrtranscript wrote:\n\nHello,\nYour transcript has '
             'been sent to the address provided.\n\n'
-            in self.student.transcript_comment)
+            in self.student['studycourse'].transcript_comment)
         # The comment has been logged
         logfile = os.path.join(
@@ -2165,8 +2230,129 @@
         logcontent = open(logfile).read()
         self.assertTrue(
-            'mrtranscript - students.browser.StudentTranscriptRequestProcessFormPage - '
+            'mrtranscript - students.browser.StudentTranscriptReleaseFormPage - '
             'K1000000 - comment: Hello,<br>'
             'Your transcript has been sent to the address provided'
             in logcontent)
+        # File has been stored in the file system
+        # Check if transcript exists in the file system and is a PDF file
+        storage = getUtility(IExtFileStore)
+        file_id = IFileStoreNameChooser(
+            self.student).chooseName(attr='final_transcript.pdf')
+        pdf = storage.getFile(file_id).read()
+        self.assertTrue(len(pdf) > 0)
+        self.assertEqual(pdf[:8], '%PDF-1.4')
+        # Copy the file to samples_dir
+        path = os.path.join(samples_dir(), 'final_transcript.pdf')
+        open(path, 'wb').write(pdf)
+        print "Sample PDF final_transcript.pdf written to %s" % path
+        # Check if there is an transcript pdf link in UI
+        self.browser.open(self.student_path)
+        self.assertTrue('Final Transcript' in self.browser.contents)
+        self.browser.getLink("Final Transcript").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.headers['Content-Type'],
+                         'application/pdf')
+        # Transcript views are no longer accessible
+        self.browser.open(self.studycourse_path)
+        self.assertFalse('studycourse/transcript' in self.browser.contents)
+        self.browser.open(self.studycourse_path + '/transcript')
+        self.assertTrue('Forbidden!' in self.browser.contents)
+        self.browser.open(self.studycourse_path + '/transcript.pdf')
+        self.assertTrue('Forbidden!' in self.browser.contents)
+        # If we reset the transcript process
+        # (can't be done by transcript officer), the file will be deleted
+        IWorkflowInfo(self.student).fireTransition('reset11')
+        self.browser.open(self.student_path)
+        self.assertFalse('Final Transcript' in self.browser.contents)
+        # ... and transcript process information has been removed
+        self.assertEqual(self.student['studycourse'].transcript_comment, None)
+        self.assertEqual(self.student['studycourse'].transcript_signees, None)
+
+    def test_landingpage_transcript_officer(self):
+        IWorkflowState(self.student).setState('transcript requested')
+        notify(grok.ObjectModifiedEvent(self.student))
+        # Create transcript officer
+        self.app['users'].addUser('mrtranscript', 'mrtranscriptsecret')
+        self.app['users']['mrtranscript'].email = 'mrtranscript@foo.ng'
+        self.app['users']['mrtranscript'].title = 'Ruth Gordon'
+        # We assign transcript officer role at faculty level
+        fac = self.app['faculties']['fac1']
+        prmlocal = IPrincipalRoleManager(fac)
+        prmlocal.assignRoleToPrincipal(
+            'waeup.local.TranscriptOfficer', 'mrtranscript')
+        notify(LocalRoleSetEvent(
+            fac, 'waeup.local.TranscriptOfficer', 'mrtranscript', granted=True))
+        # Login as transcript officer
+        self.browser.open(self.login_path)
+        self.browser.getControl(name="form.login").value = 'mrtranscript'
+        self.browser.getControl(name="form.password").value = 'mrtranscriptsecret'
+        self.browser.getControl("Login").click()
+        self.assertMatches('...You logged in...', self.browser.contents)
+        # Officer is on landing page and does see the transcript link
+        self.assertTrue(
+            'http://localhost/app/students/K1000000/studycourse/transcript'
+            in self.browser.contents)
+        self.browser.getLink("K1000000").click()
+        self.assertTrue(
+            'Anna Tester: Transcript Data' in self.browser.contents)
+        # Officer is on transcript page and can validate the transcript
+        self.browser.getLink("Validate transcript").click()
+        self.assertTrue(
+            '<div class="alert alert-success">Transcript validated.</div>'
+            in self.browser.contents)
+        # Officer is still on transcript page and can release the transcript
+        self.browser.getLink("Release transcript").click()
+        self.browser.getControl("Save comment and release transcript").click()
+        self.assertTrue(
+            '<div class="alert alert-success">'
+            'Transcript released and final transcript file saved.</div>'
+            in self.browser.contents)
+
+    def test_landingpage_transcript_signee(self):
+        IWorkflowState(self.student).setState('transcript validated')
+        notify(grok.ObjectModifiedEvent(self.student))
+        # Create transcript signee
+        self.app['users'].addUser('mrtranscript', 'mrtranscriptsecret')
+        self.app['users']['mrtranscript'].email = 'mrtranscript@foo.ng'
+        self.app['users']['mrtranscript'].title = 'Ruth Gordon'
+        # We assign transcript officer role at faculty level
+        fac = self.app['faculties']['fac1']
+        prmlocal = IPrincipalRoleManager(fac)
+        prmlocal.assignRoleToPrincipal(
+            'waeup.local.TranscriptSignee', 'mrtranscript')
+        notify(LocalRoleSetEvent(
+            fac, 'waeup.local.TranscriptSignee', 'mrtranscript', granted=True))
+        # Login as transcript officer
+        self.browser.open(self.login_path)
+        self.browser.getControl(name="form.login").value = 'mrtranscript'
+        self.browser.getControl(name="form.password").value = 'mrtranscriptsecret'
+        self.browser.getControl("Login").click()
+        self.assertMatches('...You logged in...', self.browser.contents)
+        # Officer is on landing page and does see the transcript link
+        self.assertTrue(
+            'http://localhost/app/students/K1000000/studycourse/transcript'
+            in self.browser.contents)
+        self.browser.getLink("K1000000").click()
+        self.assertTrue(
+            'Anna Tester: Transcript Data' in self.browser.contents)
+        # Officer is on transcript page and can sign the transcript
+        self.browser.getLink("Sign transcript").click()
+        self.assertTrue(
+            '<div class="alert alert-success">Transcript signed.</div>'
+            in self.browser.contents)
+        # Officer is still on transcript page
+        self.assertTrue(
+            'Anna Tester: Transcript Data' in self.browser.contents)
+        # Officer can sign the transcript only once
+        self.browser.getLink("Sign transcript").click()
+        self.assertTrue(
+            '<div class="alert alert-warning">'
+            'You have already signed this transcript.</div>'
+            in self.browser.contents)
+        # Signature can be seen on transcript page
+        self.assertTrue(
+            'Electronically signed by Ruth Gordon (mrtranscript) on'
+            in self.browser.contents)
+
 
 class StudentUITests(StudentsFullSetup):
@@ -3303,5 +3489,5 @@
             '... UTC K1000000 wrote:\n\nComment line 1 \n'
             'Comment line2\n\nDispatch Address:\nAddress line 1 \n'
-            'Address line2\n\n', self.student.transcript_comment)
+            'Address line2\n\n', self.student['studycourse'].transcript_comment)
         # The comment has been logged
         logfile = os.path.join(
@@ -3572,9 +3758,9 @@
             'firstname,flash_notice,lastname,matric_number,middlename,nationality,'
             'officer_comment,perm_address,personal_updated,phone,reg_number,'
-            'sex,student_id,suspended,suspended_comment,transcript_comment,'
-            'password,state,history,certcode,is_postgrad,current_level,'
-            'current_session\r\n'
+            'sex,student_id,suspended,suspended_comment,'
+            'password,state,history,certcode,is_postgrad,'
+            'current_level,current_session\r\n'
             ',,1981-02-04#,aa@aa.ng,,Anna,,Tester,234,,,,,,'
-            '1234#,123,m,K1000000,0,,,{SSHA}' in self.browser.contents)
+            '1234#,123,m,K1000000,0,,{SSHA}' in self.browser.contents)
 
     def test_payment_dates(self):
@@ -3678,9 +3864,9 @@
             'firstname,flash_notice,lastname,matric_number,middlename,nationality,'
             'officer_comment,perm_address,personal_updated,phone,reg_number,'
-            'sex,student_id,suspended,suspended_comment,transcript_comment,'
-            'password,state,history,certcode,is_postgrad,current_level,'
-            'current_session\r\n'
+            'sex,student_id,suspended,suspended_comment,'
+            'password,state,history,certcode,is_postgrad,'
+            'current_level,current_session\r\n'
             ',,1981-02-04#,aa@aa.ng,,Anna,,Tester,234,,,,,,'
-            '1234#,123,m,K1000000,0,,,{SSHA}' in self.browser.contents)
+            '1234#,123,m,K1000000,0,,{SSHA}' in self.browser.contents)
         self.assertEqual(len(self.app['datacenter'].running_exports), 1)
         job_id = self.app['datacenter'].running_exports[0][0]
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_export.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_export.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_export.py	(revision 15163)
@@ -127,5 +127,5 @@
         'my adm code,my clr code,1981-02-04#,anna@sample.com,,'
         'Anna,,Tester,234,M.,NG,,"Studentroad 21\nLagos 123456\n",,'
-        '+234-123-12345#,123,f,A111111,0,,,,created'
+        '+234-123-12345#,123,f,A111111,0,,,created'
         )
 
@@ -160,10 +160,10 @@
             'nationality,officer_comment,perm_address,personal_updated,'
             'phone,reg_number,sex,student_id,suspended,suspended_comment,'
-            'transcript_comment,password,state,history,certcode,is_postgrad,'
-            'current_level,current_session\r\n'
+            'password,state,history,'
+            'certcode,is_postgrad,current_level,current_session\r\n'
             'my adm code,my clr code,'
             '1981-02-04#,anna@sample.com,,Anna,,Tester,234,M.,NG,,'
             '"Studentroad 21\nLagos 123456\n",,+234-123-12345#,123,f,'
-            'A111111,0,,,,created'
+            'A111111,0,,,created'
             in result
             )
@@ -182,9 +182,9 @@
             'nationality,officer_comment,perm_address,personal_updated,'
             'phone,reg_number,sex,student_id,suspended,suspended_comment,'
-            'transcript_comment,password,state,history,certcode,'
+            'password,state,history,certcode,'
             'is_postgrad,current_level,current_session\r\n'
             'my adm code,my clr code,1981-02-04#,anna@sample.com,,'
             'Anna,,Tester,234,M.,NG,,"Studentroad 21\nLagos 123456\n"'
-            ',,+234-123-12345#,123,f,A111111,0,,,,created'
+            ',,+234-123-12345#,123,f,A111111,0,,,created'
             in result
             )
@@ -202,9 +202,9 @@
             'nationality,officer_comment,perm_address,personal_updated,'
             'phone,reg_number,sex,student_id,suspended,suspended_comment,'
-            'transcript_comment,password,state,history,certcode,'
+            'password,state,history,certcode,'
             'is_postgrad,current_level,current_session\r\n'
             'my adm code,my clr code,1981-02-04#,anna@sample.com,,'
             'Anna,,Tester,234,M.,NG,,"Studentroad 21\nLagos 123456\n"'
-            ',,+234-123-12345#,123,f,A111111,0,,,,created'
+            ',,+234-123-12345#,123,f,A111111,0,,,created'
             in result
             )
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/utils.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/utils.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/utils.py	(revision 15163)
@@ -20,4 +20,5 @@
 import grok
 from time import time
+from cStringIO import StringIO
 from reportlab.lib import colors
 from reportlab.lib.units import cm
@@ -33,5 +34,5 @@
 from waeup.kofa.interfaces import (
     IExtFileStore, IKofaUtils, RETURNING, PAID, CLEARED,
-    academic_sessions_vocab)
+    academic_sessions_vocab, IFileStoreNameChooser)
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.students.interfaces import IStudentsUtils
@@ -854,12 +855,25 @@
             level_obj.cumulative_params[0], level_obj.student)[0]
 
+    def _saveTranscriptPDF(self, student, transcript):
+        """Create a transcript PDF file and store it in student folder.
+        """
+        file_store = getUtility(IExtFileStore)
+        file_id = IFileStoreNameChooser(student).chooseName(
+            attr="final_transcript.pdf")
+        file_store.createFile(file_id, StringIO(transcript))
+        return
+
     def renderPDFTranscript(self, view, filename='transcript.pdf',
                   student=None,
                   studentview=None,
-                  note=None, signatures=None, sigs_in_footer=(),
+                  note=None,
+                  signatures=(),
+                  sigs_in_footer=(),
+                  digital_sigs=(),
                   show_scans=True, topMargin=1.5,
                   omit_fields=(),
                   tableheader=None,
-                  no_passport=False):
+                  no_passport=False,
+                  save_file=False):
         """Render pdf slip of a transcripts.
         """
@@ -957,4 +971,11 @@
                 data.append(signaturetables[0])
 
+        # Insert digital signatures
+        if digital_sigs:
+            data.append(Spacer(1, 20))
+            sigs = digital_sigs.split('\n')
+            for sig in sigs:
+                data.append(Paragraph(sig, NOTE_STYLE))
+
         view.response.setHeader(
             'Content-Type', 'application/pdf')
@@ -966,4 +987,7 @@
             view.flash(_('Error in image file.'))
             return view.redirect(view.url(view.context))
+        if save_file:
+            self._saveTranscriptPDF(student, pdf_stream)
+            return
         return pdf_stream
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py	(revision 15163)
@@ -36,9 +36,9 @@
     IStudentsContainer, IStudent, IStudentStudyCourse, IStudentStudyLevel,
     ICourseTicket, IStudentOnlinePayment, IBedTicket,
-    IStudentPaymentsContainer, IStudentsUtils
+    IStudentPaymentsContainer, IStudentsUtils,
     )
 from waeup.kofa.students.workflow import (
     ADMITTED, PAID, REQUESTED, CLEARED, REGISTERED, VALIDATED, GRADUATED,
-    TRANSCRIPT)
+    TRANSREQ, TRANSVAL, TRANSREL)
 from waeup.kofa.university.interfaces import ICourse
 
@@ -96,5 +96,4 @@
         return ''
 
-
 class StudentManageBaseLink(StudentManageLink):
     grok.order(2)
@@ -144,4 +143,22 @@
 
 
+class StudentManageTranscriptLink(StudentManageLink):
+    grok.order(9)
+    link = 'final_transcript'
+    text = _(u'Final Transcript')
+
+    def render(self):
+        slip = getUtility(IExtFileStore).getFileByContext(
+            self.context.student, attr=self.link)
+        if slip:
+            lang = self.request.cookies.get('kofa.language')
+            text = translate(
+                self.text, 'waeup.kofa', target_language=lang)
+            url = self.view.url(self.context.student, self.link)
+            return u'<li><a href="%s">%s</a></li>' % (
+                url, text)
+        return ''
+
+
 class StudentsContainerManageActionButton(ManageActionButton):
     grok.order(1)
@@ -370,38 +387,4 @@
     def target_url(self):
         if self.context.is_current:
-            return self.view.url(self.view.context, self.target)
-        return False
-
-
-class StudyCourseTranscriptActionButton(ManageActionButton):
-    grok.order(2)
-    grok.name('transcript')
-    grok.context(IStudentStudyCourse)
-    grok.view(StudyCourseDisplayFormPage)
-    grok.require('waeup.viewTranscript')
-    text = _('Transcript')
-    target = 'transcript'
-    icon = 'actionicon_transcript.png'
-
-    @property
-    def target_url(self):
-        if self.context.student.transcript_enabled:
-            return self.view.url(self.view.context, self.target)
-        return False
-
-
-class TranscriptSlipActionButton(ManageActionButton):
-    grok.order(1)
-    grok.name('transcript_slip')
-    grok.context(IStudentStudyCourse)
-    grok.view(StudyCourseTranscriptPage)
-    grok.require('waeup.viewTranscript')
-    text = _('Academic Transcript')
-    target = 'transcript.pdf'
-    icon = 'actionicon_pdf.png'
-
-    @property
-    def target_url(self):
-        if self.context.student.transcript_enabled:
             return self.view.url(self.view.context, self.target)
         return False
@@ -743,4 +726,23 @@
 
 
+class StudyCourseTranscriptActionButton(ManageActionButton):
+    grok.order(2)
+    grok.name('transcript')
+    grok.context(IStudentStudyCourse)
+    grok.view(StudyCourseDisplayFormPage)
+    grok.require('waeup.viewTranscript')
+    text = _('Transcript')
+    target = 'transcript'
+    icon = 'actionicon_transcript.png'
+
+    @property
+    def target_url(self):
+        final_slip = getUtility(IExtFileStore).getFileByContext(
+            self.context.student, attr='final_transcript')
+        if self.context.student.transcript_enabled and not final_slip:
+            return self.view.url(self.view.context, self.target)
+        return False
+
+
 class RequestTranscriptActionButton(ManageActionButton):
     grok.order(8)
@@ -759,18 +761,84 @@
 
 
-class ProcessTranscriptRequestActionButton(ManageActionButton):
-    grok.order(9)
-    grok.context(IStudent)
-    grok.view(StudentBaseDisplayFormPage)
+class ValidateTranscriptActionButton(ManageActionButton):
+    grok.order(8)
+    grok.context(IStudentStudyCourse)
+    grok.view(StudyCourseTranscriptPage)
+    grok.require('waeup.processTranscript')
+    text = _('Validate transcript')
+    target = 'validate_transcript'
+    icon = 'actionicon_transcript.png'
+
+    @property
+    def target_url(self):
+        if self.context.student.state != TRANSREQ:
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+    @property
+    def onclick(self):
+        return "return window.confirm(%s);" % _(
+            "'Course results cannot be changed after transcript "
+            "validation. \\n\\n"
+            "You really want to validate the transcript?'")
+
+
+class ReleaseTranscriptActionButton(ManageActionButton):
+    grok.order(8)
+    grok.context(IStudentStudyCourse)
+    grok.view(StudyCourseTranscriptPage)
+    grok.require('waeup.processTranscript')
+    text = _('Release transcript')
+    target = 'release_transcript'
+    icon = 'actionicon_transcript.png'
+
+    @property
+    def target_url(self):
+        if self.context.student.state != TRANSVAL:
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+
+class TranscriptSlipActionButton(ManageActionButton):
+    grok.order(1)
+    grok.name('transcript_slip')
+    grok.context(IStudentStudyCourse)
+    grok.view(StudyCourseTranscriptPage)
     grok.require('waeup.viewTranscript')
-    text = _('Manage transcript request')
-    target = 'process_transcript_request'
+    text = _('Academic Transcript')
+    target = 'transcript.pdf'
+    icon = 'actionicon_pdf.png'
+
+    @property
+    def target_url(self):
+        final_slip = getUtility(IExtFileStore).getFileByContext(
+            self.context.student, attr='final_transcript')
+        if self.context.student.transcript_enabled \
+            and not final_slip:
+            return self.view.url(self.view.context, self.target)
+        return False
+
+
+class SignTranscriptActionButton(ManageActionButton):
+    grok.order(2)
+    grok.context(IStudentStudyCourse)
+    grok.view(StudyCourseTranscriptPage)
+    grok.require('waeup.signTranscript')
+    text = _('Sign transcript electronically')
+    target = 'sign_transcript'
     icon = 'actionicon_transcript.png'
 
     @property
     def target_url(self):
-        if self.context.state != TRANSCRIPT:
-            return ''
-        return self.view.url(self.view.context, self.target)
+        if self.context.student.state != TRANSVAL:
+            return ''
+        return self.view.url(self.view.context, self.target)
+
+    @property
+    def onclick(self):
+        return "return window.confirm(%s);" % _(
+            "'Signing a transcript electronically cannot be revoked. "
+            "The electronic signature replaces your handwritten signature.\\n\\n"
+            "You really want to sign the transcript?'")
 
 
Index: main/waeup.kofa/trunk/src/waeup/kofa/students/workflow.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/students/workflow.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/workflow.py	(revision 15163)
@@ -9,5 +9,6 @@
     IObjectHistory, IKofaWorkflowInfo, IKofaUtils,
     CREATED, ADMITTED, CLEARANCE, REQUESTED, CLEARED, PAID, RETURNING,
-    REGISTERED, VALIDATED, GRADUATED, TRANSCRIPT)
+    REGISTERED, VALIDATED, GRADUATED, TRANSREQ, TRANSVAL, TRANSREL,
+    IExtFileStore)
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.workflow import KofaWorkflow, KofaWorkflowInfo
@@ -198,11 +199,32 @@
         msg = _("Transcript requested"),
         source = GRADUATED,
-        destination = TRANSCRIPT),
-
-    Transition(
-        transition_id = 'process_transcript',
-        title = _('Transcript processed'),
-        msg = _("Transcript processed"),
-        source = TRANSCRIPT,
+        destination = TRANSREQ),
+
+    Transition(
+        transition_id = 'reset10',
+        title = _('Reject transcript request'),
+        msg = _("Transcript request rejected"),
+        source = TRANSREQ,
+        destination = GRADUATED),
+
+    Transition(
+        transition_id = 'validate_transcript',
+        title = _('Validate transcript'),
+        msg = _("Transcript validated"),
+        source = TRANSREQ,
+        destination = TRANSVAL),
+
+    Transition(
+        transition_id = 'release_transcript',
+        title = _('Release transcript'),
+        msg = _("Transcript released"),
+        source = TRANSVAL,
+        destination = TRANSREL),
+
+    Transition(
+        transition_id = 'reset11',
+        title = _('Reset to graduated'),
+        msg = _("Transcript process reset"),
+        source = TRANSREL,
         destination = GRADUATED),
     )
@@ -275,4 +297,12 @@
             level_object.validated_by = None
             level_object.validation_date = None
+    elif event.transition.transition_id == 'reset11':
+        transcript_file = getUtility(IExtFileStore).getFileByContext(
+            obj, attr='final_transcript')
+        if transcript_file:
+            getUtility(IExtFileStore).deleteFileByContext(
+                obj, attr='final_transcript')
+        obj['studycourse'].transcript_comment = None
+        obj['studycourse'].transcript_signees = None
     # In some tests we don't have a students container
     try:
Index: main/waeup.kofa/trunk/src/waeup/kofa/university/faculty.py
===================================================================
--- main/waeup.kofa/trunk/src/waeup/kofa/university/faculty.py	(revision 15162)
+++ main/waeup.kofa/trunk/src/waeup/kofa/university/faculty.py	(revision 15163)
@@ -63,4 +63,6 @@
         'waeup.local.LocalStudentsManager',
         'waeup.local.LocalWorkflowManager',
+        'waeup.local.TranscriptOfficer',
+        'waeup.local.TranscriptSignee',
         ]
 
