Skip to content

Commit

Permalink
Implement adapters for settings customization
Browse files Browse the repository at this point in the history
  • Loading branch information
akx committed May 28, 2024
1 parent 73522da commit 86a91a0
Show file tree
Hide file tree
Showing 13 changed files with 276 additions and 124 deletions.
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
87 changes: 4 additions & 83 deletions lippukala/settings.py
Original file line number Diff line number Diff line change
@@ -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."
)
Loading

0 comments on commit 86a91a0

Please sign in to comment.