dc1ec3d957ba14c6688885cf822486aff52f6eab
[pygrader.git] / pygrader / template.py
1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
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 import io as _io
18
19 from jinja2 import Template
20
21 from . import LOG as _LOG
22 from .email import construct_email as _construct_email
23 from .email import send_emails as _send_emails
24 from .storage import set_notified as _set_notified
25 from .tabulate import tabulate as _tabulate
26
27
28 ASSIGNMENT_TEMPLATE = Template("""
29 {{ grade.student.alias() }},
30
31 You got {{ grade.points }} out of {{ grade.assignment.points }} available points on {{ grade.assignment.name }}.
32 {% if grade.comment %}
33 {{ grade.comment }}
34 {% endif %}
35 Yours,
36 {{ author.alias() }}
37 """.strip())
38 #{{ grade.comment|wordwrap }}
39
40 STUDENT_TEMPLATE = Template("""
41 {{ grades[0].student.alias() }},
42
43 Grades:
44 {%- for grade in grades %}
45   * {{ grade.assignment.name }}:\t{{ grade.points }} out of {{ grade.assignment.points }} available points.
46 {%- endfor %}
47
48 Comments:
49 {%- for grade in grades -%}{% if grade.comment %}
50
51 {{ grade.assignment.name }}
52
53 {{ grade.comment }}
54 {%- endif %}{% endfor %}
55
56 Yours,
57 {{ author.alias() }}
58 """.strip())
59
60 COURSE_TEMPLATE = Template("""
61 {{ target }},
62
63 Here are the (tab delimited) course grades to date:
64
65 {{ table }}
66 The available points (and weights) for each assignment are:
67 {%- for assignment in course.active_assignments() %}
68   * {{ assignment.name }}:\t{{ assignment.points }}\t{{ assignment.weight }}
69 {%- endfor %}
70
71 Yours,
72 {{ author.alias() }}
73 """.strip())
74
75
76
77
78 class NotifiedCallback (object):
79     """A callback for marking notifications with `_send_emails`
80     """
81     def __init__(self, basedir, grades):
82         self.basedir = basedir
83         self.grades = grades
84
85     def __call__(self, success):
86         if success:
87             for grade in self.grades:
88                 _set_notified(basedir=self.basedir, grade=grade)
89
90
91 def join_with_and(strings):
92     """Join a list of strings.
93
94     >>> join_with_and(['a','b','c'])
95     'a, b, and c'
96     >>> join_with_and(['a','b'])
97     'a and b'
98     >>> join_with_and(['a'])
99     'a'
100     """
101     ret = [strings[0]]
102     for i,s in enumerate(strings[1:]):
103         if len(strings) > 2:
104             ret.append(', ')
105         else:
106             ret.append(' ')
107         if i == len(strings)-2:
108             ret.append('and ')
109         ret.append(s)
110     return ''.join(ret)
111
112 def assignment_email(basedir, author, course, assignment, student=None,
113                      cc=None, smtp=None, use_color=False, debug_target=None,
114                      dry_run=False):
115     """Send each student an email with their grade on `assignment`
116     """
117     _send_emails(
118         emails=_assignment_email(
119             basedir=basedir, author=author, course=course,
120             assignment=assignment, student=student, cc=cc),
121         smtp=smtp, use_color=use_color,
122         debug_target=debug_target, dry_run=dry_run)
123
124 def _assignment_email(basedir, author, course, assignment, student=None,
125                       cc=None):
126     """Iterate through composed assignment `Message`\s
127     """
128     if student:
129         students = [student]
130     else:
131         students = course.people
132     for student in students:
133         try:
134             grade = course.grade(student=student, assignment=assignment)
135         except ValueError:
136             continue
137         if grade.notified:
138             continue
139         yield (construct_assignment_email(author=author, grade=grade, cc=cc),
140                NotifiedCallback(basedir=basedir, grades=[grade]))
141
142 def construct_assignment_email(author, grade, cc=None):
143     """Construct a `Message` notfiying a student of `grade`
144
145     >>> from pygrader.model.person import Person
146     >>> from pygrader.model.assignment import Assignment
147     >>> from pygrader.model.grade import Grade
148     >>> author = Person(name='Jack', emails=['a@b.net'])
149     >>> student = Person(name='Jill', emails=['c@d.net'])
150     >>> assignment = Assignment(name='Exam 1', points=3)
151     >>> grade = Grade(student=student, assignment=assignment, points=2)
152     >>> msg = construct_assignment_email(author=author, grade=grade)
153     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
154     Content-Type: text/plain; charset="us-ascii"
155     MIME-Version: 1.0
156     Content-Transfer-Encoding: 7bit
157     Content-Disposition: inline
158     Date: ...
159     From: Jack <a@b.net>
160     Reply-to: Jack <a@b.net>
161     To: Jill <c@d.net>
162     Subject: Your Exam 1 grade
163     <BLANKLINE>
164     Jill,
165     <BLANKLINE>
166     You got 2 out of 3 available points on Exam 1.
167     <BLANKLINE>
168     Yours,
169     Jack
170
171     >>> grade.comment = ('Some comment bla bla bla.').strip()
172     >>> msg = construct_assignment_email(author=author, grade=grade)
173     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
174     Content-Type: text/plain; charset="us-ascii"
175     MIME-Version: 1.0
176     Content-Transfer-Encoding: 7bit
177     Content-Disposition: inline
178     Date: ...
179     From: Jack <a@b.net>
180     Reply-to: Jack <a@b.net>
181     To: Jill <c@d.net>
182     Subject: Your Exam 1 grade
183     <BLANKLINE>
184     Jill,
185     <BLANKLINE>
186     You got 2 out of 3 available points on Exam 1.
187     <BLANKLINE>
188     Some comment bla bla bla.
189     <BLANKLINE>
190     Yours,
191     Jack
192     """
193     return _construct_email(
194         author=author, targets=[grade.student], cc=cc,
195         subject='Your {} grade'.format(grade.assignment.name),
196         text=ASSIGNMENT_TEMPLATE.render(author=author, grade=grade))
197
198 def student_email(basedir, author, course, student=None, cc=None, old=False,
199                   smtp=None, use_color=False, debug_target=None,
200                   dry_run=False):
201     """Send each student an email with their grade to date
202     """
203     _send_emails(
204         emails=_student_email(
205             basedir=basedir, author=author, course=course, student=student,
206             cc=cc, old=old),
207         smtp=smtp, use_color=use_color, debug_target=debug_target,
208         dry_run=dry_run)
209
210 def _student_email(basedir, author, course, student=None, cc=None, old=False):
211     """Iterate through composed student `Message`\s
212     """
213     if student:
214         students = [student]
215     else:
216         students = course.people
217     for student in students:
218         grades = [g for g in course.grades if g.student == student]
219         if not old:
220             grades = [g for g in grades if not g.notified]
221         if not grades:
222             continue
223         yield (construct_student_email(author=author, grades=grades, cc=cc),
224                NotifiedCallback(basedir=basedir, grades=grades))
225
226 def construct_student_email(author, grades, cc=None):
227     """Construct a `Message` notfiying a student of `grade`
228
229     >>> from pygrader.model.person import Person
230     >>> from pygrader.model.assignment import Assignment
231     >>> from pygrader.model.grade import Grade
232     >>> author = Person(name='Jack', emails=['a@b.net'])
233     >>> student = Person(name='Jill', emails=['c@d.net'])
234     >>> grades = []
235     >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
236     ...     assignment = Assignment(name=name, points=points)
237     ...     grade = Grade(
238     ...         student=student, assignment=assignment,
239     ...         points=int(points/2.0))
240     ...     grades.append(grade)
241     >>> msg = construct_student_email(author=author, grades=grades)
242     >>> print(msg.as_string().replace('\\t', '  '))
243     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
244     Content-Type: text/plain; charset="us-ascii"
245     MIME-Version: 1.0
246     Content-Transfer-Encoding: 7bit
247     Content-Disposition: inline
248     Date: ...
249     From: Jack <a@b.net>
250     Reply-to: Jack <a@b.net>
251     To: Jill <c@d.net>
252     Subject: Your grade
253     <BLANKLINE>
254     Jill,
255     <BLANKLINE>
256     Grades:
257       * Exam 1:  5 out of 10 available points.
258       * Homework 1:  1 out of 3 available points.
259     <BLANKLINE>
260     Comments:
261     <BLANKLINE>
262     Yours,
263     Jack
264
265     >>> grades[0].comment = ('Bla bla bla.  '*20).strip()
266     >>> grades[1].comment = ('Hello world')
267     >>> msg = construct_student_email(author=author, grades=grades)
268     >>> print(msg.as_string().replace('\\t', '  '))
269     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
270     Content-Type: text/plain; charset="us-ascii"
271     MIME-Version: 1.0
272     Content-Transfer-Encoding: 7bit
273     Content-Disposition: inline
274     Date: ...
275     From: Jack <a@b.net>
276     Reply-to: Jack <a@b.net>
277     To: Jill <c@d.net>
278     Subject: Your grade
279     <BLANKLINE>
280     Jill,
281     <BLANKLINE>
282     Grades:
283       * Exam 1:  5 out of 10 available points.
284       * Homework 1:  1 out of 3 available points.
285     <BLANKLINE>
286     Comments:
287     <BLANKLINE>
288     Exam 1
289     <BLANKLINE>
290     Hello world
291     <BLANKLINE>
292     Homework 1
293     <BLANKLINE>
294     Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.  Bla bla bla.
295     <BLANKLINE>
296     Yours,
297     Jack
298
299     >>> grades[0].comment = 'Work harder!'
300     >>> grades[1].comment = None
301     >>> msg = construct_student_email(author=author, grades=grades)
302     >>> print(msg.as_string().replace('\\t', '  '))
303     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
304     Content-Type: text/plain; charset="us-ascii"
305     MIME-Version: 1.0
306     Content-Transfer-Encoding: 7bit
307     Content-Disposition: inline
308     Date: ...
309     From: Jack <a@b.net>
310     Reply-to: Jack <a@b.net>
311     To: Jill <c@d.net>
312     Subject: Your grade
313     <BLANKLINE>
314     Jill,
315     <BLANKLINE>
316     Grades:
317       * Exam 1:  5 out of 10 available points.
318       * Homework 1:  1 out of 3 available points.
319     <BLANKLINE>
320     Comments:
321     <BLANKLINE>
322     Homework 1
323     <BLANKLINE>
324     Work harder!
325     <BLANKLINE>
326     Yours,
327     Jack
328     """
329     students = set(g.student for g in grades)
330     assert len(students) == 1, students
331     return _construct_email(
332         author=author, targets=[grades[0].student], cc=cc,
333         subject='Your grade',
334         text=STUDENT_TEMPLATE.render(author=author, grades=sorted(grades)))
335
336 def course_email(basedir, author, course, targets, assignment=None,
337                  student=None, cc=None, smtp=None, use_color=False,
338                  debug_target=None, dry_run=False):
339     """Send the professor an email with all student grades to date
340     """
341     _send_emails(
342         emails=_course_email(
343             basedir=basedir, author=author, course=course, targets=targets,
344             assignment=assignment, student=student, cc=cc),
345         smtp=smtp, use_color=use_color, debug_target=debug_target,
346         dry_run=dry_run)
347
348 def _course_email(basedir, author, course, targets, assignment=None,
349                   student=None, cc=None):
350     """Iterate through composed course `Message`\s
351     """
352     yield (construct_course_email(
353             author=author, course=course, targets=targets, cc=cc),
354            None)
355
356 def construct_course_email(author, course, targets, cc=None):
357     """Construct a `Message` notfiying a professor of all grades to date
358
359     >>> from pygrader.model.person import Person
360     >>> from pygrader.model.assignment import Assignment
361     >>> from pygrader.model.grade import Grade
362     >>> from pygrader.model.course import Course
363     >>> author = Person(name='Jack', emails=['a@b.net'])
364     >>> student = Person(name='Jill', emails=['c@d.net'])
365     >>> prof = Person(name='H.D.', emails=['hd@wall.net'])
366     >>> grades = []
367     >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
368     ...     assignment = Assignment(name=name, points=points, weight=0.5)
369     ...     grade = Grade(
370     ...         student=student, assignment=assignment,
371     ...         points=int(points/2.0))
372     ...     grades.append(grade)
373     >>> assignments = [g.assignment for g in grades]
374     >>> course = Course(
375     ...     assignments=assignments, people=[student], grades=grades)
376     >>> msg = construct_course_email(
377     ...     author=author, course=course, targets=[prof])
378     >>> print(msg.as_string().replace('\\t', '  '))
379     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
380     Content-Type: text/plain; charset="us-ascii"
381     MIME-Version: 1.0
382     Content-Transfer-Encoding: 7bit
383     Content-Disposition: inline
384     Date: ...
385     From: Jack <a@b.net>
386     Reply-to: Jack <a@b.net>
387     To: "H.D." <hd@wall.net>
388     Subject: Course grades
389     <BLANKLINE>
390     H.D.,
391     <BLANKLINE>
392     Here are the (tab delimited) course grades to date:
393     <BLANKLINE>
394     Student  Exam 1  Homework 1  Total
395     Jill  5  1  0.416...
396     --
397     Mean  5.00  1.00  0.416...
398     Std. Dev.  0.00  0.00  0.0
399     <BLANKLINE>
400     The available points (and weights) for each assignment are:
401       * Exam 1:  10  0.5
402       * Homework 1:  3  0.5
403     <BLANKLINE>
404     Yours,
405     Jack
406     """
407     target = join_with_and([t.alias() for t in targets])
408     table = _io.StringIO()
409     _tabulate(course=course, statistics=True, stream=table)
410     return _construct_email(
411         author=author, targets=targets, cc=cc,
412         subject='Course grades',
413         text=COURSE_TEMPLATE.render(
414             author=author, course=course, target=target,
415             table=table.getvalue()))