Skip to content

Commit d9d1982

Browse files
committed
Implement adapters for settings customization
1 parent 73522da commit d9d1982

File tree

14 files changed

+279
-124
lines changed

14 files changed

+279
-124
lines changed

.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ jobs:
1010
Test:
1111
runs-on: ubuntu-latest
1212
strategy:
13+
fail-fast: false
1314
matrix:
1415
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
1516
steps:

lippukala/adapter/__init__.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
from django.conf import settings
4+
from django.http import HttpRequest
5+
from django.utils.module_loading import import_string
6+
7+
from lippukala.adapter.base import LippukalaAdapter
8+
9+
try:
10+
from functools import cache
11+
except ImportError: # Remove this when deprecating Python 3.9 support
12+
from functools import lru_cache
13+
14+
cache = lru_cache(maxsize=None)
15+
16+
DEFAULT_ADAPTER_REFERENCE = "lippukala.adapter.default.DefaultLippukalaAdapter"
17+
18+
19+
@cache
20+
def get_adapter_class() -> type[LippukalaAdapter]:
21+
adapter_class_name = getattr(settings, "LIPPUKALA_ADAPTER_CLASS", DEFAULT_ADAPTER_REFERENCE)
22+
return import_string(adapter_class_name)
23+
24+
25+
def get_adapter(request: HttpRequest) -> LippukalaAdapter:
26+
return get_adapter_class()(request)

lippukala/adapter/base.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from abc import ABCMeta, abstractmethod
4+
5+
from django.http import HttpRequest
6+
7+
IMPLEMENT_IN_A_SUBCLASS = "Implement in a subclass"
8+
9+
10+
class LippukalaAdapter(metaclass=ABCMeta):
11+
def __init__(self, request: HttpRequest | None) -> None:
12+
self.request = request
13+
14+
@abstractmethod
15+
def get_prefixes(self) -> dict[str, str]:
16+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
17+
18+
@abstractmethod
19+
def get_literate_keyspace(self, prefix: str | None) -> list[str] | None:
20+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
21+
22+
@abstractmethod
23+
def get_code_digit_range(self, prefix: str) -> range:
24+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
25+
26+
@abstractmethod
27+
def get_code_allow_leading_zeroes(self, prefix: str) -> bool:
28+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
29+
30+
@abstractmethod
31+
def get_print_logo_path(self, prefix: str) -> str | None:
32+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
33+
34+
@abstractmethod
35+
def get_print_logo_size_cm(self, prefix: str) -> tuple[float, float]:
36+
raise NotImplementedError(IMPLEMENT_IN_A_SUBCLASS)
37+
38+
def get_prefix_may_be_blank(self) -> bool:
39+
return not self.get_prefixes()

