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.
- catalogue_server/API.md +274 -0
- catalogue_server/__init__.py +0 -0
- catalogue_server/admin.py +151 -0
- catalogue_server/api_urls.py +13 -0
- catalogue_server/api_views.py +276 -0
- catalogue_server/apps.py +13 -0
- catalogue_server/countries.py +85 -0
- catalogue_server/fields.py +12 -0
- catalogue_server/geo.py +74 -0
- catalogue_server/management/__init__.py +0 -0
- catalogue_server/management/commands/__init__.py +0 -0
- catalogue_server/management/commands/scan_shops.py +43 -0
- catalogue_server/migrations/0001_initial.py +156 -0
- catalogue_server/migrations/0002_initial.py +51 -0
- catalogue_server/migrations/__init__.py +0 -0
- catalogue_server/models.py +588 -0
- catalogue_server/shops/__init__.py +17 -0
- catalogue_server/shops/base.py +131 -0
- catalogue_server/shops/platforms.py +225 -0
- catalogue_server/shops/scan.py +290 -0
- catalogue_server/signals.py +57 -0
- catalogue_server/static/catalogue_server/css/catalogue.css +1096 -0
- catalogue_server/templates/catalogue/book_detail.html +62 -0
- catalogue_server/templates/catalogue/book_list.html +43 -0
- catalogue_server/templates/catalogue/distributor_detail.html +43 -0
- catalogue_server/templates/catalogue/distributor_list.html +51 -0
- catalogue_server/templates/catalogue/includes/book_card.html +19 -0
- catalogue_server/templates/catalogue/includes/country_picker.html +19 -0
- catalogue_server/templates/catalogue/includes/distributor_card.html +10 -0
- catalogue_server/templates/catalogue/includes/offer_row.html +11 -0
- catalogue_server/templates/catalogue/includes/offers_panel.html +26 -0
- catalogue_server/templates/catalogue/includes/purchase_box.html +81 -0
- catalogue_server/templatetags/__init__.py +0 -0
- catalogue_server/templatetags/catalogue_tags.py +104 -0
- catalogue_server/urls.py +13 -0
- catalogue_server/views.py +146 -0
- catalogue_server-0.1.0.dist-info/METADATA +176 -0
- catalogue_server-0.1.0.dist-info/RECORD +41 -0
- catalogue_server-0.1.0.dist-info/WHEEL +5 -0
- catalogue_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- catalogue_server-0.1.0.dist-info/top_level.txt +1 -0
catalogue_server/API.md
ADDED
|
@@ -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
|
+
]
|