3 """Handle information requests
5 Allow professors, TAs, and students to request grade information via email.
8 from email.mime.message import MIMEMessage as _MIMEMessage
9 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
11 import mailbox as _mailbox
12 import os.path as _os_path
14 import pgp_mime as _pgp_mime
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
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
37 def run(basedir, course, message, person, subject,
38 trust_email_infrastructure=False, dry_run=False, **kwargs):
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()
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):
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__))
59 Unauthenticated messages are refused by default.
62 ... basedir=course.basedir, course=course.course, message=message,
63 ... person=person, subject='[get]', max_late=0)
64 UnsignedMessage error:
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.
77 Students without grades get a reasonable response.
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
85 Content-Type: text/plain; charset="us-ascii"
87 Content-Transfer-Encoding: 7bit
88 Content-Disposition: inline
89 Subject: No grades for Billy
91 We don't have any of your grades on file for this course.
93 >>> message.authenticated = True
95 ... basedir=course.basedir, course=course.course, message=message,
96 ... person=person, subject='[get]', max_late=0)
97 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
99 Content-Type: text/plain; charset="us-ascii"
101 Content-Transfer-Encoding: 7bit
102 Content-Disposition: inline
103 Subject: No grades for Billy
105 We don't have any of your grades on file for this course.
107 Once we add a grade, they get details on all their grades for the
112 ... assignment=course.course.assignment('Attendance 1'),
114 >>> course.course.grades.append(grade)
117 ... assignment=course.course.assignment('Attendance 2'),
119 >>> course.course.grades.append(grade)
122 ... assignment=course.course.assignment('Assignment 1'),
123 ... points=10, comment='Looks good.')
124 >>> course.course.grades.append(grade)
126 ... basedir=course.basedir, course=course.course, message=message,
127 ... person=person, subject='[get]', max_late=0)
128 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
130 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
132 Content-Disposition: inline
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
139 --===============...==
140 Content-Type: text/plain; charset="us-ascii"
142 Content-Transfer-Encoding: 7bit
143 Content-Disposition: inline
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.
160 --===============...==
162 Content-Transfer-Encoding: 7bit
163 Content-Description: OpenPGP digital signature
164 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
166 -----BEGIN PGP SIGNATURE-----
167 Version: GnuPG v2.0.19 (GNU/Linux)
170 -----END PGP SIGNATURE-----
172 --===============...==--
174 Professors and TAs can request the grades for the whole course.
178 ... course.course.find_people(email='eye@tower.edu'))[0]
179 >>> person.pgp_key = None
181 ... basedir=course.basedir, course=course.course, message=message,
182 ... person=person, subject='[get]', max_late=0)
183 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
185 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
187 Content-Disposition: inline
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
194 --===============...==
195 Content-Type: text/plain; charset="us-ascii"
197 Content-Transfer-Encoding: 7bit
198 Content-Disposition: inline
200 Student Attendance 1 Attendance 2 Assignment 1
204 Std. Dev. 0.00 0.00 0.00
206 --===============...==
208 Content-Transfer-Encoding: 7bit
209 Content-Description: OpenPGP digital signature
210 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
212 -----BEGIN PGP SIGNATURE-----
213 Version: GnuPG v2.0.19 (GNU/Linux)
216 -----END PGP SIGNATURE-----
218 --===============...==--
220 They can also request grades for a particular student.
223 ... basedir=course.basedir, course=course.course, message=message,
224 ... person=person, subject='[get] {}'.format(student.name),
226 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
228 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
230 Content-Disposition: inline
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
237 --===============...==
238 Content-Type: text/plain; charset="us-ascii"
240 Content-Transfer-Encoding: 7bit
241 Content-Disposition: inline
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.
258 --===============...==
260 Content-Transfer-Encoding: 7bit
261 Content-Description: OpenPGP digital signature
262 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
264 -----BEGIN PGP SIGNATURE-----
265 Version: GnuPG v2.0.19 (GNU/Linux)
268 -----END PGP SIGNATURE-----
270 --===============...==--
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.
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)')
284 ... _handle_submission(
285 ... basedir=course.basedir, course=course.course,
286 ... message=submission, person=student,
287 ... subject='[submit] Assignment 1')
288 ... except _Response:
291 Now lets request the submissions.
294 ... basedir=course.basedir, course=course.course, message=message,
296 ... subject='[get] {}, {}'.format(student.name, 'Assignment 1'),
298 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
300 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
302 Content-Disposition: inline
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
309 --===============...==
310 Content-Type: multipart/mixed; boundary="===============...=="
313 --===============...==
314 Content-Type: text/plain; charset="us-ascii"
316 Content-Transfer-Encoding: 7bit
317 Content-Disposition: inline
319 Physics 101 assignment submissions for Bilbo Baggins:
322 --===============...==
323 Content-Type: text/plain; charset="us-ascii"
325 Content-Transfer-Encoding: 7bit
326 Content-Disposition: inline
328 Assignment 1 grade: 10
332 --===============...==
333 Content-Type: message/rfc822
336 Content-Type: text/plain; charset="us-ascii"
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)
344 --===============...==--
345 --===============...==
347 Content-Transfer-Encoding: 7bit
348 Content-Description: OpenPGP digital signature
349 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
351 -----BEGIN PGP SIGNATURE-----
352 Version: GnuPG v2.0.19 (GNU/Linux)
355 -----END PGP SIGNATURE-----
357 --===============...==--
361 if trust_email_infrastructure:
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)
375 raise NotImplementedError(
376 'strange groups {} for {}'.format(person.groups, person))
377 raise _Response(message=email, complete=True)
379 def _get_student_email(basedir, course, person, student=None):
383 _LOG.debug('construct student grade email about {} for {}'.format(
387 _LOG.debug('construct student grade email about {} for {}'.format(
389 emails = list(_student_email(
390 basedir=basedir, author=course.robot, course=course,
391 student=student, targets=targets, old=True))
395 "We don't have any of your grades on file for this course."
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
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)
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')
430 mbox = _mailbox.Maildir(mpath, factory=None, create=False)
431 except _mailbox.NoSuchMailboxError as e:
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))
441 messages.sort(key=_message_time)
443 message.attach(_MIMEMessage(msg))
444 return _construct_email(
445 author=course.robot, targets=[person], subject=subject,
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()
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),
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)
470 email = _get_student_submission_email(
471 basedir=basedir, course=course, person=person, student=student,
472 assignments=assignments)
474 raise InvalidStudent(students=students)