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.
@@ -0,0 +1,3 @@
1
+ """Client-side integration for a catalogue-server API."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class CatalogueClientConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "catalogue_client"
7
+ verbose_name = "Catalogue client"
@@ -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"))
@@ -0,0 +1,9 @@
1
+ from django.urls import path
2
+
3
+ from . import views
4
+
5
+ app_name = "catalogue_client"
6
+
7
+ urlpatterns = [
8
+ path("", views.product_list, name="list"),
9
+ ]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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