c6aa8ce4a9ac7029d3fb7a2d017e503ff4a11084
[pygrader.git] / pygrader / handler / grade.py
1 # Copyright
2
3 """Handle grade assignment
4
5 Allow professors and TAs to assign grades via email.
6 """
7
8 import io as _io
9 import mailbox as _mailbox
10 import os.path as _os_path
11
12 import pgp_mime as _pgp_mime
13
14 from .. import LOG as _LOG
15 from ..email import construct_text_email as _construct_text_email
16 from ..extract_mime import message_time as _message_time
17 from ..model.grade import Grade as _Grade
18 from ..storage import load_grade as _load_grade
19 from ..storage import parse_grade as _parse_grade
20 from ..storage import save_grade as _save_grade
21 from . import InvalidMessage as _InvalidMessage
22 from . import get_subject_assignment as _get_subject_assignment
23 from . import get_subject_student as _get_subject_student
24 from . import PermissionViolationMessage as _PermissionViolationMessage
25 from . import Response as _Response
26 from . import UnsignedMessage as _UnsignedMessage
27
28
29 class MissingGradeMessage (_InvalidMessage):
30     def __init__(self, **kwargs):
31         if 'error' not in kwargs:
32             kwargs['error'] = 'missing grade'
33         super(MissingGradeMessage, self).__init__(**kwargs)
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 ..test.course import StubCourse
41     >>> from . import InvalidMessage, Response
42     >>> course = StubCourse()
43     >>> person = list(
44     ...     course.course.find_people(email='eye@tower.edu'))[0]
45     >>> message = encodedMIMEText('10')
46     >>> message['Message-ID'] = '<123.456@home.net>'
47     >>> def process(**kwargs):
48     ...     try:
49     ...         run(**kwargs)
50     ...     except Response as response:
51     ...         print('respond with:')
52     ...         print(response.message.as_string().replace('\\t', '  '))
53     ...     except InvalidMessage as error:
54     ...         print('{} error:'.format(type(error).__name__))
55     ...         print(error)
56
57     Message authentication is handled identically to the ``get`` module.
58
59     >>> process(
60     ...     basedir=course.basedir, course=course.course, message=message,
61     ...     person=person, subject='[grade]')
62     UnsignedMessage error:
63     unsigned message
64
65     Students are denied access:
66
67     >>> student = list(
68     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
69     >>> process(
70     ...     basedir=course.basedir, course=course.course, message=message,
71     ...     person=student, subject='[grade]',
72     ...     trust_email_infrastructure=True)
73     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
74     PermissionViolationMessage error:
75     action not permitted
76
77     >>> person.pgp_key = None  # so we have plain-text to doctest
78     >>> assignment = course.course.assignments[0]
79     >>> message.authenticated = True
80     >>> process(
81     ...     basedir=course.basedir, course=course.course, message=message,
82     ...     person=person, subject='[grade] {}, {}'.format(student.name, assignment.name))
83     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
84     respond with:
85     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
86     MIME-Version: 1.0
87     Content-Disposition: inline
88     Date: ...
89     From: Robot101 <phys101@tower.edu>
90     Reply-to: Robot101 <phys101@tower.edu>
91     To: Sauron <eye@tower.edu>
92     Subject: Set Bilbo Baggins grade on Attendance 1 to 10.0
93     <BLANKLINE>
94     --===============...==
95     Content-Type: text/plain; charset="us-ascii"
96     MIME-Version: 1.0
97     Content-Transfer-Encoding: 7bit
98     Content-Disposition: inline
99     <BLANKLINE>
100     Set comment to:
101     <BLANKLINE>
102     None
103     <BLANKLINE>
104     --===============...==
105     MIME-Version: 1.0
106     Content-Transfer-Encoding: 7bit
107     Content-Description: OpenPGP digital signature
108     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
109     <BLANKLINE>
110     -----BEGIN PGP SIGNATURE-----
111     Version: GnuPG v2.0.19 (GNU/Linux)
112     <BLANKLINE>
113     ...
114     -----END PGP SIGNATURE-----
115     <BLANKLINE>
116     --===============...==--
117
118     >>> message = encodedMIMEText('9\\n\\nUnits!')
119     >>> message['Message-ID'] = '<123.456@home.net>'
120     >>> message.authenticated = True
121     >>> process(
122     ...     basedir=course.basedir, course=course.course, message=message,
123     ...     person=person, subject='[grade] {}, {}'.format(student.name, assignment.name))
124     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
125     respond with:
126     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
127     MIME-Version: 1.0
128     Content-Disposition: inline
129     Date: ...
130     From: Robot101 <phys101@tower.edu>
131     Reply-to: Robot101 <phys101@tower.edu>
132     To: Sauron <eye@tower.edu>
133     Subject: Set Bilbo Baggins grade on Attendance 1 to 9.0
134     <BLANKLINE>
135     --===============...==
136     Content-Type: text/plain; charset="us-ascii"
137     MIME-Version: 1.0
138     Content-Transfer-Encoding: 7bit
139     Content-Disposition: inline
140     <BLANKLINE>
141     Set comment to:
142     <BLANKLINE>
143     Units!
144     <BLANKLINE>
145     --===============...==
146     MIME-Version: 1.0
147     Content-Transfer-Encoding: 7bit
148     Content-Description: OpenPGP digital signature
149     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
150     <BLANKLINE>
151     -----BEGIN PGP SIGNATURE-----
152     Version: GnuPG v2.0.19 (GNU/Linux)
153     <BLANKLINE>
154     ...
155     -----END PGP SIGNATURE-----
156     <BLANKLINE>
157     --===============...==--
158
159     >>> course.cleanup()
160     """
161     if trust_email_infrastructure:
162         authenticated = True
163     else:
164         authenticated = (
165             hasattr(message, 'authenticated') and message.authenticated)
166     if not authenticated:
167         raise _UnsignedMessage()
168     if not ('professors' in person.groups or 'assistants' in person.groups):
169         raise _PermissionViolationMessage(
170             person=person, allowed_groups=['professors', 'assistants'])
171     student = _get_subject_student(course=course, subject=subject)
172     assignment = _get_subject_assignment(course=course, subject=subject)
173     grade = _get_grade(
174         basedir=basedir, message=message, assignment=assignment,
175         student=student)
176     _LOG.info('set {} grade on {} to {}'.format(
177             student, assignment, grade.points))
178     if not dry_run:
179         _save_grade(basedir=basedir, grade=grade)
180     response = _construct_text_email(
181         author=course.robot, targets=[person],
182         subject='Set {} grade on {} to {}'.format(
183             student.name, assignment.name, grade.points),
184         text='Set comment to:\n\n{}\n'.format(grade.comment))
185     raise _Response(message=response, complete=True)
186
187 def _get_grade(basedir, message, assignment, student):
188     text = None
189     for part in message.walk():
190         if part.get_content_type() == 'text/plain':
191             charset = part.get_charset()
192             if charset is None:
193                 encoding = 'ascii'
194             else:
195                 encoding = charset.input_charset
196             text = str(part.get_payload(decode=True), encoding)
197     if text is None:
198         raise _MissingGradeMessage(message=message)
199     stream = _io.StringIO(text)
200     new_grade = _parse_grade(
201         stream=stream, assignment=assignment, person=student)
202     try:
203         old_grade = _load_grade(
204             basedir=basedir, assignment=assignment, person=student)
205     except IOError as error:
206         _LOG.warn(str(error))
207         old_grade = _Grade(student=student, assignment=assignment, points=0)
208     old_grade.points = new_grade.points
209     old_grade.comment = new_grade.comment
210     return old_grade