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