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 13908)
+++ main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_browser.py	(revision 13935)
@@ -1,2 +1,3 @@
+# -*- coding: utf-8 -*-
 ## $Id$
 ##
@@ -45,5 +46,5 @@
 from waeup.kofa.university.faculty import Faculty
 from waeup.kofa.university.department import Department
-from waeup.kofa.interfaces import IUserAccount, IJobManager
+from waeup.kofa.interfaces import IUserAccount, IJobManager, VALIDATED, CREATED
 from waeup.kofa.authentication import LocalRoleSetEvent
 from waeup.kofa.hostels.hostel import Hostel, Bed, NOT_OCCUPIED
@@ -55,4 +56,5 @@
 SAMPLE_IMAGE = os.path.join(os.path.dirname(__file__), 'test_image.jpg')
 SAMPLE_IMAGE_BMP = os.path.join(os.path.dirname(__file__), 'test_image.bmp')
+URL_LECTURER_LANDING = 'http://localhost/app/my_courses'
 
 
@@ -1807,122 +1809,4 @@
         self.assertTrue(self.student_id in self.browser.contents)
 
-    def test_handle_courses_by_lecturer(self):
-        self.app['users'].addUser('mrslecturer', 'mrslecturersecret')
-        self.app['users']['mrslecturer'].email = 'mrslecturer@foo.ng'
-        self.app['users']['mrslecturer'].title = u'Mercedes Benz'
-        # Add course ticket
-        studylevel = createObject(u'waeup.StudentStudyLevel')
-        studylevel.level = 100
-        studylevel.level_session = 2004
-        self.student['studycourse'].addStudentStudyLevel(
-            self.certificate, studylevel)
-        # Assign local Lecturer role for a certificate.
-        course = self.app['faculties']['fac1']['dep1'].courses['COURSE1']
-        prmlocal = IPrincipalRoleManager(course)
-        prmlocal.assignRoleToPrincipal('waeup.local.Lecturer', 'mrslecturer')
-        notify(LocalRoleSetEvent(
-            course, 'waeup.local.Lecturer', 'mrslecturer', granted=True))
-        # Login as lecturer.
-        self.browser.open(self.login_path)
-        self.browser.getControl(name="form.login").value = 'mrslecturer'
-        self.browser.getControl(
-            name="form.password").value = 'mrslecturersecret'
-        self.browser.getControl("Login").click()
-        self.assertMatches('...You logged in...', self.browser.contents)
-        # Lecturer is on landing page
-        self.assertTrue('(<span>COURSE1</span>)' in self.browser.contents)
-        # Lecturer can click the 'My Roles' link.
-        self.browser.getLink("My Roles").click()
-        self.assertMatches(
-            '...<div>Academics Officer (view only)</div>...',
-            self.browser.contents)
-        self.assertMatches(
-            '...<a href="http://localhost/app/'
-            'faculties/fac1/dep1/courses/COURSE1">...',
-            self.browser.contents)
-        self.assertFalse('(<span>COURSE1</span>)' in self.browser.contents)
-        # Lecturer can click the 'My Courses' link ...
-        self.browser.getLink("My Courses").click()
-        # ... and is is back on landing page
-        self.assertTrue('(<span>COURSE1</span>)' in self.browser.contents)
-        # The lecturer can go to her course ...
-        self.browser.getLink("COURSE1").click()
-        # Lecturers can neither filter students ...
-        self.assertRaises(
-            Unauthorized, self.browser.open,
-            "http://localhost/app/faculties/fac1/dep1/courses/COURSE1/students")
-        # ... nor access the student ...
-        self.assertRaises(
-            Unauthorized, self.browser.open, self.student_path)
-        # ... nor the respective course ticket since
-        # editing course tickets by lecturers is not feasible.
-        self.assertTrue('COURSE1' in self.student['studycourse']['100'].keys())
-        course_ticket_path = self.student_path + '/studycourse/100/COURSE1'
-        self.assertRaises(
-            Unauthorized, self.browser.open, course_ticket_path)
-        # Course results can be batch edited via the edit_courses view.
-        self.app['faculties']['fac1']['dep1'].score_editing_disabled = True
-        self.browser.open(
-            "http://localhost/app/faculties/fac1/dep1/courses/COURSE1")
-        self.browser.getLink("Update scores").click()
-        self.assertTrue('Score editing disabled' in self.browser.contents)
-        self.app['faculties']['fac1']['dep1'].score_editing_disabled = False
-        self.browser.getLink("Update scores").click()
-        self.assertTrue(
-            'Current academic session not set' in self.browser.contents)
-        self.app['configuration'].current_academic_session = 2004
-        self.browser.getLink("Update scores").click()
-        self.assertFalse(
-            '<input type="text" name="scores" class="span1" />'
-            in self.browser.contents)
-        IWorkflowState(self.student).setState('courses validated')
-        # Student must be in state 'courses validated'.
-        self.browser.open(
-            'http://localhost/app/faculties/fac1/'
-            'dep1/courses/COURSE1/edit_scores')
-        self.assertTrue(
-            'input type="text" name="scores"' in self.browser.contents)
-        self.browser.getControl(name="scores", index=0).value = '55'
-        self.browser.getControl("Update scores").click()
-        # New score has been set.
-        self.assertEqual(
-            self.student['studycourse']['100']['COURSE1'].score, 55)
-        # Score editing has been logged.
-        logfile = os.path.join(
-            self.app['datacenter'].storage, 'logs', 'students.log')
-        logcontent = open(logfile).read()
-        self.assertTrue('mrslecturer - students.browser.EditScoresPage - '
-            'K1000000 100/COURSE1 score updated (55)' in logcontent)
-        # Non-integer scores won't be accepted.
-        self.browser.open(
-            'http://localhost/app/faculties/fac1/'
-            'dep1/courses/COURSE1/edit_scores')
-        self.assertTrue('value="55" />' in self.browser.contents)
-        self.browser.getControl(name="scores", index=0).value = 'abc'
-        self.browser.getControl("Update scores").click()
-        self.assertTrue('Error: Score(s) of Anna Tester have not be updated'
-            in self.browser.contents)
-        # Scores can be removed.
-        self.browser.open(
-            'http://localhost/app/faculties/fac1/'
-            'dep1/courses/COURSE1/edit_scores')
-        self.browser.getControl(name="scores", index=0).value = ''
-        self.browser.getControl("Update scores").click()
-        self.assertEqual(
-            self.student['studycourse']['100']['COURSE1'].score, None)
-        logcontent = open(logfile).read()
-        self.assertTrue('mrslecturer - students.browser.EditScoresPage - '
-            'K1000000 100/COURSE1 score updated (None)' in logcontent)
-        # A course ticket slip can be downloaded
-        self.browser.open(
-            'http://localhost/app/faculties/fac1/'
-            'dep1/courses/COURSE1/coursetickets.pdf')
-        self.assertEqual(self.browser.headers['Status'], '200 Ok')
-        self.assertEqual(self.browser.headers['Content-Type'],
-                         'application/pdf')
-        path = os.path.join(samples_dir(), 'coursetickets.pdf')
-        open(path, 'wb').write(self.browser.contents)
-        print "Sample PDF coursetickets.pdf written to %s" % path
-
     def test_change_current_mode(self):
         self.browser.addHeader('Authorization', 'Basic mgr:mgrpw')
