color: add ColoredFormatter for more transparent coloring.
[pygrader.git] / pygrader / handler / submission.py
1 # Copyright
2
3 """Assignment submission handler
4
5 Allow students to submit assignments via email (if
6 ``Assignment.submittable`` is set).
7 """
8
9 from email.utils import formatdate as _formatdate
10 import mailbox as _mailbox
11 import os as _os
12 import os.path as _os_path
13
14 import pgp_mime as _pgp_mime
15
16 from .. import LOG as _LOG
17 from ..color import GOOD_DEBUG as _GOOD_DEBUG
18 from ..extract_mime import extract_mime as _extract_mime
19 from ..extract_mime import message_time as _message_time
20 from ..storage import assignment_path as _assignment_path
21 from ..storage import set_late as _set_late
22 from . import InvalidMessage as _InvalidMessage
23 from . import Response as _Response
24
25
26 class InvalidAssignment (_InvalidMessage):
27     def __init__(self, assignment, **kwargs):
28         if 'error' not in kwargs:
29             kwargs['error'] = 'Received invalid {} submission'.format(
30                 assignment.name)
31         super(InvalidAssignment, self).__init__(**kwargs)
32         self.assignment = assignment
33
34
35 def run(basedir, course, message, person, subject, max_late=0, dry_run=None,
36         **kwargs):
37     """
38     >>> from pgp_mime.email import encodedMIMEText
39     >>> from ..test.course import StubCourse
40     >>> from . import Response
41     >>> course = StubCourse()
42     >>> person = list(
43     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
44     >>> message = encodedMIMEText('The answer is 42.')
45     >>> message['Message-ID'] = '<123.456@home.net>'
46     >>> message['Received'] = (
47     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
48     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
49     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
50     >>> subject = '[submit] assignment 1'
51     >>> try:
52     ...     run(basedir=course.basedir, course=course.course, message=message,
53     ...         person=person, subject=subject, max_late=0)
54     ... except Response as e:
55     ...     print('respond with:')
56     ...     print(e.message.as_string())
57     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
58     respond with:
59     Content-Type: text/plain; charset="us-ascii"
60     MIME-Version: 1.0
61     Content-Transfer-Encoding: 7bit
62     Content-Disposition: inline
63     Subject: Received Assignment 1 submission
64     <BLANKLINE>
65     We received your submission for Assignment 1 on ....
66
67     >>> course.cleanup()
68     """
69     time = _message_time(message=message)
70     assignment = _get_assignment(course=course, subject=subject)
71     assignment_path = _assignment_path(basedir, assignment, person)
72     _save_local_message_copy(
73         msg=message, person=person, assignment_path=assignment_path,
74         dry_run=dry_run)
75     _extract_mime(message=message, output=assignment_path, dry_run=dry_run)
76     _check_late(
77         basedir=basedir, assignment=assignment, person=person, time=time,
78         max_late=max_late, dry_run=dry_run)
79     if time:
80         time_str = 'on {}'.format(_formatdate(time))
81     else:
82         time_str = 'at an unknown time'
83     message = _pgp_mime.encodedMIMEText((
84             'We received your submission for {} {}.'
85             ).format(
86             assignment.name, time_str))
87     message['Subject'] = 'Received {} submission'.format(assignment.name)
88     raise _Response(message=message)
89
90 def _match_assignment(assignment, subject):
91     return assignment.name.lower() in subject.lower()
92
93 def _get_assignment(course, subject):
94     assignments = [a for a in course.assignments
95                    if _match_assignment(a, subject)]
96     if len(assignments) != 1:
97         if len(assignments) == 0:
98             response_subject = 'no assignment found in {!r}'.format(subject)
99             error = (
100                 'does not match any submittable assignment name\n'
101                 'for {}.\n').format(course.name)
102         else:
103             response_subject = 'several assignments found in {!r}'.format(
104                 subject)
105             error = (
106                 'matches several submittable assignment names\n'
107                 'for {}:  * {}\n').format(
108                 course.name,
109                 '\n  * '.join(a.name for a in assignments))
110         submittable_assignments = [
111             a for a in course.assignments if a.submittable]
112         if not submittable_assignments:
113             hint = (
114                 'In fact, there are no submittable assignments for\n'
115                 'this course!')
116         else:
117             hint = (
118                 'Remember to use the full name for the assignment in the\n'
119                 'subject.  For example:\n'
120                 '  {} submission').format(
121                 submittable_assignments[0].name)
122         message = _pgp_mime.encodedMIMEText((
123                 'We got an email from you with the following subject:\n'
124                 '  {!r}\n'
125                 'which {}.\n\n'
126                 '{}\n').format(subject, course.name, hint))
127         message['Subject'] = response_subject
128         raise _Response(
129             message=message, exception=ValueError(response_subject))
130     assignment = assignments[0]
131
132     if not assignment.submittable:
133         raise InvalidAssignment(assignment)
134     return assignments[0]
135
136 def _save_local_message_copy(msg, person, assignment_path, dry_run=False):
137     try:
138         _os.makedirs(assignment_path)
139     except OSError:
140         pass
141     mpath = _os_path.join(assignment_path, 'mail')
142     try:
143         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
144     except _mailbox.NoSuchMailboxError as e:
145         _LOG.warn('could not open mailbox at {}'.format(mpath))
146         mbox = None
147         new_msg = True
148     else:
149         new_msg = True
150         for other_msg in mbox:
151             if other_msg['Message-ID'] == msg['Message-ID']:
152                 new_msg = False
153                 break
154     if new_msg:
155         _LOG.log(_GOOD_DEBUG, 'saving email from {} to {}'.format(
156                 person, assignment_path))
157         if mbox is not None and not dry_run:
158             mdmsg = _mailbox.MaildirMessage(msg)
159             mdmsg.add_flag('S')
160             mbox.add(mdmsg)
161             mbox.close()
162     else:
163         _LOG.log(_GOOD_DEBUG, 'already found {} in {}'.format(
164                     msg['Message-ID'], mpath))
165
166 def _check_late(basedir, assignment, person, time, max_late=0, dry_run=False):
167     if time > assignment.due + max_late:
168         dt = time - assignment.due
169         _LOG.warning('{} {} late by {} seconds ({} hours)'.format(
170             person.name, assignment.name, dt, dt/3600.))
171         if not dry_run:
172             _set_late(basedir=basedir, assignment=assignment, person=person)