handler:grade: add new handler for submitting grades.
authorW. Trevor King <wking@tremily.us>
Sun, 2 Sep 2012 18:29:02 +0000 (14:29 -0400)
committerW. Trevor King <wking@tremily.us>
Sun, 2 Sep 2012 18:29:02 +0000 (14:29 -0400)
Now profs and TAs can submit grades (points and comments) via email.

README
pygrader/handler/__init__.py
pygrader/handler/grade.py [new file with mode: 0644]
pygrader/mailpipe.py
test/mail-in/cur/1335305600.00000_4.home:2,S [new file with mode: 0644]
test/mail-in/cur/1335305600.00000_5.home:2,S [new file with mode: 0644]

diff --git a/README b/README
index f0318d4159e248898cfbbd9acc1dd801ecd74998..2f7a6049f98af3927348517582027ed9cf252d3a 100644 (file)
--- a/README
+++ b/README
@@ -46,10 +46,12 @@ Usage
 
 Pygrader will help keep you organized in a course where the students
 submit homework via email, or the homework submissions are otherwise
-digital (i.e. scanned in after submission).  There is currently no
-support for multiple graders, although I will likely add this in the
-future.  In the following sections, I'll walk you through
-administering the homework for the ``test`` course.
+digital (i.e. scanned in after submission).  You can also use it to
+assign and `manage any type of grade via email`__.  In the following
+sections, I'll walk you through local administration for the ``test``
+course.
+
+__ `Mailpipe details`_
 
 All of the processing involves using the ``pg.py`` command.  Run::
 
@@ -141,8 +143,8 @@ If a person has the ``pgp-key`` option set, that key will be used to
 encrypt messages to that person and sign messages from that person
 with PGP_.  It will also be used to authenticate ownership of incoming
 emails.  You'll need to have GnuPG_ on your local host for this to