@@ -3898,39 +3782,4 @@
             )
 
-    def test_course_download_lecturer(self):
-        # We add study level 100 to the student's studycourse
-        studylevel = StudentStudyLevel()
-        studylevel.level = 100
-        studylevel.level_session = 2004
-        IWorkflowState(self.student).setState('courses validated')
-        self.student['studycourse'].addStudentStudyLevel(
-            self.certificate,studylevel)
-        course1_path = 'http://localhost/app/faculties/fac1/dep1/courses/COURSE1'
-        # Create lecturer
-        self.app['users'].addUser('mrlecturer', 'mrlecturersecret')
-        self.app['users']['mrlecturer'].email = 'mrlecturer@foo.ng'
-        self.app['users']['mrlecturer'].title = 'Carlo Intelligent'
-        prmglobal = IPrincipalRoleManager(self.course)
-        prmglobal.assignRoleToPrincipal('waeup.local.Lecturer', 'mrlecturer')
-        # Login as lecturer
-        self.browser.open(self.login_path)
-        self.browser.getControl(name="form.login").value = 'mrlecturer'
-        self.browser.getControl(name="form.password").value = 'mrlecturersecret'
-        self.browser.getControl("Login").click()
-        self.assertMatches('...You logged in...', self.browser.contents)
-        self.browser.open(course1_path)
-        self.assertFalse('Export' in self.browser.contents)
-        self.browser.getLink("Update scores").click()
-        self.assertTrue('Current academic session not set' in self.browser.contents)
-        self.app['configuration'].current_academic_session = 2004
-        self.browser.getLink("Update scores").click()
-        self.browser.getLink("Download editable tickets").click()
-        self.assertEqual(self.browser.headers['Status'], '200 Ok')
-        self.assertEqual(self.browser.headers['Content-Type'],
-                         'text/csv; charset=UTF-8')
-        self.assertEqual(self.browser.contents, 'matric_number,student_id,'
-            'display_fullname,level,code,level_session,score\r\n234,'
-            'K1000000,Anna Tester,100,COURSE1,2004,\r\n')
-
     def test_export_departmet_officers(self):
         # Create department officer
