Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(emprinten): Finnish bank barcode and other stuff #511

Merged
merged 8 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ minimum_pre_commit_version: 2.15.0

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.1.13"
rev: "v0.4.9"
hooks:
- id: ruff
args:
Expand Down
5 changes: 2 additions & 3 deletions backend/core/management/commands/core_update_maysendinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ def handle(self, *args, **options):
users = User.objects.filter(person__may_send_info=True)
group.user_set.set(users, clear=True)
logger.info(
"{num_users} users will now receive info spam".format(
num_users=Group.objects.get(name=settings.KOMPASSI_MAY_SEND_INFO_GROUP_NAME).user_set.count(),
)
"%d users will now receive info spam",
Group.objects.get(name=settings.KOMPASSI_MAY_SEND_INFO_GROUP_NAME).user_set.count(),
)
12 changes: 11 additions & 1 deletion backend/emprinten/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from django.conf import settings
from django.contrib import admin
from django.urls import reverse

from . import models

admin.site.register(models.Project)

@admin.register(models.Project)
class ProjectAdmin(admin.ModelAdmin):
# noinspection PyMethodOverriding
def view_on_site(self, obj: models.Project) -> str | None: # pyright: ignore reportIncompatibleMethodOverride
if obj.event is None:
return None
return reverse("emprinten_index", kwargs={"event": obj.event.slug, "slug": obj.slug})


admin.site.register(models.FileVersion)


Expand Down
8 changes: 3 additions & 5 deletions backend/emprinten/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@

# noinspection PyPropertyDefinition
class Pathlike(typing.Protocol):
def open(self, mode: str = "rb") -> io.BytesIO:
...
def open(self, mode: str = "rb") -> io.BytesIO: ...

@property
def name(self) -> str:
...
def name(self) -> str: ...


