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