Index: /main/waeup.kofa/trunk/CHANGES.txt
===================================================================
--- /main/waeup.kofa/trunk/CHANGES.txt	(revision 15421)
+++ /main/waeup.kofa/trunk/CHANGES.txt	(revision 15422)
@@ -4,4 +4,6 @@
 1.6.1.dev0 (unreleased)
 =======================
+
+* Implement course result validation workflow for lecturers.
 
 * Add graduated students filter.
Index: /main/waeup.kofa/trunk/src/waeup/kofa/app.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/app.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/app.py	(revision 15422)
@@ -87,5 +87,6 @@
         XXX: Tests for this method were disabled.
         """
-        for name in ['faculties', 'departments', 'certificates', 'certcourses',
+        for name in ['faculties', 'departments', 'certificates',
+                     'certcourses', 'courses',
                      'site-pluggable-auth']:
             getUtility(IKofaPluggable, name=name).update(
Index: /main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/browser/pages.py	(revision 15422)
@@ -44,4 +44,5 @@
 from zope.password.interfaces import IPasswordManager
 from waeup.kofa.utils.helpers import html2dict
+from waeup.kofa.widgets.datewidget import FriendlyDatetimeDisplayWidget
 from waeup.kofa.browser.layout import (
     KofaPage, KofaFormPage, KofaEditFormPage, KofaAddFormPage,
@@ -2484,4 +2485,6 @@
     pnav = 1
     form_fields = grok.AutoFields(ICourse)
+    form_fields[
+        'results_validation_date'].custom_widget = FriendlyDatetimeDisplayWidget('le')
 
     @property
@@ -2505,4 +2508,9 @@
 
     form_fields = grok.AutoFields(ICourse).omit('code')
+    form_fields['results_validated_by'].for_display = True
+    form_fields['results_validation_date'].for_display = True
+    #form_fields['results_validation_session'].for_display = True
+    form_fields[
+        'results_validation_date'].custom_widget = FriendlyDatetimeDisplayWidget('le')
 
     @action(_('Save'), style='primary')
Index: /main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/browser/viewlets.py	(revision 15422)
@@ -31,4 +31,5 @@
     DepartmentPage, CoursePage, CertificatePage, CertificateCoursePage,
     UsersContainerPage, UserManageFormPage, DatacenterImportStep1)
+from waeup.kofa.students.browser import EditScoresPage
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.interfaces import (
@@ -615,4 +616,27 @@
         return _('Update session ${a} scores', mapping = {'a':st})
 
+class DownloadTicketOverviewActionButton(ManageActionButton):
+    """ 'Download ticket overview' button for courses.
+    """
+    grok.context(ICourse)
+    grok.view(CoursePage)
+    grok.name('coursetickets')
+    grok.require('waeup.showStudents')
+    icon = 'actionicon_pdf.png'
+    text = _('Download course tickets overview')
+    target = 'coursetickets.pdf'
+    grok.order(5)
+
+class DownloadCSVFileActionButton(ManageActionButton):
+    """ 'Download csv file' button for courses.
+    """
+    grok.context(ICourse)
+    grok.view(EditScoresPage)
+    grok.name('downloadcsv')
+    grok.require('waeup.editScores')
+    icon = 'actionicon_down.png'
+    text = _('Download csv file (editable scores only)')
+    target = 'download_scores'
+    grok.order(7)
 
 class ManageCertificateActionButton(ManageActionButton):
Index: /main/waeup.kofa/trunk/src/waeup/kofa/permissions.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/permissions.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/permissions.py	(revision 15422)
@@ -433,4 +433,5 @@
     grok.title(u'Lecturer')
     grok.permissions('waeup.editScores',
+                     'waeup.showStudents',
                      'waeup.viewAcademics',
                      'waeup.exportData')
Index: /main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/browser.py	(revision 15422)
@@ -33,4 +33,5 @@
 from zope.schema.interfaces import ConstraintNotSatisfied, RequiredMissing
 from zope.security import checkPermission
+from zope.securitypolicy.interfaces import IPrincipalRoleManager
 from waeup.kofa.accesscodes import invalidate_accesscode, get_access_code
 from waeup.kofa.accesscodes.workflow import USED
@@ -3379,5 +3380,4 @@
         return
 
-
 class EditScoresPage(KofaPage):
     """Page that allows to edit batches of scores.
