ac9a1b36b7db6f36efa2173a18edb9b09bf9f14b
[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, 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, message=message,
54     ...         person=person, subject=subject, max_late=0)
55     ... except Response as e:
56     ...     print('respond with:')
57     ...     print(e.message.as_string())
58     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
59     respond with:
60     Content-Type: text/plain; charset="us-ascii"
61     MIME-Version: 1.0
62     Content-Transfer-Encoding: 7bit
63     Content-Disposition: inline
64     Subject: Received Assignment 1 submission
65     <BLANKLINE>
66     We received your submission for Assignment 1 on ....
67
68     >>> course.cleanup()
69     """
70     time = _message_time(message=message, use_color=use_color)
71     assignment = _get_assignment(
72         course=course, subject=subject, use_color=use_color)
73     assignment_path = _assignment_path(basedir, assignment, person)
74     _save_local_message_copy(
75         msg=message, person=person, assignment_path=assignment_path,
76         use_color=use_color, dry_run=dry_run)
77     _extract_mime(message=message, output=assignment_path, dry_run=dry_run)
78     _check_late(
79         basedir=basedir, assignment=assignment, person=person, time=time,
80         max_late=max_late, use_color=use_color, dry_run=dry_run)
81     if time:
82         time_str = 'on {}'.format(_formatdate(time))
83     else:
84         time_str = 'at an unknown time'
85     message = _pgp_mime.encodedMIMEText((
86             'We received your submission for {} {}.'
87             ).format(
88             assignment.name, time_str))
89     message['Subject'] = 'Received {} submission'.format(assignment.name)
90     raise _Response(message=message)
91
92 def _match_assignment(assignment, subject):
93     return assignment.name.lower() in subject.lower()
94
95 def _get_assignment(course, subject, use_color):
96     assignments = [a for a in course.assignments
97                    if _match_assignment(a, subject)]
98     if len(assignments) != 1:
99         if len(assignments) == 0:
100             response_subject = 'no assignment found in {!r}'.format(subject)
101             error = (
102                 'does not match any submittable assignment name\n'
103                 'for {}.\n').format(course.name)
104         else:
105             response_subject = 'several assignments found in {!r}'.format(
106                 subject)
107             error = (
108                 'matches several submittable assignment names\n'
109                 'for {}:  * {}\n').format(
110                 course.name,
111                 '\n  * '.join(a.name for a in assignments))
112         submittable_assignments = [
113             a for a in course.assignments if a.submittable]
114         if not submittable_assignments:
115             hint = (
116                 'In fact, there are no submittable assignments for\n'
117                 'this course!')
118         else:
119             hint = (
120                 'Remember to use the full name for the assignment in the\n'
121                 'subject.  For example:\n'
122                 '  {} submission').format(
123                 submittable_assignments[0].name)
124         message = _pgp_mime.encodedMIMEText((
125                 'We got an email from you with the following subject:\n'
126                 '  {!r}\n'
127                 'which {}.\n\n'
128                 '{}\n').format(subject, course.name, hint))
129         message['Subject'] = response_subject
130         raise _Response(
131             message=message, exception=ValueError(response_subject))
132     assignment = assignments[0]
133
134     if not assignment.submittable:
135         raise InvalidAssignment(assignment)
136     return assignments[0]
137
138 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
139                              dry_run=False):
140     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
141     try:
142         _os.makedirs(assignment_path)
143     except OSError:
144         pass
145     mpath = _os_path.join(assignment_path, 'mail')
146     try:
147         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
148     except _mailbox.NoSuchMailboxError as e:
149         _LOG.debug(_color_string(
150                 string='could not open mailbox at {}'.format(mpath),
151                 color=bad))
152         mbox = None
153         new_msg = True
154     else:
155         new_msg = True
156         for other_msg in mbox:
157             if other_msg['Message-ID'] == msg['Message-ID']:
158                 new_msg = False
159                 break
160     if new_msg:
161         _LOG.debug(_color_string(
162                 string='saving email from {} to {}'.format(
163                     person, assignment_path), color=good))
164         if mbox is not None and not dry_run:
165             mdmsg = _mailbox.MaildirMessage(msg)
166             mdmsg.add_flag('S')
167             mbox.add(mdmsg)
168             mbox.close()
169     else:
170         _LOG.debug(_color_string(
171                 string='already found {} in {}'.format(
172                     msg['Message-ID'], mpath), color=good))
173
174 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
175                 dry_run=False):
176     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
177     if time > assignment.due + max_late:
178         dt = time - assignment.due
179         _LOG.warn(_color_string(
180                 string='{} {} late by {} seconds ({} hours)'.format(
181                     person.name, assignment.name, dt, dt/3600.),
182                 color=bad))
183         if not dry_run:
184             _set_late(basedir=basedir, assignment=assignment, person=person)