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 15066)
+++ 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 15066)
+++ 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 15066)
+++ 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
             )
