Index: main/waeup.aaue/trunk/src/waeup/aaue/students/reports/__init__.py
===================================================================
--- main/waeup.aaue/trunk/src/waeup/aaue/students/reports/__init__.py	(revision 13857)
+++ main/waeup.aaue/trunk/src/waeup/aaue/students/reports/__init__.py	(revision 13857)
@@ -0,0 +1,1 @@
+# Make this a package.
Index: main/waeup.aaue/trunk/src/waeup/aaue/students/reports/browser_templates/studentlevelstatisticsreportgeneratorpage.pt
===================================================================
--- main/waeup.aaue/trunk/src/waeup/aaue/students/reports/browser_templates/studentlevelstatisticsreportgeneratorpage.pt	(revision 13857)
+++ main/waeup.aaue/trunk/src/waeup/aaue/students/reports/browser_templates/studentlevelstatisticsreportgeneratorpage.pt	(revision 13857)
@@ -0,0 +1,35 @@
+<form  i18n:domain="waeup.kofa"  method="POST">
+  <p i18n:translate="">
+    Please pick the session
+    for which the report should be generated.
+    The report data will be calculated and then made available for you
+    to view/download.
+  </p>
+  <br />
+  <label for="breakdown" i18n:translate="">Breakdown</label>
+    <select name="breakdown" class="form-control half">
+      <span tal:repeat="items view/breakdowns" tal:omit-tag="">
+        <option
+            tal:define="name python: items[1]; title python: items[0]"
+            tal:attributes="value name">
+          <span tal:replace="title">TITLE</span>
+        </option>
+      </span>
+    </select>
+  <br />
+  <label for="session">Academic Session</label>
+  <select name="session" class="form-control half">
+    <span tal:repeat="items view/sessions" tal:omit-tag="">
+      <option
+          tal:define="name python: items[1]; title python: items[0]"
+          tal:attributes="value name">
+        <span tal:replace="title">TITLE</span>
+      </option>
+    </span>
+  </select>
+  <br />
+  <input i18n:translate="" type="submit" class="btn btn-primary"
+         name="CREATE" value="Create" />
+  <a class="btn btn-default" href=""
+     tal:attributes="href view/parent_url">Cancel</a>
+</form>
Index: main/waeup.aaue/trunk/src/waeup/aaue/students/reports/student_level_statistics.py
===================================================================
--- main/waeup.aaue/trunk/src/waeup/aaue/students/reports/student_level_statistics.py	(revision 13857)
+++ main/waeup.aaue/trunk/src/waeup/aaue/students/reports/student_level_statistics.py	(revision 13857)
@@ -0,0 +1,254 @@
+## $Id: student_statistics.py 13236 2015-09-03 15:12:03Z henrik $
+##
+## Copyright (C) 2012 Uli Fouquet & Henrik Bettermann
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published by
+## the Free Software Foundation; either version 2 of the License, or
+## (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+##
+import grok
+from zope.i18n import translate
+from zope.catalog.interfaces import ICatalog
+from zope.component import queryUtility, getUtility
+from zope.interface import implementer, Interface, Attribute
+from waeup.kofa.interfaces import (
+    IKofaUtils,
+    academic_sessions_vocab, registration_states_vocab)
+from waeup.kofa.students.reports.student_statistics import (
+    StudentStatisticsReportPDFView)
+from waeup.kofa.students.vocabularies import StudyLevelSource
+from waeup.kofa.interfaces import MessageFactory as _
+from waeup.kofa.reports import IReport
+
+class IStudentLevelStatisticsReport(IReport):
+
+    session = Attribute('Session to report')
+    creation_dt_string = Attribute('Human readable report creation datetime')
+
+def get_student_stats(session, breakdown):
+    """Get students in a certain session.
+
+    Returns a table ordered by code (faculty or department, one per row) and
+    current level (cols). The result is a 3-tuple representing
+    ((<PATHS>), (<LEVELS>), (<NUM_OF_STUDENTS>)). The
+    (<NUM_OF_STUDENTS>) is an n-tuple with each entry containing the
+    number of students found in that faculty/department and with the respective
+    current level.
+
+    Sample result:
+
+      >>> ((u'FAC1', u'FAC2'),
+      ...  ('100', '200', '300'),
+      ...  ((12, 10, 1), (0, 5, 25)))
+
+    This result means: there are 5 students in FAC2 at level
+    200 while 12 students in FAC1 are at level 100.
+    """
+    site = grok.getSite()
+    levels = (100, 200, 300, 400, 500)
+    levels = levels + (u'Total',)
+    if breakdown == 'faccode':
+        paths = tuple(sorted([x for x in site['faculties'].keys()],
+                                 key=lambda x: x.lower()))
+    elif breakdown == 'depcode':
+        faculties = site['faculties']
+        deppaths = []
+        for fac in faculties.values():
+            for dep in fac.values():
+                deppaths.append('%s/%s' % (fac.code, dep.code))
+        paths = tuple(sorted([x for x in deppaths],
+                                 key=lambda x: x.lower()))
+    # XXX: Here we do _one_ query and then examine the single
+    #   students. One could also do multiple queries and just look for
+    #   the result length (not introspecting the delivered students
+    #   further).
+    cat = queryUtility(ICatalog, name="students_catalog")
+    result = cat.searchResults(current_session=(session, session))
+    table = [[0 for x in xrange(len(levels))] for y in xrange(len(paths)+1)]
+    for stud in result:
+        if getattr(stud, breakdown) is None:
+            continue
+        if breakdown == 'faccode':
+            row_name = getattr(stud, 'faccode')
+        elif breakdown == 'depcode':
+            row_name = '%s/%s' % (
+                getattr(stud, 'faccode'), getattr(stud, 'depcode'))
+        row = paths.index(row_name)
+        normalized_level = 100*(stud.current_level/100)
+        col = levels.index(normalized_level)
+        table[row][col] += 1
+        table[-1][col] += 1
+        table[row][-1] += 1
+        table[-1][-1] += 1
+    # turn lists into tuples
+    table = tuple([tuple(row) for row in table])
+
+    paths = paths + (u'Total',)
+    return (paths, levels, table)
+
+from reportlab.lib import colors
+from reportlab.lib.styles import getSampleStyleSheet
+from reportlab.lib.units import cm
+from reportlab.platypus import Paragraph, Table, Spacer
+from waeup.kofa.reports import IReport, IReportGenerator
+from waeup.kofa.reports import Report
+from waeup.kofa.browser.interfaces import IPDFCreator
+
+STYLE = getSampleStyleSheet()
+
+def tbl_data_to_table(row_names, col_names, data):
+    result = []
+    head = [''] + list(col_names)
+    result = [head]
+    for idx, row_name in enumerate(row_names):
+        row = [row_name] + list(data[idx])
+        result.append(row)
+    return result
+
+TABLE_STYLE = [
+    ('FONT', (0,0), (-1,-1), 'Helvetica', 8),
+    ('FONT', (0,0), (0,-1), 'Helvetica-Bold', 8),
+    ('FONT', (0,0), (-1,0), 'Helvetica-Bold', 8),
+    ('FONT', (0,-1), (-1,-1), 'Helvetica-Bold', 8),
+    ('FONT', (-1,0), (-1,-1), 'Helvetica-Bold', 8),
+    ('ALIGN', (1,1), (-1,-1), 'RIGHT'),
+    ('INNERGRID', (0,0), (-1,-1), 0.25, colors.black),
+    ('LINEBELOW', (0,-1), (-1,-1), 0.25, colors.black),
+    ('LINEAFTER', (-1,0), (-1,-1), 0.25, colors.black),
+    ('LINEBEFORE', (-1,0), (-1,-1), 1.0, colors.black),
+    #('LINEABOVE', (0,-1), (-1,-1), 1.0, colors.black),
+    #('LINEABOVE', (0,0), (-1,0), 0.25, colors.black),
+    ]
+
+@implementer(IStudentLevelStatisticsReport)
+class StudentLevelStatisticsReport(Report):
+    data = None
+    session = None
+
+    def __init__(self, session, breakdown, author='System'):
+        super(StudentLevelStatisticsReport, self).__init__(
+            args=[session, breakdown], kwargs={'author':author})
+        self.studylevelsource = StudyLevelSource().factory
+        self.portal_language = getUtility(IKofaUtils).PORTAL_LANGUAGE
+        self.session = academic_sessions_vocab.getTerm(session).title
+        self.breakdown = breakdown
+        self.author = author
+        self.creation_dt_string = self.creation_dt.astimezone(
+            getUtility(IKofaUtils).tzinfo).strftime("%Y-%m-%d %H:%M:%S %Z")
+        self.data = get_student_stats(session, breakdown)
+
+    def create_pdf(self):
+        creator = getUtility(IPDFCreator, name='landscape')
+        table_data = tbl_data_to_table(*self.data)
+        col_widths = [None,] + [1.6*cm] * len(self.data[1]) + [None,]
+        pdf_data = [Paragraph('<b>%s</b>' % self.creation_dt_string,
+                              STYLE["Normal"]),
+                    Spacer(1, 12),]
+        pdf_data += [
+            Table(table_data, style=TABLE_STYLE, colWidths=col_widths)]
+        doc_title = translate(
+            _('Students - ${c}',
+            mapping = {'c':self.session}))
+        pdf = creator.create_pdf(
+            pdf_data, None, doc_title, self.author, doc_title + '-'
+            )
+        return pdf
+
+@implementer(IReportGenerator)
+class StudentLevelStatisticsReportGenerator(grok.GlobalUtility):
+
+    title = _('Student Level Statistics')
+    grok.name('student_level_stats')
+
+    def generate(
+        self, site, session=None, 
+        breakdown=None, author=None):
+        result = StudentLevelStatisticsReport(
+            session=session,
+            breakdown=breakdown, author=author)
+        return result
+
+###############################################################
+## Browser related stuff
+##
+## XXX: move to local browser module
+###############################################################
+from waeup.kofa.browser.layout import KofaPage
+from waeup.kofa.reports import get_generators
+from waeup.kofa.browser.breadcrumbs import Breadcrumb
+grok.templatedir('browser_templates')
+class StudentLevelStatisticsReportGeneratorPage(KofaPage):
+
+    grok.context(StudentLevelStatisticsReportGenerator)
+    grok.name('index.html')
+    grok.require('waeup.handleReports')
+
+    label = _('Create student level statistics report')
+
+    @property
+    def generator_name(self):
+        for name, gen in get_generators():
+            if gen == self.context:
+                return name
+        return None
+
+    def update(
+        self, CREATE=None, session=None, breakdown=None):
+        self.parent_url = self.url(self.context.__parent__)
+        self._set_session_values()
+        self._set_breakdown_values()
+        if CREATE and session:
+            # create a new report job for students by session and level
+            container = self.context.__parent__
+            user_id = self.request.principal.id
+            kw = dict(
+                session=int(session),
+                breakdown=breakdown)
+            self.flash(_('New report is being created in background'))
+            job_id = container.start_report_job(
+                self.generator_name, user_id, kw=kw)
+            ob_class = self.__implemented__.__name__.replace('waeup.kofa.','')
+            grok.getSite().logger.info(
+                '%s - report %s created: %s (session=%s, breakdown=%s)' % (
+                ob_class, job_id, self.context.title,
+                session, breakdown))
+            self.redirect(self.parent_url)
+            return
+        return
+
+    def _set_session_values(self):
+        vocab_terms = academic_sessions_vocab.by_value.values()
+        self.sessions = [(x.title, x.token) for x in vocab_terms]
+        return
+
+    def _set_breakdown_values(self):
+        self.breakdowns = [('Faculties', 'faccode'), ('Departments', 'depcode')]
+        return
+
+class StudentLevelStatisticsReportPDFView(StudentStatisticsReportPDFView):
+
+    grok.context(IStudentLevelStatisticsReport)
+    grok.name('pdf')
+    grok.require('waeup.handleReports')
+    prefix = ''
+
+    def _filename(self):
+        return 'StudentLevelStatisticsReport_%s_%s.pdf' % (
+            self.context.session,
+            self.context.creation_dt_string)
+
+class StudentLevelStatsBreadcrumb(Breadcrumb):
+    """A breadcrumb for reports.
+    """
+    grok.context(StudentLevelStatisticsReportGenerator)
+    title = _(u'Student Level Statistics')
+    target = None
Index: main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/__init__.py
===================================================================
--- main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/__init__.py	(revision 13857)
+++ main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/__init__.py	(revision 13857)
@@ -0,0 +1,1 @@
+# Make this a package.
Index: main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/test_student_level_statistics.py
===================================================================
--- main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/test_student_level_statistics.py	(revision 13857)
+++ main/waeup.aaue/trunk/src/waeup/aaue/students/reports/tests/test_student_level_statistics.py	(revision 13857)
@@ -0,0 +1,103 @@
+# Tests for student related reports
+import os
+from zc.async.testing import wait_for_result
+from zope.interface.verify import verifyClass, verifyObject
+from zope.component import getUtility
+from waeup.kofa.interfaces import IJobManager
+from waeup.kofa.students.tests.test_catalog import CatalogTestSetup
+from waeup.kofa.students.tests.test_browser import StudentsFullSetup
+from waeup.kofa.tests.test_async import FunctionalAsyncTestCase
+from waeup.kofa.browser.tests.test_pdf import samples_dir
+
+from waeup.aaue.testing import FunctionalLayer
+from waeup.aaue.students.reports.student_level_statistics import (
+    get_student_stats, StudentLevelStatisticsReport, IStudentLevelStatisticsReport)
+
+class StudentLevelStatisticsReportTests(CatalogTestSetup):
+
+    layer = FunctionalLayer
+
+    levels = (100, 200, 300, 400, 500, 'Total')
+
+    def test_iface(self):
+        # ensure we fullfill interface contracts
+        obj = StudentLevelStatisticsReport(2010, 'faccode')
+        verifyClass(IStudentLevelStatisticsReport, StudentLevelStatisticsReport)
+        verifyObject(IStudentLevelStatisticsReport, obj)
+        return
+
+    def test_get_student_stats_session_simple(self):
+        # we can get a table with one student
+        result1 = get_student_stats(2010, 'faccode')
+        result2 = get_student_stats(2009, 'faccode')
+        self.assertEqual(
+            result1,
+            ((u'fac1', u'Total',),
+             self.levels,
+             ((1, 0, 0, 0, 0, 1),
+              (1, 0, 0, 0, 0, 1),)
+             ))
+        self.assertEqual(
+            result2,
+            ((u'fac1', u'Total'),
+             self.levels,
+             ((0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0),)
+              ))
+        return
+
+    def test_get_student_stats_session_multiple(self):
+        # we can get a table with several students
+        self.create_cert(u'fac2', u'dep2', u'CERT2')
+        result1 = get_student_stats(2010, 'faccode')
+        result2 = get_student_stats(2009, 'faccode')
+        self.assertEqual(
+            result1,
+            ((u'fac1', u'fac2', u'Total'),
+             self.levels,
+             ((1, 0, 0, 0, 0, 1),
+              (0, 0, 0, 0, 0, 0),
+              (1, 0, 0, 0, 0, 1),)
+             ))
+        self.assertEqual(
+            result2,
+            ((u'fac1', u'fac2', u'Total'),
+             self.levels,
+             ((0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0),)
+             ))
+        return
+
+    def test_get_student_stats_session_multiple_dep_breakdown(self):
+        # we can get a table with several students
+        self.create_cert(u'fac2', u'dep2', u'CERT2')
+        result1 = get_student_stats(2010, 'depcode')
+        result2 = get_student_stats(2009, 'depcode')
+        self.assertEqual(
+            result1,
+            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
+             self.levels,
+             ((1, 0, 0, 0, 0, 1),
+              (0, 0, 0, 0, 0, 0),
+              (1, 0, 0, 0, 0, 1),)
+             ))
+        self.assertEqual(
+            result2,
+            ((u'fac1/dep1', u'fac2/dep2', u'Total'),
+             self.levels,
+             ((0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0),
+              (0, 0, 0, 0, 0, 0),)
+             ))
+        return
+
+    def test_create_pdf(self):
+        self.create_cert(u'FAC2', u'dept2', u'CERT2')
+        report = StudentLevelStatisticsReport(2010, 'faccode')
+        result = report.create_pdf()
+        self.assertTrue(result.startswith('%PDF-'))
+        path = os.path.join(samples_dir(), 'student_level_statistics.pdf')
+        open(path, 'wb').write(result)
+        print "Sample PDF student_level_statistics.pdf written to %s" % path
+        return
