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::
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
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
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
[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
--- /dev/null
+# 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
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
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
'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:
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: