devilry_qualifiesforexam

Database models, APIs and UI for qualifying students for final exams.

UI workflow

How users are qualified for final exam i plugin-based. The subject/period admin is taken through a wizard with the following steps/pages:

  1. If no configuration exists for the period:
    List the title and description of each plugin (see Plugins below), and let the user select the plugin they want to use. The selection is stored in QualifiesForFinalExamPeriodStatus.plugin.
    If a configuration exists for the period:
    Show the overview of the semester (basically the same as the preview described as page 3 below). Includes a button to change the configuration. Clicking this button will show the list of plugins, just like when no configuration exists, with the previously used plugin selected. The change-button is only available on active periods.
  2. Completely controlled by the plugin. May be more than one page if that should be needed by the plugin. The plugin can also just redirect directly to the next page if it does not require any input from the user. We supply a box with save and back buttons that should be the same for all plugins.
  3. Preview the results with the option to save or go back to the previous page.

Plugins

A plugin is a regular Django app. Your best source for a simple example is the devilry_qualifiesforexam_approved-module which contains two plugins. You will find the package in the src/-directory of the devilry repository.

The role of the plugin

A plugin is basically one or more Django views that, for the qualifies-for-exam system, acts like a black box with the following input and output:

  • The input is a dict store by the qualifies-for-exam system in the users session (request.session):

    periodid

    The ID of the class:devilry.apps.core.models.Period.

    pluginsessionid

    An ID that is generated by the qualifies-for-exam system. It is used to ensure that we do not get session key collisions when using the wizard from multiple browser windows at the same time.

  • The output is a devilry_qualifiesforexam.pluginhelpers.PreviewData-object stored in the users session (request.session) under the qualifiesforexam-<pluginsessionid> key. The output object is used by the REST-api that generates the preview-data.

Registering an app as a qualifiesforexam plugin

Add something like the following to yourapp/devilry_plugin.py:

from devilry_qualifiesforexam.registry import qualifiesforexam_plugins
from django.urls import reverse
from django.utils.translation import gettext_lazy

qualifiesforexam_plugins.add(
    id='myapp',
    url=reverse('myapp-myplugin'), # The url of the view to use for step/page 2 in the workflow - the input parameters (see above) is added to this url.
    title=gettext_lazy('My plugin'),
    description=gettext_lazy('Does <strong>this</strong> and <em>that</em>.')
)

Create the view

See Plugin helpers and take a look at the sourcecode for devilry_qualifiesforexam_approved (in the src/ directory of the Devilry sources).

Configure available plugins

Available plugins are configured in settings.DEVILRY_QUALIFIESFOREXAM_PLUGINS, which is a list of plugin ids. Note that the apps containing the plugin must also be in settings.INSTALLED_APPS, and the urls must be registered. The plugins are shown in listed order on page 1 of the wizard described in the UI workflow.

Note

You can safely remove plugins from settings.DEVILRY_QUALIFIESFOREXAM_PLUGINS. They will simply not be available in the list of plugins in the UI workflow.

Write tests

If you want your plugin to be considered for inclusion in Devilry you will have to write good tests. These plugins handle very sensitive data, so it would be madness to deploy them in production without proper tests. We provide a helper-mixin for tests, devilry_qualifiesforexam.pluginhelpers.QualifiesForExamPluginTestMixin, which you should use. See the tests-module in devilry_qualifiesforexam_approved for examples.

Plugin helpers

The mixin classes

QualifiesForExamPluginViewMixin is a mixin class that simplifies the common tasks for all plugin views (getting input and setting output).

Basic usage

Basic usage of the class turns the input and output steps described in The role of the plugin into two methods: get_plugin_input_and_authenticate(), save_plugin_output(). Those two methods greatly simplify writing plugins. For example, we can create a view like this:

from django.views.generic import View
class MyPluginView(View, QualifiesForExamPluginViewMixin):
    def post(self, request):
        try:
            self.get_plugin_input_and_authenticate()
        except PermissionDenied:
            return HttpResponseForbidden()
        # Your code to detect passing students
        passing_relatedstudentsids = [1,2,3]
        self.save_plugin_output(passing_relatedstudentsids)
        return HttpResponseRedirect(self.get_preview_url())

A more complete example

The example above is very simple. You will usually have to iterate over all the students in a period to find out who qualifies:

from django.views.generic import View
from devilry_qualifiesforexam.pluginhelpers import PeriodResultsCollector
from devilry_qualifiesforexam.pluginhelpers import QualifiesForExamPluginViewMixin