lippukala/adapter/default.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from string import digits
5+
6+
from django.conf import settings
7+
from django.core.exceptions import ImproperlyConfigured
8+
9+
from lippukala.adapter.base import LippukalaAdapter
10+
11+
12+
def get_setting(name, default=None):
13+
return getattr(settings, f"LIPPUKALA_{name}", default)
14+
15+
16+
def get_integer_setting(name, default=0):
17+
try:
18+
value = get_setting(name, default)
19+
return int(value)
20+
except ValueError: # pragma: no cover
21+
raise ImproperlyConfigured(f"LIPPUKALA_{name} must be an integer (got {value!r})")
22+
23+
24+
class LippukalaSettings:
25+
def __init__(self) -> None:
26+
self.prefixes = get_setting("PREFIXES", {})
27+
self.literate_keyspaces = get_setting("LITERATE_KEYSPACES", {})
28+
self.code_min_n_digits = get_integer_setting("CODE_MIN_N_DIGITS", 10)
29+
self.code_max_n_digits = get_integer_setting("CODE_MAX_N_DIGITS", 10)
30+
self.code_allow_leading_zeroes = bool(get_setting("CODE_ALLOW_LEADING_ZEROES", True))
31+
self.print_logo_path = get_setting("PRINT_LOGO_PATH")
32+
self.print_logo_size_cm = get_setting("PRINT_LOGO_SIZE_CM")
33+
34+
if self.prefixes:
35+
self.prefix_choices = [(p, f"{p} [{t}]") for (p, t) in sorted(self.prefixes.items())]
36+
self.prefix_may_be_blank = False
37+
else:
38+
self.prefix_choices = [("", "---")]
39+
self.prefix_may_be_blank = True
40+
41+
def validate(self) -> None: # pragma: no cover
42+
self._validate_code()
43+
self._validate_prefixes()
44+
self._validate_print()
45+
46+
def _validate_code(self) -> None:
47+
if self.code_min_n_digits <= 5 or self.code_max_n_digits < self.code_min_n_digits:
48+
raise ImproperlyConfigured(
49+
f"The range ({self.code_min_n_digits} .. {self.code_max_n_digits}) for "
50+
f"Lippukala code digits is invalid"
51+
)
52+
53+
def _validate_prefixes(self):
54+
key_lengths = [len(k) for k in self.prefixes]
55+
if key_lengths and not all(k == key_lengths[0] for k in key_lengths):
56+
raise ImproperlyConfigured("All LIPPUKALA_PREFIXES keys must be the same length!")
57+
for prefix in self.prefixes:
58+
if not all(c in digits for c in prefix):
59+
raise ImproperlyConfigured(
60+
f"The prefix {prefix!r} has invalid characters. Only digits are allowed."
61+
)
62+
for prefix, literate_keyspace in list(self.literate_keyspaces.items()):
63+
if isinstance(literate_keyspace, str):
64+
raise ImproperlyConfigured(
65+
f"A string ({literate_keyspace!r}) was passed as the "
66+
f"literate keyspace for prefix {prefix!r}"
67+
)
68+
too_short_keys = any(len(key) <= 1 for key in literate_keyspace)
69+
maybe_duplicate = len(set(literate_keyspace)) != len(literate_keyspace)
70+
if too_short_keys or maybe_duplicate:
71+
raise ImproperlyConfigured(
72+
f"The literate keyspace for prefix {prefix!r} has invalid or duplicate entries."
73+
)
74+
75+
def _validate_print(self):
76+
if not self.print_logo_path:
77+
return
78+
if not os.path.isfile(self.print_logo_path):
79+
raise ImproperlyConfigured(
80+
f"PRINT_LOGO_PATH was defined, but does not exist ({self.print_logo_path!r})"
81+
)
82+
if not all(float(s) > 0 for s in self.print_logo_size_cm):
83+
raise ImproperlyConfigured(f"PRINT_LOGO_SIZE_CM values not valid: {self.print_logo_size_cm!r}")
84+
85+
86+
class DefaultLippukalaAdapter(LippukalaAdapter):
87+
_settings: LippukalaSettings | None = None
88+
89+
@classmethod
90+
def get_settings(cls) -> LippukalaSettings:
91+
if not cls._settings:
92+
cls._settings = LippukalaSettings()
93+
cls._settings.validate()
94+
return cls._settings
95+
96+
def get_prefixes(self) -> dict[str, str]:
97+
return self.get_settings().prefixes
98+
99+
def get_literate_keyspace(self, prefix: str | None) -> list[str] | None:
100+
literate_keyspaces = self.get_settings().literate_keyspaces
101+
return literate_keyspaces.get(prefix)
102+
103+
def get_code_digit_range(self, prefix: str) -> range:
104+
s = self.get_settings()
105+
return range(s.code_min_n_digits, s.code_max_n_digits + 1)
106+
107+
def get_code_allow_leading_zeroes(self, prefix: str) -> bool:
108+
return self.get_settings().code_allow_leading_zeroes
109+
110+
def get_print_logo_path(self, prefix: str) -> str | None:
111+
return self.get_settings().print_logo_path
112+
113+
def get_print_logo_size_cm(self, prefix: str) -> tuple[float, float]:
114+
return self.get_settings().print_logo_size_cm

