From 91c601bdf592f0b14aa6d7a81c223d4a56e2c90d Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Sun, 2 Sep 2012 14:29:02 -0400 Subject: [PATCH] handler:grade: add new handler for submitting grades. Now profs and TAs can submit grades (points and comments) via email. --- README | 42 +++- pygrader/handler/__init__.py | 9 + pygrader/handler/grade.py | 210 +++++++++++++++++++ pygrader/mailpipe.py | 21 +- test/mail-in/cur/1335305600.00000_4.home:2,S | 19 ++ test/mail-in/cur/1335305600.00000_5.home:2,S | 19 ++ 6 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 pygrader/handler/grade.py create mode 100644 test/mail-in/cur/1335305600.00000_4.home:2,S create mode 100644 test/mail-in/cur/1335305600.00000_5.home:2,S diff --git a/README b/README index f0318d4..2f7a604 100644 --- 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 diff --git a/pygrader/handler/__init__.py b/pygrader/handler/__init__.py index d813813..06edeca 100644 --- a/pygrader/handler/__init__.py +++ b/pygrader/handler/__init__.py @@ -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 index 0000000..c6aa8ce --- /dev/null +++ b/pygrader/handler/grade.py @@ -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 + Reply-to: Robot101 + To: Sauron + Subject: Set Bilbo Baggins grade on Attendance 1 to 10.0 + + --===============...== + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + + Set comment to: + + None + + --===============...== + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Description: OpenPGP digital signature + Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii" + + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v2.0.19 (GNU/Linux) + + ... + -----END PGP SIGNATURE----- + + --===============...==-- + + >>> 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 + Reply-to: Robot101 + To: Sauron + Subject: Set Bilbo Baggins grade on Attendance 1 to 9.0 + + --===============...== + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + + Set comment to: + + Units! + + --===============...== + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Description: OpenPGP digital signature + Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii" + + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v2.0.19 (GNU/Linux) + + ... + -----END PGP SIGNATURE----- + + --===============...==-- + + >>> 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 diff --git a/pygrader/mailpipe.py b/pygrader/mailpipe.py index ea5a495..4402fbf 100644 --- a/pygrader/mailpipe.py +++ b/pygrader/mailpipe.py @@ -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 index 0000000..945f270 --- /dev/null +++ b/test/mail-in/cur/1335305600.00000_4.home:2,S @@ -0,0 +1,19 @@ +Return-Path: +Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) + by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 + for ; 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 ; Sun, 2 Sep 2012 01:04:01 -0400 (EDT) +Date: Sun, 2 Sep 2012 01:04:00 -0400 +From: Saury +To: Physics 101 +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 index 0000000..0482d4c --- /dev/null +++ b/test/mail-in/cur/1335305600.00000_5.home:2,S @@ -0,0 +1,19 @@ +Return-Path: +Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) + by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 + for ; 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 ; Sun, 2 Sep 2012 01:05:01 -0400 (EDT) +Date: Sun, 2 Sep 2012 01:05:00 -0400 +From: "Billy B." +To: Physics 101 +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! -- 2.26.2