Skip to content

Implement adapters for settings customization #26

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

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
Test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
Expand All @@ -21,9 +22,11 @@ jobs:
cache: pip
cache-dependency-path: |
pyproject.toml
- run: pip install tox-gh-actions tox
- run: pip install tox-gh-actions tox tox-uv
- run: tox
- uses: codecov/codecov-action@v4
env:
CODECOV_TOKEN: 950d92fa-25d7-4471-99b6-a11b82ba3847
Lint-Backend:
runs-on: ubuntu-latest
steps:
Expand Down
26 changes: 26 additions & 0 deletions lippukala/adapter/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from django.conf import settings
from django.http import HttpRequest
from django.utils.module_loading import import_string

from lippukala.adapter.base import LippukalaAdapter

try:
from functools import cache
except ImportError: # Remove this when deprecating Python 3.9 support
from functools import lru_cache

cache = lru_cache(maxsize=None)

DEFAULT_ADAPTER_REFERENCE = "lippukala.adapter.default.DefaultLippukalaAdapter"


@cache
def get_adapter_class() -> type[LippukalaAdapter]:
adapter_class_name = getattr(settings, "LIPPUKALA_ADAPTER_CLASS", DEFAULT_ADAPTER_REFERENCE)
return import_string(adapter_class_name)


def get_adapter(request: HttpRequest) -> LippukalaAdapter:
return get_adapter_class()(request)
39 changes: 39 additions & 0 deletions lippukala/adapter/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod

from django.http import HttpRequest

IMPLEMENT_IN_A_SUBCLASS = "Implement in a subclass"


class LippukalaAdapter(metaclass=ABCMeta):
def __init__(self, request: HttpRequest | None) -> None:
self.request = request

@abstractmethod
def get_prefixes(self) -> dict[str, str]:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

@abstractmethod
def get_literate_keyspace(self, prefix: str | None) -> list[str] | None:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

@abstractmethod
def get_code_digit_range(self, prefix: str) -> range:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

@abstractmethod
def get_code_allow_leading_zeroes(self, prefix: str) -> bool:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

@abstractmethod
def get_print_logo_path(self, prefix: str) -> str | None:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

@abstractmethod
def get_print_logo_size_cm(self, prefix: str) -> tuple[float, float]:
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)

def get_prefix_may_be_blank(self) -> bool:
return not self.get_prefixes()
114 changes: 114 additions & 0 deletions lippukala/adapter/default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from __future__ import annotations

import os
from string import digits

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

from lippukala.adapter.base import LippukalaAdapter


def get_setting(name, default=None):
return getattr(settings, f"LIPPUKALA_{name}", default)


def get_integer_setting(name, default=0):
try:
value = get_setting(name, default)
return int(value)
except ValueError: # pragma: no cover
raise ImproperlyConfigured(f"LIPPUKALA_{name} must be an integer (got {value!r})")


class LippukalaSettings:
def __init__(self) -> None:
self.prefixes = get_setting("PREFIXES", {})
self.literate_keyspaces = get_setting("LITERATE_KEYSPACES", {})
self.code_min_n_digits = get_integer_setting("CODE_MIN_N_DIGITS", 10)
self.code_max_n_digits = get_integer_setting("CODE_MAX_N_DIGITS", 10)
self.code_allow_leading_zeroes = bool(get_setting("CODE_ALLOW_LEADING_ZEROES", True))
self.print_logo_path = get_setting("PRINT_LOGO_PATH")
self.print_logo_size_cm = get_setting("PRINT_LOGO_SIZE_CM")

if self.prefixes:
self.prefix_choices = [(p, f"{p} [{t}]") for (p, t) in sorted(self.prefixes.items())]
self.prefix_may_be_blank = False
else:
self.prefix_choices = [("", "---")]
self.prefix_may_be_blank = True

def validate(self) -> None: # pragma: no cover
self._validate_code()
self._validate_prefixes()
self._validate_print()

