Metadata-Version: 2.4
Name: catalogue-server
Version: 0.1.0
Summary: Django server and read-only API for a central, country-aware book catalogue
Author: Vasudeva Server
License-Expression: MIT
Project-URL: Homepage, https://vasudevaserver.org
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django<6.0,>=5.2
Provides-Extra: servicetrees
Requires-Dist: servicetrees>=0.1.0; extra == "servicetrees"
Provides-Extra: geoip
Requires-Dist: geoip2>=5; extra == "geoip"
Provides-Extra: shops
Requires-Dist: requests>=2; extra == "shops"
Requires-Dist: beautifulsoup4>=4; extra == "shops"
Dynamic: license-file

# catalogue-server

`catalogue-server` is a Django app for a central book catalogue that other sites
read. It owns works, editions, country-specific prices, distributors,
public catalogue pages, and the read-only JSON API documented in [API.md](API.md).

Use it when one catalogue needs to describe the same work in several editions
and languages, show different availability by visitor country, and publish the
same structured data to other websites. The package contains no catalogue
content and makes no assumptions about a particular author, publisher, shop,
or country.

The central model is a `Work` (the intellectual title), containing one or more
`Book` editions. A book can have country-specific details and offers from
distributors or other shops. Distributors may be maintained by hand or updated
from supported shop platforms.

## Quick start

Install the core package, or select extras for the integrations you use:

```sh
pip install catalogue-server
pip install "catalogue-server[geoip,shops,servicetrees]"
```

Add `catalogue_server.apps.CatalogueServerConfig` to `INSTALLED_APPS`, then
include both URL modules:

```python
path("api/v1/", include("catalogue_server.api_urls")),
path("books/", include("catalogue_server.urls")),
```

Run migrations, then create works, books, distributors, and offers through
Django admin or your project's own editor:

```sh
python manage.py migrate
python manage.py runserver
```

The public catalogue is now under `/books/`, and the read-only API is under
`/api/v1/`. See [API.md](API.md) for endpoints and response formats.

The Django app label remains `catalogue`, so an existing installation can
change its Python import path without renaming database tables or migration
dependencies. `servicetrees` is an optional extra (`catalogue-server[servicetrees]`)
because a book can link to a display page. The image fields do not need it.

Optional settings:

- `GEOIP_COUNTRY_DB`: path to a GeoLite2 Country database.
- `CATALOGUE_DEFAULT_COUNTRY`: fallback ISO country code.
- `CATALOGUE_API_CACHE_SECONDS`: public API cache lifetime (default `3600`).
- `CATALOGUE_SHOP_PARSERS`: extra shop parsers, `{'key': 'dotted.path.to.parse'}`.
- `CATALOGUE_AUTHOR`: for a single-author catalogue, new books' default author, used in
  page titles ("Books by …") and by shop scans to keep unmatched products by that author.
- `CATALOGUE_NAME`: the catalogue's name in page titles (when the site has no `site_name`).
- `CATALOGUE_SHOP_USER_AGENT`: User-Agent for shop scans (name your site and a contact URL).

Signals keep scanned offers matched: adding or deleting a book, or changing what matching
uses (title, subtitle, ISBN, language, format, author), matches the stored shop products
again without asking the shops. `shops.scan.scan_in_background(distributor)` reads one shop
in a thread, for a site's editor to call when a distributor's shop settings change.

## Shops and offers

An `Offer` is one shop selling a book: a link, and when known a price, currency and
stock. A book page shows the best offer for the visitor's country as the main button
(in stock, a distributor before other shops, surest match, cheapest) and every other
shop in a side panel.

Offers come from two places:

- **Scanned:** `python manage.py scan_shops` (daily cron) reads each published
  distributor's online shop with its `shop_parser` and `shop_options`, matches the
  products to books (ISBN, else title: see `shops/scan.py`) and keeps one offer per
  product URL. Products naming one of the catalogue's authors that match no book (or
  all products, with the `keep_unmatched` option) are kept without a book: filter
  offers by "Book: none" and pick the book (scans keep that choice).
  Built-in parsers read WooCommerce's Store API, Shopify's `products.json`,
  Squarespace's `?format=json`, and Wix Stores' public storefront API. A site adds
  its own with `CATALOGUE_SHOP_PARSERS` (`parse(options, session)` yielding
  `shops.ShopProduct`). Needs the `shops` extra.
- **By hand:** for shops without a parser, or that are not distributors
  (marketplaces and app stores), on the book's admin page: a shop name and countries instead of a
  distributor. Without countries such a shop counts as selling everywhere.

Each distributor has a public page (`/books/distributors/<slug>/`) with the books it
sells and their prices.

A distributor's `ships_to` lists every country it delivers to. When it is known it decides
where its offers count as available; when empty only its own `countries` do. Parsers that
can read it (Shopify `meta.json`, or a site's own parsers) fill it on each scan
unless `ships_to_locked`; for the others enter it from the shop's delivery terms and lock it.
WooCommerce's cart-page country list is not used: it mixes selling and shipping countries
and leaves out countries without state data. When no shop delivers to the visitor's
country, the purchase box still shows the best shop, with a warning.

### Built-in shop parser options

Every parser needs `url`, the shop's base URL. The remaining JSON options are
platform-specific:

| Parser | Options |
| --- | --- |
| `woocommerce` | `category`: optional category slug or id |
| `shopify` | `category`: optional collection handle; `currency`: currency code when the feed omits it |
| `squarespace` | `path`: store page path, default `/shop` |
| `wix` | `path`: store page path, default `/shop`; `categories`: names to include; `currency`: fallback currency |

Wix can also set language metadata without baking one shop into the package:
`category_languages` maps a category name or a word in it to an ISO language code,
and `default_language` supplies the fallback. For example:

```json
{
  "url": "https://shop.example",
  "path": "/books",
  "categories": ["Books EN", "Books DE"],
  "category_languages": {"EN": "en", "DE": "de"}
}
```

Keep publisher-, author-, or site-specific cleanup in the host project. Register a
small wrapper with `CATALOGUE_SHOP_PARSERS`; it can call a built-in parser and adjust
the yielded `ShopProduct` objects. This keeps the package useful to unrelated catalogues.

The purchase box also links the book's editions in other languages ("Czech language
edition / Vydání v češtině: …"), those sold in the visitor's country first. The phrases in
each language are `countries.NATIVE_EDITION_LABELS`.

Covers, extra images and distributor logos are image fields. They upload as normal files. When servicetrees mediaapp is installed it becomes the default file storage, so those uploads (and every other file field) are recorded in the media library. Linking a book to a display page still uses servicetrees displaypage.

The package holds no catalogue data: a site loads its own books and distributors
(fixtures, a seed command, or the admin).

## Development

For local development, install the checkout as an editable dependency:

```sh
pip install -e ./path/to/catalogue_server
python manage.py test catalogue_server
```

When testing an integration with `servicetrees`, install that checkout too or
use the `servicetrees` extra. Templates and static files use Django's standard
override rules, so a host project can provide its own catalogue design.

[Vasudeva Server](https://vasudevaserver.org)
