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