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