README: Remove duplicate Gentoo hyperlink target
[pygrader.git] / pygrader / handler / submission.py
1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of pygrader.
4 #
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader.  If not, see <http://www.gnu.org/licenses/>.
16
17 """Assignment submission handler
18
19 Allow students to submit assignments via email (if
20 ``Assignment.submittable`` is set).
21 """
22
23 from email.utils import formatdate as _formatdate
24 import mailbox as _mailbox
25 import os as _os
26 import os.path as _os_path
27
28 import pgp_mime as _pgp_mime
29
30 from .. import LOG as _LOG
31 from ..color import GOOD_DEBUG as _GOOD_DEBUG
32 from ..extract_mime import extract_mime as _extract_mime
33 from ..extract_mime import message_time as _message_time
34 from ..storage import assignment_path as _assignment_path
35 from ..storage import set_late as _set_late
36 from . import get_subject_assignment as _get_subject_assignment
37 from . import InvalidMessage as _InvalidMessage
38 from . import Response as _Response
39
40
41 class InvalidSubmission (_InvalidMessage):
42     def __init__(self, assignment=None, **kwargs):
43         if 'error' not in kwargs:
44             kwargs['error'] = 'invalid submission'
45         super(InvalidSubmission, self).__init__(**kwargs)
46         self.assignment = assignment
47
48
49 def run(basedir, course, message, person, subject, max_late=0, dry_run=None,
50         **kwargs):
51     """
52     >>> from pgp_mime.email import encodedMIMEText
53     >>> from ..test.course import StubCourse
54     >>> from . import Response
55     >>> course = StubCourse()
56     >>> person = list(
57     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
58     >>> message = encodedMIMEText('The answer is 42.')
59     >>> message['Message-ID'] = '<123.456@home.net>'
60     >>> message['Received'] = (
61     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
62     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
63     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
64     >>> subject = '[submit] assignment 1'
65     >>> try:
66     ...     run(basedir=course.basedir, course=course.course, message=message,
67     ...         person=person, subject=subject, max_late=0)
68     ... except Response as e:
69     ...     print('respond with:')
70     ...     print(e.message.as_string())
71     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
72     respond with:
73     Content-Type: text/plain; charset="us-ascii"
74     MIME-Version: 1.0
75     Content-Transfer-Encoding: 7bit
76     Content-Disposition: inline
77     Subject: Received Assignment 1 submission
78     <BLANKLINE>
79     We received your submission for Assignment 1 on ....
80
81     >>> course.cleanup()
82     """
83     time = _message_time(message=message)
84     assignment = _get_assignment(course=course, subject=subject)
85     assignment_path = _assignment_path(basedir, assignment, person)
86     _save_local_message_copy(
87         msg=message, person=person, assignment_path=assignment_path,
88         dry_run=dry_run)
89     _extract_mime(message=message, output=assignment_path, dry_run=dry_run)
90     _check_late(
91         basedir=basedir, assignment=assignment, person=person, time=time,
92         max_late=max_late, dry_run=dry_run)
93     if time:
94         time_str = 'on {}'.format(_formatdate(time))
95     else:
96         time_str = 'at an unknown time'
97     message = _pgp_mime.encodedMIMEText((
98             'We received your submission for {} {}.'
99             ).format(
100             assignment.name, time_str))
101     message['Subject'] = 'Received {} submission'.format(assignment.name)
102     raise _Response(message=message)
103
104 def _get_assignment(course, subject):
105     assignment = _get_subject_assignment(course, subject)
106     if not assignment.submittable:
107         raise InvalidSubmission(assignment=assignment)
108     return assignment
109
110 def _save_local_message_copy(msg, person, assignment_path, dry_run=False):
111     try:
112         _os.makedirs(assignment_path)
113     except OSError:
114         pass
115     mpath = _os_path.join(assignment_path, 'mail')
116     try:
117         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
118     except _mailbox.NoSuchMailboxError as e:
119         _LOG.warn('could not open mailbox at {}'.format(mpath))
120         mbox = None
121         new_msg = True
122     else:
123         new_msg = True
124         for other_msg in mbox:
125             if other_msg['Message-ID'] == msg['Message-ID']:
126                 new_msg = False
127                 break
128     if new_msg:
129         _LOG.log(_GOOD_DEBUG, 'saving email from {} to {}'.format(
130                 person, assignment_path))
131         if mbox is not None and not dry_run:
132             mdmsg = _mailbox.MaildirMessage(msg)
133             mdmsg.add_flag('S')
134             mbox.add(mdmsg)
135             mbox.close()
136     else:
137         _LOG.log(_GOOD_DEBUG, 'already found {} in {}'.format(
138                     msg['Message-ID'], mpath))
139
140 def _check_late(basedir, assignment, person, time, max_late=0, dry_run=False):
141     if time > assignment.due + max_late:
142         dt = time - assignment.due
143         _LOG.warning('{} {} late by {} seconds ({} hours)'.format(
144             person.name, assignment.name, dt, dt/3600.))
145         if not dry_run:
146             _set_late(basedir=basedir, assignment=assignment, person=person)