handler:get: only add comment to submission response if it exists.
[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, complete=True)
377
378 def _get_student_email(basedir, course, person, student=None):
379     if student is None:
380         student = person
381         targets = None
382         _LOG.debug('construct student grade email about {} for {}'.format(
383                 student, student))
384     else:
385         targets = [person]
386         _LOG.debug('construct student grade email about {} for {}'.format(
387                 student, person))
388     emails = list(_student_email(
389         basedir=basedir, author=course.robot, course=course,
390         student=student, targets=targets, old=True))
391     if len(emails) == 0:
392         if targets is None:
393             text = (
394                 "We don't have any of your grades on file for this course."
395                 )
396         else:
397             text = (
398                 "We don't have any grades for {} on file for this course."
399                 ).format(student.name)
400         message = _pgp_mime.encodedMIMEText(text)
401         message['Subject'] = 'No grades for {}'.format(student.alias())
402         raise _Response(message=message, complete=True)
403     elif len(emails) > 1:
404         raise NotImplementedError(emails)
405     email,callback = emails[0]
406     # callback records notification, but don't bother here
407     return email
408
409 def _get_student_submission_email(
410     basedir, course, person, assignments, student):
411     _LOG.debug('construct student submission email about {} {} for {}'.format(
412             student, assignments, person))
413     subject = '{} assignment submissions for {}'.format(
414         course.name, student.name)
415     text = '{}:\n  * {}\n'.format(
416         subject, '\n  * '.join(a.name for a in assignments))
417     message = _MIMEMultipart('mixed')
418     message.attach(_pgp_mime.encodedMIMEText(text))
419     for assignment in assignments:
420         grade = course.grade(student=student, assignment=assignment)
421         if grade is not None:
422             text = '{} grade: {}\n'.format(assignment.name, grade.points)
423             if grade.comment:
424                 text += '\n{}\n'.format(grade.comment)
425             message.attach(_pgp_mime.encodedMIMEText(text))
426         assignment_path = _assignment_path(basedir, assignment, student)
427         mpath = _os_path.join(assignment_path, 'mail')
428         try:
429             mbox = _mailbox.Maildir(mpath, factory=None, create=False)
430         except _mailbox.NoSuchMailboxError as e:
431             pass
432         else:
433             for msg in mbox:
434                 message.attach(_MIMEMessage(msg))
435     return _construct_email(
436         author=course.robot, targets=[person], subject=subject,
437         message=message)
438
439 def _get_admin_email(basedir, course, person, subject):
440     lsubject = subject.lower()
441     students = [p for p in course.find_people()
442                 if p.name.lower() in lsubject]
443     if len(students) == 0:
444         _LOG.debug('construct course grades email for {}'.format(person))
445         stream = _io.StringIO()
446         _tabulate(
447             course=course, statistics=True, stream=stream, use_color=False)
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, person=person, student=student)
460         else:
461             email = _get_student_submission_email(
462                 basedir=basedir, course=course, person=person, student=student,
463                 assignments=assignments)
464     else:
465         raise InvalidStudent(students=students)
466     return email