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