def _validate_code(self) -> None:
if self.code_min_n_digits <= 5 or self.code_max_n_digits < self.code_min_n_digits:
raise ImproperlyConfigured(
f"The range ({self.code_min_n_digits} .. {self.code_max_n_digits}) for "
f"Lippukala code digits is invalid"
)

def _validate_prefixes(self):
key_lengths = [len(k) for k in self.prefixes]
if key_lengths and not all(k == key_lengths[0] for k in key_lengths):
raise ImproperlyConfigured("All LIPPUKALA_PREFIXES keys must be the same length!")
for prefix in self.prefixes:
if not all(c in digits for c in prefix):
raise ImproperlyConfigured(
f"The prefix {prefix!r} has invalid characters. Only digits are allowed."
)
for prefix, literate_keyspace in list(self.literate_keyspaces.items()):
if isinstance(literate_keyspace, str):
raise ImproperlyConfigured(
f"A string ({literate_keyspace!r}) was passed as the "
f"literate keyspace for prefix {prefix!r}"
)
too_short_keys = any(len(key) <= 1 for key in literate_keyspace)
maybe_duplicate = len(set(literate_keyspace)) != len(literate_keyspace)
if too_short_keys or maybe_duplicate:
raise ImproperlyConfigured(
f"The literate keyspace for prefix {prefix!r} has invalid or duplicate entries."
)

def _validate_print(self):
if not self.print_logo_path:
return
if not os.path.isfile(self.print_logo_path):
raise ImproperlyConfigured(
f"PRINT_LOGO_PATH was defined, but does not exist ({self.print_logo_path!r})"
)
if not all(float(s) > 0 for s in self.print_logo_size_cm):
raise ImproperlyConfigured(f"PRINT_LOGO_SIZE_CM values not valid: {self.print_logo_size_cm!r}")


class DefaultLippukalaAdapter(LippukalaAdapter):
_settings: LippukalaSettings | None = None

@classmethod
def get_settings(cls) -> LippukalaSettings:
if not cls._settings:
cls._settings = LippukalaSettings()
cls._settings.validate()
return cls._settings

def get_prefixes(self) -> dict[str, str]:
return self.get_settings().prefixes

def get_literate_keyspace(self, prefix: str | None) -> list[str] | None:
literate_keyspaces = self.get_settings().literate_keyspaces
return literate_keyspaces.get(prefix)

def get_code_digit_range(self, prefix: str) -> range:
s = self.get_settings()
return range(s.code_min_n_digits, s.code_max_n_digits + 1)

def get_code_allow_leading_zeroes(self, prefix: str) -> bool:
return self.get_settings().code_allow_leading_zeroes

def get_print_logo_path(self, prefix: str) -> str | None:
return self.get_settings().print_logo_path

def get_print_logo_size_cm(self, prefix: str) -> tuple[float, float]:
return self.get_settings().print_logo_size_cm
12 changes: 12 additions & 0 deletions lippukala/models/adapter_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from __future__ import annotations

from lippukala.adapter import LippukalaAdapter


class AdapterMixin:
_adapter: LippukalaAdapter | None = None

def get_adapter(self) -> LippukalaAdapter:
if not self._adapter:
raise ValueError(f"An adapter needs to be set on {self.__class__.__name__}")
return self._adapter
31 changes: 20 additions & 11 deletions lippukala/models/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.db import models
from django.utils.timezone import now

import lippukala.settings as settings
from lippukala.adapter import LippukalaAdapter
from lippukala.consts import CODE_STATUS_CHOICES, UNUSED, USED
from lippukala.excs import CantUseException

Expand Down Expand Up @@ -32,23 +32,30 @@ class Code(models.Model):
def __str__(self):
return f"Code {self.full_code} ({self.literate_code}) ({self.get_status_display()})"

def get_adapter(self) -> LippukalaAdapter:
return self.order.get_adapter()

def _generate_code(self):
qs = self.__class__.objects
adapter = self.get_adapter()
digit_range = adapter.get_code_digit_range(self.prefix)
allow_leading_zeroes = adapter.get_code_allow_leading_zeroes(self.prefix)

