911b26e31c5391c483a336b42d2ead2943ffbb1d
[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, 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, message=message,
65     ...     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, message=message,
83     ...     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, message=message,
98     ...     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, message=message,
129     ...     person=person, subject='[get]', max_late=0)
130     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
131     respond with:
132     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
133     MIME-Version: 1.0
134     Content-Disposition: inline
135     Date: ...
136     From: Robot101 <phys101@tower.edu>
137     Reply-to: Robot101 <phys101@tower.edu>
138     To: Bilbo Baggins <bb@shire.org>
139     Subject: Physics 101 grades
140     <BLANKLINE>
141     --===============...==
142     Content-Type: text/plain; charset="us-ascii"
143     MIME-Version: 1.0
144     Content-Transfer-Encoding: 7bit
145     Content-Disposition: inline
146     <BLANKLINE>
147     Billy,
148     <BLANKLINE>
149     Grades:
150       * Attendance 1:  1 out of 1 available points.
151       * Attendance 2:  1 out of 1 available points.
152       * Assignment 1:  10 out of 10 available points.
153     <BLANKLINE>
154     Comments:
155     <BLANKLINE>
156     Assignment 1
157     <BLANKLINE>
158     Looks good.
159     <BLANKLINE>
160     Yours,
161     phys-101 robot
162     --===============...==
163     MIME-Version: 1.0
164     Content-Transfer-Encoding: 7bit
165     Content-Description: OpenPGP digital signature
166     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
167     <BLANKLINE>
168     -----BEGIN PGP SIGNATURE-----
169     Version: GnuPG v2.0.19 (GNU/Linux)
170     <BLANKLINE>
171     ...
172     -----END PGP SIGNATURE-----
173     <BLANKLINE>
174     --===============...==--
175
176     Professors and TAs can request the grades for the whole course.
177
178     >>> student = person
179     >>> person = list(
180     ...     course.course.find_people(email='eye@tower.edu'))[0]
181     >>> person.pgp_key = None
182     >>> process(
183     ...     basedir=course.basedir, course=course.course, message=message,
184     ...     person=person, subject='[get]', max_late=0)
185     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
186     respond with:
187     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
188     MIME-Version: 1.0
189     Content-Disposition: inline
190     Date: ...
191     From: Robot101 <phys101@tower.edu>
192     Reply-to: Robot101 <phys101@tower.edu>
193     To: Sauron <eye@tower.edu>
194     Subject: All grades for Physics 101
195     <BLANKLINE>
196     --===============...==
197     Content-Type: text/plain; charset="us-ascii"
198     MIME-Version: 1.0
199     Content-Transfer-Encoding: 7bit
200     Content-Disposition: inline
201     <BLANKLINE>
202     Student  Attendance 1  Attendance 2  Assignment 1
203     Bilbo Baggins  1  1  10
204     --
205     Mean  1.00  1.00  10.00
206     Std. Dev.  0.00  0.00  0.00
207     <BLANKLINE>
208     --===============...==
209     MIME-Version: 1.0
210     Content-Transfer-Encoding: 7bit
211     Content-Description: OpenPGP digital signature
212     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
213     <BLANKLINE>
214     -----BEGIN PGP SIGNATURE-----
215     Version: GnuPG v2.0.19 (GNU/Linux)
216     <BLANKLINE>
217     ...
218     -----END PGP SIGNATURE-----
219     <BLANKLINE>
220     --===============...==--
221
222     They can also request grades for a particular student.
223
224     >>> process(
225     ...     basedir=course.basedir, course=course.course, message=message,
226     ...     person=person, subject='[get] {}'.format(student.name),
227     ...     max_late=0)
228     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
229     respond with:
230     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
231     MIME-Version: 1.0
232     Content-Disposition: inline
233     Date: ...
234     From: Robot101 <phys101@tower.edu>
235     Reply-to: Robot101 <phys101@tower.edu>
236     To: Sauron <eye@tower.edu>
237     Subject: Physics 101 grades for Bilbo Baggins
238     <BLANKLINE>
239     --===============...==
240     Content-Type: text/plain; charset="us-ascii"
241     MIME-Version: 1.0
242     Content-Transfer-Encoding: 7bit
243     Content-Disposition: inline
244     <BLANKLINE>
245     Saury,
246     <BLANKLINE>
247     Grades:
248       * Attendance 1:  1 out of 1 available points.
249       * Attendance 2:  1 out of 1 available points.
250       * Assignment 1:  10 out of 10 available points.
251     <BLANKLINE>
252     Comments:
253     <BLANKLINE>
254     Assignment 1
255     <BLANKLINE>
256     Looks good.
257     <BLANKLINE>
258     Yours,
259     phys-101 robot
260     --===============...==
261     MIME-Version: 1.0
262     Content-Transfer-Encoding: 7bit
263     Content-Description: OpenPGP digital signature
264     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
265     <BLANKLINE>
266     -----BEGIN PGP SIGNATURE-----
267     Version: GnuPG v2.0.19 (GNU/Linux)
268     <BLANKLINE>
269     ...
270     -----END PGP SIGNATURE-----
271     <BLANKLINE>
272     --===============...==--
273
274     They can also request every submission for a particular student on
275     a particular assignment.  Lets give the student a submission email
276     to see how that works.
277
278     >>> from .submission import run as _handle_submission
279     >>> submission = encodedMIMEText('The answer is 42.')
280     >>> submission['Message-ID'] = '<789.abc@home.net>'
281     >>> submission['Received'] = (
282     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
283     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
284     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
285     >>> try:
286     ...     _handle_submission(
287     ...         basedir=course.basedir, course=course.course,
288     ...         message=submission, person=student,
289     ...         subject='[submit] Assignment 1')
290     ... except _Response:
291     ...     pass
292
293     Now lets request the submissions.
294
295     >>> process(
296     ...     basedir=course.basedir, course=course.course, message=message,
297     ...     person=person,
298     ...     subject='[get] {}, {}'.format(student.name, 'Assignment 1'),
299     ...     max_late=0)
300     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
301     respond with:
302     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
303     MIME-Version: 1.0
304     Content-Disposition: inline
305     Date: ...
306     From: Robot101 <phys101@tower.edu>
307     Reply-to: Robot101 <phys101@tower.edu>
308     To: Sauron <eye@tower.edu>
309     Subject: Physics 101 assignment submissions for Bilbo Baggins
310     <BLANKLINE>
311     --===============...==
312     Content-Type: multipart/mixed; boundary="===============...=="
313     MIME-Version: 1.0
314     <BLANKLINE>
315     --===============...==
316     Content-Type: text/plain; charset="us-ascii"
317     MIME-Version: 1.0
318     Content-Transfer-Encoding: 7bit
319     Content-Disposition: inline
320     <BLANKLINE>
321     Physics 101 assignment submissions for Bilbo Baggins:
322       * Assignment 1
323     <BLANKLINE>
324     --===============...==
325     Content-Type: text/plain; charset="us-ascii"
326     MIME-Version: 1.0
327     Content-Transfer-Encoding: 7bit
328     Content-Disposition: inline
329     <BLANKLINE>
330     Assignment 1 grade: 10
331     <BLANKLINE>
332     Looks good.
333     <BLANKLINE>
334     --===============...==
335     Content-Type: message/rfc822
336     MIME-Version: 1.0
337     <BLANKLINE>
338     Content-Type: text/plain; charset="us-ascii"
339     MIME-Version: 1.0
340     Content-Transfer-Encoding: 7bit
341     Content-Disposition: inline
342     Message-ID: <789.abc@home.net>
343     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)
344     <BLANKLINE>
345     The answer is 42.
346     --===============...==--
347     --===============...==
348     MIME-Version: 1.0
349     Content-Transfer-Encoding: 7bit
350     Content-Description: OpenPGP digital signature
351     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
352     <BLANKLINE>
353     -----BEGIN PGP SIGNATURE-----
354     Version: GnuPG v2.0.19 (GNU/Linux)
355     <BLANKLINE>
356     ...
357     -----END PGP SIGNATURE-----
358     <BLANKLINE>
359     --===============...==--
360
361     >>> course.cleanup()
362     """
363     if trust_email_infrastructure:
364         authenticated = True
365     else:
366         authenticated = (
367             hasattr(message, 'authenticated') and message.authenticated)
368     if not authenticated:
369         raise _UnsignedMessage()
370     if 'assistants' in person.groups or 'professors' in person.groups:
371         email = _get_admin_email(
372             basedir=basedir, course=course, person=person, subject=subject,
373             use_color=use_color)
374     elif 'students' in person.groups:
375         email = _get_student_email(
376             basedir=basedir, course=course, person=person,
377             use_color=use_color)
378     else:
379         raise NotImplementedError(
380             'strange groups {} for {}'.format(person.groups, person))
381     raise _Response(message=email)
382
383 def _get_student_email(basedir, course, person, student=None, use_color=None):
384     if student is None:
385         student = person
386         targets = None
387     else:
388         targets = [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)
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, use_color=None):
412     subject = '{} assignment submissions for {}'.format(
413         course.name, student.name)
414     text = '{}:\n  * {}\n'.format(
415         subject, '\n  * '.join(a.name for a in assignments))
416     message = _MIMEMultipart('mixed')
417     message.attach(_pgp_mime.encodedMIMEText(text))
418     for assignment in assignments:
419         grade = course.grade(student=student, assignment=assignment)
420         if grade is not None:
421             message.attach(_pgp_mime.encodedMIMEText(
422                     '{} grade: {}\n\n{}\n'.format(
423                         assignment.name, grade.points, grade.comment)))
424         assignment_path = _assignment_path(basedir, assignment, student)
425         mpath = _os_path.join(assignment_path, 'mail')
426         try:
427             mbox = _mailbox.Maildir(mpath, factory=None, create=False)
428         except _mailbox.NoSuchMailboxError as e:
429             pass
430         else:
431             for msg in mbox:
432                 message.attach(_MIMEMessage(msg))
433     return _construct_email(
434         author=course.robot, targets=[person], subject=subject,
435         message=message)
436
437 def _get_admin_email(basedir, course, person, subject, use_color=True):
438     lsubject = subject.lower()
439     students = [p for p in course.find_people()
440                 if p.name.lower() in lsubject]
441     if len(students) == 0:
442         stream = _io.StringIO()
443         _tabulate(course=course, statistics=True, stream=stream)
444         text = stream.getvalue()
445         email = _construct_text_email(
446             author=course.robot, targets=[person],
447             subject='All grades for {}'.format(course.name),
448             text=text)
449     elif len(students) == 1:
450         student = students[0]
451         assignments = [a for a in course.assignments
452                        if a.name.lower() in lsubject]
453         if len(assignments) == 0:
454             email = _get_student_email(
455                 basedir=basedir, course=course, person=person, student=student)
456         else:
457             email = _get_student_submission_email(
458                 basedir=basedir, course=course, person=person, student=student,
459                 assignments=assignments, use_color=use_color)
460     else:
461         raise InvalidStudent(students=students)
462     return email