@@ -4004,2 +3853,274 @@
         self.browser.getControl("Discard").click()
         self.assertEqual(len(self.app['datacenter'].running_exports), 0)
+
+
+UPLOAD_CSV_TEMPLATE = (
+    'matric_number,student_id,display_fullname,level,code,level_session,'
+    'score\r\n'
+    '234,K1000000,Anna Tester,100,COURSE1,2004,%s\r\n')
+
+class LecturerUITests(StudentsFullSetup):
+    # Tests for UI actions when acting as lecturer.
+
+    def login_as_lecturer(self):
+        self.app['users'].addUser('mrslecturer', 'mrslecturersecret')
+        self.app['users']['mrslecturer'].email = 'mrslecturer@foo.ng'
+        self.app['users']['mrslecturer'].title = u'Mercedes Benz'
+        # Add course ticket
+        studylevel = createObject(u'waeup.StudentStudyLevel')
+        studylevel.level = 100
+        studylevel.level_session = 2004
+        self.student['studycourse'].addStudentStudyLevel(
+            self.certificate, studylevel)
+        # Assign local Lecturer role for a certificate.
+        course = self.app['faculties']['fac1']['dep1'].courses['COURSE1']
+        prmlocal = IPrincipalRoleManager(course)
+        prmlocal.assignRoleToPrincipal('waeup.local.Lecturer', 'mrslecturer')
+        notify(LocalRoleSetEvent(
+            course, 'waeup.local.Lecturer', 'mrslecturer', granted=True))
+        # Login as lecturer.
+        self.browser.open(self.login_path)
+        self.browser.getControl(name="form.login").value = 'mrslecturer'
+        self.browser.getControl(
+            name="form.password").value = 'mrslecturersecret'
+        self.browser.getControl("Login").click()
+        # Store reused urls/paths
+        self.course_url = (
+            'http://localhost/app/faculties/fac1/dep1/courses/COURSE1')
+        self.edit_scores_url = '%s/edit_scores' % self.course_url
+        # Set standard parameters
+        self.app['configuration'].current_academic_session = 2004
+        self.app['faculties']['fac1']['dep1'].score_editing_disabled = False
+        IWorkflowState(self.student).setState(VALIDATED)
+
+    @property
+    def stud_log_path(self):
+        return os.path.join(
+            self.app['datacenter'].storage, 'logs', 'students.log')
+
+    def test_lecturer_lands_on_landing_page(self):
+        # lecturers can login and will be led to landing page.
+        self.login_as_lecturer()
+        self.assertMatches('...You logged in...', self.browser.contents)
+        self.assertEqual(self.browser.url, URL_LECTURER_LANDING)
+
+    def test_my_roles_link_works(self):
+        # lecturers can see their roles
+        self.login_as_lecturer()
+        self.browser.getLink("My Roles").click()
+        self.assertTrue(
+            "<div>Academics Officer (view only)</div>"
+            in self.browser.contents)
+        self.assertTrue(
+            '<a href="%s">' % self.course_url in self.browser.contents)
+
+    def test_my_roles_page_contains_backlink(self):
+        # we can get back from 'My Roles' view to landing page
+        self.login_as_lecturer()
+        self.browser.getLink("My Roles").click()
+        self.browser.getLink("My Courses").click()
+        self.assertEqual(self.browser.url, URL_LECTURER_LANDING)
+
+    def test_lecturers_can_reach_their_courses(self):
+        # lecturers get links to their courses on the landing page
+        self.login_as_lecturer()
+        self.browser.getLink("COURSE1").click()
+        self.assertEqual(self.browser.url, self.course_url)
+
+    def test_lecturers_student_access_is_restricted(self):
+        # lecturers are not able to change other student data
+        self.login_as_lecturer()
+        # Lecturers can neither filter students ...
+        self.assertRaises(
+            Unauthorized, self.browser.open, '%s/students' % self.course_url)
+        # ... nor access the student ...
+        self.assertRaises(
+            Unauthorized, self.browser.open, self.student_path)
+        # ... nor the respective course ticket since editing course
+        # tickets by lecturers is not feasible.
+        self.assertTrue('COURSE1' in self.student['studycourse']['100'].keys())
+        course_ticket_path = self.student_path + '/studycourse/100/COURSE1'
+        self.assertRaises(
+            Unauthorized, self.browser.open, course_ticket_path)
+
+    def test_score_editing_requires_department_permit(self):
+        # we get a warning if we try to update score while we are not allowed
+        self.login_as_lecturer()
+        self.app['faculties']['fac1']['dep1'].score_editing_disabled = True
+        self.browser.open(self.course_url)
+        self.browser.getLink("Update scores").click()
+        self.assertTrue('Score editing disabled' in self.browser.contents)
+        self.app['faculties']['fac1']['dep1'].score_editing_disabled = False
+        self.browser.open(self.course_url)
+        self.browser.getLink("Update scores").click()
+        self.assertFalse('Score editing disabled' in self.browser.contents)
+
+    def test_score_editing_requires_validated_students(self):
+        # we can edit only scores of students whose courses have been
+        # validated.
+        self.login_as_lecturer()
+        # set invalid student state
+        IWorkflowState(self.student).setState(CREATED)
+        self.browser.open(self.edit_scores_url)
+        self.assertRaises(
+            LookupError, self.browser.getControl, name="scores")
+        # set valid student state
+        IWorkflowState(self.student).setState(VALIDATED)
+        self.browser.open(self.edit_scores_url)
+        self.assertTrue(
+            self.browser.getControl(name="scores:list") is not None)
+
+    def test_score_editing_offers_only_current_scores(self):
+        # only scores from current academic session can be edited
+        self.login_as_lecturer()
+        IWorkflowState(self.student).setState('courses validated')
+        # with no academic session set
+        self.app['configuration'].current_academic_session = None
+        self.browser.open(self.edit_scores_url)
+        self.assertRaises(
+            LookupError, self.browser.getControl, name="scores")
+        # with wrong academic session set
+        self.app['configuration'].current_academic_session = 1999
+        self.browser.open(self.edit_scores_url)
+        self.assertRaises(
+            LookupError, self.browser.getControl, name="scores")
+        # with right academic session set
+        self.app['configuration'].current_academic_session = 2004
+        self.browser.reload()
+        self.assertTrue(
+            self.browser.getControl(name="scores:list") is not None)
+
+    def test_score_editing_can_change_scores(self):
+        # we can really change scores via edit_scores view
+        self.login_as_lecturer()
+        self.assertEqual(
+            self.student['studycourse']['100']['COURSE1'].score, None)
+        self.browser.open(self.edit_scores_url)
+        self.browser.getControl(name="scores:list", index=0).value = '55'
+        self.browser.getControl("Update scores").click()
+        # the new value is stored in data
+        self.assertEqual(
+            self.student['studycourse']['100']['COURSE1'].score, 55)
+        # the new value is displayed on page/prefilled in form
+        self.assertEqual(
+            self.browser.getControl(name="scores:list", index=0).value, '55')
+        # The change has been logged
+        with open(self.stud_log_path, 'r') as fd:
+            self.assertTrue(
+                'mrslecturer - students.browser.EditScoresPage - '
+                'K1000000 100/COURSE1 score updated (55)' in fd.read())
+
+    def test_scores_editing_scores_must_be_integers(self):
+        # Non-integer scores won't be accepted.
+        self.login_as_lecturer()
+        self.browser.open(self.edit_scores_url)
+        self.browser.getControl(name="scores:list", index=0).value = 'abc'
+        self.browser.getControl("Update scores").click()
+        self.assertTrue(
+            'Error: Score(s) of following students have not been updated '
+            '(only integers are allowed): Anna Tester.'
+            in self.browser.contents)
+
+    def test_scores_editing_allows_score_removal(self):
+        # we can remove scores, once they were set
+        self.login_as_lecturer()
+        # without a prior value, we cannot remove
+        self.student['studycourse']['100']['COURSE1'].score = None
+        self.browser.open(self.edit_scores_url)
+        self.browser.getControl(name="scores:list", index=0).value = ''
+        self.browser.getControl("Update scores").click()
+        logcontent = open(self.stud_log_path, 'r').read()
+        self.assertFalse('COURSE1 score updated (None)' in logcontent)
+        # now retry with some value set
+        self.student['studycourse']['100']['COURSE1'].score = 55
+        self.browser.getControl(name="scores:list", index=0).value = ''
+        self.browser.getControl("Update scores").click()
+        logcontent = open(self.stud_log_path, 'r').read()
+        self.assertTrue('COURSE1 score updated (None)' in logcontent)
+
+    def test_lecturers_can_download_course_tickets(self):
+        # A course ticket slip can be downloaded
+        self.login_as_lecturer()
+        pdf_url = '%s/coursetickets.pdf' % self.course_url
+        self.browser.open(pdf_url)
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(
+            self.browser.headers['Content-Type'], 'application/pdf')
+        path = os.path.join(samples_dir(), 'coursetickets.pdf')
+        open(path, 'wb').write(self.browser.contents)
+        print "Sample PDF coursetickets.pdf written to %s" % path
+
+    def test_lecturers_can_download_scores_as_csv(self):
+        # Lecturers can download course scores as CSV.
+        self.login_as_lecturer()
+        self.browser.open(self.edit_scores_url)
+        self.browser.getLink("Download editable tickets").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.headers['Content-Type'],
+                         'text/csv; charset=UTF-8')
+        self.assertEqual(self.browser.contents, 'matric_number,student_id,'
+            'display_fullname,level,code,level_session,score\r\n234,'
+            'K1000000,Anna Tester,100,COURSE1,2004,\r\n')
+
+    def test_scores_csv_upload_available(self):
+        # lecturers can upload a CSV file to set values.
+        self.login_as_lecturer()
+        # set value to change from
+        self.student['studycourse']['100']['COURSE1'].score = 55
+        self.browser.open(self.edit_scores_url)
+        upload_ctrl = self.browser.getControl(name='uploadfile:file')
+        upload_file = StringIO(UPLOAD_CSV_TEMPLATE % '65')
+        upload_ctrl.add_file(upload_file, 'text/csv', 'myscores.csv')
+        self.browser.getControl("Update scores").click()
+        # value changed
+        self.assertEqual(
+            self.student['studycourse']['100']['COURSE1'].score, 65)
+
+    def test_scores_csv_upload_ignored(self):
+        # for many type of file contents we simply ignore uploaded data
+        self.login_as_lecturer()
+        self.student['studycourse']['100']['COURSE1'].score = 55
+        self.browser.open(self.edit_scores_url)
+        for content, mimetype, name in (
+                # empty file
+                ('', 'text/foo', 'my.foo'),
+                # plain ASCII text, w/o comma
+                ('abcdef' * 200, 'text/plain', 'my.txt'),
+                # plain UTF-8 text, with umlauts
+                ('umlauts: äöü', 'text/plain', 'my.txt'),
+                # csv file with only a header row
+                ('student_id,score', 'text/csv', 'my.csv'),
+                # csv with student_id column missing
+                ('foo,score\r\nbar,66\r\n', 'text/csv', 'my.csv'),
+                # csv with score column missing
+                ('student_id,foo\r\nK1000000,bar\r\n', 'text/csv', 'my.csv'),
+                # csv with non number as score value
+                (UPLOAD_CSV_TEMPLATE % 'not-a-number', 'text/csv', 'my.csv'),
+                ):
+            upload_ctrl = self.browser.getControl(name='uploadfile:file')
+            upload_ctrl.add_file(StringIO(content), mimetype, name)
+            self.browser.getControl("Update scores").click()
+            self.assertEqual(
+                self.student['studycourse']['100']['COURSE1'].score, 55)
+            self.assertFalse(
+                'Uploaded file contains illegal data' in self.browser.contents)
+
+    def test_scores_csv_upload_warn_illegal_chars(self):
+        # for some types of files we issue a warning if upload data
+        # contains illegal chars (and ignore the data)
+        self.login_as_lecturer()
+        self.student['studycourse']['100']['COURSE1'].score = 55
+        self.browser.open(self.edit_scores_url)
+        for content, mimetype, name in (
+                # plain ASCII text, commas, control chars
+                ('abv,qwe\n\r\r\t\b\n' * 20, 'text/plain', 'my.txt'),
+                # image data (like a JPEG image)
+                (open(SAMPLE_IMAGE, 'rb').read(), 'image/jpg', 'my.jpg'),
+                ):
+            upload_ctrl = self.browser.getControl(name='uploadfile:file')
+            upload_ctrl.add_file(StringIO(content), mimetype, name)
+            self.browser.getControl("Update scores").click()
+            self.assertEqual(
+                self.student['studycourse']['100']['COURSE1'].score, 55)
+            self.assertTrue(
+                'Uploaded file contains illegal data' in self.browser.contents)
