catalogue-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- catalogue_client/__init__.py +3 -0
- catalogue_client/api.py +102 -0
- catalogue_client/apps.py +7 -0
- catalogue_client/conf.py +21 -0
- catalogue_client/forms.py +10 -0
- catalogue_client/migrations/__init__.py +1 -0
- catalogue_client/models.py +160 -0
- catalogue_client/templates/catalogue_client/product_list.html +21 -0
- catalogue_client/tests/__init__.py +0 -0
- catalogue_client/tests/test_client.py +115 -0
- catalogue_client/urls.py +9 -0
- catalogue_client/utils.py +61 -0
- catalogue_client/views.py +21 -0
- catalogue_client-0.1.0.dist-info/METADATA +125 -0
- catalogue_client-0.1.0.dist-info/RECORD +18 -0
- catalogue_client-0.1.0.dist-info/WHEEL +5 -0
- catalogue_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- catalogue_client-0.1.0.dist-info/top_level.txt +1 -0
catalogue_client/api.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Cached HTTP interface to catalogue-server's read-only API."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from django.core.cache import cache
|
|
8
|
+
|
|
9
|
+
from .conf import get_setting
|
|
10
|
+
from .models import get_product_class
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CatalogueError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CatalogueUnavailable(CatalogueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CatalogueResponseError(CatalogueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CatalogueClient:
|
|
26
|
+
def __init__(self, base_url=None, *, timeout=None, session=None, cache_backend=None):
|
|
27
|
+
base = (base_url or get_setting("CATALOGUE_SERVER_URL") or "").strip()
|
|
28
|
+
if not base:
|
|
29
|
+
raise CatalogueError("Set CATALOGUE_SERVER_URL to the catalogue-server origin.")
|
|
30
|
+
self.base_url = base.rstrip("/") + "/"
|
|
31
|
+
api_path = str(get_setting("CATALOGUE_CLIENT_API_PATH")).strip("/")
|
|
32
|
+
self.api_url = urljoin(self.base_url, f"{api_path}/")
|
|
33
|
+
self.timeout = timeout if timeout is not None else get_setting("CATALOGUE_CLIENT_TIMEOUT")
|
|
34
|
+
self.session = session or requests.Session()
|
|
35
|
+
self.cache = cache_backend or cache
|
|
36
|
+
|
|
37
|
+
def _cache_key(self, endpoint, params):
|
|
38
|
+
encoded = json.dumps([self.api_url, endpoint, params], sort_keys=True, separators=(",", ":"))
|
|
39
|
+
return "catalogue-client:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
40
|
+
|
|
41
|
+
def _get(self, endpoint, params=None):
|
|
42
|
+
params = {key: value for key, value in (params or {}).items() if value not in (None, "", [], ())}
|
|
43
|
+
key = self._cache_key(endpoint, params)
|
|
44
|
+
cached = self.cache.get(key)
|
|
45
|
+
if cached is not None:
|
|
46
|
+
return cached
|
|
47
|
+
try:
|
|
48
|
+
response = self.session.get(
|
|
49
|
+
urljoin(self.api_url, endpoint.lstrip("/")),
|
|
50
|
+
params=params,
|
|
51
|
+
headers={"Accept": "application/json"},
|
|
52
|
+
timeout=self.timeout,
|
|
53
|
+
)
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
payload = response.json()
|
|
56
|
+
except (requests.RequestException, ValueError) as exc:
|
|
57
|
+
raise CatalogueUnavailable(f"Could not retrieve the catalogue: {exc}") from exc
|
|
58
|
+
if not isinstance(payload, dict):
|
|
59
|
+
raise CatalogueResponseError("The catalogue returned a non-object JSON response.")
|
|
60
|
+
self.cache.set(key, payload, get_setting("CATALOGUE_CLIENT_CACHE_SECONDS"))
|
|
61
|
+
return payload
|
|
62
|
+
|
|
63
|
+
def list_products(self, *, country=None, slugs=None, kinds=None, groups=None, q=None, attributes=None):
|
|
64
|
+
"""Every matching product. *attributes* maps an attribute name to a value or list of values."""
|
|
65
|
+
params = {
|
|
66
|
+
"country": country,
|
|
67
|
+
"slug": _csv(slugs),
|
|
68
|
+
"kind": _csv(kinds),
|
|
69
|
+
"group": _csv(groups),
|
|
70
|
+
"q": q,
|
|
71
|
+
"limit": 200,
|
|
72
|
+
"offset": 0,
|
|
73
|
+
}
|
|
74
|
+
for name, value in (attributes or {}).items():
|
|
75
|
+
params[f"attributes.{name}"] = _csv(value)
|
|
76
|
+
rows = []
|
|
77
|
+
while True:
|
|
78
|
+
payload = self._get("products/", params)
|
|
79
|
+
page = payload.get("results")
|
|
80
|
+
if not isinstance(page, list):
|
|
81
|
+
raise CatalogueResponseError("The catalogue products response has no results list.")
|
|
82
|
+
rows.extend(page)
|
|
83
|
+
if len(rows) >= int(payload.get("count", len(rows))) or not page:
|
|
84
|
+
break
|
|
85
|
+
params["offset"] = len(rows)
|
|
86
|
+
product_class = get_product_class()
|
|
87
|
+
return [product_class.from_dict(row) for row in rows]
|
|
88
|
+
|
|
89
|
+
def get_product(self, slug, *, country=None):
|
|
90
|
+
if not slug:
|
|
91
|
+
return None
|
|
92
|
+
return get_product_class().from_dict(self._get(f"products/{slug}/", {"country": country}))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _csv(value):
|
|
96
|
+
if isinstance(value, (list, tuple, set)):
|
|
97
|
+
return ",".join(str(item) for item in value if item)
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_client():
|
|
102
|
+
return CatalogueClient()
|
catalogue_client/apps.py
ADDED
catalogue_client/conf.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DEFAULTS = {
|
|
5
|
+
"CATALOGUE_SERVER_URL": "",
|
|
6
|
+
"CATALOGUE_CLIENT_API_PATH": "/api/v1/",
|
|
7
|
+
"CATALOGUE_CLIENT_TIMEOUT": 10,
|
|
8
|
+
"CATALOGUE_CLIENT_CACHE_SECONDS": 3600,
|
|
9
|
+
"CATALOGUE_CLIENT_DEFAULT_COUNTRY": "",
|
|
10
|
+
"CATALOGUE_CLIENT_TEMPLATE": "catalogue_client/product_list.html",
|
|
11
|
+
# Dotted path to a Product subclass that every returned product is built as.
|
|
12
|
+
"CATALOGUE_CLIENT_PRODUCT_CLASS": "",
|
|
13
|
+
# Dotted path to ``(site) -> dict`` of list_products() filters that apply to a
|
|
14
|
+
# site, e.g. ``{"attributes": {"language": "cs"}}`` for a Czech site.
|
|
15
|
+
"CATALOGUE_CLIENT_SITE_FILTERS": "",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_setting(name):
|
|
20
|
+
"""Read a client setting at call time so override_settings works in tests."""
|
|
21
|
+
return getattr(settings, name, DEFAULTS[name])
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CatalogueProductChoiceField(forms.ChoiceField):
|
|
5
|
+
"""A slug choice listing catalogue products by their ``choice_label``."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, *args, products=(), empty_label="---------", **kwargs):
|
|
8
|
+
super().__init__(*args, **kwargs)
|
|
9
|
+
choices = [(product.slug, product.choice_label) for product in products]
|
|
10
|
+
self.choices = ([("", empty_label)] if empty_label is not None else []) + choices
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Value objects built from catalogue-server's API (there are no local ORM models).
|
|
2
|
+
|
|
3
|
+
A site that needs its own vocabulary (a book's author, a garment's size) subclasses
|
|
4
|
+
``Product`` and names the subclass in ``CATALOGUE_CLIENT_PRODUCT_CLASS``: every
|
|
5
|
+
product the client returns is then an instance of it.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field, fields
|
|
8
|
+
from datetime import date
|
|
9
|
+
|
|
10
|
+
from django.urls import NoReverseMatch, reverse
|
|
11
|
+
from django.utils.module_loading import import_string
|
|
12
|
+
|
|
13
|
+
from .conf import get_setting
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Image:
|
|
18
|
+
url: str
|
|
19
|
+
|
|
20
|
+
def get_absolute_url(self):
|
|
21
|
+
return self.url
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PurchaseInfo:
|
|
26
|
+
"""Buying information for one country, as catalogue-server computed it."""
|
|
27
|
+
|
|
28
|
+
country: str = ""
|
|
29
|
+
is_country_specific: bool = False
|
|
30
|
+
region_label: str = ""
|
|
31
|
+
currency: str = ""
|
|
32
|
+
price: str = ""
|
|
33
|
+
formatted_price: str = ""
|
|
34
|
+
best_offer: dict | None = None
|
|
35
|
+
offers: list = field(default_factory=list)
|
|
36
|
+
other_offers: list = field(default_factory=list)
|
|
37
|
+
store_links: list = field(default_factory=list)
|
|
38
|
+
note: str = ""
|
|
39
|
+
available_from: str | None = None
|
|
40
|
+
distributors: list = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, data):
|
|
44
|
+
data = data if isinstance(data, dict) else {}
|
|
45
|
+
return cls(
|
|
46
|
+
country=str(data.get("country") or ""),
|
|
47
|
+
is_country_specific=bool(data.get("is_country_specific")),
|
|
48
|
+
region_label=str(data.get("region_label") or ""),
|
|
49
|
+
currency=str(data.get("currency") or ""),
|
|
50
|
+
price=str(data.get("price") or ""),
|
|
51
|
+
formatted_price=str(data.get("formatted_price") or ""),
|
|
52
|
+
best_offer=data.get("best_offer") if isinstance(data.get("best_offer"), dict) else None,
|
|
53
|
+
offers=list(data.get("offers") or []),
|
|
54
|
+
other_offers=list(data.get("other_offers") or []),
|
|
55
|
+
store_links=list(data.get("store_links") or []),
|
|
56
|
+
note=str(data.get("note") or ""),
|
|
57
|
+
available_from=data.get("available_from") or None,
|
|
58
|
+
distributors=list(data.get("distributors") or []),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def has_buy_options(self):
|
|
63
|
+
return bool(self.store_links or self.offers or self.note)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def direct_buy_link(self):
|
|
67
|
+
"""The only store link when there is nothing else to say, else None."""
|
|
68
|
+
if len(self.store_links) == 1 and not (self.note or "").strip():
|
|
69
|
+
return self.store_links[0]
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_upcoming(self):
|
|
74
|
+
if not self.available_from:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
return date.fromisoformat(self.available_from) > date.today()
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Product:
|
|
84
|
+
"""One published product. ``attributes`` holds the server site's own fields;
|
|
85
|
+
``extra`` holds keys the server site added with CATALOGUE_API_PRODUCT_DATA."""
|
|
86
|
+
|
|
87
|
+
slug: str
|
|
88
|
+
title: str
|
|
89
|
+
subtitle: str = ""
|
|
90
|
+
kind: str = ""
|
|
91
|
+
identifier: str = ""
|
|
92
|
+
attributes: dict = field(default_factory=dict)
|
|
93
|
+
group: dict | None = None
|
|
94
|
+
short_description: str = ""
|
|
95
|
+
description_html: str = ""
|
|
96
|
+
image_url: str = ""
|
|
97
|
+
images: list = field(default_factory=list)
|
|
98
|
+
url: str = ""
|
|
99
|
+
external_url: str = ""
|
|
100
|
+
related_products: list = field(default_factory=list)
|
|
101
|
+
purchase: PurchaseInfo = field(default_factory=PurchaseInfo)
|
|
102
|
+
extra: dict = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, data):
|
|
106
|
+
data = data if isinstance(data, dict) else {}
|
|
107
|
+
known = {item.name for item in fields(Product)}
|
|
108
|
+
return cls(
|
|
109
|
+
slug=str(data.get("slug") or ""),
|
|
110
|
+
title=str(data.get("title") or ""),
|
|
111
|
+
subtitle=str(data.get("subtitle") or ""),
|
|
112
|
+
kind=str(data.get("kind") or ""),
|
|
113
|
+
identifier=str(data.get("identifier") or ""),
|
|
114
|
+
attributes=data.get("attributes") if isinstance(data.get("attributes"), dict) else {},
|
|
115
|
+
group=data.get("group") if isinstance(data.get("group"), dict) else None,
|
|
116
|
+
short_description=str(data.get("short_description") or ""),
|
|
117
|
+
description_html=str(data.get("description_html") or ""),
|
|
118
|
+
image_url=str(data.get("image_url") or ""),
|
|
119
|
+
images=list(data.get("images") or []),
|
|
120
|
+
url=str(data.get("url") or ""),
|
|
121
|
+
external_url=str(data.get("external_url") or ""),
|
|
122
|
+
related_products=list(data.get("related_products") or []),
|
|
123
|
+
purchase=PurchaseInfo.from_dict(data.get("purchase")),
|
|
124
|
+
extra={key: value for key, value in data.items() if key not in known},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def attr(self, name, default=""):
|
|
128
|
+
"""An attribute of the product, or *default* when the server has none."""
|
|
129
|
+
value = self.attributes.get(name)
|
|
130
|
+
return default if value in (None, "") else value
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def pk(self):
|
|
134
|
+
"""A stable, template-friendly identity (there is no local database row)."""
|
|
135
|
+
return self.slug
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def anchor_id(self):
|
|
139
|
+
return f"product-{self.slug}"
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def image(self):
|
|
143
|
+
return Image(self.image_url) if self.image_url else None
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def choice_label(self):
|
|
147
|
+
"""How a choice field lists the product; subclasses add what tells products apart."""
|
|
148
|
+
return f"{self.title} ({self.kind})" if self.kind else self.title
|
|
149
|
+
|
|
150
|
+
def get_absolute_url(self):
|
|
151
|
+
"""This site's product list when catalogue_client.urls is mounted, else the server's page."""
|
|
152
|
+
try:
|
|
153
|
+
return f"{reverse('catalogue_client:list')}?product={self.slug}#{self.anchor_id}"
|
|
154
|
+
except NoReverseMatch:
|
|
155
|
+
return self.url
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_product_class():
|
|
159
|
+
path = get_setting("CATALOGUE_CLIENT_PRODUCT_CLASS")
|
|
160
|
+
return import_string(path) if path else Product
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>Products</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<main>
|
|
6
|
+
<h1>Products</h1>
|
|
7
|
+
{% if catalogue_error %}<p role="alert">{{ catalogue_error }}</p>{% endif %}
|
|
8
|
+
{% for product in products %}
|
|
9
|
+
<article id="{{ product.anchor_id }}">
|
|
10
|
+
{% if product.image_url %}<a href="{{ product.url }}"><img src="{{ product.image_url }}" alt="" loading="lazy"></a>{% endif %}
|
|
11
|
+
<h2><a href="{{ product.url }}">{{ product.title }}</a></h2>
|
|
12
|
+
{% if product.subtitle %}<p>{{ product.subtitle }}</p>{% endif %}
|
|
13
|
+
{% if product.short_description %}<p>{{ product.short_description }}</p>{% endif %}
|
|
14
|
+
{% if product.purchase.formatted_price %}<p>{{ product.purchase.formatted_price }}</p>{% endif %}
|
|
15
|
+
</article>
|
|
16
|
+
{% empty %}
|
|
17
|
+
{% if not catalogue_error %}<p>No products are available.</p>{% endif %}
|
|
18
|
+
{% endfor %}
|
|
19
|
+
</main>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
from django.test import RequestFactory, SimpleTestCase, override_settings
|
|
4
|
+
|
|
5
|
+
from catalogue_client.api import CatalogueClient
|
|
6
|
+
from catalogue_client.forms import CatalogueProductChoiceField
|
|
7
|
+
from catalogue_client.models import Product
|
|
8
|
+
from catalogue_client.utils import products_for_request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PRODUCT = {
|
|
12
|
+
"slug": "sport-and-meditation",
|
|
13
|
+
"title": "Sport and Meditation",
|
|
14
|
+
"kind": "book",
|
|
15
|
+
"identifier": "9780884971234",
|
|
16
|
+
"attributes": {"author": "Sri Chinmoy", "language": "en"},
|
|
17
|
+
"group": {"slug": "sport-and-meditation", "title": "Sport and Meditation"},
|
|
18
|
+
"image_url": "https://books.example/media/cover.jpg",
|
|
19
|
+
"url": "https://books.example/books/sport-and-meditation/",
|
|
20
|
+
"description_html": "<p>Description</p>",
|
|
21
|
+
"purchase": {
|
|
22
|
+
"country": "GB",
|
|
23
|
+
"currency": "GBP",
|
|
24
|
+
"price": "16.99",
|
|
25
|
+
"formatted_price": "£16.99",
|
|
26
|
+
"store_links": [{"label": "Shop", "url": "https://shop.example/book"}],
|
|
27
|
+
},
|
|
28
|
+
"reading_age": "12+",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryCache:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.values = {}
|
|
35
|
+
|
|
36
|
+
def get(self, key):
|
|
37
|
+
return self.values.get(key)
|
|
38
|
+
|
|
39
|
+
def set(self, key, value, timeout):
|
|
40
|
+
self.values[key] = value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Book(Product):
|
|
44
|
+
"""What a consuming site's own subclass looks like."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def author(self):
|
|
48
|
+
return self.attr("author")
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def choice_label(self):
|
|
52
|
+
return f"{self.title} ({self.attr('language').upper()})"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def czech_sites(site):
|
|
56
|
+
return {"attributes": {"language": "cs"}}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def session_returning(payload):
|
|
60
|
+
response = Mock()
|
|
61
|
+
response.raise_for_status.return_value = None
|
|
62
|
+
response.json.return_value = payload
|
|
63
|
+
session = Mock()
|
|
64
|
+
session.get.return_value = response
|
|
65
|
+
return session
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProductValueTests(SimpleTestCase):
|
|
69
|
+
def test_api_data_exposes_the_display_interface(self):
|
|
70
|
+
product = Product.from_dict(PRODUCT)
|
|
71
|
+
self.assertEqual(product.pk, "sport-and-meditation")
|
|
72
|
+
self.assertEqual(product.image.get_absolute_url(), PRODUCT["image_url"])
|
|
73
|
+
self.assertEqual(product.attr("author"), "Sri Chinmoy")
|
|
74
|
+
self.assertEqual(product.attr("isbn", "none"), "none")
|
|
75
|
+
self.assertEqual(product.purchase.formatted_price, "£16.99")
|
|
76
|
+
self.assertEqual(product.purchase.direct_buy_link["label"], "Shop")
|
|
77
|
+
|
|
78
|
+
def test_keys_added_by_the_server_site_are_kept(self):
|
|
79
|
+
self.assertEqual(Product.from_dict(PRODUCT).extra, {"reading_age": "12+"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@override_settings(CATALOGUE_CLIENT_CACHE_SECONDS=60)
|
|
83
|
+
class ApiClientTests(SimpleTestCase):
|
|
84
|
+
def test_list_products_passes_filters_and_caches(self):
|
|
85
|
+
session = session_returning({"count": 1, "results": [PRODUCT]})
|
|
86
|
+
client = CatalogueClient("https://books.example", session=session, cache_backend=MemoryCache())
|
|
87
|
+
|
|
88
|
+
first = client.list_products(country="GB", kinds=["book"], attributes={"language": ["en", "cs"]})
|
|
89
|
+
second = client.list_products(country="GB", kinds=["book"], attributes={"language": ["en", "cs"]})
|
|
90
|
+
|
|
91
|
+
self.assertEqual(first[0].slug, "sport-and-meditation")
|
|
92
|
+
self.assertEqual(second[0].purchase.country, "GB")
|
|
93
|
+
self.assertEqual(session.get.call_count, 1)
|
|
94
|
+
self.assertTrue(session.get.call_args.args[0].endswith("/api/v1/products/"))
|
|
95
|
+
params = session.get.call_args.kwargs["params"]
|
|
96
|
+
self.assertEqual((params["country"], params["kind"], params["attributes.language"]), ("GB", "book", "en,cs"))
|
|
97
|
+
|
|
98
|
+
@override_settings(CATALOGUE_CLIENT_PRODUCT_CLASS="catalogue_client.tests.test_client.Book")
|
|
99
|
+
def test_products_are_built_as_the_site_class(self):
|
|
100
|
+
session = session_returning(PRODUCT)
|
|
101
|
+
client = CatalogueClient("https://books.example", session=session, cache_backend=MemoryCache())
|
|
102
|
+
book = client.get_product("sport-and-meditation", country="GB")
|
|
103
|
+
self.assertIsInstance(book, Book)
|
|
104
|
+
self.assertEqual(book.author, "Sri Chinmoy")
|
|
105
|
+
field = CatalogueProductChoiceField(products=[book])
|
|
106
|
+
self.assertEqual(field.choices[1], ("sport-and-meditation", "Sport and Meditation (EN)"))
|
|
107
|
+
|
|
108
|
+
@override_settings(CATALOGUE_CLIENT_SITE_FILTERS="catalogue_client.tests.test_client.czech_sites")
|
|
109
|
+
def test_site_filters_apply_to_request_lookups(self):
|
|
110
|
+
session = session_returning({"count": 0, "results": []})
|
|
111
|
+
client = CatalogueClient("https://books.example", session=session, cache_backend=MemoryCache())
|
|
112
|
+
request = RequestFactory().get("/", {"country": "cz"})
|
|
113
|
+
products_for_request(request, client=client)
|
|
114
|
+
params = session.get.call_args.kwargs["params"]
|
|
115
|
+
self.assertEqual((params["country"], params["attributes.language"]), ("CZ", "cs"))
|
catalogue_client/urls.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from django.utils.module_loading import import_string
|
|
4
|
+
|
|
5
|
+
from .api import get_client
|
|
6
|
+
from .conf import get_setting
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def site_country(site):
|
|
10
|
+
domain_settings = getattr(site, "domain_settings", None) if site else None
|
|
11
|
+
return (
|
|
12
|
+
getattr(domain_settings, "country", "")
|
|
13
|
+
or get_setting("CATALOGUE_CLIENT_DEFAULT_COUNTRY")
|
|
14
|
+
).strip().upper()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def country_for_request(request):
|
|
18
|
+
"""?country=, then Cloudflare's CF-IPCountry, then the site's country.
|
|
19
|
+
|
|
20
|
+
Always pass a country to the server: without one it would locate the web
|
|
21
|
+
server's IP address instead of the visitor's.
|
|
22
|
+
"""
|
|
23
|
+
if request is None:
|
|
24
|
+
return get_setting("CATALOGUE_CLIENT_DEFAULT_COUNTRY")
|
|
25
|
+
explicit = (request.GET.get("country") or "").strip().upper()
|
|
26
|
+
if len(explicit) == 2:
|
|
27
|
+
return explicit
|
|
28
|
+
cloudflare = (request.META.get("HTTP_CF_IPCOUNTRY") or "").strip().upper()
|
|
29
|
+
if len(cloudflare) == 2 and cloudflare not in {"XX", "T1"}:
|
|
30
|
+
return cloudflare
|
|
31
|
+
return site_country(getattr(request, "site", None))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def site_filters(site):
|
|
35
|
+
"""The CATALOGUE_CLIENT_SITE_FILTERS filters for *site* ({} without the setting)."""
|
|
36
|
+
path = get_setting("CATALOGUE_CLIENT_SITE_FILTERS")
|
|
37
|
+
return dict(import_string(path)(site) or {}) if path else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def products_for_site(site, *, country=None, client=None, **filters):
|
|
41
|
+
return (client or get_client()).list_products(
|
|
42
|
+
country=country if country is not None else site_country(site),
|
|
43
|
+
**{**site_filters(site), **filters},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def products_for_request(request, *, client=None, **filters):
|
|
48
|
+
site = getattr(request, "site", None)
|
|
49
|
+
return (client or get_client()).list_products(
|
|
50
|
+
country=country_for_request(request),
|
|
51
|
+
**{**site_filters(site), **filters},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def random_products_for_request(request, limit, *, client=None, **filters):
|
|
56
|
+
products = products_for_request(request, client=client, **filters)
|
|
57
|
+
return random.sample(products, min(max(int(limit), 0), len(products)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def product_for_request(request, slug, *, client=None):
|
|
61
|
+
return (client or get_client()).get_product(slug, country=country_for_request(request))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from django.shortcuts import render
|
|
2
|
+
|
|
3
|
+
from .api import CatalogueError
|
|
4
|
+
from .conf import get_setting
|
|
5
|
+
from .utils import products_for_request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def product_list(request):
|
|
9
|
+
error = ""
|
|
10
|
+
try:
|
|
11
|
+
products = products_for_request(request)
|
|
12
|
+
except CatalogueError as exc:
|
|
13
|
+
products = []
|
|
14
|
+
error = str(exc)
|
|
15
|
+
selected_slug = (request.GET.get("product") or "").strip()
|
|
16
|
+
selected_product = next((product for product in products if product.slug == selected_slug), None)
|
|
17
|
+
return render(request, get_setting("CATALOGUE_CLIENT_TEMPLATE"), {
|
|
18
|
+
"products": products,
|
|
19
|
+
"selected_product": selected_product,
|
|
20
|
+
"catalogue_error": error,
|
|
21
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: catalogue-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django client for showing products from a catalogue-server site
|
|
5
|
+
Author: Vasudeva Server
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://vasudevaserver.org
|
|
8
|
+
Project-URL: Source, https://gitlab.com/vasudeva-server/catalogue-client
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 5.2
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: Django<6.0,>=5.2
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# catalogue-client
|
|
21
|
+
|
|
22
|
+
`catalogue-client` lets a Django site show products from another site that runs
|
|
23
|
+
[catalogue-server](https://gitlab.com/vasudeva-server/catalogue-server): titles,
|
|
24
|
+
descriptions, images, and the buying options for the visitor's country. It
|
|
25
|
+
stores nothing locally. Every product is a Python value object built from the
|
|
26
|
+
server's read-only JSON API and cached.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
INSTALLED_APPS += ["catalogue_client.apps.CatalogueClientConfig"]
|
|
32
|
+
|
|
33
|
+
CATALOGUE_SERVER_URL = "https://catalogue.example.org"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Optionally mount the list page:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
path("products/", include("catalogue_client.urls")),
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Reading products
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from catalogue_client.utils import products_for_request, product_for_request
|
|
46
|
+
|
|
47
|
+
products = products_for_request(request, kinds=["book"], attributes={"language": "en"})
|
|
48
|
+
product = product_for_request(request, "trail-pack")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Filters: `slugs`, `kinds`, `groups`, `q`, and `attributes` (attribute name to one
|
|
52
|
+
value or a list of values). Calls made for a request pass the visitor's country
|
|
53
|
+
(`?country=`, Cloudflare's `CF-IPCountry`, then the site's country), because
|
|
54
|
+
without one the server would locate the web server instead of the visitor.
|
|
55
|
+
`products_for_site(site)` does the same outside a request, for example in a
|
|
56
|
+
management command.
|
|
57
|
+
|
|
58
|
+
A `Product` has the server's generic fields (`slug`, `title`, `subtitle`,
|
|
59
|
+
`kind`, `identifier`, `group`, `short_description`, `description_html`,
|
|
60
|
+
`image_url`, `images`, `url`, `external_url`, `related_products`) and a
|
|
61
|
+
`purchase` (`PurchaseInfo`: price, offers, store links, note, distributors).
|
|
62
|
+
|
|
63
|
+
## Fields the server site added
|
|
64
|
+
|
|
65
|
+
A catalogue-server site keeps its own fields in each product's `attributes`
|
|
66
|
+
(a books site stores the author, language, publisher and so on there), and can
|
|
67
|
+
add computed keys to the API with its `CATALOGUE_API_PRODUCT_DATA` setting. Both
|
|
68
|
+
reach the client unchanged: `product.attributes` and `product.attr("author")`
|
|
69
|
+
for the first, `product.extra` for the second.
|
|
70
|
+
|
|
71
|
+
To give them names, subclass `Product` in your own app and point
|
|
72
|
+
`CATALOGUE_CLIENT_PRODUCT_CLASS` at it. Every product the client returns is then
|
|
73
|
+
built as your class:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# my_site/catalogue.py
|
|
77
|
+
from catalogue_client.models import Product
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Book(Product):
|
|
81
|
+
@property
|
|
82
|
+
def author(self):
|
|
83
|
+
return self.attr("author")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def choice_label(self): # how CatalogueProductChoiceField lists it
|
|
87
|
+
return f"{self.title} ({self.attr('language').upper()})"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
CATALOGUE_CLIENT_PRODUCT_CLASS = "my_site.catalogue.Book"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Filters that always apply to a site, such as showing a Czech site only Czech
|
|
95
|
+
editions, come from `CATALOGUE_CLIENT_SITE_FILTERS`, a function
|
|
96
|
+
`(site) -> dict` of the filters above.
|
|
97
|
+
|
|
98
|
+
## Settings
|
|
99
|
+
|
|
100
|
+
| Setting | Default | |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `CATALOGUE_SERVER_URL` | (required) | catalogue-server origin, no path |
|
|
103
|
+
| `CATALOGUE_CLIENT_API_PATH` | `/api/v1/` | |
|
|
104
|
+
| `CATALOGUE_CLIENT_TIMEOUT` | `10` | seconds |
|
|
105
|
+
| `CATALOGUE_CLIENT_CACHE_SECONDS` | `3600` | |
|
|
106
|
+
| `CATALOGUE_CLIENT_DEFAULT_COUNTRY` | `""` | when neither the request nor the site gives one |
|
|
107
|
+
| `CATALOGUE_CLIENT_TEMPLATE` | `catalogue_client/product_list.html` | template of the list page (context: `products`, `selected_product`, `catalogue_error`) |
|
|
108
|
+
| `CATALOGUE_CLIENT_PRODUCT_CLASS` | `""` | dotted path to your `Product` subclass |
|
|
109
|
+
| `CATALOGUE_CLIENT_SITE_FILTERS` | `""` | dotted path to `(site) -> dict` of filters |
|
|
110
|
+
|
|
111
|
+
The site's country is read from `site.domain_settings.country` when the site
|
|
112
|
+
uses [servicetrees](https://gitlab.com/vasudeva-server/servicetrees); otherwise
|
|
113
|
+
set `CATALOGUE_CLIENT_DEFAULT_COUNTRY`.
|
|
114
|
+
|
|
115
|
+
## Status
|
|
116
|
+
|
|
117
|
+
Unmaintained and provided as-is. There is no commitment to updates, security fixes, or support.
|
|
118
|
+
|
|
119
|
+
Pull requests are welcome and may be looked at. There is no commitment to review or merge them. Contributions are licensed under the same license as this project.
|
|
120
|
+
|
|
121
|
+
Issues are not tracked. Please do not open bug reports or feature requests.
|
|
122
|
+
|
|
123
|
+
This repository is [MIT licensed](LICENSE).
|
|
124
|
+
|
|
125
|
+
[Vasudeva Server](https://vasudevaserver.org)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
catalogue_client/__init__.py,sha256=e3DYJHa2s-GWNRNAJRDyEH8YI0WjWZLmZhZ6SHJKH0E,81
|
|
2
|
+
catalogue_client/api.py,sha256=4sCXvWxHlvYqzYMFOf_5FqkQHSSgt3EgCreGYz8s17E,3851
|
|
3
|
+
catalogue_client/apps.py,sha256=SIr4IkR_FI94z3Lw1P0G4UBopZN4h5fUyVNkcNRS7es,201
|
|
4
|
+
catalogue_client/conf.py,sha256=rZ9XIFJnh2Ca9z3WsKlsFnmycn2TyWcIRFHXxyLR9qc,801
|
|
5
|
+
catalogue_client/forms.py,sha256=IzdZqT6PcTyKVe48xgcxjQzJjN5pLY1f8E32i2wf15w,452
|
|
6
|
+
catalogue_client/models.py,sha256=ptJOTCCT1eNAlK5JwYEycqsM4BCBP5aFM6h8R7LOOVw,5863
|
|
7
|
+
catalogue_client/urls.py,sha256=AeZLnDOrS1WpPIbYsCXMhBB9JPuYaDMN8Pb4HO84N-U,147
|
|
8
|
+
catalogue_client/utils.py,sha256=IQlYHWQ5hNERuWAuvChhSonShjLILuPfIHYC7wWn1D8,2161
|
|
9
|
+
catalogue_client/views.py,sha256=xekV_r6fbc_Dg-kGmuqvBUtMDvS6BgThFwddAdR8cy4,675
|
|
10
|
+
catalogue_client/migrations/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
11
|
+
catalogue_client/templates/catalogue_client/product_list.html,sha256=0R5BXhlmRPxjASzgKKEyIF9iE01W3IdLp3nrStRdddI,921
|
|
12
|
+
catalogue_client/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
catalogue_client/tests/test_client.py,sha256=KMBMXY2tbFckLQ9lDfX_3Jnmks9lMudiO3_FF-oKVUE,4694
|
|
14
|
+
catalogue_client-0.1.0.dist-info/licenses/LICENSE,sha256=cjw5dL5wDMy6mDzC1e2pA6_73FVPaDJnoO1Nxmr582Q,1072
|
|
15
|
+
catalogue_client-0.1.0.dist-info/METADATA,sha256=VPEHiK_YPF_To-9Wo-i970wYzSMf9Ds9Wutm7sa4j64,4705
|
|
16
|
+
catalogue_client-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
17
|
+
catalogue_client-0.1.0.dist-info/top_level.txt,sha256=8KQTQydDz3d9uZdQYBLgzYnUOO2XwuIq8IeqKjaMbcQ,17
|
|
18
|
+
catalogue_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vasudeva Server
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
catalogue_client
|