class MyPeriodResultsCollector(PeriodResultsCollector):
    def student_qualifies_for_exam(self, aggregated_relstudentinfo):
        # Test if the student in the AggreatedRelatedStudentInfo qualifies.
        # Typically something like this (all students must pass all assignments):
        for assignmentid, grouplist in aggregated_relstudentinfo.assignments.iteritems():
            feedback = grouplist.get_feedback_with_most_points()
            if not feedback or not feedback.is_passing_grade:
                return False
        return True

class MyPluginView(View, QualifiesForExamPluginViewMixin):
    def post(self, request):
        try:
            self.get_plugin_input_and_authenticate()
        except PermissionDenied:
            return HttpResponseForbidden()
        # Your code to detect passing students
        passing_relatedstudentsids = MyPeriodResultsCollector().get_relatedstudents_that_qualify_for_exam()
        self.save_plugin_output(passing_relatedstudentsids)
        return HttpResponseRedirect(self.get_preview_url())
class devilry_qualifiesforexam.pluginhelpers.QualifiesForExamPluginViewMixin
periodid

The ID of the period — set by get_plugin_input().

period

The period object loaded using the django.shortcuts.get_object_or_404() — set by get_plugin_input().

pluginsessionid

The pluginsessionid described in The role of the plugin — set by get_plugin_input().

get_plugin_input_and_authenticate()

Reads the parameters (periodid and pluginsessionid) from the querystring and store them as in the following instance variables: periodid, period, pluginsessionid.

Raise:django.core.exceptions.PermissionDenied if the request user is not administrator on the period.
save_plugin_output(*args, **kwargs)

Shortcut that saves a PreviewData in the session key generated using create_sessionkey(). Args and kwargs are forwarded to PreviewData.

save_settings_in_session(settings)

Save settings in the session. You get this back as an argument to your post_statussave-handler if your plugin is configured with uses_settings=True.

get_preview_url()

Get the preview URL - the URL you must redirect to after saving the output (save_plugin_output()) to proceed to the preview.

get_selectplugin_url()

Get the preview URL - the URL you should navigate to when users select Back from your plugin view.

redirect_to_preview_url()

Returns a HttpResponseRedirect that redirects to get_preview_url().

Helper for unit tests

class devilry_qualifiesforexam.pluginhelpers.QualifiesForExamPluginTestMixin

Mixin-class for test-cases for plugin-views (the views that typically inherit from QualifiesForExamPluginViewMixin). This class has a couple of helpers that simplifies writing tests, and some unimplemented methods that ensure you do not forget to write permission tests.

Note

If you use this class as base for your tests, your chances of getting a plugin approved for inclusion as part of Devilry is greatly increased. You have to include at least one test in addition to the unimplemented tests, a test that uses a realistic dataset to make sure your plugin behaves as intended (E.g.: Approves/disapproves the expected students). You may need more than one extra test if your plugin is complex.

testhelper

A devilry.apps.core.testhelper.TestHelper-object which is required for create_feedbacks() and create_relatedstudent() to work.

Typcally created with something like this in setUp:

from django.test import TestCase
from devilry.apps.core.testhelper import TestHelper

class TestMyPluginView(TestCase, QualifiesForExamPluginTestMixin):
    def setUp(self):
        self.testhelper = TestHelper()

        # Create:
        # - the uni-node with ``uniadmin`` as admin
        # - the uni.sub.p1 period with ``periodadmin`` as admin.
        # - the a1 and a2 assignments within ``p1``, with separate groups on each
        #   assignment for student1 and student2, and with examiner1 as examiner.
        # - a deadline on each group
        self.testhelper.add(nodes='uni:admin(uniadmin)',
            subjects=['sub'],
            periods=['p1:admin(periodadmin):begins(-3):ends(6)'],
            assignments=['a1', 'a2'],
            assignmentgroups=[
                'gstudent1:candidate(student1):examiner(examiner1)',
                'gstudent2:candidate(student2):examiner(examiner1)'],
            deadlines=['d1:ends(10)']
        )
period

The period you use in your tests. Needs to be set in the setUp-method for create_relatedstudent() to work. Typically defined with the following code after the core in the example in testhelper:

self.period = self.testhelper.sub_p1
create_relatedstudent(username)

Create and return a related student on the period. A user with the given username is created if it does not exist.

create_feedbacks(*feedbacks):

Create feedbacks on groups from the given list of feedbacks.

Parameters:feedbacks

Each item in the arguments list is a (group, feedback) tuple where group is the devilry.apps.core.models.AssignmentGroup-object that it to be given feedback, and feedbacks is a dict with attributes for the devilry.apps.core.models.StaticFeedback with the following keys:

grade
See devilry.apps.core.models.StaticFeedback.grade.
points
See devilry.apps.core.models.StaticFeedback.points.
is_passing_grade
See devilry.apps.core.models.StaticFeedback.is_passing_grade.