-work, and the user running pygrader should have the associated keys in
-their keychain.
+work, and the user running ``pygrader`` should have the associated
+keys in their keychain.
 
 The ``course.robot`` option defines a dummy person used to sign
 automatically generated emails (e.g. responses to mailpipe-processed
@@ -161,7 +163,7 @@ As the due date approaches, student submissions will start arriving in
 your inbox.  Use ``pg.py``'s ``mailpipe`` command to sort them into
 directories (using the ``pygrader.handler.submission`` handler).  This
 will also extract any files that were attached to the emails and place
-them in that persons assignment directory::
+them in that person's assignment directory::
 
   $ pg.py -d test mailpipe -m maildir -i ~/.maildir -o ./mail-old
 
@@ -192,8 +194,22 @@ all out with ``pg.py``'s ``email`` command::
 Mailpipe details
 ~~~~~~~~~~~~~~~~
 
-Mailpipe is the most complicated part of ``pygrader``, and the place
-where things are most likely to get sticky.  Since there are several
+Besides accepting student submissions from incoming email,
+``mailpipe`` also accepts other types of requests, and can be
+configured to respond automatically:
+
+* Incoming student assignment submissions are archived (see the
+  ``submit`` command).
+* Students can check their grades without having to bother anyone (see
+  the ``get`` commands).
+* Professors and teaching assistants can request student submissions
+  so that they can grade them (see the ``get`` commands).
+* Professors and TAs can request the grades for the entire class (see
+  the ``get`` commands).
+* Professors and TAs can assign grades (see the ``grade`` command).
+
+To enable automatic responses, you'll need to add the ``-r`` or
+``--respond`` argument when you call ``pg.py``.
 
 If you get tired of filtering your inbox by hand using ``pg.py
 mailpipe``, you can (depending on how your mail delivery is setup) use
@@ -233,6 +249,14 @@ targets include (see the ``handlers`` argument to
     [get] Bilbo Baggins
     [get] Bilbo Baggins Assignment 1
 
+``grade``
+  professors and TAs can submit a grade for a particular student on a
+  particular assignment.  The body of the (possibly signed or
+  encrypted) email should be identical to the grade file that the
+  sender wishes to create.  An example subject would be::
+
+    [grade] Bilbo Baggins Assignment 1
+
 To allow you to easily sort the email, you can also prefix the target
 with additional information (see
 ``pygrader.mailpipe._get_message_target``).  For example, if you were
index d813813b2fb075677208b456cd285ca6b7031208..06edeca117ebfc33efb884166d1d08c2c1d59744 100644 (file)
@@ -25,6 +25,15 @@ class InvalidMessage (ValueError):
         return None
 
 
+class PermissionViolationMessage (InvalidMessage):
+    def __init__(self, person=None, allowed_groups=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'action not permitted'
+        super(PermissionViolationMessage, self).__init__(**kwargs)
+        self.person = person
+        self.allowed_groups = allowed_groups
+
+
 class InsecureMessage (InvalidMessage):
     def __init__(self, **kwargs):
         if 'error' not in kwargs:
diff --git a/pygrader/handler/grade.py b/pygrader/handler/grade.py
new file mode 100644 (file)
index 0000000..c6aa8ce
--- /dev/null
@@ -0,0 +1,210 @@
+# Copyright
+
+"""Handle grade assignment
+
+Allow professors and TAs to assign grades via email.
+"""
+
+import io as _io
+import mailbox as _mailbox
+import os.path as _os_path
+
+import pgp_mime as _pgp_mime
+
+from .. import LOG as _LOG
+from ..email import construct_text_email as _construct_text_email
+from ..extract_mime import message_time as _message_time
+from ..model.grade import Grade as _Grade
+from ..storage import load_grade as _load_grade
+from ..storage import parse_grade as _parse_grade
+from ..storage import save_grade as _save_grade
+from . import InvalidMessage as _InvalidMessage
+from . import get_subject_assignment as _get_subject_assignment
+from . import get_subject_student as _get_subject_student
+from . import PermissionViolationMessage as _PermissionViolationMessage
+from . import Response as _Response
+from . import UnsignedMessage as _UnsignedMessage
+
+
+class MissingGradeMessage (_InvalidMessage):
+    def __init__(self, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'missing grade'
+        super(MissingGradeMessage, self).__init__(**kwargs)
+
+
+def run(basedir, course, message, person, subject,
+        trust_email_infrastructure=False, dry_run=False, **kwargs):
+    """
+    >>> from pgp_mime.email import encodedMIMEText
+    >>> from ..test.course import StubCourse
+    >>> from . import InvalidMessage, Response
+    >>> course = StubCourse()
+    >>> person = list(
+    ...     course.course.find_people(email='eye@tower.edu'))[0]
+    >>> message = encodedMIMEText('10')
+    >>> message['Message-ID'] = '<123.456@home.net>'
+    >>> def process(**kwargs):
+    ...     try:
+    ...         run(**kwargs)
+    ...     except Response as response:
+    ...         print('respond with:')
+    ...         print(response.message.as_string().replace('\\t', '  '))
+    ...     except InvalidMessage as error:
+    ...         print('{} error:'.format(type(error).__name__))
+    ...         print(error)
+
+    Message authentication is handled identically to the ``get`` module.
+
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, message=message,
+    ...     person=person, subject='[grade]')
+    UnsignedMessage error:
+    unsigned message
+
+    Students are denied access:
+
+    >>> student = list(
+    ...     course.course.find_people(email='bb@greyhavens.net'))[0]
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, message=message,
+    ...     person=student, subject='[grade]',
+    ...     trust_email_infrastructure=True)
+    ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+    PermissionViolationMessage error:
+    action not permitted
+
+    >>> person.pgp_key = None  # so we have plain-text to doctest
+    >>> assignment = course.course.assignments[0]
+    >>> message.authenticated = True
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, message=message,
+    ...     person=person, subject='[grade] {}, {}'.format(student.name, assignment.name))
+    ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+    respond with:
+    Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Date: ...
+    From: Robot101 <phys101@tower.edu>
+    Reply-to: Robot101 <phys101@tower.edu>
+    To: Sauron <eye@tower.edu>
+    Subject: Set Bilbo Baggins grade on Attendance 1 to 10.0
+    <BLANKLINE>
+    --===============...==
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    <BLANKLINE>
+    Set comment to:
+    <BLANKLINE>
+    None
+    <BLANKLINE>
+    --===============...==
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: OpenPGP digital signature
+    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    Version: GnuPG v2.0.19 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
+
+    >>> message = encodedMIMEText('9\\n\\nUnits!')
+    >>> message['Message-ID'] = '<123.456@home.net>'
+    >>> message.authenticated = True
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, message=message,
+    ...     person=person, subject='[grade] {}, {}'.format(student.name, assignment.name))
+    ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+    respond with:
+    Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Date: ...
+    From: Robot101 <phys101@tower.edu>
+    Reply-to: Robot101 <phys101@tower.edu>
+    To: Sauron <eye@tower.edu>
+    Subject: Set Bilbo Baggins grade on Attendance 1 to 9.0
+    <BLANKLINE>
+    --===============...==
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    <BLANKLINE>
+    Set comment to:
+    <BLANKLINE>
+    Units!
+    <BLANKLINE>
+    --===============...==
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: OpenPGP digital signature
+    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    Version: GnuPG v2.0.19 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
+
+    >>> course.cleanup()
+    """
+    if trust_email_infrastructure:
+        authenticated = True
+    else:
+        authenticated = (
+            hasattr(message, 'authenticated') and message.authenticated)
+    if not authenticated:
+        raise _UnsignedMessage()
+    if not ('professors' in person.groups or 'assistants' in person.groups):
+        raise _PermissionViolationMessage(
+            person=person, allowed_groups=['professors', 'assistants'])
+    student = _get_subject_student(course=course, subject=subject)
+    assignment = _get_subject_assignment(course=course, subject=subject)
+    grade = _get_grade(
+        basedir=basedir, message=message, assignment=assignment,
+        student=student)
+    _LOG.info('set {} grade on {} to {}'.format(
+            student, assignment, grade.points))
+    if not dry_run:
+        _save_grade(basedir=basedir, grade=grade)
+    response = _construct_text_email(
+        author=course.robot, targets=[person],
+        subject='Set {} grade on {} to {}'.format(
+            student.name, assignment.name, grade.points),
+        text='Set comment to:\n\n{}\n'.format(grade.comment))
+    raise _Response(message=response, complete=True)
+
+def _get_grade(basedir, message, assignment, student):
+    text = None
+    for part in message.walk():
+        if part.get_content_type() == 'text/plain':
+            charset = part.get_charset()
+            if charset is None:
+                encoding = 'ascii'
+            else:
+                encoding = charset.input_charset
+            text = str(part.get_payload(decode=True), encoding)
+    if text is None:
+        raise _MissingGradeMessage(message=message)
+    stream = _io.StringIO(text)
+    new_grade = _parse_grade(
+        stream=stream, assignment=assignment, person=student)
+    try:
+        old_grade = _load_grade(
+            basedir=basedir, assignment=assignment, person=student)
+    except IOError as error:
+        _LOG.warn(str(error))
+        old_grade = _Grade(student=student, assignment=assignment, points=0)
+    old_grade.points = new_grade.points
+    old_grade.comment = new_grade.comment
+    return old_grade
index ea5a495376a5bcff35c75b6d01cf8d2afc129dd7..4402fbf55d617825006bfa59fc3b7d3f5b7e7b20 100644 (file)
@@ -39,9 +39,12 @@ from .handler import InvalidAssignmentSubject as _InvalidAssignmentSubject
 from .handler import InvalidMessage as _InvalidMessage
 from .handler import InvalidStudentSubject as _InvalidStudentSubject
 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
+from .handler import PermissionViolationMessage as _PermissionViolationMessage
 from .handler import Response as _Response
 from .handler import UnsignedMessage as _UnsignedMessage
 from .handler.get import run as _handle_get
+from .handler.grade import run as _handle_grade
+from .handler.grade import MissingGradeMessage as _MissingGradeMessage
 from .handler.submission import run as _handle_submission
 from .handler.submission import InvalidSubmission as _InvalidSubmission
 
@@ -113,6 +116,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
              trust_email_infrastructure=False,
              handlers={
         'get': _handle_get,
+        'grade': _handle_grade,
         'submit': _handle_submission,
         }, respond=None, dry_run=False, **kwargs):
     """Run from procmail to sort incomming submissions
@@ -974,6 +978,12 @@ def _get_error_response(error):
             'We received your submission for {}, but you are not\n'
             'allowed to submit that assignment via email.'
             ).format(error.assignment.name)
+    elif isinstance(error, _MissingGradeMessage):
+        subject = 'No grade in {!r}'.format(error.subject)
+        text = (
+            'Your grade submission did not include a text/plain\n'
+            'part containing the new grade and comment.'
+            )
     elif isinstance(error, InvalidHandlerMessage):
         targets = sorted(error.handlers.keys())
         if not targets:
@@ -1038,7 +1048,16 @@ def _get_error_response(error):
         subject = 'unsigned message {}'.format(error.message['Message-ID'])
         text = (
             'We received an email message from you without a valid\n'
-            'PGP signature.')
+            'PGP signature.'
+            )
+    elif isinstance(error, _PermissionViolationMessage):
+        text = (
+            'We got an email from you with the following subject:\n'
+            '  {!r}\n'
+            "but you can't do that unless you belong to one of the\n"
+            'following groups:\n'
+            '  * {}').format(
+            error.subject, '\n  * '.join(error.allowed_groups))
     elif isinstance(error, _InvalidMessage):
         text = subject
     else:
diff --git a/test/mail-in/cur/1335305600.00000_4.home:2,S b/test/mail-in/cur/1335305600.00000_4.home:2,S
new file mode 100644 (file)
index 0000000..945f270
--- /dev/null
@@ -0,0 +1,19 @@
+Return-Path: <eye@tower.edu>
+Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1])
+  by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453
+  for <phys101@tower.edu>; Sun, 2 Sep 2012 01:04:02 -0400 (EDT)
+Received: from smtp.home.net (smtp.home.net [123.456.123.456])
+  by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF
+  for <phys101@tower.edu>; Sun, 2 Sep 2012 01:04:01 -0400 (EDT)
+Date: Sun, 2 Sep 2012 01:04:00 -0400
+From: Saury <eye@tower.edu>
+To: Physics 101 <phys101@tower.edu>
+Subject: [grade] Frodo Baggins - assignment 1
+Message-ID: <20120902010400.AB1234@home.net>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+10
+
+Very persistent.
diff --git a/test/mail-in/cur/1335305600.00000_5.home:2,S b/test/mail-in/cur/1335305600.00000_5.home:2,S
new file mode 100644 (file)
index 0000000..0482d4c
--- /dev/null
@@ -0,0 +1,19 @@
+Return-Path: <bb@shire.org>
+Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1])
+  by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453
+  for <phys101@tower.edu>; Sun, 2 Sep 2012 01:05:02 -0400 (EDT)
+Received: from smtp.home.net (smtp.home.net [123.456.123.456])
+  by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF
+  for <phys101@tower.edu>; Sun, 2 Sep 2012 01:05:01 -0400 (EDT)
+Date: Sun, 2 Sep 2012 01:05:00 -0400
+From: "Billy B." <bb@shire.org>
+To: Physics 101 <phys101@tower.edu>
+Subject: [grade] Bilbo Baggins - assignment 1
+Message-ID: <20120902010500.AB1234@home.net>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+10
+
+Change my grade!