catalogue-server 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.
Files changed (41) hide show
  1. catalogue_server/API.md +274 -0
  2. catalogue_server/__init__.py +0 -0
  3. catalogue_server/admin.py +151 -0
  4. catalogue_server/api_urls.py +13 -0
  5. catalogue_server/api_views.py +276 -0
  6. catalogue_server/apps.py +13 -0
  7. catalogue_server/countries.py +85 -0
  8. catalogue_server/fields.py +12 -0
  9. catalogue_server/geo.py +74 -0
  10. catalogue_server/management/__init__.py +0 -0
  11. catalogue_server/management/commands/__init__.py +0 -0
  12. catalogue_server/management/commands/scan_shops.py +43 -0
  13. catalogue_server/migrations/0001_initial.py +156 -0
  14. catalogue_server/migrations/0002_initial.py +51 -0
  15. catalogue_server/migrations/__init__.py +0 -0
  16. catalogue_server/models.py +588 -0
  17. catalogue_server/shops/__init__.py +17 -0
  18. catalogue_server/shops/base.py +131 -0
  19. catalogue_server/shops/platforms.py +225 -0
  20. catalogue_server/shops/scan.py +290 -0
  21. catalogue_server/signals.py +57 -0
  22. catalogue_server/static/catalogue_server/css/catalogue.css +1096 -0
  23. catalogue_server/templates/catalogue/book_detail.html +62 -0
  24. catalogue_server/templates/catalogue/book_list.html +43 -0
  25. catalogue_server/templates/catalogue/distributor_detail.html +43 -0
  26. catalogue_server/templates/catalogue/distributor_list.html +51 -0
  27. catalogue_server/templates/catalogue/includes/book_card.html +19 -0
  28. catalogue_server/templates/catalogue/includes/country_picker.html +19 -0
  29. catalogue_server/templates/catalogue/includes/distributor_card.html +10 -0
  30. catalogue_server/templates/catalogue/includes/offer_row.html +11 -0
  31. catalogue_server/templates/catalogue/includes/offers_panel.html +26 -0
  32. catalogue_server/templates/catalogue/includes/purchase_box.html +81 -0
  33. catalogue_server/templatetags/__init__.py +0 -0
  34. catalogue_server/templatetags/catalogue_tags.py +104 -0
  35. catalogue_server/urls.py +13 -0
  36. catalogue_server/views.py +146 -0
  37. catalogue_server-0.1.0.dist-info/METADATA +176 -0
  38. catalogue_server-0.1.0.dist-info/RECORD +41 -0
  39. catalogue_server-0.1.0.dist-info/WHEEL +5 -0
  40. catalogue_server-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. catalogue_server-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ # Book catalogue API (v1)