A delivery to save the feedback on is created automatically, so all that is needed of the groups is an examiner, a candidate and a deadline.

Example:

self.create_feedbacks(
    (self.testhelper.sub_p1_a1_gstudent2, {'grade': 'B', 'points': 86, 'is_passing_grade': True}),
    (self.testhelper.sub_p1_a2_gstudent2, {'grade': 'A', 'points': 97, 'is_passing_grade': True})
)
test_perms_as_periodadmin()

Must be implemented in subclasses.

test_perms_as_nodeadmin()

Must be implemented in subclasses.

test_perms_as_superuser

Must be implemented in subclasses.

test_perms_as_nobody

Must be implemented in subclasses.

test_invalid_period

Must be implemented in subclasses.

Other helpers

class devilry_qualifiesforexam.pluginhelpers.PreviewData(passing_relatedstudentids)

Stores the output from a plugin. You should not need to use this directly. Use QualifiesForExamPluginViewMixin.save_plugin_output() instead.

Parameters:passing_relatedstudentids – See passing_relatedstudentids.
passing_relatedstudentids

List of the IDs of all devilry.apps.core.models.RelatedStudent that qualifies for final exams according to the plugin that generated the data.

devilry_qualifiesforexam.pluginhelpers.create_sessionkey(pluginsessionid)

Generate the session key for the plugin output as described in The role of the plugin. You should not need to use this directly. Use QualifiesForExamPluginViewMixin.get_plugin_input_and_authenticate() instead.

Plugins shipped with Devilry

devilry_qualifiesforexam_approved

TODO

Database models

How the models fit together

Each time a periodadmin qualifies students for final exams, even when they only partly qualify their students, a new Status-record is saved in the database. A status has a ForeignKey to devilry.apps.core.models.Period, so the last saved Status is the active qualified-for-exam status for a Period.

Each time a Status is saved, all of the devilry.apps.core.models.RelatedStudent`s for that period gets a :class:.QualifiesForFinalExam`-record, which saves the qualifies-for-exam status for the student. When a status is almostready, we use NULL in the QualifiesForFinalExam.qualifies-field to indicate students that are not ready.

Node administrators or systems that intergrate with Devilry uses Status.exported_timestamp to mark Status-records that have been exported to an external system. It is important to note that we export statuses, not periods. This means that we can create new statuses, and re-export them. An automatic system can check timestamps to handle status changes, and the Node admin UI can show/hilight periods with exported statuses and more recent statuses.

DeadlineTag is used to organize periods by the time when they should have made a ready-Status.

The models

class devilry_qualifiesforexam.models.DeadlineTag

A deadlinetag is used to tag devilry.apps.core.models.Period-objects with a timestamp and an optional tag describing the timestamp.

timestamp

Database field containing the date and time when a period admin should be finished qualifying students for final exams.

tag

A tag for node-admins for this deadlinetag. Max 30 chars. May be empty or null.

class devilry_qualifiesforexam.models.PeriodTag

This table is used to create a one-to-many relation from DeadlineTag to devilry.apps.core.models.Period.

deadlinetag

Database foreign key to the DeadlineTag that the Period should be tagged by.

period

Database foreign key to the devilry.apps.core.models.Period that this tag points to.

class devilry_qualifiesforexam.models.Status

Every time the admin updates qualifies-for-exam on a period, we save new object of this database model.

This gives us a history of changes, and it makes it possible for subject/period admins to communicate simple information to whoever it is that is responsible for handling examinations.

period

Database foreign key to the devilry.apps.core.models.Period that the status is for.

exported_timestamp

Database datetime field that tells when the status was exported out of Devilry to an external system. This is null if the status has not been expored out of Devilry.

status

Database char field that accepts the following values:

  • ready is used to indicate the the entire period is ready for export/use.
  • almostready is used to indicate that the period is almost ready for export/use, and that the exceptions are explained in the message.
  • notready is used to indicate that the period has no useful data yet. This is typically only used when the period used to be ready or almostready, but had to be retracted for a reason explained in the status
createtime

Database datetime field where we store when we added the status.

message

Database field with an optional message about the status change.

user

Database foreign key to the user that made the status change.

plugin

Database char field that stores the id of the plugin (see Plugins) that was used to change the status.

class devilry_qualifiesforexam.models.QualifiesForFinalExam
relatedstudent

Database one-to-one relation to devilry.apps.core.models.RelatedStudent.

qualifies

Boolean database field telling if the student qualifies or not. This may be None (NULL), if the status is almostready, to mark students as not ready for export.

status

Foreign key to a QualifiesForFinalExamPeriodStatus.