for attempt in range(500): # 500 attempts really REALLY should be enough.
n_digits = randint(settings.CODE_MIN_N_DIGITS, settings.CODE_MAX_N_DIGITS + 1)
n_digits = randint(digit_range.start, digit_range.stop - 1)
code = "".join(choice(digits) for x in range(n_digits))
if not settings.CODE_ALLOW_LEADING_ZEROES:
if not allow_leading_zeroes:
code = code.lstrip("0")
# Leading zeroes could have dropped digits off the code, so recheck that.
if settings.CODE_MIN_N_DIGITS <= len(code) <= settings.CODE_MAX_N_DIGITS:
if len(code) in digit_range:
if not qs.filter(code=code).exists():
return code

raise ValueError("Unable to find an unused code! Is the keyspace exhausted?")

def _generate_literate_code(self):
default_literate_keyspace = settings.LITERATE_KEYSPACES.get(None)
keyspace = settings.LITERATE_KEYSPACES.get(self.prefix) or default_literate_keyspace
adapter = self.get_adapter()
keyspace = adapter.get_literate_keyspace(self.prefix) or adapter.get_literate_keyspace(None)

# When absolutely no keyspaces can be found, assume (prefix+code) will do
if not keyspace:
Expand All @@ -67,7 +74,7 @@ def _generate_literate_code(self):

# Oh -- and if we had a prefix, add its literate counterpart now.
if self.prefix:
bits.insert(0, settings.PREFIXES[self.prefix])
bits.insert(0, adapter.get_prefixes()[self.prefix])

return " ".join(bits).strip()

Expand All @@ -81,10 +88,12 @@ def _check_sanity(self):
"Un-sane situation detected: full_code contains non-digits. "
"(This might mean a contaminated prefix configuration.)"
)
if not settings.PREFIX_MAY_BE_BLANK and not self.prefix:
raise ValueError("Un-sane situation detected: prefix may not be blank")
if self.prefix and self.prefix not in settings.PREFIXES:
raise ValueError(f"Un-sane situation detected: prefix {self.prefix!r} is not in PREFIXES")
if not self.pk: # If we've already saved the code, we will assume these are good
adapter = self.get_adapter()
if not adapter.get_prefix_may_be_blank() and not self.prefix:
raise ValueError("Un-sane situation detected: prefix may not be blank")
if self.prefix and self.prefix not in adapter.get_prefixes():
raise ValueError(f"Un-sane situation detected: prefix {self.prefix!r} is not in PREFIXES")

def save(self, *args, **kwargs):
if not self.code:
Expand Down
10 changes: 9 additions & 1 deletion lippukala/models/order.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.db import models

from lippukala.models.adapter_mixin import AdapterMixin

class Order(models.Model):

class Order(AdapterMixin, models.Model):
"""Encapsulates an order, which may contain zero or more codes.

:var event: An (optional) event identifier for this order. May be used at the client app's discretion.
Expand All @@ -23,3 +25,9 @@ class Order(models.Model):

def __str__(self):
return "LK-%08d (ref %s)" % (self.pk, self.reference_number)

def __init__(self, *args, **kwargs) -> None:
adapter = kwargs.pop("adapter", None)
if adapter:
self._adapter = adapter
super().__init__(*args, **kwargs)
4 changes: 1 addition & 3 deletions lippukala/printing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from reportlab.lib.units import cm, mm
from reportlab.pdfgen.canvas import Canvas

from lippukala.settings import PRINT_LOGO_PATH, PRINT_LOGO_SIZE_CM


class Bold(str):
pass
Expand Down Expand Up @@ -61,7 +59,7 @@ class OrderPrinter:

ONE_TICKET_PER_PAGE = False

def __init__(self, print_logo_path=PRINT_LOGO_PATH, print_logo_size_cm=PRINT_LOGO_SIZE_CM):
def __init__(self, *, print_logo_path, print_logo_size_cm):
self.output = BytesIO()
self.canvas = Canvas(self.output, pagesize=(self.PAGE_WIDTH, self.PAGE_HEIGHT))
self.n_orders = 0
Expand Down
Loading
Loading