def make_lut(file: Pathlike, encoding: str) -> Lut:
Expand Down Expand Up @@ -113,7 +111,7 @@ def read_csv(csv_io: typing.TextIO) -> list[dict[str, str | dict[str, typing.Any
header = next(reader)
header = parse_header_names(header)
for row_index, row in enumerate(reader):
row_data = OrderedDict(zip(header, row, strict=False))
row_data: dict[str, typing.Any] = OrderedDict(zip(header, row, strict=False))
row_data.setdefault(
"META",
{
Expand Down
22 changes: 17 additions & 5 deletions backend/emprinten/filters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import datetime
import re
import typing

import jinja2.nodes
from jinja2 import is_undefined, pass_eval_context
from django.conf import settings
from jinja2 import is_undefined as jinja_is_undefined
from jinja2 import pass_eval_context
from jinja2.runtime import Undefined
from markupsafe import Markup

Expand All @@ -11,15 +14,24 @@
SValue = str | Undefined


def add_all_to(filters: dict[str, callable]) -> None:
def add_all_to(filters: dict[str, typing.Callable]) -> None:
filters["nl2br"] = nl2br
filters["filename"] = NameFactory.sanitize
filters.update(make_date_fns())
filters.update(
make_date_fns(
date_input=settings.DATE_FORMAT_STRFTIME,
datetime_input=settings.DATETIME_FORMAT_STRFTIME,
)
)


def is_undefined(value: typing.Any) -> typing.TypeGuard[Undefined]:
return jinja_is_undefined(value)


@pass_eval_context
def nl2br(eval_ctx: jinja2.nodes.EvalContext, value: SValue, with_p: int = 0) -> Markup | SValue:
if is_undefined(value):
if not isinstance(value, str):
return value
br = "<br>\n"

Expand Down Expand Up @@ -47,7 +59,7 @@ def datetime_format(value: datetime.datetime | str | Undefined, format: str = "%
value = datetime.datetime.strptime(value, datetime_input)
return value.strftime(format)

def date(value: datetime.date | str | Undefined) -> SValue:
def date(value: datetime.date | str | Undefined) -> datetime.date | Undefined:
if is_undefined(value):
return value
if isinstance(value, str):
Expand Down
76 changes: 76 additions & 0 deletions backend/emprinten/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import base64
import datetime
import typing

from .svg_code128 import SvgCode128


def get() -> dict[str, typing.Callable]:
return {
"fi_bank_barcode": fi_bank_barcode,
}


class FiBankBarCode(typing.NamedTuple):
valid: bool
number: str
svg64: str

@classmethod
def invalid(cls) -> FiBankBarCode:
return FiBankBarCode(valid=False, number="", svg64="")


def _number_or_none(number: str | int | None) -> int | None:
if isinstance(number, str):
if number.isdigit():
return int(number)
elif isinstance(number, int):
return number
return None


FI_BANK_IBAN_LENGTH = 16
FI_BANK_MAX_EUROCENTS = 99
FI_BANK_MAX_EUROS = 1_000_000
FI_BANK_MAX_REF_LENGTH = 20
FI_BANK_VIRTUAL_BARCODE_LENGTH = 54


def fi_bank_barcode(iban: str, euro: int, cents: int, viite: str | int, era: datetime.date | None) -> FiBankBarCode:
_euro = _number_or_none(euro)
_cents = _number_or_none(cents)
if iban is None or _euro is None or _cents is None or viite is None:
return FiBankBarCode.invalid()

if (
not iban
or not iban.startswith("FI")
or _cents < 0
or _cents > FI_BANK_MAX_EUROCENTS
or _euro < 0
or _euro >= FI_BANK_MAX_EUROS
):
return FiBankBarCode.invalid()

iban = iban.replace(" ", "").removeprefix("FI")
if len(iban) != FI_BANK_IBAN_LENGTH or not iban.isdigit():
return FiBankBarCode.invalid()

if not isinstance(viite, str):
viite = str(viite)
viite = viite.replace(" ", "")
if len(viite) > FI_BANK_MAX_REF_LENGTH or not viite.isdigit():
return FiBankBarCode.invalid()

_era = era.strftime("%y%m%d") if era else "000000"
vv = f"4{iban}{_euro:06d}{_cents:02d}000{viite:0>20s}{_era}"
if len(vv) != FI_BANK_VIRTUAL_BARCODE_LENGTH:
return FiBankBarCode.invalid()

code = SvgCode128(vv)
svg = code.draw_svg()
svg64 = base64.b64encode(svg).decode("utf-8")
return FiBankBarCode(valid=True, number=vv, svg64=svg64)
53 changes: 43 additions & 10 deletions backend/emprinten/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shutil
import tempfile
import typing
import urllib.request
import zipfile

import jinja2.nodes
Expand All @@ -13,13 +14,13 @@
from jinja2 import FunctionLoader
from jinja2.sandbox import SandboxedEnvironment

from . import filters
from . import filters, functions
from .files import Lut, NameFactory, make_lut, make_name
from .models import FileVersion, ProjectFile

DEBUG = False

FileWithData = tuple[str, dict[str, str] | None]
FileWithData = tuple[str, dict[str, str | dict[str, typing.Any]] | None]
DataRow = dict[str, str | dict[str, typing.Any]]
DataSet = list[DataRow]
Vfs = dict[str, FileVersion]
Expand Down Expand Up @@ -60,7 +61,7 @@ def find_main(files: typing.Iterable[FileVersion]) -> FileVersion | None:

def find_lookup_tables(files: typing.Iterable[FileVersion]) -> dict[str, Lut]:
return {
make_name(file_version.file.file_name): make_lut(file_version.data, "utf-8")
make_name(os.path.splitext(file_version.file.file_name)[0]): make_lut(file_version.data, "utf-8")
for file_version in files
if file_version.file.type == ProjectFile.Type.CSV
}
Expand Down Expand Up @@ -154,20 +155,34 @@ def render_pdf(
return HttpResponse(status=201)


T = typing.TypeVar("T", bound=collections.abc.Callable)


class _TemplateCompiler:
T = typing.TypeVar("T", bound=collections.abc.Callable)
SafeBuiltins = (
"dict.items",
"dict.keys",
"dict.values",
)

@staticmethod
def wrap_as_safe_call(fn: T) -> T:
@functools.wraps(fn)
def wrapped(*args, **kwargs):
return fn(*args, **kwargs)

wrapped.is_safe_to_call = True
return wrapped
wrapped.is_safe_to_call = True # pyright: ignore reportAttributeAccessIssue
return typing.cast(T, wrapped)

class Environment(SandboxedEnvironment):
def is_safe_callable(self, obj: typing.Any) -> bool:
if isinstance(obj, jinja2.runtime.Macro):
return True
if (
type(obj).__name__ == "builtin_function_or_method"
and getattr(obj, "__qualname__", None) in _TemplateCompiler.SafeBuiltins
):
return True
return hasattr(obj, "is_safe_to_call") and obj.is_safe_to_call

def __init__(self, vfs: Vfs) -> None:
Expand All @@ -184,6 +199,7 @@ def __init__(self, vfs: Vfs) -> None:
env.globals[name] = self.wrap_as_safe_call(fn)

filters.add_all_to(env.filters)
env.globals.update({k: self.wrap_as_safe_call(v) for k, v in functions.get().items()})

self.env = env

Expand All @@ -193,7 +209,7 @@ def from_string(self, s: str | None) -> jinja2.Template | None:
return self.env.from_string(s)

def get_source(self, file_name: str) -> str:
return self.env.loader.get_source(self.env, file_name)[0]
return self.env.loader.get_source(self.env, file_name)[0] # pyright: ignore reportOptionalMemberAccess

def parse(self, source: str, **kwargs) -> jinja2.nodes.Template:
return self.env.parse(source, **kwargs)
Expand All @@ -203,13 +219,13 @@ def compile(
) -> list[FileWithData]:
lookups = find_lookup_tables(self.vfs.values())
tpl = self.env.get_template(main_file_name)
title_pattern = self.from_string(title_pattern)
_title_pattern = self.env.from_string(title_pattern)

sources: list[FileWithData] = []
if split_output:
for idx, row in enumerate(data, start=1):
row_copy = dict(row)
title = title_pattern.render(row=row_copy)
title = _title_pattern.render(row=row_copy)

src_name = os.path.join(src_dir, f"{idx:03d}.html")
sources.append((src_name, row_copy))
Expand All @@ -221,7 +237,7 @@ def compile(
else:
# Render title if we have any data, but supply the row only if it is singular.
row_copy = dict(data[0]) if len(data) == 1 else None
title = title_pattern.render(row=row_copy) if data else ""
title = _title_pattern.render(row=row_copy) if data else ""

src_name = os.path.join(src_dir, "master.html")
sources.append((src_name, row_copy))
Expand Down Expand Up @@ -282,6 +298,10 @@ def compile(self, sources: list[FileWithData], result_dir: str) -> list[FileWith
pdf = pdf_html.write_pdf(
stylesheets=parsed_sheets,
)
# We don't give `target` parameter, so the function should return bytes.
if pdf is None:
raise RuntimeError("Unexpectedly None result")

dst_base = os.path.splitext(os.path.basename(source))[0]
dst_name = os.path.join(result_dir, dst_base + ".pdf")
results.append((dst_name, row))
Expand All @@ -293,6 +313,19 @@ def compile(self, sources: list[FileWithData], result_dir: str) -> list[FileWith
# See `weasyprint.urls.default_url_fetcher` for function signature.
# Note: At least some exceptions are silently ignored by weasyprint.
def _do_lookup(self, url: str, timeout: int = 10, ssl_context=None) -> dict:
if url.startswith("data:"):
director = urllib.request.OpenerDirector()
director.add_handler(urllib.request.DataHandler())
data_response = director.open(url)
if data_response is None:
restricted_url = "Invalid data URL"
raise ValueError(restricted_url)
return {
"redirected_url": url,
"mime_type": data_response.headers["content-type"],
"string": data_response.file.read(),
}

file_url = url.removeprefix(LOCAL_FILE_URI_PREFIX)
if file_url == url:
restricted_url = "Invalid URL to look up for"
Expand Down
54 changes: 54 additions & 0 deletions backend/emprinten/svg_code128.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import xml.dom

from reportlab.graphics.barcode.code128 import Code128Auto

DIM = "{0:.3f}pt"


class SvgCode128(Code128Auto):
def __init__(self, value, **kwargs):
super().__init__(value, **kwargs)
self._blocks: list[tuple[float, float, float, float]] = []

# Assigned in reportlab.graphics.barcode.common.MultiWidthBarcode.computeSize
self._width: float
self._height: float

def draw(self) -> None:
raise NotImplementedError

# Separate function for different signature.
def draw_svg(self) -> bytes:
# Super implementation calls self.rect(..) repeatedly.
super().draw()

impl = xml.dom.getDOMImplementation()
doc = impl.createDocument("http://www.w3.org/2000/svg", "svg", None)
root = doc.documentElement

root.setAttribute("version", "1.1")
root.setAttribute("width", DIM.format(self._width))
root.setAttribute("height", DIM.format(self._height))

drawing = doc.createElement("g")
root.appendChild(drawing)

bg = doc.createElement("rect")
bg.setAttribute("width", DIM.format(self._width))
bg.setAttribute("height", DIM.format(self._height))
bg.setAttribute("style", "fill:#ffffff;")
drawing.appendChild(bg)

for block in self._blocks:
element = doc.createElement("rect")
element.setAttribute("x", DIM.format(block[0]))
element.setAttribute("y", DIM.format(block[1]))
element.setAttribute("width", DIM.format(block[2]))
element.setAttribute("height", DIM.format(block[3]))
element.setAttribute("style", "fill:#000000;")
drawing.appendChild(element)

return doc.toxml(encoding="utf-8")

def rect(self, x: float, y: float, w: float, h: float) -> None:
self._blocks.append((x, y, w, h))
Loading
Loading