Skip to content

Commit 5c7e45e

Browse files
committed
Implement adapters for settings customization
1 parent d670fd4 commit 5c7e45e

File tree

13 files changed

+255
-117
lines changed

13 files changed

+255
-117
lines changed

lippukala/adapter/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from functools import cache
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+
DEFAULT_ADAPTER_REFERENCE = "lippukala.adapter.default.DefaultLippukalaAdapter"
10+
11+
12+
@cache
13+
def get_adapter_class() -> type[LippukalaAdapter]:
14+
adapter_class_name = getattr(settings, "LIPPUKALA_ADAPTER_CLASS", DEFAULT_ADAPTER_REFERENCE)
15+
return import_string(adapter_class_name)
16+
17+
18+
def get_adapter(request: HttpRequest) -> LippukalaAdapter:
19+
return get_adapter_class()(request)

lippukala/adapter/base.py

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

lippukala/adapter/default.py

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

lippukala/settings.py

+4-83
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,4 @@
1-
import os
2-
from string import digits
3-
4-
from django.conf import settings
5-
from django.core.exceptions import ImproperlyConfigured
6-
7-
8-
def get_setting(name, default=None):
9-
return getattr(settings, f"LIPPUKALA_{name}", default)
10-
11-
12-
def get_integer_setting(name, default=0):
13-
try:
14-
value = get_setting(name, default)
15-
return int(value)
16-
except ValueError: # pragma: no cover
17-
raise ImproperlyConfigured(f"LIPPUKALA_{name} must be an integer (got {value!r})")
18-
19-
20-
PREFIXES = get_setting("PREFIXES", {})
21-
LITERATE_KEYSPACES = get_setting("LITERATE_KEYSPACES", {})
22-
CODE_MIN_N_DIGITS = get_integer_setting("CODE_MIN_N_DIGITS", 10)
23-
CODE_MAX_N_DIGITS = get_integer_setting("CODE_MAX_N_DIGITS", 10)
24-
CODE_ALLOW_LEADING_ZEROES = bool(get_setting("CODE_ALLOW_LEADING_ZEROES", True))
25-
PRINT_LOGO_PATH = get_setting("PRINT_LOGO_PATH")
26-
PRINT_LOGO_SIZE_CM = get_setting("PRINT_LOGO_SIZE_CM")
27-
28-
if PREFIXES:
29-
PREFIX_CHOICES = [(p, f"{p} [{t}]") for (p, t) in sorted(PREFIXES.items())]
30-
PREFIX_MAY_BE_BLANK = False
31-
else:
32-
PREFIX_CHOICES = [("", "---")]
33-
PREFIX_MAY_BE_BLANK = True
34-
35-
36-
def validate_settings(): # pragma: no cover
37-
_validate_code()
38-
_validate_prefixes()
39-
_validate_print()
40-
41-
42-
def _validate_code():
43-
if CODE_MIN_N_DIGITS <= 5 or CODE_MAX_N_DIGITS < CODE_MIN_N_DIGITS:
44-
raise ImproperlyConfigured(
45-
"The range (%d .. %d) for Lippukala code digits is invalid"
46-
% (CODE_MIN_N_DIGITS, CODE_MAX_N_DIGITS)
47-
)
48-
49-
50-
def _validate_prefixes():
51-
key_lengths = [len(k) for k in PREFIXES]
52-
if key_lengths and not all(k == key_lengths[0] for k in key_lengths):
53-
raise ImproperlyConfigured("All LIPPUKALA_PREFIXES keys must be the same length!")
54-
for prefix in PREFIXES:
55-
if not all(c in digits for c in prefix):
56-
raise ImproperlyConfigured(
57-
f"The prefix {prefix!r} has invalid characters. Only digits are allowed."
58-
)
59-
for prefix, literate_keyspace in list(LITERATE_KEYSPACES.items()):
60-
if isinstance(literate_keyspace, str):
61-
raise ImproperlyConfigured(
62-
f"A string ({literate_keyspace!r}) was passed as the literate keyspace for prefix {prefix!r}"
63-
)
64-
too_short_keys = any(len(key) <= 1 for key in literate_keyspace)
65-
maybe_duplicate = len(set(literate_keyspace)) != len(literate_keyspace)
66-
if too_short_keys or maybe_duplicate:
67-
raise ImproperlyConfigured(
68-
f"The literate keyspace for prefix {prefix!r} has invalid or duplicate entries."
69-
)
70-
71-
72-
def _validate_print():
73-
if PRINT_LOGO_PATH:
74-
if not os.path.isfile(PRINT_LOGO_PATH):
75-
raise ImproperlyConfigured(
76-
f"PRINT_LOGO_PATH was defined, but does not exist ({PRINT_LOGO_PATH!r})"
77-
)
78-
if not all(float(s) > 0 for s in PRINT_LOGO_SIZE_CM):
79-
raise ImproperlyConfigured(f"PRINT_LOGO_SIZE_CM values not valid: {PRINT_LOGO_SIZE_CM!r}")
80-
81-
82-
validate_settings()
83-
del validate_settings # aaaand it's gone
1+
raise NotImplementedError(
2+
"Do not import anything from `lippukala.settings`! "
3+
"Please migrate your code to use LippukalaAdapter subclasses."
4+
)

lippukala_test_app/__main__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def seed():
1111
from lippukala_tests.utils import _create_test_order
1212

1313
for x in range(20):
14-
print(_create_test_order().pk)
14+
print(_create_test_order(None).pk)
1515

1616

1717
def manage():

lippukala_tests/conftest.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
from lippukala.adapter import LippukalaAdapter, get_adapter
4+
5+
6+
@pytest.fixture
7+
def adapter(rf) -> LippukalaAdapter:
8+
return get_adapter(rf.get("/"))

0 commit comments

Comments
 (0)