template: remove use_color from template functions.
[pygrader.git] / pygrader / template.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 import io as _io
18
19 from jinja2 import Template
20
21 from . import LOG as _LOG
22 from .email import construct_text_email as _construct_text_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 {{ target }},
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 class NotifiedCallback (object):
77     """A callback for marking notifications with `_send_emails`
78     """
79     def __init__(self, basedir, grades):
80         self.basedir = basedir
81         self.grades = grades
82
83     def __call__(self, success):
84         if success:
85             for grade in self.grades:
86                 _set_notified(basedir=self.basedir, grade=grade)
87
88
89 def join_with_and(strings):
90     """Join a list of strings.
91
92     >>> join_with_and(['a','b','c'])
93     'a, b, and c'
94     >>> join_with_and(['a','b'])
95     'a and b'
96     >>> join_with_and(['a'])
97     'a'
98     """
99     ret = [strings[0]]
100     for i,s in enumerate(strings[1:]):
101         if len(strings) > 2:
102             ret.append(', ')
103         else:
104             ret.append(' ')
105         if i == len(strings)-2:
106             ret.append('and ')
107         ret.append(s)
108     return ''.join(ret)
109
110 def assignment_email(basedir, author, course, assignment, student=None,
111                      cc=None, smtp=None, debug_target=None, dry_run=False):
112     """Send each student an email with their grade on `assignment`
113     """
114     _send_emails(
115         emails=_assignment_email(
116             basedir=basedir, author=author, course=course,
117             assignment=assignment, student=student, cc=cc),
118         smtp=smtp, debug_target=debug_target, dry_run=dry_run)
119
120 def _assignment_email(basedir, author, course, assignment, student=None,
121                       cc=None):
122     """Iterate through composed assignment `Message`\s
123     """
124     if student:
125         students = [student]
126     else:
127         students = course.people
128     for student in students:
129         try:
130             grade = course.grade(student=student, assignment=assignment)
131         except ValueError:
132             continue
133         if grade.notified:
134             continue
135         yield (construct_assignment_email(author=author, grade=grade, cc=cc),
136                NotifiedCallback(basedir=basedir, grades=[grade]))
137
138 def construct_assignment_email(author, grade, cc=None):
139     """Construct a `Message` notfiying a student of `grade`
140
141     >>> from pygrader.model.person import Person
142     >>> from pygrader.model.assignment import Assignment
143     >>> from pygrader.model.grade import Grade
144     >>> author = Person(name='Jack', emails=['a@b.net'])
145     >>> student = Person(name='Jill', emails=['c@d.net'])
146     >>> assignment = Assignment(name='Exam 1', points=3)
147     >>> grade = Grade(student=student, assignment=assignment, points=2)
148     >>> msg = construct_assignment_email(author=author, grade=grade)
149     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
150     Content-Type: text/plain; charset="us-ascii"
151     MIME-Version: 1.0
152     Content-Transfer-Encoding: 7bit
153     Content-Disposition: inline
154     Date: ...
155     From: Jack <a@b.net>
156     Reply-to: Jack <a@b.net>
157     To: Jill <c@d.net>
158     Subject: Your Exam 1 grade
159     <BLANKLINE>
160     Jill,
161     <BLANKLINE>
162     You got 2 out of 3 available points on Exam 1.
163     <BLANKLINE>
164     Yours,
165     Jack
166
167     >>> grade.comment = ('Some comment bla bla bla.').strip()
168     >>> msg = construct_assignment_email(author=author, grade=grade)
169     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
170     Content-Type: text/plain; charset="us-ascii"
171     MIME-Version: 1.0
172     Content-Transfer-Encoding: 7bit
173     Content-Disposition: inline
174     Date: ...
175     From: Jack <a@b.net>
176     Reply-to: Jack <a@b.net>
177     To: Jill <c@d.net>
178     Subject: Your Exam 1 grade
179     <BLANKLINE>
180     Jill,
181     <BLANKLINE>
182     You got 2 out of 3 available points on Exam 1.
183     <BLANKLINE>
184     Some comment bla bla bla.
185     <BLANKLINE>
186     Yours,
187     Jack
188     """
189     return _construct_text_email(
190         author=author, targets=[grade.student], cc=cc,
191         subject='Your {} grade'.format(grade.assignment.name),
192         text=ASSIGNMENT_TEMPLATE.render(author=author, grade=grade))
193
194 def student_email(basedir, author, course, student=None, cc=None, old=False,
195                   smtp=None, debug_target=None, dry_run=False):
196     """Send each student an email with their grade to date
197     """
198     _send_emails(
199         emails=_student_email(
200             basedir=basedir, author=author, course=course, student=student,
201             cc=cc, old=old),
202         smtp=smtp, debug_target=debug_target, dry_run=dry_run)
203
204 def _student_email(basedir, author, course, student=None, targets=None, cc=None, old=False):
205     """Iterate through composed student `Message`\s
206     """
207     if student:
208         students = [student]
209     else:
210         students = course.people
211     for student in students:
212         grades = [g for g in course.grades if g.student == student]
213         if not old:
214             grades = [g for g in grades if not g.notified]
215         if not grades:
216             continue
217         yield (construct_student_email(
218                 author=author, course=course, grades=grades, targets=targets,
219                 cc=cc),
220                NotifiedCallback(basedir=basedir, grades=grades))
221
222 def construct_student_email(author, course, grades, targets=None, cc=None):
223     """Construct a `Message` notfiying a student of `grade`
224
225     >>> from pygrader.model.person import Person
226     >>> from pygrader.model.assignment import Assignment
227     >>> from pygrader.model.course import Course
228     >>> from pygrader.model.grade import Grade
229     >>> course = Course(name='Physics 101')
230     >>> author = Person(name='Jack', emails=['a@b.net'])
231     >>> student = Person(name='Jill', emails=['c@d.net'])
232     >>> grades = []
233     >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
234     ...     assignment = Assignment(name=name, points=points)
235     ...     grade = Grade(
236     ...         student=student, assignment=assignment,
237     ...         points=int(points/2.0))
238     ...     grades.append(grade)
239     >>> msg = construct_student_email(
240     ...     author=author, course=course, grades=grades)
241     >>> print(msg.as_string().replace('\\t', '  '))
242     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
243     Content-Type: text/plain; charset="us-ascii"
244     MIME-Version: 1.0
245     Content-Transfer-Encoding: 7bit
246     Content-Disposition: inline
247     Date: ...
248     From: Jack <a@b.net>
249     Reply-to: Jack <a@b.net>
250     To: Jill <c@d.net>
251     Subject: Physics 101 grades
252     <BLANKLINE>
253     Jill,
254     <BLANKLINE>
255     Grades:
256       * Exam 1:  5 out of 10 available points.
257       * Homework 1:  1 out of 3 available points.
258     <BLANKLINE>
259     Comments:
260     <BLANKLINE>
261     Yours,
262     Jack
263
264     >>> grades[0].comment = ('Bla bla bla.  '*20).strip()
265     >>> grades[1].comment = ('Hello world')
266     >>> msg = construct_student_email(
267     ...     author=author, course=course, 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: Physics 101 grades
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(
302     ...     author=author, course=course, grades=grades)
303     >>> print(msg.as_string().replace('\\t', '  '))
304     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
305     Content-Type: text/plain; charset="us-ascii"
306     MIME-Version: 1.0
307     Content-Transfer-Encoding: 7bit
308     Content-Disposition: inline
309     Date: ...
310     From: Jack <a@b.net>
311     Reply-to: Jack <a@b.net>
312     To: Jill <c@d.net>
313     Subject: Physics 101 grades
314     <BLANKLINE>
315     Jill,
316     <BLANKLINE>
317     Grades:
318       * Exam 1:  5 out of 10 available points.
319       * Homework 1:  1 out of 3 available points.
320     <BLANKLINE>
321     Comments:
322     <BLANKLINE>
323     Homework 1
324     <BLANKLINE>
325     Work harder!
326     <BLANKLINE>
327     Yours,
328     Jack
329
330     You can also send the student grades to alternative targets:
331
332     >>> prof = Person(name='H.D.', emails=['hd@wall.net'])
333     >>> msg = construct_student_email(
334     ...     author=author, course=course, grades=grades, targets=[prof])
335     >>> print(msg.as_string().replace('\\t', '  '))
336     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
337     Content-Type: text/plain; charset="us-ascii"
338     MIME-Version: 1.0
339     Content-Transfer-Encoding: 7bit
340     Content-Disposition: inline
341     Date: ...
342     From: Jack <a@b.net>
343     Reply-to: Jack <a@b.net>
344     To: "H.D." <hd@wall.net>
345     Subject: Physics 101 grades for Jill
346     <BLANKLINE>
347     H.D.,
348     <BLANKLINE>
349     Grades:
350       * Exam 1:  5 out of 10 available points.
351       * Homework 1:  1 out of 3 available points.
352     <BLANKLINE>
353     Comments:
354     <BLANKLINE>
355     Homework 1
356     <BLANKLINE>
357     Work harder!
358     <BLANKLINE>
359     Yours,
360     Jack
361     """
362     students = set(g.student for g in grades)
363     assert len(students) == 1, students
364     student = students.pop()
365     subject = '{} grades'.format(course.name)
366     if not targets:
367         targets = [student]
368     else:
369         subject += ' for {}'.format(student.name)
370     target = join_with_and([t.alias() for t in targets])
371     return _construct_text_email(
372         author=author, targets=targets, cc=cc, subject=subject,
373         text=STUDENT_TEMPLATE.render(
374             author=author, target=target, grades=sorted(grades)))
375
376 def course_email(basedir, author, course, targets, assignment=None,
377                  student=None, cc=None, smtp=None, debug_target=None,
378                  dry_run=False):
379     """Send the professor an email with all student grades to date
380     """
381     _send_emails(
382         emails=_course_email(
383             basedir=basedir, author=author, course=course, targets=targets,
384             assignment=assignment, student=student, cc=cc),
385         smtp=smtp, debug_target=debug_target, dry_run=dry_run)
386
387 def _course_email(basedir, author, course, targets, assignment=None,
388                   student=None, cc=None):
389     """Iterate through composed course `Message`\s
390     """
391     yield (construct_course_email(
392             author=author, course=course, targets=targets, cc=cc),
393            None)
394
395 def construct_course_email(author, course, targets, cc=None):
396     """Construct a `Message` notfiying a professor of all grades to date
397
398     >>> from pygrader.model.person import Person
399     >>> from pygrader.model.assignment import Assignment
400     >>> from pygrader.model.grade import Grade
401     >>> from pygrader.model.course import Course
402     >>> author = Person(name='Jack', emails=['a@b.net'])
403     >>> student = Person(name='Jill', emails=['c@d.net'])
404     >>> prof = Person(name='H.D.', emails=['hd@wall.net'])
405     >>> grades = []
406     >>> for name,points in [('Homework 1', 3), ('Exam 1', 10)]:
407     ...     assignment = Assignment(name=name, points=points, weight=0.5)
408     ...     grade = Grade(
409     ...         student=student, assignment=assignment,
410     ...         points=int(points/2.0))
411     ...     grades.append(grade)
412     >>> assignments = [g.assignment for g in grades]
413     >>> course = Course(
414     ...     assignments=assignments, people=[student], grades=grades)
415     >>> msg = construct_course_email(
416     ...     author=author, course=course, targets=[prof])
417     >>> print(msg.as_string().replace('\\t', '  '))
418     ... # doctest: +REPORT_UDIFF, +ELLIPSIS
419     Content-Type: text/plain; charset="us-ascii"
420     MIME-Version: 1.0
421     Content-Transfer-Encoding: 7bit
422     Content-Disposition: inline
423     Date: ...
424     From: Jack <a@b.net>
425     Reply-to: Jack <a@b.net>
426     To: "H.D." <hd@wall.net>
427     Subject: Course grades
428     <BLANKLINE>
429     H.D.,
430     <BLANKLINE>
431     Here are the (tab delimited) course grades to date:
432     <BLANKLINE>
433     Student  Exam 1  Homework 1  Total
434     Jill  5  1  0.416...
435     --
436     Mean  5.00  1.00  0.416...
437     Std. Dev.  0.00  0.00  0.0
438     <BLANKLINE>
439     The available points (and weights) for each assignment are:
440       * Exam 1:  10  0.5
441       * Homework 1:  3  0.5
442     <BLANKLINE>
443     Yours,
444     Jack
445     """
446     target = join_with_and([t.alias() for t in targets])
447     table = _io.StringIO()
448     _tabulate(course=course, statistics=True, stream=table, use_color=False)
449     return _construct_text_email(
450         author=author, targets=targets, cc=cc,
451         subject='Course grades',
452         text=COURSE_TEMPLATE.render(
453             author=author, course=course, target=target,
454             table=table.getvalue()))