From 2c0d803403f1c07c61145084560c0ef435049694 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sat, 7 Oct 2023 14:33:09 +0900 Subject: [PATCH] Implement adapters for settings customization --- lippukala/adapter/__init__.py | 19 +++++ lippukala/adapter/base.py | 39 ++++++++++ lippukala/adapter/default.py | 114 ++++++++++++++++++++++++++++++ lippukala/models/adapter_mixin.py | 12 ++++ lippukala/models/code.py | 31 +++++--- lippukala/models/order.py | 10 ++- lippukala/printing.py | 4 +- lippukala/settings.py | 87 ++--------------------- lippukala_test_app/__main__.py | 4 +- lippukala_tests/conftest.py | 15 ++++ lippukala_tests/test_lippukala.py | 37 +++++----- lippukala_tests/test_pos.py | 6 +- lippukala_tests/utils.py | 15 +++- 13 files changed, 269 insertions(+), 124 deletions(-) create mode 100644 lippukala/adapter/__init__.py create mode 100644 lippukala/adapter/base.py create mode 100644 lippukala/adapter/default.py create mode 100644 lippukala/models/adapter_mixin.py create mode 100644 lippukala_tests/conftest.py diff --git a/lippukala/adapter/__init__.py b/lippukala/adapter/__init__.py new file mode 100644 index 0000000..edf3d7c --- /dev/null +++ b/lippukala/adapter/__init__.py @@ -0,0 +1,19 @@ +from functools import cache + +from django.conf import settings +from django.http import HttpRequest +from django.utils.module_loading import import_string + +from lippukala.adapter.base import LippukalaAdapter + +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) diff --git a/lippukala/adapter/base.py b/lippukala/adapter/base.py new file mode 100644 index 0000000..1f8918b --- /dev/null +++ b/lippukala/adapter/base.py @@ -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() diff --git a/lippukala/adapter/default.py b/lippukala/adapter/default.py new file mode 100644 index 0000000..54006c7 --- /dev/null +++ b/lippukala/adapter/default.py @@ -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 diff --git a/lippukala/models/adapter_mixin.py b/lippukala/models/adapter_mixin.py new file mode 100644 index 0000000..516db94 --- /dev/null +++ b/lippukala/models/adapter_mixin.py @@ -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 diff --git a/lippukala/models/code.py b/lippukala/models/code.py index 83ea0fc..07896d2 100644 --- a/lippukala/models/code.py +++ b/lippukala/models/code.py @@ -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 @@ -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: @@ -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() @@ -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: diff --git a/lippukala/models/order.py b/lippukala/models/order.py index 9f7785c..bfd382e 100644 --- a/lippukala/models/order.py +++ b/lippukala/models/order.py @@ -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. @@ -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) diff --git a/lippukala/printing.py b/lippukala/printing.py index ddf8775..ad99a22 100755 --- a/lippukala/printing.py +++ b/lippukala/printing.py @@ -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 @@ -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 diff --git a/lippukala/settings.py b/lippukala/settings.py index b4eeb77..2295950 100644 --- a/lippukala/settings.py +++ b/lippukala/settings.py @@ -1,83 +1,4 @@ -import os -from string import digits - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - - -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})") - - -PREFIXES = get_setting("PREFIXES", {}) -LITERATE_KEYSPACES = get_setting("LITERATE_KEYSPACES", {}) -CODE_MIN_N_DIGITS = get_integer_setting("CODE_MIN_N_DIGITS", 10) -CODE_MAX_N_DIGITS = get_integer_setting("CODE_MAX_N_DIGITS", 10) -CODE_ALLOW_LEADING_ZEROES = bool(get_setting("CODE_ALLOW_LEADING_ZEROES", True)) -PRINT_LOGO_PATH = get_setting("PRINT_LOGO_PATH") -PRINT_LOGO_SIZE_CM = get_setting("PRINT_LOGO_SIZE_CM") - -if PREFIXES: - PREFIX_CHOICES = [(p, f"{p} [{t}]") for (p, t) in sorted(PREFIXES.items())] - PREFIX_MAY_BE_BLANK = False -else: - PREFIX_CHOICES = [("", "---")] - PREFIX_MAY_BE_BLANK = True - - -def validate_settings(): # pragma: no cover - _validate_code() - _validate_prefixes() - _validate_print() - - -def _validate_code(): - if CODE_MIN_N_DIGITS <= 5 or CODE_MAX_N_DIGITS < CODE_MIN_N_DIGITS: - raise ImproperlyConfigured( - "The range (%d .. %d) for Lippukala code digits is invalid" - % (CODE_MIN_N_DIGITS, CODE_MAX_N_DIGITS) - ) - - -def _validate_prefixes(): - key_lengths = [len(k) for k in 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 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(LITERATE_KEYSPACES.items()): - if isinstance(literate_keyspace, str): - raise ImproperlyConfigured( - f"A string ({literate_keyspace!r}) was passed as the 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(): - if PRINT_LOGO_PATH: - if not os.path.isfile(PRINT_LOGO_PATH): - raise ImproperlyConfigured( - f"PRINT_LOGO_PATH was defined, but does not exist ({PRINT_LOGO_PATH!r})" - ) - if not all(float(s) > 0 for s in PRINT_LOGO_SIZE_CM): - raise ImproperlyConfigured(f"PRINT_LOGO_SIZE_CM values not valid: {PRINT_LOGO_SIZE_CM!r}") - - -validate_settings() -del validate_settings # aaaand it's gone +raise NotImplementedError( + "Do not import anything from `lippukala.settings`! " + "Please migrate your code to use LippukalaAdapter subclasses." +) diff --git a/lippukala_test_app/__main__.py b/lippukala_test_app/__main__.py index c6f3e5a..fbb099e 100644 --- a/lippukala_test_app/__main__.py +++ b/lippukala_test_app/__main__.py @@ -8,10 +8,10 @@ def seed(): import django django.setup() - from lippukala_tests.utils import _create_test_order + from lippukala_tests.utils import create_test_order for x in range(20): - print(_create_test_order().pk) + print(create_test_order(None).pk) def manage(): diff --git a/lippukala_tests/conftest.py b/lippukala_tests/conftest.py new file mode 100644 index 0000000..261df3c --- /dev/null +++ b/lippukala_tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from lippukala.adapter import LippukalaAdapter, get_adapter +from lippukala.models import Order +from lippukala_tests.utils import create_test_order + + +@pytest.fixture +def adapter(rf) -> LippukalaAdapter: + return get_adapter(rf.get("/")) + + +@pytest.fixture +def test_order(adapter) -> Order: + return create_test_order(adapter) diff --git a/lippukala_tests/test_lippukala.py b/lippukala_tests/test_lippukala.py index d00a11e..b10ec48 100644 --- a/lippukala_tests/test_lippukala.py +++ b/lippukala_tests/test_lippukala.py @@ -5,18 +5,17 @@ from lippukala.models import Code, Order from lippukala.reports import CodeReportWriter, get_code_report -from lippukala.settings import PREFIXES -from .utils import _create_test_order +from .utils import create_test_order pytestmark = pytest.mark.django_db -def test_creating_order(): - order = _create_test_order() - assert order.code_set.count() == 25, "orders don't hold their codes" - for code in order.code_set.all(): - assert code.literate_code.startswith(PREFIXES[code.prefix]), "prefixes don't work" +def test_creating_order(adapter, test_order): + prefixes = adapter.get_prefixes() + assert test_order.code_set.count() == 25, "orders don't hold their codes" + for code in test_order.code_set.all(): + assert code.literate_code.startswith(prefixes[code.prefix]), "prefixes don't work" def test_cant_create_invalid_prefix(): @@ -31,9 +30,8 @@ def test_cant_create_invalid_prefix(): order.delete() -def test_double_use_code(): - order = _create_test_order() - code = order.code_set.all()[:1][0] +def test_double_use_code(test_order): + code = test_order.code_set.all()[:1][0] assert not code.is_used, "code is not used" code.set_used(save=True) assert code.is_used, "code is used" @@ -44,17 +42,15 @@ def test_double_use_code(): code.set_used() -def test_csv_reports_have_good_stuff(): - order = _create_test_order() +def test_csv_reports_have_good_stuff(test_order): csv_report_data = get_code_report("csv", False).decode(settings.DEFAULT_CHARSET) # We don't particularly care if we have extra orders/codes at this point, just as long # as the ones we just created are found - for code in order.code_set.all(): + for code in test_order.code_set.all(): assert code.literate_code in csv_report_data, f"code {code!r} was missing" -def test_all_report_formats_seem_to_work(): - _create_test_order() +def test_all_report_formats_seem_to_work(test_order): formats = [n.split("_")[1] for n in dir(CodeReportWriter) if n.startswith("format_")] for format in formats: assert get_code_report(format, False, True) @@ -62,13 +58,18 @@ def test_all_report_formats_seem_to_work(): @pytest.mark.parametrize("one_per_page", (False, True)) -def test_printing(one_per_page): +def test_printing(one_per_page, adapter): from lippukala.printing import OrderPrinter - printer = OrderPrinter() + prefix = next(iter(adapter.get_prefixes())) + + printer = OrderPrinter( + print_logo_path=adapter.get_print_logo_path(prefix), + print_logo_size_cm=adapter.get_print_logo_size_cm(prefix), + ) printer.ONE_TICKET_PER_PAGE = one_per_page for x in range(3): - order = _create_test_order() + order = create_test_order(adapter) printer.process_order(order) outf = BytesIO() diff --git a/lippukala_tests/test_pos.py b/lippukala_tests/test_pos.py index 794c71b..b72983c 100644 --- a/lippukala_tests/test_pos.py +++ b/lippukala_tests/test_pos.py @@ -1,11 +1,11 @@ from lippukala.models import Code -from .utils import _create_test_order +from .utils import create_test_order -def test_pos_view(admin_client): +def test_pos_view(admin_client, adapter): for x in range(5): - _create_test_order() + create_test_order(adapter) admin_client.get("/pos/") codes = admin_client.get("/pos/", {"json": "1"}).json()["codes"] code_id = codes[0]["id"] diff --git a/lippukala_tests/utils.py b/lippukala_tests/utils.py index 9b6b80c..dd69c81 100644 --- a/lippukala_tests/utils.py +++ b/lippukala_tests/utils.py @@ -3,14 +3,23 @@ from django.utils.encoding import force_str +from lippukala.adapter import LippukalaAdapter from lippukala.models import Code, Order -def _create_test_order(): +def create_test_order(adapter: LippukalaAdapter | None) -> Order: fname = random.choice(["Teppo", "Tatu", "Tauno", "Tintti", "Taika"]) + address_text = "\n".join( + ( + f"{fname} Testinen", + f"Testikatu {random.randint(1, 50):d}", + f"{random.randint(0, 99999):05d} Turku", + "Finland", + ) + ) order = Order.objects.create( - address_text="%s Testinen\nTestikatu %d\n%05d Turku\nFinland" - % (fname, random.randint(1, 50), random.randint(0, 99999)), + adapter=adapter, + address_text=address_text, free_text="Tervetuloa Testiconiin!", comment=f"{fname} on kiva jätkä.", reference_number=str(int(time.time() * 10000 + random.randint(0, 35474500))),