2
+
3
+ A catalogue-server site publishes its books, cards, audiobooks and music at
4
+ `/api/v1/` (here `https://catalogue.example.org/api/v1/`). Other sites use it to show
5
+ books, prices for the visitor's country and where to buy them, without keeping
6
+ their own copies. The examples below use made-up data.
7
+
8
+ - Read-only JSON. `GET`, `HEAD` and `OPTIONS` only; anything else gets `405`.
9
+ - No authentication. CORS is open: `Access-Control-Allow-Origin: *`, so you can call it from
10
+ browser JavaScript on any site.
11
+ - Responses carry `Cache-Control: public, max-age=3600` (setting `CATALOGUE_API_CACHE_SECONDS`).
12
+ Cache them on your side too.
13
+ - Text is UTF-8. `description_html` and `purchase.note` contain trusted HTML from the catalogue
14
+ editors; all other strings are plain text.
15
+ - Errors return `{"detail": "..."}` with a 404 or 405 status.
16
+
17
+ ## Countries and prices
18
+
19
+ Each book has default buying options (price, shops) plus optional rows for particular
20
+ countries (for example "UK & Ireland" in GBP with UK shops). The `purchase` object in
21
+ every book is already resolved for one country:
22
+
23
+ 1. `?country=XX` (ISO 3166 code, e.g. `GB`, `CZ`) when you pass it, **otherwise**
24
+ 2. the country detected from the request: the `country` cookie on the catalogue site,
25
+ Cloudflare's `CF-IPCountry` header, or a GeoIP lookup of the caller's IP.
26
+
27
+ **Server-side callers should always pass `country`**: without it, the country is
28
+ detected from *your server's* IP, not your visitor's. Browser calls from your
29
+ visitor's page can leave it out, but passing it is still clearer. `?country=` (empty)
30
+ asks for the international defaults.
31
+
32
+ `purchase.is_country_specific` tells you whether a country row matched. When it is
33
+ `false`, show the defaults as general "Buying options" and link to the
34
+ distributors page (`https://catalogue.example.org/books/distributors/`).
35
+
36
+ ## Endpoints
37
+
38
+ ### `GET /api/v1/books/`
39
+
40
+ All published books (editions), ordered by work and edition.
41
+
42
+ | Parameter | Meaning |
43
+ |-----------|---------|
44
+ | `language` | Language code(s), comma-separated: `en`, `cs,sk` |
45
+ | `country` | ISO country for `purchase` (see above) |
46
+ | `work` | Work slug(s): all editions of a title, e.g. `movement-and-focus` |
47
+ | `format` | `book`, `ebook`, `audiobook`, `cards`, `music`, `other` (comma-separated) |
48
+ | `slug` | Book slug(s), comma-separated, in any order |
49
+ | `q` | Text search in title, subtitle, author, descriptions, ISBN and work title |
50
+ | `limit`, `offset` | Paging; `limit` defaults to 50, max 200 |
51
+
52
+ ```bash
53
+ curl 'https://catalogue.example.org/api/v1/books/?language=en&country=GB'
54
+ curl 'https://catalogue.example.org/api/v1/books/?work=movement-and-focus&country=CZ'
55
+ curl 'https://catalogue.example.org/api/v1/books/?slug=quiet-river,forest-notes&country=US'
56
+ curl 'https://catalogue.example.org/api/v1/books/?format=music'
57
+ curl 'https://catalogue.example.org/api/v1/books/?q=training&limit=10&offset=10'
58
+ ```
59
+
60
+ Response:
61
+
62
+ ```json
63
+ {
64
+ "count": 16,
65
+ "limit": 50,
66
+ "offset": 0,
67
+ "country": "GB",
68
+ "results": [ { "...book..." } ]
69
+ }
70
+ ```
71
+
72
+ ### `GET /api/v1/books/<slug>/`
73
+
74
+ One book, same shape as in the list. Accepts `country`.
75
+
76
+ ```bash
77
+ curl 'https://catalogue.example.org/api/v1/books/movement-and-focus/?country=GB'
78
+ ```
79
+
80
+ ```json
81
+ {
82
+ "slug": "movement-and-focus",
83
+ "title": "Movement and Focus",
84
+ "subtitle": "Attention in everyday movement",
85
+ "author": "Ann Author",
86
+ "language": "en",
87
+ "format": "book",
88
+ "work": {"slug": "movement-and-focus", "title": "Movement and Focus"},
89
+ "short_description": "A practical guide to bringing attention into training and competition, ...",
90
+ "description_html": "<p>In this book the author ...</p>",
91
+ "isbn": "978-3-89532-213-6",
92
+ "publisher": "Example Press",
93
+ "year": null,
94
+ "pages": 220,
95
+ "cover_url": "https://catalogue.example.org/media/catalogue/movement-and-focus.jpg",
96
+ "images": ["https://catalogue.example.org/media/catalogue/movement-and-focus-3d.png"],
97
+ "url": "https://catalogue.example.org/books/movement-and-focus/",
98
+ "external_url": "https://publisher.example.org/movement-and-focus/",
99
+ "editions": [
100
+ {"slug": "pohyb-a-soustredeni", "language": "cs", "title": "Pohyb a soustředění", "format": "book",
101
+ "url": "https://catalogue.example.org/books/pohyb-a-soustredeni/"}
102
+ ],
103
+ "purchase": {
104
+ "country": "GB",
105
+ "is_country_specific": true,
106
+ "region_label": "UK & Ireland",
107
+ "currency": "GBP",
108
+ "price": "16.99",
109
+ "formatted_price": "£16.99",
110
+ "best_offer": {
111
+ "shop": "Example Books UK", "distributor": "example-books-uk",
112
+ "url": "https://books-uk.example/product/...", "countries": ["GB", "IE"],
113
+ "region_label": "Great Britain & Ireland", "price": "13.99", "currency": "GBP",
114
+ "formatted_price": "£13.99", "in_stock": true, "last_seen": "2026-10-22T06:00:00+00:00"
115
+ },
116
+ "offers": [{"shop": "Example Books UK", "...": "..."}, {"shop": "Worldwide Books", "...": "..."}],
117
+ "other_offers": [{"shop": "Example Books US", "countries": ["US"], "...": "..."}],
118
+ "store_links": [
119
+ {"label": "Example Books UK", "url": "https://books-uk.example/product/..."},
120
+ {"label": "Worldwide Books", "url": "https://worldwide.example.org/books/123"}
121
+ ],
122
+ "note": "<p>Also at Watkins Books ...</p>",
123
+ "available_from": null,
124
+ "streaming_links": [],
125
+ "distributors": [
126
+ {"name": "Example Books UK", "url": "https://books-uk.example",
127
+ "region_label": "Great Britain & Ireland", "kind": "retail"}
128
+ ]
129
+ }
130
+ }
131
+ ```
132
+
133
+ Field notes:
134
+
135
+ - `slug` is stable; store it if you link a page on your site to a catalogue book.
136
+ - `work` groups editions of the same title (translations, audiobook, new editions); it is
137
+ `null` for a standalone title. `editions` lists the other published editions in the work.
138
+ - `url` is the book's page on the catalogue site (a dedicated page when one exists,
139
+ otherwise the generated one).
140
+ - `cover_url` and `images` are absolute URLs; `cover_url` is `""` when there is no image.
141
+ - `year`, `pages` may be `null`; other missing values are `""`.
142
+ - `purchase.price` is a string as entered (`"16.99"`, `"17,95"`, `"Free"`); show
143
+ `formatted_price`, which adds the currency symbol. It is `""` when no price is known.
144
+ - `purchase.available_from` is an ISO date or `null`. Only worth showing when it is in the future.
145
+ - `purchase.offers`: the shops delivering to that country, best first (in stock, shops in the
146
+ country before shops sending there, a distributor before other shops, cheapest).
147
+ A distributor delivers to its own countries, or to its `ships_to` list when that is known.
148
+ Without a country they are the shops selling everywhere. `other_offers` are all the other
149
+ shops. `best_offer` is the first offer; when no shop delivers to the country it is the best
150
+ shop anywhere, and `best_offer_ships` says whether it delivers there: `true`, `false`, or
151
+ `null` when unknown (or no country) — show a warning unless it is `true`. Shop prices come from the distributors' online shops (`last_seen`
152
+ is when they were last read; `null` for links entered by hand) and are in the shop's
153
+ `currency`; `price` is a decimal string or `null`, `in_stock` is `null` when unknown.
154
+ - `purchase.store_links`: `offers` as `[{label, url}]`, kept for older clients.
155
+ - `purchase.streaming_links`: `[{label, url}]`, for music.
156
+ - `purchase.distributors`: retail distributors serving that country that have no offer for the
157
+ book (empty without a country).
158
+
159
+ ### `GET /api/v1/works/`
160
+
161
+ Every work with its published editions.
162
+
163
+ ```bash
164
+ curl 'https://catalogue.example.org/api/v1/works/'
165
+ ```
166
+
167
+ ```json
168
+ {"count": 16, "results": [
169
+ {"slug": "movement-and-focus", "title": "Movement and Focus", "books": [
170
+ {"slug": "movement-and-focus", "language": "en", "title": "Movement and Focus", "format": "book",
171
+ "url": "https://catalogue.example.org/books/movement-and-focus/"}
172
+ ]}
173
+ ]}
174
+ ```
175
+
176
+ ### `GET /api/v1/distributors/?country=XX[&kind=retail|trade]`
177
+
178
+ Published distributors and shops. With `country`, those serving that country followed by worldwide
179
+ ones (`countries: ["*"]`); without it, all of them. `kind=trade` are the wholesalers for
180
+ bookshops ("For booksellers"). `page_url` is the distributor's page on the catalogue site
181
+ (with the books it sells and their prices); `url` is its own website.
182
+
183
+ ```bash
184
+ curl 'https://catalogue.example.org/api/v1/distributors/?country=AU'
185
+ curl 'https://catalogue.example.org/api/v1/distributors/?kind=trade'
186
+ ```
187
+
188
+ ```json
189
+ {"country": "AU", "count": 3, "results": [
190
+ {"name": "Example Books Australia", "slug": "example-books-australia",
191
+ "url": "https://www.bookshop.example.au/books.htm",
192
+ "region_label": "Australia", "kind": "retail",
193
+ "page_url": "https://catalogue.example.org/books/distributors/example-books-australia/", "countries": ["AU"],
194
+ "logo_url": "https://catalogue.example.org/media/catalogue/example-books-australia.jpg",
195
+ "address": "", "phone": "03 0000 0000", "email": "info@bookshop.example.au", "note": ""}
196
+ ]}
197
+ ```
198
+
199
+ ### `GET /api/v1/countries/`
200
+
201
+ The countries that have their own buying information: a country row on at least one book,
202
+ or a local distributor. Useful to build a country picker or to decide whether to show a
203
+ "prices for your country" hint.
204
+
205
+ ```bash
206
+ curl 'https://catalogue.example.org/api/v1/countries/'
207
+ ```
208
+
209
+ ```json
210
+ {"count": 17, "results": [
211
+ {"code": "GB", "name": "United Kingdom", "books": 9, "distributors": 4}
212
+ ]}
213
+ ```
214
+
215
+ ## Recipe: books in my language, with prices for my visitor's country
216
+
217
+ Say your site is in Czech and you want a "Books" block.
218
+
219
+ 1. Work out the visitor's country on your side: your own GeoIP, Cloudflare's
220
+ `CF-IPCountry` header, or the visitor's choice. Fall back to your site's home country.
221
+ 2. Fetch the books in your language for that country, and cache the response
222
+ (per language + country) for an hour:
223
+
224
+ ```bash
225
+ curl 'https://catalogue.example.org/api/v1/books/?language=cs&country=SK'
226
+ ```
227
+
228
+ 3. For each result show `cover_url`, `title`, `subtitle`, `short_description` and
229
+ `purchase.formatted_price`, linking the title to `url` (or to your own page about the book).
230
+ 4. For the buying box show `purchase.best_offer` as the main button ("Buy from {shop}",
231
+ `formatted_price`), the rest of `purchase.offers` / `other_offers` as "Also available from",
232
+ then `purchase.note`.
233
+ If `purchase.is_country_specific` is `false`, title it "Buying options" and add a link to
234
+ `https://catalogue.example.org/books/distributors/`; `purchase.distributors` gives the
235
+ local shops when there are any.
236
+ 5. Few books in your language? Make a second call with `language=en` and show them as
237
+ "Also in English", or use `editions` on an English book to link to your translation.
238
+
239
+ From the browser (no server code; the visitor's country is detected from their own request):
240
+
241
+ ```html
242
+ <div id="books"></div>
243
+ <script>
244
+ fetch('https://catalogue.example.org/api/v1/books/?language=cs')
245
+ .then(r => r.json())
246
+ .then(data => {
247
+ document.getElementById('books').innerHTML = data.results.map(b => `
248
+ <article>
249
+ <a href="${b.url}"><img src="${b.cover_url}" alt="" width="120"></a>
250
+ <h3><a href="${b.url}">${b.title}</a></h3>
251
+ <p>${b.short_description}</p>
252
+ <p><strong>${b.purchase.formatted_price}</strong></p>
253
+ </article>`).join('');
254
+ });
255
+ </script>
256
+ ```
257
+
258
+ (Escape the strings if you build HTML from them like this; `title` and `short_description`
259
+ are plain text.)
260
+
261
+ From Python on a server:
262
+
263
+ ```python
264
+ import requests
265
+
266
+ def catalogue_books(language, country):
267
+ response = requests.get(
268
+ 'https://catalogue.example.org/api/v1/books/',
269
+ params={'language': language, 'country': country, 'limit': 200},
270
+ timeout=10,
271
+ )
272
+ response.raise_for_status()
273
+ return response.json()['results']
274
+ ```
File without changes
@@ -0,0 +1,151 @@
1
+ from django.contrib import admin, messages
2
+ from django.utils.html import format_html
3
+
4
+ from .models import Book, BookCountryInfo, BookImage, Distributor, Offer, Work
5
+
6
+
7
+ class BookInline(admin.TabularInline):
8
+ model = Book
9
+ fields = ('title', 'slug', 'language', 'format', 'published', 'order')
10
+ show_change_link = True
11
+ extra = 0
12
+
13
+
14
+ @admin.register(Work)
15
+ class WorkAdmin(admin.ModelAdmin):
16
+ list_display = ('title', 'slug', 'edition_count', 'order')
17
+ list_editable = ('order',)
18
+ search_fields = ('title', 'slug', 'books__title')
19
+ prepopulated_fields = {'slug': ('title',)}
20
+ inlines = [BookInline]
21
+
22
+ @admin.display(description='Editions')
23
+ def edition_count(self, obj):
24
+ return obj.books.count()
25
+
26
+
27
+ class BookImageInline(admin.TabularInline):
28
+ model = BookImage
29
+ fields = ('image', 'caption', 'order')
30
+ extra = 0
31
+
32
+
33
+ class BookCountryInfoInline(admin.StackedInline):
34
+ model = BookCountryInfo
35
+ fields = (
36
+ ('countries', 'label'), ('currency', 'price', 'available_from'),
37
+ 'note', 'description', 'order',
38
+ )
39
+ extra = 0
40
+
41
+
42
+ class BookOfferInline(admin.TabularInline):
43
+ model = Offer
44
+ fk_name = 'book'
45
+ fields = ('distributor', 'shop_name', 'countries', 'url', 'price', 'currency', 'in_stock', 'source', 'hidden', 'order')
46
+ readonly_fields = ('source',)
47
+ autocomplete_fields = ('distributor',)
48
+ extra = 0
49
+ verbose_name_plural = 'Shops (offers): scanned ones are updated by scan_shops; add other shops by hand'
50
+
51
+
52
+ @admin.register(Book)
53
+ class BookAdmin(admin.ModelAdmin):
54
+ list_display = ('cover_thumb', 'title', 'language', 'format', 'work', 'price_display', 'country_count', 'published')
55
+ list_display_links = ('cover_thumb', 'title')
56
+ list_filter = ('published', 'language', 'format', 'work')
57
+ search_fields = ('title', 'subtitle', 'slug', 'isbn', 'author', 'publisher', 'work__title')
58
+ list_select_related = ('work',)
59
+ raw_id_fields = ('display_page',)
60
+ autocomplete_fields = ('work',)
61
+ prepopulated_fields = {'slug': ('title',)}
62
+ inlines = [BookImageInline, BookCountryInfoInline, BookOfferInline]
63
+ fieldsets = (
64
+ (None, {'fields': ('work', 'title', 'subtitle', 'slug', 'author', ('language', 'format'), 'published', 'order')}),
65
+ ('Description', {'fields': ('short_description', 'description', 'cover', 'display_page', 'external_url')}),
66
+ ('Details', {'fields': (('isbn', 'publisher'), ('year', 'pages'))}),
67
+ ('Default buying options', {
68
+ 'description': 'Used where no country row below matches the visitor\'s country.',
69
+ 'fields': (('currency', 'price'), 'streaming_links', 'purchase_note'),
70
+ }),
71
+ ('Internal', {'classes': ('collapse',), 'fields': ('source', 'notes')}),
72
+ )
73
+
74
+ def get_queryset(self, request):
75
+ return super().get_queryset(request).prefetch_related('country_infos')
76
+
77
+ @admin.display(description='Cover')
78
+ def cover_thumb(self, obj):
79
+ cover = obj.cover
80
+ if not cover:
81
+ return ''
82
+ return format_html('<img src="{}" alt="" style="height:48px;width:auto">', cover.get_absolute_url())
83
+
84
+ @admin.display(description='Default price')
85
+ def price_display(self, obj):
86
+ return obj.purchase_info().formatted_price
87
+
88
+ @admin.display(description='Countries')
89
+ def country_count(self, obj):
90
+ return len(obj.country_infos.all())
91
+
92
+
93
+ @admin.register(Distributor)
94
+ class DistributorAdmin(admin.ModelAdmin):
95
+ list_display = ('name', 'kind', 'region_label', 'countries', 'shop_parser', 'last_scanned', 'published', 'order')
96
+ list_editable = ('order',)
97
+ list_filter = ('kind', 'published', 'shop_parser')
98
+ search_fields = ('name', 'countries', 'region_label', 'url')
99
+ prepopulated_fields = {'slug': ('name',)}
100
+ readonly_fields = ('last_scanned', 'scan_report')
101
+ actions = ['scan_shops']
102
+ fieldsets = (
103
+ (None, {'fields': ('name', 'slug', 'kind', 'published', 'order')}),
104
+ ('Where', {'fields': (('countries', 'region_label'), 'ships_to', 'ships_to_locked')}),
105
+ ('Page', {'fields': ('logo', 'url', 'note', 'description')}),
106
+ ('Contact', {'fields': ('address', ('phone', 'email'))}),
107
+ ('Online shop', {'fields': ('shop_parser', 'shop_options', 'last_scanned', 'scan_report')}),
108
+ )
109
+
110
+ @admin.action(description='Scan the shops now')
111
+ def scan_shops(self, request, queryset):
112
+ from .shops.base import make_session
113
+ from .shops.scan import scan_distributor
114
+
115
+ session = make_session()
116
+ for distributor in queryset.exclude(shop_parser=''):
117
+ result = scan_distributor(distributor, session=session)
118
+ level = messages.ERROR if result.error else messages.SUCCESS
119
+ self.message_user(request, f'{distributor.name}: {result.summary()}', level)
120
+
121
+
122
+ @admin.register(Offer)
123
+ class OfferAdmin(admin.ModelAdmin):
124
+ """Every offer; filter "Book: none" for scanned products waiting to be linked to a book."""
125
+
126
+ list_display = ('title_or_url', 'book', 'shop', 'price_display', 'in_stock', 'match', 'source', 'hidden', 'last_seen')
127
+ list_filter = (('book', admin.EmptyFieldListFilter), 'distributor', 'source', 'match', 'hidden', 'in_stock')
128
+ list_editable = ('book', 'hidden')
129
+ list_select_related = ('book', 'distributor')
130
+ search_fields = ('title', 'url', 'shop_name', 'book__title', 'isbn')
131
+ autocomplete_fields = ('distributor',)
132
+ raw_id_fields = ('book',)
133
+ readonly_fields = ('last_seen', 'created')
134
+
135
+ def save_model(self, request, obj, form, change):
136
+ # Also called for the list's editable "book" column; scans keep an editor's choice.
137
+ if 'book' in form.changed_data:
138
+ obj.match = Offer.MATCH_MANUAL
139
+ super().save_model(request, obj, form, change)
140
+
141
+ @admin.display(description='Product', ordering='title')
142
+ def title_or_url(self, obj):
143
+ return format_html('<a href="{}" target="_blank" rel="noopener">{}</a>', obj.url, obj.title or obj.url)
144
+
145
+ @admin.display(description='Shop')
146
+ def shop(self, obj):
147
+ return obj.shop_label
148
+
149
+ @admin.display(description='Price', ordering='price')
150
+ def price_display(self, obj):
151
+ return obj.formatted_price
@@ -0,0 +1,13 @@
1
+ from django.urls import path
2
+
3
+ from . import api_views
4
+
5
+ app_name = 'catalogue_api'
6
+
7
+ urlpatterns = [
8
+ path('books/', api_views.books, name='books'),
9
+ path('books/<slug:slug>/', api_views.book, name='book'),
10
+ path('works/', api_views.works, name='works'),
11
+ path('distributors/', api_views.distributors, name='distributors'),
12
+ path('countries/', api_views.country_list, name='countries'),
13
+ ]