@@ -3464,4 +3464,30 @@
         return True
 
+    def _validate_results(self, form):
+        ob_class = self.__implemented__.__name__.replace('waeup.kofa.', '')
+        user = get_current_principal()
+        if user is None:
+            usertitle = 'system'
+        else:
+            usertitle = getattr(user, 'public_name', None)
+            if not usertitle:
+                usertitle = user.title
+        self.context.results_validated_by = usertitle
+        self.context.results_validation_date = datetime.utcnow()
+        self.context.results_validation_session = self.current_academic_session
+        return
+
+    def _results_editable(self, results_validation_session,
+                         current_academic_session):
+        user = get_current_principal()
+        prm = IPrincipalRoleManager(self.context)
+        roles = [x[0] for x in prm.getRolesForPrincipal(user.id)]
+        if 'waeup.local.LocalStudentsManager' in roles:
+            return True
+        if results_validation_session \
+            and results_validation_session >= current_academic_session:
+            return False
+        return True
+
     def update(self,  *args, **kw):
         form = self.request.form
@@ -3476,4 +3502,12 @@
             self.redirect(self.url(self.context))
             return
+        vs = self.context.results_validation_session
+        if not self._results_editable(vs, self.current_academic_session):
+            self.flash(
+                _('Course results have already been '
+                  'validated and can no longer be changed.'),
+                type="danger")
+            self.redirect(self.url(self.context))
+            return
         self.session_title = academic_sessions_vocab.getTerm(
             self.current_academic_session).title
@@ -3485,5 +3519,16 @@
         self.editable_tickets = [
             ticket for ticket in self.tickets if ticket.editable_by_lecturer]
-        if not 'UPDATE_TABLE' in form and not 'UPDATE_FILE' in form:
+        if not 'UPDATE_TABLE' in form and not 'UPDATE_FILE' in form\
+            and not 'VALIDATE_RESULTS' in form:
+            return
+        if 'VALIDATE_RESULTS' in form:
+            if vs and vs >= self.current_academic_session:
+                self.flash(
+                    _('Course results have already been validated.'),
+                    type="danger")
+                return
+            self._validate_results(form)
+            self.flash(_('You successfully validated the course results.'))
+            self.redirect(self.url(self.context))
             return
         if not self.editable_tickets:
@@ -3494,5 +3539,4 @@
         return
 
