1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
3 # This file is part of pygrader.
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader. If not, see <http://www.gnu.org/licenses/>.
17 """Handle information requests
19 Allow professors, TAs, and students to request grade information via email.
22 from email.mime.message import MIMEMessage as _MIMEMessage
23 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
25 import mailbox as _mailbox
26 import os.path as _os_path
28 import pgp_mime as _pgp_mime
30 from .. import LOG as _LOG
31 from ..email import construct_text_email as _construct_text_email
32 from ..email import construct_email as _construct_email
33 from ..extract_mime import message_time as _message_time
34 from ..storage import assignment_path as _assignment_path
35 from ..tabulate import tabulate as _tabulate
36 from ..template import _student_email as _student_email
37 from . import get_subject_assignment as _get_subject_assignment
38 from . import get_subject_student as _get_subject_student
39 from . import InvalidStudentSubject as _InvalidStudentSubject
40 from . import InvalidAssignmentSubject as _InvalidAssignmentSubject
41 from . import Response as _Response
42 from . import UnsignedMessage as _UnsignedMessage
45 def run(basedir, course, message, person, subject,
46 trust_email_infrastructure=False, dry_run=False, **kwargs):
48 >>> from pgp_mime.email import encodedMIMEText
49 >>> from ..model.grade import Grade
50 >>> from ..test.course import StubCourse
51 >>> from . import InvalidMessage, Response
52 >>> course = StubCourse()
54 ... course.course.find_people(email='bb@greyhavens.net'))[0]
55 >>> message = encodedMIMEText('This text is not important.')
56 >>> message['Message-ID'] = '<123.456@home.net>'
57 >>> def process(**kwargs):
60 ... except Response as response:
61 ... print('respond with:')
62 ... print(response.message.as_string().replace('\\t', ' '))
63 ... except InvalidMessage as error:
64 ... print('{} error:'.format(type(error).__name__))
67 Unauthenticated messages are refused by default.
70 ... basedir=course.basedir, course=course.course, message=message,
71 ... person=person, subject='[get]', max_late=0)
72 UnsignedMessage error:
75 Although you can process them by setting the
76 ``trust_email_infrastructure`` option. This might not be too
77 dangerous, since you're sending the email to the user's configured
78 email address, not just replying blindly to the incoming email
79 address. With ``trust_email_infrastructure`` and missing user PGP
80 keys, sysadmins on the intervening systems will be able to read
81 our responses, possibly leaking grade information. If leaking to
82 sysadmins is considered unacceptable, you've can only email users
83 who have registered PGP keys.
85 Students without grades get a reasonable response.
88 ... basedir=course.basedir, course=course.course, message=message,
89 ... person=person, subject='[get]', max_late=0,
90 ... trust_email_infrastructure=True)
91 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
93 Content-Type: text/plain; charset="us-ascii"
95 Content-Transfer-Encoding: 7bit
96 Content-Disposition: inline
97 Subject: No grades for Billy
99 We don't have any of your grades on file for this course.
101 >>> message.authenticated = True
103 ... basedir=course.basedir, course=course.course, message=message,
104 ... person=person, subject='[get]', max_late=0)
105 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
107 Content-Type: text/plain; charset="us-ascii"
109 Content-Transfer-Encoding: 7bit
110 Content-Disposition: inline
111 Subject: No grades for Billy
113 We don't have any of your grades on file for this course.
115 Once we add a grade, they get details on all their grades for the
120 ... assignment=course.course.assignment('Attendance 1'),
122 >>> course.course.grades.append(grade)
125 ... assignment=course.course.assignment('Attendance 2'),
127 >>> course.course.grades.append(grade)
130 ... assignment=course.course.assignment('Assignment 1'),
131 ... points=10, comment='Looks good.')
132 >>> course.course.grades.append(grade)
134 ... basedir=course.basedir, course=course.course, message=message,
135 ... person=person, subject='[get]', max_late=0)
136 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
138 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
140 Content-Disposition: inline
142 From: Robot101 <phys101@tower.edu>
143 Reply-to: Robot101 <phys101@tower.edu>
144 To: Bilbo Baggins <bb@shire.org>
145 Subject: Physics 101 grades
147 --===============...==
148 Content-Type: text/plain; charset="us-ascii"
150 Content-Transfer-Encoding: 7bit
151 Content-Disposition: inline
156 * Attendance 1: 1 out of 1 available points.
157 * Attendance 2: 1 out of 1 available points.
158 * Assignment 1: 10 out of 10 available points.
168 --===============...==
170 Content-Transfer-Encoding: 7bit
171 Content-Description: OpenPGP digital signature
172 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
174 -----BEGIN PGP SIGNATURE-----
175 Version: GnuPG v2.0.19 (GNU/Linux)
178 -----END PGP SIGNATURE-----
180 --===============...==--
182 Professors and TAs can request the grades for the whole course.
186 ... course.course.find_people(email='eye@tower.edu'))[0]
187 >>> person.pgp_key = None
189 ... basedir=course.basedir, course=course.course, message=message,
190 ... person=person, subject='[get]', max_late=0)
191 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
193 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
195 Content-Disposition: inline
197 From: Robot101 <phys101@tower.edu>
198 Reply-to: Robot101 <phys101@tower.edu>
199 To: Sauron <eye@tower.edu>
200 Subject: All grades for Physics 101
202 --===============...==
203 Content-Type: text/plain; charset="us-ascii"
205 Content-Transfer-Encoding: 7bit
206 Content-Disposition: inline
208 Student Attendance 1 Attendance 2 Assignment 1
212 Std. Dev. 0.00 0.00 0.00
214 --===============...==
216 Content-Transfer-Encoding: 7bit
217 Content-Description: OpenPGP digital signature
218 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
220 -----BEGIN PGP SIGNATURE-----
221 Version: GnuPG v2.0.19 (GNU/Linux)
224 -----END PGP SIGNATURE-----
226 --===============...==--
228 They can also request grades for a particular student.
231 ... basedir=course.basedir, course=course.course, message=message,
232 ... person=person, subject='[get] {}'.format(student.name),
234 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
236 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
238 Content-Disposition: inline
240 From: Robot101 <phys101@tower.edu>
241 Reply-to: Robot101 <phys101@tower.edu>
242 To: Sauron <eye@tower.edu>
243 Subject: Physics 101 grades for Bilbo Baggins
245 --===============...==
246 Content-Type: text/plain; charset="us-ascii"
248 Content-Transfer-Encoding: 7bit
249 Content-Disposition: inline
254 * Attendance 1: 1 out of 1 available points.
255 * Attendance 2: 1 out of 1 available points.
256 * Assignment 1: 10 out of 10 available points.
266 --===============...==
268 Content-Transfer-Encoding: 7bit
269 Content-Description: OpenPGP digital signature
270 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
272 -----BEGIN PGP SIGNATURE-----
273 Version: GnuPG v2.0.19 (GNU/Linux)
276 -----END PGP SIGNATURE-----
278 --===============...==--
280 They can also request every submission for a particular student on
281 a particular assignment. Lets give the student a submission email
282 to see how that works.
284 >>> from .submission import run as _handle_submission
285 >>> submission = encodedMIMEText('The answer is 42.')
286 >>> submission['Message-ID'] = '<789.abc@home.net>'
287 >>> submission['Received'] = (
288 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
289 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
290 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
292 ... _handle_submission(
293 ... basedir=course.basedir, course=course.course,
294 ... message=submission, person=student,
295 ... subject='[submit] Assignment 1')
296 ... except _Response:
299 Now lets request the submissions.
302 ... basedir=course.basedir, course=course.course, message=message,
304 ... subject='[get] {}, {}'.format(student.name, 'Assignment 1'),
306 ... # doctest: +ELLIPSIS, +REPORT_UDIFF
308 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
310 Content-Disposition: inline
312 From: Robot101 <phys101@tower.edu>
313 Reply-to: Robot101 <phys101@tower.edu>
314 To: Sauron <eye@tower.edu>
315 Subject: Physics 101 assignment submissions for Bilbo Baggins
317 --===============...==
318 Content-Type: multipart/mixed; boundary="===============...=="
321 --===============...==
322 Content-Type: text/plain; charset="us-ascii"
324 Content-Transfer-Encoding: 7bit
325 Content-Disposition: inline
327 Physics 101 assignment submissions for Bilbo Baggins:
330 --===============...==
331 Content-Type: text/plain; charset="us-ascii"
333 Content-Transfer-Encoding: 7bit
334 Content-Disposition: inline
336 Assignment 1 grade: 10
340 --===============...==
341 Content-Type: message/rfc822
344 Content-Type: text/plain; charset="us-ascii"
346 Content-Transfer-Encoding: 7bit
347 Content-Disposition: inline
348 Message-ID: <789.abc@home.net>
349 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)
352 --===============...==--
353 --===============...==
355 Content-Transfer-Encoding: 7bit
356 Content-Description: OpenPGP digital signature
357 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
359 -----BEGIN PGP SIGNATURE-----
360 Version: GnuPG v2.0.19 (GNU/Linux)
363 -----END PGP SIGNATURE-----
365 --===============...==--
369 if trust_email_infrastructure:
373 hasattr(message, 'authenticated') and message.authenticated)
374 if not authenticated:
375 raise _UnsignedMessage()
376 if 'assistants' in person.groups or 'professors' in person.groups:
377 email = _get_admin_email(
378 basedir=basedir, course=course, person=person, subject=subject)
379 elif 'students' in person.groups:
380 email = _get_student_email(
381 basedir=basedir, course=course, person=person)
383 raise NotImplementedError(
384 'strange groups {} for {}'.format(person.groups, person))
385 raise _Response(message=email, complete=True)
387 def _get_student_email(basedir, course, person, student=None):
391 _LOG.debug('construct student grade email about {} for {}'.format(
395 _LOG.debug('construct student grade email about {} for {}'.format(
397 emails = list(_student_email(
398 basedir=basedir, author=course.robot, course=course,
399 student=student, targets=targets, old=True))
403 "We don't have any of your grades on file for this course."
407 "We don't have any grades for {} on file for this course."
408 ).format(student.name)
409 message = _pgp_mime.encodedMIMEText(text)
410 message['Subject'] = 'No grades for {}'.format(student.alias())
411 raise _Response(message=message, complete=True)
412 elif len(emails) > 1:
413 raise NotImplementedError(emails)
414 email,callback = emails[0]
415 # callback records notification, but don't bother here
418 def _get_student_submission_email(
419 basedir, course, person, assignments, student):
420 _LOG.debug('construct student submission email about {} {} for {}'.format(
421 student, assignments, person))
422 subject = '{} assignment submissions for {}'.format(
423 course.name, student.name)
424 text = '{}:\n * {}\n'.format(
425 subject, '\n * '.join(a.name for a in assignments))
426 message = _MIMEMultipart('mixed')
427 message.attach(_pgp_mime.encodedMIMEText(text))
428 for assignment in assignments:
429 grade = course.grade(student=student, assignment=assignment)
430 if grade is not None:
431 text = '{} grade: {}\n'.format(assignment.name, grade.points)
433 text += '\n{}\n'.format(grade.comment)
434 message.attach(_pgp_mime.encodedMIMEText(text))
435 assignment_path = _assignment_path(basedir, assignment, student)
436 mpath = _os_path.join(assignment_path, 'mail')
438 mbox = _mailbox.Maildir(mpath, factory=None, create=False)
439 except _mailbox.NoSuchMailboxError as e:
443 for key,msg in mbox.items():
444 subpath = mbox._lookup(key)
445 if subpath.endswith('.gitignore'):
446 _LOG.debug('skipping non-message {}'.format(subpath))
449 messages.sort(key=_message_time)
451 message.attach(_MIMEMessage(msg))
452 return _construct_email(
453 author=course.robot, targets=[person], subject=subject,
456 def _get_admin_email(basedir, course, person, subject):
458 student = _get_subject_student(course, subject)
459 except _InvalidStudentSubject as error:
460 if error.students: # several students
463 _LOG.debug('construct course grades email for {}'.format(person))
464 stream = _io.StringIO()
466 course=course, statistics=True, stream=stream, use_color=False)
467 text = stream.getvalue()
468 email = _construct_text_email(
469 author=course.robot, targets=[person],
470 subject='All grades for {}'.format(course.name),
472 else: # a single student
474 assignment = _get_subject_assignment(course, subject)
475 except _InvalidAssignmentSubject as error:
476 if error.assignments: # several assignments
477 email = _get_student_submission_email(
478 basedir=basedir, course=course, person=person,
479 student=student, assignments=error.assignments)
480 else: # no assignments
481 email = _get_student_email(
482 basedir=basedir, course=course, person=person,
484 else: # a single assignment
485 email = _get_student_submission_email(
486 basedir=basedir, course=course, person=person, student=student,
487 assignments=[assignment])