pgp: don't assume protocol/micalg ordering in doctest output.
[pygrader.git] / pygrader / handler / get.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 """Handle information requests
18
19 Allow professors, TAs, and students to request grade information via email.
20 """
21
22 from email.mime.message import MIMEMessage as _MIMEMessage
23 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
24 import io as _io
25 import mailbox as _mailbox
26 import os.path as _os_path
27
28 import pgp_mime as _pgp_mime
29
30 from .. import LOG as _LOG
31 from ..email import construct_text_email as _construct_text_email
32 from ..email import construct_email as _construct_email
33 from ..extract_mime import message_time as _message_time
34 from ..storage import assignment_path as _assignment_path
35 from ..tabulate import tabulate as _tabulate
36 from ..template import _student_email as _student_email
37 from . import get_subject_assignment as _get_subject_assignment
38 from . import get_subject_student as _get_subject_student
39 from . import InvalidStudentSubject as _InvalidStudentSubject
40 from . import InvalidAssignmentSubject as _InvalidAssignmentSubject
41 from . import Response as _Response
42 from . import UnsignedMessage as _UnsignedMessage
43
44
45 def run(basedir, course, message, person, subject,
46         trust_email_infrastructure=False, dry_run=False, **kwargs):
47     """
48     >>> from pgp_mime.email import encodedMIMEText
49     >>> from ..model.grade import Grade
50     >>> from ..test.course import StubCourse
51     >>> from . import InvalidMessage, Response
52     >>> course = StubCourse()
53     >>> person = list(
54     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
55     >>> message = encodedMIMEText('This text is not important.')
56     >>> message['Message-ID'] = '<123.456@home.net>'
57     >>> def process(**kwargs):
58     ...     try:
59     ...         run(**kwargs)
60     ...     except Response as response:
61     ...         print('respond with:')
62     ...         print(response.message.as_string().replace('\\t', '  '))
63     ...     except InvalidMessage as error:
64     ...         print('{} error:'.format(type(error).__name__))
65     ...         print(error)
66
67     Unauthenticated messages are refused by default.
68
69     >>> process(
70     ...     basedir=course.basedir, course=course.course, message=message,
71     ...     person=person, subject='[get]', max_late=0)
72     UnsignedMessage error:
73     unsigned message
74
75     Although you can process them by setting the
76     ``trust_email_infrastructure`` option.  This might not be too
77     dangerous, since you're sending the email to the user's configured
78     email address, not just replying blindly to the incoming email
79     address.  With ``trust_email_infrastructure`` and missing user PGP
80     keys, sysadmins on the intervening systems will be able to read
81     our responses, possibly leaking grade information.  If leaking to
82     sysadmins is considered unacceptable, you've can only email users
83     who have registered PGP keys.
84
85     Students without grades get a reasonable response.
86
87     >>> process(
88     ...     basedir=course.basedir, course=course.course, message=message,
89     ...     person=person, subject='[get]', max_late=0,
90     ...     trust_email_infrastructure=True)
91     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
92     respond with:
93     Content-Type: text/plain; charset="us-ascii"
94     MIME-Version: 1.0
95     Content-Transfer-Encoding: 7bit
96     Content-Disposition: inline
97     Subject: No grades for Billy
98     <BLANKLINE>
99     We don't have any of your grades on file for this course.
100
101     >>> message.authenticated = True
102     >>> process(
103     ...     basedir=course.basedir, course=course.course, message=message,
104     ...     person=person, subject='[get]', max_late=0)
105     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
106     respond with:
107     Content-Type: text/plain; charset="us-ascii"
108     MIME-Version: 1.0
109     Content-Transfer-Encoding: 7bit
110     Content-Disposition: inline
111     Subject: No grades for Billy
112     <BLANKLINE>
113     We don't have any of your grades on file for this course.
114
115     Once we add a grade, they get details on all their grades for the
116     course.
117
118     >>> grade = Grade(
119     ...     student=person,
120     ...     assignment=course.course.assignment('Attendance 1'),
121     ...     points=1)
122     >>> course.course.grades.append(grade)
123     >>> grade = Grade(
124     ...     student=person,
125     ...     assignment=course.course.assignment('Attendance 2'),
126     ...     points=1)
127     >>> course.course.grades.append(grade)
128     >>> grade = Grade(
129     ...     student=person,
130     ...     assignment=course.course.assignment('Assignment 1'),
131     ...     points=10, comment='Looks good.')
132     >>> course.course.grades.append(grade)
133     >>> process(
134     ...     basedir=course.basedir, course=course.course, message=message,
135     ...     person=person, subject='[get]', max_late=0)
136     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
137     respond with:
138     Content-Type: multipart/signed; ...protocol="application/pgp-signature"; ...boundary="===============...=="
139     MIME-Version: 1.0
140     Content-Disposition: inline
141     Date: ...
142     From: Robot101 <phys101@tower.edu>
143     Reply-to: Robot101 <phys101@tower.edu>
144     To: Bilbo Baggins <bb@shire.org>
145     Subject: Physics 101 grades
146     <BLANKLINE>
147     --===============...==
148     Content-Type: text/plain; charset="us-ascii"
149     MIME-Version: 1.0
150     Content-Transfer-Encoding: 7bit
151     Content-Disposition: inline
152     <BLANKLINE>
153     Billy,
154     <BLANKLINE>
155     Grades:
156       * Attendance 1:  1 out of 1 available points.
157       * Attendance 2:  1 out of 1 available points.
158       * Assignment 1:  10 out of 10 available points.
159     <BLANKLINE>
160     Comments:
161     <BLANKLINE>
162     Assignment 1
163     <BLANKLINE>
164     Looks good.
165     <BLANKLINE>
166     Yours,
167     phys-101 robot
168     --===============...==
169     MIME-Version: 1.0
170     Content-Transfer-Encoding: 7bit
171     Content-Description: OpenPGP digital signature
172     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
173     <BLANKLINE>
174     -----BEGIN PGP SIGNATURE-----
175     Version: GnuPG v2.0.19 (GNU/Linux)
176     <BLANKLINE>
177     ...
178     -----END PGP SIGNATURE-----
179     <BLANKLINE>
180     --===============...==--
181
182     Professors and TAs can request the grades for the whole course.
183
184     >>> student = person
185     >>> person = list(
186     ...     course.course.find_people(email='eye@tower.edu'))[0]
187     >>> person.pgp_key = None
188     >>> process(
189     ...     basedir=course.basedir, course=course.course, message=message,
190     ...     person=person, subject='[get]', max_late=0)
191     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
192     respond with:
193     Content-Type: multipart/signed; ...protocol="application/pgp-signature"; ...boundary="===============...=="
194     MIME-Version: 1.0
195     Content-Disposition: inline
196     Date: ...
197     From: Robot101 <phys101@tower.edu>
198     Reply-to: Robot101 <phys101@tower.edu>
199     To: Sauron <eye@tower.edu>
200     Subject: All grades for Physics 101
201     <BLANKLINE>
202     --===============...==
203     Content-Type: text/plain; charset="us-ascii"
204     MIME-Version: 1.0
205     Content-Transfer-Encoding: 7bit
206     Content-Disposition: inline
207     <BLANKLINE>
208     Student  Attendance 1  Attendance 2  Assignment 1
209     Bilbo Baggins  1  1  10
210     --
211     Mean  1.00  1.00  10.00
212     Std. Dev.  0.00  0.00  0.00
213     <BLANKLINE>
214     --===============...==
215     MIME-Version: 1.0
216     Content-Transfer-Encoding: 7bit
217     Content-Description: OpenPGP digital signature
218     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
219     <BLANKLINE>
220     -----BEGIN PGP SIGNATURE-----
221     Version: GnuPG v2.0.19 (GNU/Linux)
222     <BLANKLINE>
223     ...
224     -----END PGP SIGNATURE-----
225     <BLANKLINE>
226     --===============...==--
227
228     They can also request grades for a particular student.
229
230     >>> process(
231     ...     basedir=course.basedir, course=course.course, message=message,
232     ...     person=person, subject='[get] {}'.format(student.name),
233     ...     max_late=0)
234     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
235     respond with:
236     Content-Type: multipart/signed; ...protocol="application/pgp-signature"; ...boundary="===============...=="
237     MIME-Version: 1.0
238     Content-Disposition: inline
239     Date: ...
240     From: Robot101 <phys101@tower.edu>
241     Reply-to: Robot101 <phys101@tower.edu>
242     To: Sauron <eye@tower.edu>
243     Subject: Physics 101 grades for Bilbo Baggins
244     <BLANKLINE>
245     --===============...==
246     Content-Type: text/plain; charset="us-ascii"
247     MIME-Version: 1.0
248     Content-Transfer-Encoding: 7bit
249     Content-Disposition: inline
250     <BLANKLINE>
251     Saury,
252     <BLANKLINE>
253     Grades:
254       * Attendance 1:  1 out of 1 available points.
255       * Attendance 2:  1 out of 1 available points.
256       * Assignment 1:  10 out of 10 available points.
257     <BLANKLINE>
258     Comments:
259     <BLANKLINE>
260     Assignment 1
261     <BLANKLINE>
262     Looks good.
263     <BLANKLINE>
264     Yours,
265     phys-101 robot
266     --===============...==
267     MIME-Version: 1.0
268     Content-Transfer-Encoding: 7bit
269     Content-Description: OpenPGP digital signature
270     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
271     <BLANKLINE>
272     -----BEGIN PGP SIGNATURE-----
273     Version: GnuPG v2.0.19 (GNU/Linux)
274     <BLANKLINE>
275     ...
276     -----END PGP SIGNATURE-----
277     <BLANKLINE>
278     --===============...==--
279
280     They can also request every submission for a particular student on
281     a particular assignment.  Lets give the student a submission email
282     to see how that works.
283
284     >>> from .submission import run as _handle_submission
285     >>> submission = encodedMIMEText('The answer is 42.')
286     >>> submission['Message-ID'] = '<789.abc@home.net>'
287     >>> submission['Received'] = (
288     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
289     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
290     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
291     >>> try:
292     ...     _handle_submission(
293     ...         basedir=course.basedir, course=course.course,
294     ...         message=submission, person=student,
295     ...         subject='[submit] Assignment 1')
296     ... except _Response:
297     ...     pass
298
299     Now lets request the submissions.
300
301     >>> process(
302     ...     basedir=course.basedir, course=course.course, message=message,
303     ...     person=person,
304     ...     subject='[get] {}, {}'.format(student.name, 'Assignment 1'),
305     ...     max_late=0)
306     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
307     respond with:
308     Content-Type: multipart/signed; ...protocol="application/pgp-signature"; ...boundary="===============...=="
309     MIME-Version: 1.0
310     Content-Disposition: inline
311     Date: ...
312     From: Robot101 <phys101@tower.edu>
313     Reply-to: Robot101 <phys101@tower.edu>
314     To: Sauron <eye@tower.edu>
315     Subject: Physics 101 assignment submissions for Bilbo Baggins
316     <BLANKLINE>
317     --===============...==
318     Content-Type: multipart/mixed; boundary="===============...=="
319     MIME-Version: 1.0
320     <BLANKLINE>
321     --===============...==
322     Content-Type: text/plain; charset="us-ascii"
323     MIME-Version: 1.0
324     Content-Transfer-Encoding: 7bit
325     Content-Disposition: inline
326     <BLANKLINE>
327     Physics 101 assignment submissions for Bilbo Baggins:
328       * Assignment 1
329     <BLANKLINE>
330     --===============...==
331     Content-Type: text/plain; charset="us-ascii"
332     MIME-Version: 1.0
333     Content-Transfer-Encoding: 7bit
334     Content-Disposition: inline
335     <BLANKLINE>
336     Assignment 1 grade: 10
337     <BLANKLINE>
338     Looks good.
339     <BLANKLINE>
340     --===============...==
341     Content-Type: message/rfc822
342     MIME-Version: 1.0
343     <BLANKLINE>
344     Content-Type: text/plain; charset="us-ascii"
345     MIME-Version: 1.0
346     Content-Transfer-Encoding: 7bit
347     Content-Disposition: inline
348     Message-ID: <789.abc@home.net>
349     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)
350     <BLANKLINE>
351     The answer is 42.
352     --===============...==--
353     --===============...==
354     MIME-Version: 1.0
355     Content-Transfer-Encoding: 7bit
356     Content-Description: OpenPGP digital signature
357     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
358     <BLANKLINE>
359     -----BEGIN PGP SIGNATURE-----
360     Version: GnuPG v2.0.19 (GNU/Linux)
361     <BLANKLINE>
362     ...
363     -----END PGP SIGNATURE-----
364     <BLANKLINE>
365     --===============...==--
366
367     >>> course.cleanup()
368     """
369     if trust_email_infrastructure:
370         authenticated = True
371     else:
372         authenticated = (
373             hasattr(message, 'authenticated') and message.authenticated)
374     if not authenticated:
375         raise _UnsignedMessage()
376     if person.is_admin():
377         email = _get_admin_email(
378             basedir=basedir, course=course, person=person, subject=subject)
379     elif 'students' in person.groups:
380         email = _get_student_email(
381             basedir=basedir, course=course, person=person)
382     else:
383         raise NotImplementedError(
384             'strange groups {} for {}'.format(person.groups, person))
385     raise _Response(message=email, complete=True)
386
387 def _get_student_email(basedir, course, person, student=None):
388     if student is None:
389         student = person
390         targets = None
391         _LOG.debug('construct student grade email about {} for {}'.format(
392                 student, student))
393     else:
394         targets = [person]
395         _LOG.debug('construct student grade email about {} for {}'.format(
396                 student, person))
397     emails = list(_student_email(
398         basedir=basedir, author=course.robot, course=course,
399         student=student, targets=targets, old=True))
400     if len(emails) == 0:
401         if targets is None:
402             text = (
403                 "We don't have any of your grades on file for this course."
404                 )
405         else:
406             text = (
407                 "We don't have any grades for {} on file for this course."
408                 ).format(student.name)
409         message = _pgp_mime.encodedMIMEText(text)
410         message['Subject'] = 'No grades for {}'.format(student.alias())
411         raise _Response(message=message, complete=True)
412     elif len(emails) > 1:
413         raise NotImplementedError(emails)
414     email,callback = emails[0]
415     # callback records notification, but don't bother here
416     return email
417
418 def _get_student_submission_email(
419     basedir, course, person, assignments, student):
420     _LOG.debug('construct student submission email about {} {} for {}'.format(
421             student, assignments, person))
422     subject = '{} assignment submissions for {}'.format(
423         course.name, student.name)
424     text = '{}:\n  * {}\n'.format(
425         subject, '\n  * '.join(a.name for a in assignments))
426     message = _MIMEMultipart('mixed')
427     message.attach(_pgp_mime.encodedMIMEText(text))
428     for assignment in assignments:
429         grade = course.grade(student=student, assignment=assignment)
430         if grade is not None:
431             text = '{} grade: {}\n'.format(assignment.name, grade.points)
432             if grade.comment:
433                 text += '\n{}\n'.format(grade.comment)
434             message.attach(_pgp_mime.encodedMIMEText(text))
435         assignment_path = _assignment_path(basedir, assignment, student)
436         mpath = _os_path.join(assignment_path, 'mail')
437         try:
438             mbox = _mailbox.Maildir(mpath, factory=None, create=False)
439         except _mailbox.NoSuchMailboxError as e:
440             pass
441         else:
442             messages = []
443             for key,msg in mbox.items():
444                 subpath = mbox._lookup(key)
445                 if subpath.endswith('.gitignore'):
446                     _LOG.debug('skipping non-message {}'.format(subpath))
447                     continue
448                 messages.append(msg)
449             messages.sort(key=_message_time)
450             for msg in messages:
451                 message.attach(_MIMEMessage(msg))
452     return _construct_email(
453         author=course.robot, targets=[person], subject=subject,
454         message=message)
455
456 def _get_admin_email(basedir, course, person, subject):
457     try:
458         student = _get_subject_student(course, subject)
459     except _InvalidStudentSubject as error:
460         if error.students:  # several students
461             raise
462         # no students
463         _LOG.debug('construct course grades email for {}'.format(person))
464         stream = _io.StringIO()
465         _tabulate(
466             course=course, statistics=True, stream=stream, use_color=False)
467         text = stream.getvalue()
468         email = _construct_text_email(
469             author=course.robot, targets=[person],
470             subject='All grades for {}'.format(course.name),
471             text=text)
472     else:  # a single student
473         try:
474             assignment = _get_subject_assignment(course, subject)
475         except _InvalidAssignmentSubject as error:
476             if error.assignments:  # several assignments
477                 email = _get_student_submission_email(
478                     basedir=basedir, course=course, person=person,
479                     student=student, assignments=error.assignments)
480             else: # no assignments
481                 email = _get_student_email(
482                     basedir=basedir, course=course, person=person,
483                     student=student)
484         else:  # a single assignment
485             email = _get_student_submission_email(
486                 basedir=basedir, course=course, person=person, student=student,
487                 assignments=[assignment])
488     return email