-
 class DownloadScoresView(UtilityView, grok.View):
     """View that exports scores.
@@ -3501,4 +3545,16 @@
     grok.require('waeup.editScores')
     grok.name('download_scores')
+
+    def _results_editable(self, results_validation_session,
+                         current_academic_session):
+        user = get_current_principal()
+        prm = IPrincipalRoleManager(self.context)
+        roles = [x[0] for x in prm.getRolesForPrincipal(user.id)]
+        if 'waeup.local.LocalStudentsManager' in roles:
+            return True
+        if results_validation_session \
+            and results_validation_session >= current_academic_session:
+            return False
+        return True
 
     def update(self):
@@ -3511,4 +3567,12 @@
         if not self.current_academic_session:
             self.flash(_('Current academic session not set.'), type="warning")
+            self.redirect(self.url(self.context))
+            return
+        vs = self.context.results_validation_session
+        if not self._results_editable(vs, self.current_academic_session):
+            self.flash(
+                _('Course results have already been '
+                  'validated and can no longer be changed.'),
+                type="danger")
             self.redirect(self.url(self.context))
             return
@@ -3537,5 +3601,5 @@
     grok.context(ICourse)
     grok.name('coursetickets.pdf')
-    grok.require('waeup.editScores')
+    grok.require('waeup.showStudents')
 
     @property
Index: /main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/editscorespage.pt
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/editscorespage.pt	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/browser_templates/editscorespage.pt	(revision 15422)
@@ -108,3 +108,7 @@
          value="Update scores from table" class="btn btn-primary"
          />
+
+  <input type="submit" name="VALIDATE_RESULTS" i18n:translate=""
+         value="Validate course results" class="btn btn-primary"
+         />
 </form>
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 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/tests/test_browser.py	(revision 15422)
@@ -4458,4 +4458,49 @@
         self.assertTrue('COURSE1 score updated (None)' in logcontent)
 
+    def test_lecturer_can_validate_courses(self):
+        # the form is locked after validation
+        self.login_as_lecturer()
+        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()
+        self.browser.getControl("Validate").click()
+        self.assertTrue(
+            'You successfully validated the course results'
+            in self.browser.contents)
+        self.assertEqual(self.course.results_validation_session, 2004)
+        self.assertEqual(self.course.results_validated_by, 'Mercedes Benz')
+        self.assertEqual(self.browser.url, self.course_url)
+        # Lecturer can't open edit_scores again
+        self.browser.getLink("Update session 2004/2005 scores").click()
+        self.assertEqual(self.browser.url, self.course_url)
+        self.assertTrue(
+            'Course results have already been validated'
+            ' and can no longer be changed.'
+            in self.browser.contents)
+        # Also DownloadScoresView is blocked
+        self.browser.open(self.browser.url + '/download_scores')
+        self.assertEqual(self.browser.url, self.course_url)
+        self.assertTrue(
+            'Course results have already been validated'
+            ' and can no longer be changed.'
+            in self.browser.contents)
+        # Students Manager can open page ...
+        prmlocal = IPrincipalRoleManager(self.course)
+        prmlocal.assignRoleToPrincipal(
+            'waeup.local.LocalStudentsManager', 'mrslecturer')
+        self.browser.getLink("Update session 2004/2005 scores").click()
+        self.assertEqual(self.browser.url, self.edit_scores_url)
+        self.browser.getLink("Download csv file").click()
+        self.assertEqual(self.browser.headers['Status'], '200 Ok')
+        self.assertEqual(self.browser.headers['Content-Type'],
+                         'text/csv; charset=UTF-8')
+        # ... but can't validate courses a second time
+        self.browser.open(self.edit_scores_url)
+        self.browser.getControl("Validate").click()
+        self.assertTrue(
+            'Course results have already been validated.'
+            in self.browser.contents)
+
     def test_lecturers_can_download_course_tickets(self):
         # A course ticket slip can be downloaded
Index: /main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/students/viewlets.py	(revision 15422)
@@ -32,5 +32,5 @@
     OnlinePaymentDisplayFormPage, BedTicketDisplayFormPage,
     StudentClearanceEditFormPage, StudentPersonalEditFormPage,
-    PaymentsManageFormPage, StudyCourseTranscriptPage, EditScoresPage)
+    PaymentsManageFormPage, StudyCourseTranscriptPage)
 from waeup.kofa.students.interfaces import (
     IStudentsContainer, IStudent, IStudentStudyCourse, IStudentStudyLevel,
@@ -41,5 +41,4 @@
     ADMITTED, PAID, REQUESTED, CLEARED, REGISTERED, VALIDATED, GRADUATED,
     TRANSREQ, TRANSVAL, TRANSREL)
-from waeup.kofa.university.interfaces import ICourse
 
 
@@ -922,28 +921,2 @@
             ]
         return targets
-
-
-class DownloadCSVFileActionButton(ManageActionButton):
-    """ 'Download csv file' button for courses.
-    """
-    grok.context(ICourse)
-    grok.view(EditScoresPage)
-    grok.name('downloadcsv')
-    grok.require('waeup.editScores')
-    icon = 'actionicon_down.png'
-    text = _('Download csv file (editable scores only)')
-    target = 'download_scores'
-    grok.order(1)
-
-
-class DownloadTicketOverviewActionButton(ManageActionButton):
-    """ 'Download ticket overview' button for courses.
-    """
-    grok.context(ICourse)
-    grok.view(EditScoresPage)
-    grok.name('coursetickets')
-    grok.require('waeup.editScores')
-    icon = 'actionicon_pdf.png'
-    text = _('Download pdf file')
-    target = 'coursetickets.pdf'
-    grok.order(2)
Index: /main/waeup.kofa/trunk/src/waeup/kofa/university/course.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/university/course.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/university/course.py	(revision 15422)
@@ -56,4 +56,7 @@
         self.semester = semester
         self.former_course = former_course
+        self.results_validated_by = None
+        self.results_validation_date = None
+        self.results_validation_session = None
 
     def traverse(self, name):
Index: /main/waeup.kofa/trunk/src/waeup/kofa/university/export.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/university/export.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/university/export.py	(revision 15422)
@@ -116,5 +116,7 @@
 
     fields = ('code', 'faculty_code', 'department_code', 'title', 'credits',
-              'passmark', 'semester', 'users_with_local_roles', 'former_course')
+              'passmark', 'semester', 'users_with_local_roles', 'former_course',
+              'results_validated_by', 'results_validation_date',
+              'results_validation_session')
 
     title = _(u'Courses')
Index: /main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/university/interfaces.py	(revision 15422)
@@ -21,5 +21,6 @@
 from zope import schema
 from zope.interface import Attribute, invariant, Invalid
-from waeup.kofa.interfaces import IKofaObject, IKofaContainer, validate_id
+from waeup.kofa.interfaces import (
+    IKofaObject, IKofaContainer, validate_id, academic_sessions_vocab)
 from waeup.kofa.interfaces import MessageFactory as _
 from waeup.kofa.university.vocabularies import (
@@ -189,4 +190,22 @@
         )
 
+    results_validated_by = schema.TextLine(
+        title = _(u'Results validated by'),
+        default = None,
+        required = False,
+        )
+
+    results_validation_date = schema.Datetime(
+        title = _(u'Results validation date'),
+        required = False,
+        readonly = False,
+        )
+
+    results_validation_session = schema.Choice(
+        title = _(u'Results validation session'),
+        source = academic_sessions_vocab,
+        required = False,
+        readonly = False,
+        )
 
 class ICertificate(IKofaObject):
Index: /main/waeup.kofa/trunk/src/waeup/kofa/university/tests/test_export.py
===================================================================
--- /main/waeup.kofa/trunk/src/waeup/kofa/university/tests/test_export.py	(revision 15421)
+++ /main/waeup.kofa/trunk/src/waeup/kofa/university/tests/test_export.py	(revision 15422)
@@ -281,6 +281,8 @@
             result,
             'code,faculty_code,department_code,title,credits,'
-            'passmark,semester,users_with_local_roles,former_course\r\n'
-            'C1,F1,D1,Cheese Basics,0,40,1,[],0\r\n'
+            'passmark,semester,users_with_local_roles,former_course,'
+            'results_validated_by,results_validation_date,'
+            'results_validation_session\r\n'
+            'C1,F1,D1,Cheese Basics,0,40,1,[],0,,,\r\n'
             )
         return
@@ -293,7 +295,9 @@
             result,
             'code,faculty_code,department_code,title,credits,passmark,'
-            'semester,users_with_local_roles,former_course\r\n'
-            'C1,F1,D1,Cheese Basics,0,40,1,[],0\r\n'
-            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0\r\n'
+            'semester,users_with_local_roles,former_course,'
+            'results_validated_by,results_validation_date,'
+            'results_validation_session\r\n'
+            'C1,F1,D1,Cheese Basics,0,40,1,[],0,,,\r\n'
+            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0,,,\r\n'
             )
         return
@@ -307,8 +311,10 @@
             result,
             'code,faculty_code,department_code,title,credits,passmark,'
-            'semester,users_with_local_roles,former_course\r\n'
-            'C1,F1,D1,Cheese Basics,0,40,1,[],0\r\n'
-            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0\r\n'
-            'C3,F1,D2,Selling Cheese,0,40,1,[],0\r\n'
+            'semester,users_with_local_roles,former_course,'
+            'results_validated_by,results_validation_date,'
+            'results_validation_session\r\n'
+            'C1,F1,D1,Cheese Basics,0,40,1,[],0,,,\r\n'
+            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0,,,\r\n'
+            'C3,F1,D2,Selling Cheese,0,40,1,[],0,,,\r\n'
             )
         return
@@ -321,8 +327,10 @@
             result,
             'code,faculty_code,department_code,title,credits,passmark,'
-            'semester,users_with_local_roles,former_course\r\n'
-            'C1,F1,D1,Cheese Basics,0,40,1,[],0\r\n'
-            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0\r\n'
-            'C3,F1,D2,Selling Cheese,0,40,1,[],0\r\n'
+            'semester,users_with_local_roles,former_course,'
+            'results_validated_by,results_validation_date,'
+            'results_validation_session\r\n'
+            'C1,F1,D1,Cheese Basics,0,40,1,[],0,,,\r\n'
+            'C2,F1,D1,Advanced Cheese Making,0,40,1,[],0,,,\r\n'
+            'C3,F1,D2,Selling Cheese,0,40,1,[],0,,,\r\n'
             )
         return
