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