diff --git a/baikal/templates/index.html b/baikal/templates/index.html index 6589e3f..1a647c8 100644 --- a/baikal/templates/index.html +++ b/baikal/templates/index.html @@ -48,6 +48,8 @@

Programs

{% endif %}
Reserve... + — + Print... {% endfor %} diff --git a/baikal/urls.py b/baikal/urls.py index d42dd2c..d07a9ad 100644 --- a/baikal/urls.py +++ b/baikal/urls.py @@ -2,6 +2,7 @@ from django.contrib import admin from baikal.views import IndexView +from paikkala.printing.views import PrintView from paikkala.views import InspectionView, RelinquishView, ReservationView urlpatterns = [ @@ -9,5 +10,6 @@ url(r'^relinquish/(?P\d+)/$', RelinquishView.as_view(require_same_user=False), name='relinquish'), url(r'^reserve/(?P\d+)/$', ReservationView.as_view(template_name='generic_form.html'), name='reserve'), url(r'^inspect/(?P\d+)/(?P.+?)/$', InspectionView.as_view(require_same_user=False, template_name='inspect.html'), name='inspect'), + url(r'^print/(?P\d+)/$', PrintView.as_view(template_name='generic_form.html'), name='print'), url(r'^$', IndexView.as_view()), ] diff --git a/paikkala/printing/__init__.py b/paikkala/printing/__init__.py new file mode 100644 index 0000000..4d3c320 --- /dev/null +++ b/paikkala/printing/__init__.py @@ -0,0 +1,41 @@ +from io import BytesIO +from itertools import groupby +from typing import Optional, List, Set + + +from paikkala.models import Program, Zone +from paikkala.printing.configuration import PrintingConfiguration +from paikkala.printing.ticket_info import TicketInfo, generate_ticket_infos + + +def generate_ticket_pdf( + *, + drawer_class, + configuration: PrintingConfiguration, + program: Program, + zones: Optional[List[Zone]] = None, + included_numbers: Optional[Set[int]] = None, + excluded_numbers: Optional[Set[int]] = None, +) -> bytes: + from reportlab.pdfgen.canvas import Canvas + + output_bio = BytesIO() + canvas = Canvas(output_bio) + ticket_infos = generate_ticket_infos( + program=program, + zone_ids=(set(z.id for z in zones) if zones is not None else None), + included_numbers=included_numbers, + excluded_numbers=excluded_numbers, + ) + + if configuration.separate_zones: + groups = groupby(ticket_infos, key=lambda ti: ti.zone.id) + else: + groups = [(None, ticket_infos)] + + drawer = drawer_class(canvas=canvas, configuration=configuration) + for _, ticket_infos in groups: + drawer.draw_tickets(ticket_infos) + canvas.showPage() # blank page between zones + canvas.save() + return output_bio.getvalue() diff --git a/paikkala/printing/configuration.py b/paikkala/printing/configuration.py new file mode 100644 index 0000000..4fdc46f --- /dev/null +++ b/paikkala/printing/configuration.py @@ -0,0 +1,31 @@ +from typing import List + +from .ticket_info import TicketInfo + +inch = 72.0 +cm = inch / 2.54 +A4 = (21.0 * cm, 29.7 * cm) + + +class PrintingConfiguration: + base_image = None + size_x = 8.0 * cm + size_y = 5.0 * cm + n_x = 2 + n_y = 5 + ticket_margin = 0.5 * cm + page_margin = 1.5 * cm + page_size = A4 + line_spacing = 16 + font_name = "Helvetica" + font_size = 12 + text_align = "left" + left_offset = 0.45 * cm + separate_zones = True + + def get_text_lines(self, ticket_info: TicketInfo) -> List[str]: + return [ + ticket_info.program, + ticket_info.qualified_zone, + str(ticket_info.number), + ] diff --git a/paikkala/printing/drawing.py b/paikkala/printing/drawing.py new file mode 100644 index 0000000..e72f5ce --- /dev/null +++ b/paikkala/printing/drawing.py @@ -0,0 +1,71 @@ +import typing +from typing import Iterable + +from .configuration import PrintingConfiguration, cm +from .ticket_info import TicketInfo + +if getattr(typing, "TYPE_CHECKING", False): + from reportlab.pdfgen.canvas import Canvas + + +class TicketDrawer: + def __init__(self, canvas: "Canvas", configuration: PrintingConfiguration): + self.canvas = canvas + self.configuration = configuration + + def draw_tickets( + self, ticket_infos: Iterable[TicketInfo], + ): + while ticket_infos: + self.canvas.setFont( + self.configuration.font_name, self.configuration.font_size + ) + for iy in range(self.configuration.n_y): + for ix in range(self.configuration.n_x): + ticket = next(ticket_infos, None) + if not ticket: + return + page_x, page_y = self.get_ticket_coords(ix, iy) + self.canvas.saveState() + self.canvas.translate(page_x, page_y) + self.draw_single_ticket(ticket) + self.canvas.restoreState() + self.canvas.showPage() + + def get_ticket_coords(self, ix, iy): + ticket_margin = self.configuration.ticket_margin + page_margin = self.configuration.page_margin + + # Compute mathematical coordinates + page_x = ix * (self.configuration.size_x + ticket_margin) + page_y = (1 + iy) * (self.configuration.size_y + ticket_margin) + + # Flip Y upside down and add margins + page_y = self.configuration.page_size[1] - page_margin - page_y + page_x = page_margin + page_x + return page_x, page_y + + def draw_single_ticket(self, ticket: TicketInfo): + """ + Draw a single ticket. `self.canvas` has already been translated correctly. + """ + if self.configuration.base_image: + self.canvas.drawImage( + self.configuration.base_image, + 0, + 0, + self.configuration.size_x, + self.configuration.size_y, + ) + if self.configuration.text_align == "left": + draw_text = self.canvas.drawString + elif self.configuration.text_align == "center": + draw_text = self.canvas.drawCentredString + for y, line in enumerate(self.configuration.get_text_lines(ticket)): + draw_text( + self.configuration.left_offset, + self.configuration.size_y + - 0.25 * cm + - (y + 1) * self.configuration.line_spacing, + str(line), + ) diff --git a/paikkala/printing/forms.py b/paikkala/printing/forms.py new file mode 100644 index 0000000..e6483da --- /dev/null +++ b/paikkala/printing/forms.py @@ -0,0 +1,10 @@ +from django import forms + +from paikkala.models import Zone + + +class PrintForm(forms.Form): + zones = forms.ModelMultipleChoiceField(queryset=Zone.objects.none(), required=False) + included_numbers = forms.CharField(required=False) + excluded_numbers = forms.CharField(required=False) + exclude_reserved_seats = forms.BooleanField(initial=True, required=False) diff --git a/paikkala/printing/ticket_info.py b/paikkala/printing/ticket_info.py new file mode 100644 index 0000000..7d5b5b4 --- /dev/null +++ b/paikkala/printing/ticket_info.py @@ -0,0 +1,57 @@ +from collections import namedtuple, defaultdict +from typing import List, Optional, Set + +from paikkala.models import Program, Zone, Row, SeatQualifier + + +class TicketInfo( + namedtuple("_TicketInfo", ("program", "zone", "row", "number", "qualifier_texts")) +): + program: Program + zone: Zone + row: Row + number: int + qualifier_texts: List[str] + + @property + def qualified_zone(self): + name = self.zone + if self.qualifier_texts: + name = "{name} {qualifiers}".format( + name=name, qualifiers=" ".join(self.qualifier_texts), + ) + return name + + +def generate_ticket_infos( + *, + program: Program, + zone_ids: Optional[Set[int]], + included_numbers: Optional[Set[int]], + excluded_numbers: Optional[Set[int]], +): + # TODO: this all could use smarter queries, maybe + + seat_qualifiers_by_zone_id = defaultdict(list) + for sq in SeatQualifier.objects.filter(zone__room=program.room): + seat_qualifiers_by_zone_id[sq.zone_id].append(sq) + + for row, numbers in program.get_rows_and_numbers(): + if zone_ids and row.zone_id not in zone_ids: + continue + for number in numbers: + if included_numbers and number not in included_numbers: + continue + if excluded_numbers and number in excluded_numbers: + continue + yield TicketInfo( + program=program, + zone=row.zone, + row=row, + number=number, + qualifier_texts=[ + q.text + for q in seat_qualifiers_by_zone_id[row.zone_id] + if q.start_number <= number <= q.end_number + ], + ) diff --git a/paikkala/printing/views.py b/paikkala/printing/views.py new file mode 100644 index 0000000..bede78e --- /dev/null +++ b/paikkala/printing/views.py @@ -0,0 +1,52 @@ +from django.forms import CheckboxSelectMultiple +from django.http import HttpResponse +from django.views.generic import FormView, DetailView + +from paikkala.models import Program +from paikkala.printing.configuration import PrintingConfiguration +from paikkala.printing import generate_ticket_pdf +from paikkala.printing.drawing import TicketDrawer +from paikkala.printing.forms import PrintForm +from paikkala.utils.ranges import parse_number_set + + +class PrintView(FormView, DetailView): + model = Program + context_object_name = "program" + form_class = PrintForm + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.fields["zones"].widget = CheckboxSelectMultiple() + form.fields["zones"].queryset = self.object.zones.all() + form.fields["exclude_reserved_seats"].help_text = ( + "%d currently reserved" % self.object.tickets.count() + ) + return form + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + included_numbers = parse_number_set(form.cleaned_data["included_numbers"]) + excluded_numbers = parse_number_set(form.cleaned_data["excluded_numbers"]) + if form.cleaned_data["exclude_reserved_seats"]: + excluded_numbers |= set( + self.object.tickets.values_list("number", flat=True) + ) + pdf_bytes = generate_ticket_pdf( + drawer_class=self.get_drawer_class(), + configuration=self.get_ticket_configuration(), + program=self.object, + zones=form.cleaned_data["zones"], + included_numbers=included_numbers, + excluded_numbers=excluded_numbers, + ) + return HttpResponse(content=pdf_bytes, content_type="application/pdf",) + + def get_ticket_configuration(self): + return PrintingConfiguration() + + def get_drawer_class(self): + return TicketDrawer diff --git a/paikkala/tests/Sibeliustalo_paasali_paikkanumerointi.pdf b/paikkala/tests/Sibeliustalo_paasali_paikkanumerointi.pdf new file mode 100644 index 0000000..b0f2125 Binary files /dev/null and b/paikkala/tests/Sibeliustalo_paasali_paikkanumerointi.pdf differ diff --git a/paikkala/tests/test_printing.py b/paikkala/tests/test_printing.py new file mode 100644 index 0000000..cca4239 --- /dev/null +++ b/paikkala/tests/test_printing.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.mark.django_db +def test_smoke_printing(jussi_program): + from paikkala.printing import generate_ticket_pdf + from paikkala.printing.drawing import TicketDrawer + from paikkala.printing.configuration import PrintingConfiguration + + assert b'%PDF' in generate_ticket_pdf( + drawer_class=TicketDrawer, + configuration=PrintingConfiguration(), + program=jussi_program, + ) diff --git a/setup.py b/setup.py index 39a5dc7..cfdea59 100755 --- a/setup.py +++ b/setup.py @@ -19,5 +19,8 @@ 'pytest-django', 'pytest-cov', ], + 'printing': [ + 'reportlab>=3.0', + ], }, ) diff --git a/tox.ini b/tox.ini index 5ade285..d58a975 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = [testenv] commands = py.test -ra -vvv --cov=paikkala --cov-report=term-missing -extras = dev +extras = dev,printing deps = django111: Django~=1.11 django20: Django~=2.0