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