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