From 073f0120449aa40813a792119167e54fc6c82177 Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Fri, 14 Jul 2017 18:47:56 +0200 Subject: [PATCH] Add an admin action to print teacher charge sheets --- common/settings.py | 2 + requirements.txt | 1 + stages/admin.py | 30 +++++++++++++ stages/models.py | 23 ++++++++++ stages/pdf.py | 83 +++++++++++++++++++++++++++++++++++ stages/static/img/header.gif | Bin 0 -> 5430 bytes stages/tests.py | 22 ++++++++++ 7 files changed, 161 insertions(+) create mode 100644 stages/pdf.py create mode 100644 stages/static/img/header.gif diff --git a/common/settings.py b/common/settings.py index 7d69991..9444467 100644 --- a/common/settings.py +++ b/common/settings.py @@ -151,4 +151,6 @@ INSTRUCTOR_IMPORT_MAPPING = { 'MAIL_FORMATEUR': 'email', } +CHARGE_SHEET_TITLE = "Feuille de charge pour l'année scolaire 2017-2018" + from .local_settings import * diff --git a/requirements.txt b/requirements.txt index 4c46249..c43a59c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django==1.11.2 tabimport>=0.4.0 openpyxl==2.2.6 xlrd +reportlab diff --git a/stages/admin.py b/stages/admin.py index bd0bdcc..16a8aa1 100644 --- a/stages/admin.py +++ b/stages/admin.py @@ -1,11 +1,40 @@ +import os +import tempfile +import zipfile + from django import forms from django.contrib import admin from django.db import models +from django.http import HttpResponse from stages.models import ( Teacher, Student, Section, Level, Klass, Referent, Corporation, CorpContact, Domain, Period, Availability, Training, Course, ) +from stages.pdf import ChargeSheetPDF + + +def print_charge_sheet(modeladmin, request, queryset): + """ + Génère un pdf pour chaque enseignant, écrit le fichier créé + dans une archive et renvoie une archive de pdf + """ + filename = 'archive_FeuillesDeCharges.zip' + path = os.path.join(tempfile.gettempdir(), filename) + + with zipfile.ZipFile(path, mode='w', compression=zipfile.ZIP_DEFLATED) as filezip: + for teacher in queryset: + activities = teacher.calc_activity() + pdf = ChargeSheetPDF(teacher) + pdf.produce(activities) + filezip.write(pdf.filename) + + with open(filezip.filename, mode='rb') as fh: + response = HttpResponse(fh.read(), content_type='application/zip') + response['Content-Disposition'] = 'attachment; filename="{0}"'.format(filename) + return response + +print_charge_sheet.short_description = "Imprimer les feuilles de charge" class ArchivedListFilter(admin.BooleanFieldListFilter): @@ -35,6 +64,7 @@ class KlassAdmin(admin.ModelAdmin): class TeacherAdmin(admin.ModelAdmin): list_display = ('__str__', 'abrev', 'email') + actions = [print_charge_sheet] class StudentAdmin(admin.ModelAdmin): diff --git a/stages/models.py b/stages/models.py index f5888e4..b816752 100644 --- a/stages/models.py +++ b/stages/models.py @@ -78,6 +78,29 @@ class Teacher(models.Model): def __str__(self): return '{0} {1}'.format(self.last_name, self.first_name) + def calc_activity(self): + """ + Return a dictionary of calculations relative to teacher courses. + """ + mandats = self.course_set.filter(subject__startswith='#') + ens = self.course_set.exclude(subject__startswith='#') + tot_mandats = mandats.aggregate(models.Sum('period'))['period__sum'] or 0 + tot_ens = ens.aggregate(models.Sum('period'))['period__sum'] or 0 + tot_formation = int(round((tot_mandats + tot_ens) / 1900 * 250)) + tot_trav = self.previous_report + tot_mandats + tot_ens + tot_formation + tot_paye = tot_trav + if self.rate == 100 and tot_paye != 100: + tot_paye = 2150 + return { + 'mandats': mandats, + 'tot_mandats': tot_mandats, + 'tot_ens': tot_ens, + 'tot_formation': tot_formation, + 'tot_trav': tot_trav, + 'tot_paye': tot_paye, + 'report': tot_trav - tot_paye, + } + class Student(models.Model): ext_id = models.IntegerField(null=True, unique=True, verbose_name='ID externe') diff --git a/stages/pdf.py b/stages/pdf.py new file mode 100644 index 0000000..5370f40 --- /dev/null +++ b/stages/pdf.py @@ -0,0 +1,83 @@ +import os +import tempfile +from datetime import date + +from django.conf import settings +from django.contrib.staticfiles.finders import find + +from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, + PageBreak, Table, TableStyle, Image) +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.units import cm +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT +from reportlab.lib import colors +from reportlab.lib.styles import ParagraphStyle as PS + +style_8_c = PS(name='CORPS', fontName='Helvetica', fontSize=6, alignment = TA_CENTER) +style_normal = PS(name='CORPS', fontName='Helvetica', fontSize=8, alignment = TA_LEFT) +style_mandat = PS(name='CORPS', fontName='Helvetica', fontSize=8, alignment = TA_LEFT, leftIndent=30) +style_bold = PS(name='CORPS', fontName='Helvetica-Bold', fontSize=10, alignment = TA_LEFT) +style_title = PS(name='CORPS', fontName='Helvetica-Bold', fontSize=12, alignment = TA_LEFT, spaceBefore=2*cm) +style_adress = PS(name='CORPS', fontName='Helvetica', fontSize=10, alignment = TA_LEFT, leftIndent=280) +style_normal_right = PS(name='CORPS', fontName='Helvetica', fontSize=8, alignment = TA_RIGHT) + + +class ChargeSheetPDF(SimpleDocTemplate): + """ + Génération des feuilles de charges en pdf. + """ + + def __init__(self, teacher): + self.teacher = teacher + filename = '{0}_{1}.pdf'.format(teacher.last_name, teacher.first_name) + path = os.path.join(tempfile.gettempdir(), filename) + super().__init__(path, pagesize=A4, topMargin=0*cm, leftMargin=2*cm) + + def produce(self, activities): + self.story = [] + self.story.append(Image(find('img/header.gif'), width=520, height=75)) + self.story.append(Spacer(0, 2*cm)) + destinataire = '{0}
{1}'.format(self.teacher.civility, str(self.teacher)) + self.story.append(Paragraph(destinataire, style_adress)) + self.story.append(Spacer(0, 2*cm)) + + data = [[settings.CHARGE_SHEET_TITLE]] + + data.append(["Report de l'année précédente", + '{0:3d} pér.'.format(self.teacher.previous_report) ]) + data.append(['Mandats', + '{0:3d} pér.'.format(activities['tot_mandats'])]) + + for act in activities['mandats']: + data.append([' * {0} ({1} pér.)'.format(act.subject, act.period)]) + + data.append(['Enseignement (coef.2)', + '{0:3d} pér.'.format(activities['tot_ens'])]) + data.append(['Formation continue et autres tâches', + '{0:3d} pér.'.format(activities['tot_formation'])]) + data.append(['Total des heures travaillées', + '{0:3d} pér.'.format(activities['tot_trav']), + '{0:4.1f} %'.format(activities['tot_trav']/21.50)]) + data.append(['Total des heures payées', + '{0:3d} pér.'.format(activities['tot_paye']), + '{0:4.1f} %'.format(activities['tot_paye']/21.50)]) + data.append(["Report à l'année prochaine", + '{0:3d} pér.'.format(activities['report'])]) + + t = Table(data, colWidths=[12*cm, 2*cm, 2*cm] ) + t.setStyle(TableStyle([('ALIGN',(1,0),(-1,-1),'RIGHT'), + ('FONT', (0,0),(-1,0), 'Helvetica-Bold'), + ('LINEBELOW', (0,0),(-1,0), 0.5, colors.black), + ('LINEABOVE', (0,-3) ,(-1,-1), 0.5, colors.black), + ('FONT', (0,-2),(-1,-2), 'Helvetica-Bold'), + ])) + t.hAlign = TA_CENTER + self.story.append(t) + + self.story.append(Spacer(0, 2*cm)) + d = 'La Chaux-de-Fonds, le {0}'.format(date.today().strftime('%d.%m.%y')) + self.story.append(Paragraph(d, style_normal)) + self.story.append(Spacer(0, 0.5*cm)) + self.story.append(Paragraph('la direction', style_normal)) + self.story.append(PageBreak()) + self.build(self.story) diff --git a/stages/static/img/header.gif b/stages/static/img/header.gif new file mode 100644 index 0000000000000000000000000000000000000000..a02c592c075ad9b2815758633b8dd23d296fd0f1 GIT binary patch literal 5430 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 F{{TLc`9=T$ literal 0 HcmV?d00001 diff --git a/stages/tests.py b/stages/tests.py index add51c6..8a93174 100644 --- a/stages/tests.py +++ b/stages/tests.py @@ -149,6 +149,28 @@ class PeriodTest(TestCase): self.assertEqual(per.weeks, 2) +class TeacherTests(TestCase): + def setUp(self): + User.objects.create_superuser('me', 'me@example.org', 'mepassword') + + def test_export_charge_sheet(self): + Teacher.objects.create( + first_name='Laurie', last_name='Bernasconi', birth_date='1974-08-08' + ) + change_url = reverse('admin:stages_teacher_changelist') + self.client.login(username='me', password='mepassword') + response = self.client.post(change_url, { + 'action': 'print_charge_sheet', + '_selected_action': Teacher.objects.values_list('pk', flat=True) + }, follow=True) + self.assertEqual( + response['Content-Disposition'], + 'attachment; filename="archive_FeuillesDeCharges.zip"' + ) + self.assertEqual(response['Content-Type'], 'application/zip') + self.assertGreater(len(response.content), 200) + + class ImportTests(TestCase): def setUp(self): User.objects.create_user('me', 'me@example.org', 'mepassword')