lippukala/models/adapter_mixin.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from __future__ import annotations
2+
3+
from lippukala.adapter import LippukalaAdapter
4+
5+
6+
class AdapterMixin:
7+
_adapter: LippukalaAdapter | None = None
8+
9+
def get_adapter(self) -> LippukalaAdapter:
10+
if not self._adapter:
11+
raise ValueError(f"An adapter needs to be set on {self.__class__.__name__}")
12+
return self._adapter

lippukala/models/code.py

+20-11
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.db import models
55
from django.utils.timezone import now
66

7-
import lippukala.settings as settings
7+
from lippukala.adapter import LippukalaAdapter
88
from lippukala.consts import CODE_STATUS_CHOICES, UNUSED, USED
99
from lippukala.excs import CantUseException
1010

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

35+
def get_adapter(self) -> LippukalaAdapter:
36+
return self.order.get_adapter()
37+
3538
def _generate_code(self):
3639
qs = self.__class__.objects
40+
adapter = self.get_adapter()
41+
digit_range = adapter.get_code_digit_range(self.prefix)
42+
allow_leading_zeroes = adapter.get_code_allow_leading_zeroes(self.prefix)
43+
3744
for attempt in range(500): # 500 attempts really REALLY should be enough.
38-
n_digits = randint(settings.CODE_MIN_N_DIGITS, settings.CODE_MAX_N_DIGITS + 1)
45+
n_digits = randint(digit_range.start, digit_range.stop - 1)
3946
code = "".join(choice(digits) for x in range(n_digits))
40-
if not settings.CODE_ALLOW_LEADING_ZEROES:
47+
if not allow_leading_zeroes:
4148
code = code.lstrip("0")
4249
# Leading zeroes could have dropped digits off the code, so recheck that.
43-
if settings.CODE_MIN_N_DIGITS <= len(code) <= settings.CODE_MAX_N_DIGITS:
50+
if len(code) in digit_range:
4451
if not qs.filter(code=code).exists():
4552
return code
4653

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

4956
def _generate_literate_code(self):
50-
default_literate_keyspace = settings.LITERATE_KEYSPACES.get(None)
51-
keyspace = settings.LITERATE_KEYSPACES.get(self.prefix) or default_literate_keyspace
57+
adapter = self.get_adapter()
58+
keyspace = adapter.get_literate_keyspace(self.prefix) or adapter.get_literate_keyspace(None)
5259

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

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

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

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

8998
def save(self, *args, **kwargs):
9099
if not self.code:

lippukala/models/order.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from django.db import models
22

3+
from lippukala.models.adapter_mixin import AdapterMixin
34

4-
class Order(models.Model):
5+
6+
class Order(AdapterMixin, models.Model):
57
"""Encapsulates an order, which may contain zero or more codes.
68
79
: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):
2325

2426
def __str__(self):
2527
return "LK-%08d (ref %s)" % (self.pk, self.reference_number)
28+
29+
def __init__(self, *args, **kwargs) -> None:
30+
adapter = kwargs.pop("adapter", None)
31+
if adapter:
32+
self._adapter = adapter
33+
super().__init__(*args, **kwargs)

lippukala/printing.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
from reportlab.lib.units import cm, mm
77
from reportlab.pdfgen.canvas import Canvas
88

9-
from lippukala.settings import PRINT_LOGO_PATH, PRINT_LOGO_SIZE_CM
10-
119

1210
class Bold(str):
1311
pass
@@ -61,7 +59,7 @@ class OrderPrinter:
6159

6260
ONE_TICKET_PER_PAGE = False
6361

64-
def __init__(self, print_logo_path=PRINT_LOGO_PATH, print_logo_size_cm=PRINT_LOGO_SIZE_CM):
62+
def __init__(self, *, print_logo_path, print_logo_size_cm):
6563
self.output = BytesIO()
6664
self.canvas = Canvas(self.output, pagesize=(self.PAGE_WIDTH, self.PAGE_HEIGHT))
6765
self.n_orders = 0

0 commit comments

Comments
 (0)