############################### :mod:`devilry_qualifiesforexam` ############################### .. module:: devilry_qualifiesforexam Database models, APIs and UI for qualifying students for final exams. .. _qualifiesforexam-uiworkflow: *********** 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 :ref:`qualifiesforexam-plugins` below), and let the user select the plugin they want to use. The selection is stored in :attr:`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. .. _qualifiesforexam-plugins: ******* 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. .. _qualifiesforexam-plugins-what: 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 :class:`devilry_qualifiesforexam.pluginhelpers.PreviewData`-object stored in the users session (``request.session``) under the ``qualifiesforexam-`` 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 this and that.') ) Create the view =============== See :ref:`qualifiesforexam-pluginhelpers` 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 :ref:`qualifiesforexam-uiworkflow`. .. note:: You can safely remove plugins from ``settings.DEVILRY_QUALIFIESFOREXAM_PLUGINS``. They will simply not be available in the list of plugins in the :ref:`qualifiesforexam-uiworkflow`. 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, :class:`devilry_qualifiesforexam.pluginhelpers.QualifiesForExamPluginTestMixin`, which you should use. See the ``tests``-module in ``devilry_qualifiesforexam_approved`` for examples. .. _qualifiesforexam-pluginhelpers: ************** Plugin helpers ************** .. py:currentmodule:: devilry_qualifiesforexam.pluginhelpers The mixin classes ================= :class:`~devilry_qualifiesforexam.pluginhelpers.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 :ref:`qualifiesforexam-plugins-what` into two methods: :meth:`get_plugin_input_and_authenticate`, :meth:`.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()) .. _qualifiesforexam-pluginhelpers-completeexample: 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()) .. py::class:: PeriodResultsCollector .. py:method:: student_qualifies_for_exam Must be implemented in subclasses. :return: Does the student qualify for exam? :rtype: bool .. py:method:: get_relatedstudents_that_qualify_for_exam Uses :ref:`utils_groups_groupedby_relatedstudent_and_assignment` to aggregate all data for all students in the period. Loops through the resulting :class:`~devilry.utils.groups_groupedby_relatedstudent_and_assignment.AggreatedRelatedStudentInfo`-objects and sends them to :meth:`.student_qualifies_for_exam`. :return: A list with the ids of all relatedstudents for which :meth:`.student_qualifies_for_exam` returned ``True``. .. py:class:: QualifiesForExamPluginViewMixin .. py:attribute:: periodid The ID of the period --- set by :meth:`.get_plugin_input`. .. py:attribute:: period The period object loaded using the :func:`django.shortcuts.get_object_or_404` --- set by :meth:`.get_plugin_input`. .. py:attribute:: pluginsessionid The pluginsessionid described in :ref:`qualifiesforexam-plugins-what` --- set by :meth:`.get_plugin_input`. .. py:method:: get_plugin_input_and_authenticate Reads the parameters (periodid and pluginsessionid) from the querystring and store them as in the following instance variables: :attr:`.periodid`, :attr:`.period`, :attr:`.pluginsessionid`. :raise: :exc:`django.core.exceptions.PermissionDenied` if the request user is not administrator on the period. .. py:method:: save_plugin_output(*args, **kwargs) Shortcut that saves a :class:`.PreviewData` in the session key generated using :func:`.create_sessionkey`. Args and kwargs are forwarded to :class:`.PreviewData`. .. py:method:: 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``. .. py:method:: get_preview_url Get the preview URL - the URL you must redirect to after saving the output (:meth:`.save_plugin_output`) to proceed to the preview. .. py:method:: get_selectplugin_url Get the preview URL - the URL you should navigate to when users select *Back* from your plugin view. .. py:method:: redirect_to_preview_url Returns a ``HttpResponseRedirect`` that redirects to :meth:`.get_preview_url`. Helper for unit tests ===================== .. py:class:: QualifiesForExamPluginTestMixin Mixin-class for test-cases for plugin-views (the views that typically inherit from :class:`.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. .. py:attribute:: testhelper A :class:`devilry.apps.core.testhelper.TestHelper`-object which is required for :meth:`.create_feedbacks` and :meth:`.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)'] ) .. py:attribute:: period The period you use in your tests. Needs to be set in the ``setUp``-method for :meth:`.create_relatedstudent` to work. Typically defined with the following code after the core in the example in :attr:`.testhelper`:: self.period = self.testhelper.sub_p1 .. py:method:: create_relatedstudent(username) Create and return a related student on the :attr:`.period`. A user with the given username is created if it does not exist. .. py:method:: create_feedbacks(*feedbacks): Create feedbacks on groups from the given list of ``feedbacks``. :param feedbacks: Each item in the arguments list is a ``(group, feedback)`` tuple where ``group`` is the :class:`devilry.apps.core.models.AssignmentGroup`-object that it to be given feedback, and ``feedbacks`` is a dict with attributes for the :class:`devilry.apps.core.models.StaticFeedback` with the following keys: ``grade`` See :attr:`devilry.apps.core.models.StaticFeedback.grade`. ``points`` See :attr:`devilry.apps.core.models.StaticFeedback.points`. ``is_passing_grade`` See :attr:`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}) ) .. py:method:: test_perms_as_periodadmin Must be implemented in subclasses. .. py:method:: test_perms_as_nodeadmin Must be implemented in subclasses. .. py:attribute:: test_perms_as_superuser Must be implemented in subclasses. .. py:attribute:: test_perms_as_nobody Must be implemented in subclasses. .. py:attribute:: test_invalid_period Must be implemented in subclasses. Other helpers ============= .. py:class:: PreviewData(passing_relatedstudentids) Stores the output from a plugin. You should not need to use this directly. Use :meth:`.QualifiesForExamPluginViewMixin.save_plugin_output` instead. :param passing_relatedstudentids: See :attr:`.passing_relatedstudentids`. .. py:attribute:: passing_relatedstudentids List of the IDs of all :class:`devilry.apps.core.models.RelatedStudent` that qualifies for final exams according to the plugin that generated the data. .. py:function:: create_sessionkey(pluginsessionid) Generate the session key for the plugin output as described in :ref:`qualifiesforexam-plugins-what`. You should not need to use this directly. Use :meth:`.QualifiesForExamPluginViewMixin.get_plugin_input_and_authenticate` instead. **************************** Plugins shipped with Devilry **************************** ``devilry_qualifiesforexam_approved`` ===================================== TODO .. _qualifiesforexam-models: *************** Database models *************** .. py:currentmodule:: devilry_qualifiesforexam.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 :class:`.Status`-record is saved in the database. A status has a ForeignKey to :class:`devilry.apps.core.models.Period`, so the last saved Status is the active qualified-for-exam status for a Period. Each time a :class:`.Status` is saved, all of the :class:`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 :attr:`.QualifiesForFinalExam.qualifies`-field to indicate students that are not ready. Node administrators or systems that intergrate with Devilry uses :attr:`.Status.exported_timestamp` to mark :class:`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. :class:`DeadlineTag` is used to organize periods by the time when they should have made a ``ready``-:class:`.Status`. The models ========== .. py:class:: DeadlineTag A deadlinetag is used to tag :class:`devilry.apps.core.models.Period`-objects with a timestamp and an optional tag describing the timestamp. .. py:attribute:: timestamp Database field containing the date and time when a period admin should be finished qualifying students for final exams. .. py:attribute:: tag A tag for node-admins for this deadlinetag. Max 30 chars. May be empty or ``null``. .. py:class:: PeriodTag This table is used to create a one-to-many relation from :class:`.DeadlineTag` to :class:`devilry.apps.core.models.Period`. .. py:attribute:: deadlinetag Database foreign key to the :class:`.DeadlineTag` that the Period should be tagged by. .. py:attribute:: period Database foreign key to the :class:`devilry.apps.core.models.Period` that this tag points to. .. py:class:: 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. .. py:attribute:: period Database foreign key to the :class:`devilry.apps.core.models.Period` that the status is for. .. py:attribute:: 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. .. py:attribute:: 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 :attr:`.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 .. py:attribute:: createtime Database datetime field where we store when we added the status. .. py:attribute:: message Database field with an optional message about the status change. .. py:attribute:: user Database foreign key to the user that made the status change. .. py:attribute:: plugin Database char field that stores the id of the plugin (see :ref:`qualifiesforexam-plugins`) that was used to change the status. .. py:class:: QualifiesForFinalExam .. py:attribute:: relatedstudent Database one-to-one relation to :class:`devilry.apps.core.models.RelatedStudent`. .. py:attribute:: 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. .. py:attribute:: status Foreign key to a :class:`.QualifiesForFinalExamPeriodStatus`.