Metadata-Version: 2.4
Name: harriers-django-geocoding
Version: 0.1.0
Summary: Django address autocomplete and reverse geocoding: Google plus free national-register providers (CZ RÚIAN, SK Register adries).
Project-URL: Repository, https://gitlab.com/harriers/django-geocoding
Author-email: "Harriers s.r.o." <devs@harriers.cz>
License-Expression: MIT
License-File: LICENSE
Keywords: address,autocomplete,django,geocoding,postgis,register-adries,reverse-geocoding,ruian
Classifier: Framework :: Django
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.10
Requires-Dist: django>=5.0
Requires-Dist: djangorestframework>=3.15
Requires-Dist: httpx>=0.24
Requires-Dist: psycopg>=3.1
Provides-Extra: celery
Requires-Dist: celery>=5.3; extra == 'celery'
Provides-Extra: dev
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: test
Requires-Dist: pytest-django>=4.8; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Provides-Extra: unfold
Requires-Dist: django-unfold>=0.91; extra == 'unfold'
Description-Content-Type: text/markdown

# harriers-django-geocoding

A self-contained Django app for address autocomplete and reverse geocoding behind a
provider-agnostic interface. Ships a metered **Google** provider (Places Autocomplete
New + Geocoding API) as the default for any country, plus free **local** providers
backed by national address registers — Czech (ČÚZK RÚIAN) and Slovak (ZBGIS Register
adries) today.

## Requirements

- Python 3.10–3.14, Django ≥ 5.0.
- PostgreSQL with PostGIS, reached through GeoDjango and psycopg 3
  (`"ENGINE": "django.contrib.gis.db.backends.postgis"`). Migration `0001` creates the
  `postgis` and `pg_trgm` extensions and LIST-partitions the `Address` table by
  country, so the database user must be allowed to create extensions.
- DRF is a dependency. `drf-spectacular` is not, but is used when the host has it:
  the views then carry full `@extend_schema` annotations. Two extras:

  | Extra | Adds | Without it |
  |---|---|---|
  | `unfold` | [django-unfold](https://unfoldadmin.com/) admin: country dropdown filter, one-click import buttons | plain `django.contrib.admin` registration |
  | `celery` | the import tasks in `harriers_django_geocoding.tasks` | run the import commands from cron or by hand |

## Install

1. `pip install harriers-django-geocoding` — or
   `harriers-django-geocoding[unfold]` for the rich
   [django-unfold](https://unfoldadmin.com/) admin (plain admin fallback otherwise).

2. Add `"harriers_django_geocoding"` to `INSTALLED_APPS` and configure. The app label
   is `geocoding` — that, not the import path, is what names the table, the migrations
   and the admin URLs. With the `[unfold]` extra, also add `"unfold"` and
   `"unfold.contrib.filters"` (the dropdown filter's templates live there) **above**
   `django.contrib.admin`:

   ```python
   GEOCODING = {
       "GOOGLE_MAPS_API_KEY": os.environ.get("GOOGLE_MAPS_API_KEY", ""),
       # DRF permission classes as dotted paths. Default: [IsAuthenticated].
       "PERMISSION_CLASSES": ["myapp.permissions.IsStaff"],
       # Language for Google responses. Default: "en".
       "LANGUAGE": "cs",
       # How far (m) a reverse-geocoded match may lie from the queried point;
       # farther means 204 "no address here". Default: 500.
       "REVERSE_MAX_DISTANCE_M": 500,
       # How many addresses a country must have imported before its local provider
       # takes over from Google, so a partial load does not serve the country from
       # a handful of rows. Default: 10000.
       "LOCAL_MIN_ADDRESSES": 10000,
       # Country a request without a `country` parameter is served as. Unset, such a
       # request goes to Google whatever is imported. Default: "" (none).
       "DEFAULT_COUNTRY": "cz",
       # Shortest word the local providers search a register for; below it the
       # trigram index cannot serve the query and every keystroke would scan the
       # country's whole partition. Default: 3.
       "LOCAL_MIN_QUERY_LENGTH": 3,
   }
   ```

3. Mount the URLs (they define `geocode`, `geocode/details`, `geocode/reverse`):

   ```python
   path("", include("harriers_django_geocoding.urls")),
   ```

4. `python manage.py migrate`.

## HTTP API

All endpoints are GET and permissioned by `GEOCODING["PERMISSION_CLASSES"]`.

| Endpoint | Query params | Returns |
|---|---|---|
| `…/geocode` | `q`, `country` (ISO alpha-2) | `[{ id, label }]` autocomplete predictions |
| `…/geocode/details` | `placeId`, `country` | one resolved address (below), or 204 |
| `…/geocode/reverse` | `lat`, `lng`, `country` | one resolved address (below), or 204 |

Resolved address (`GeocodeResult`):

```json
{ "label": "...", "address": "...", "address_line_2": "...", "city": "...",
  "postcode": "...", "country_code": "cz", "latitude": 0.0, "longitude": 0.0,
  "source": "local:cz" }
```

Serializers use snake_case; with `djangorestframework-camel-case` the responses are
camelCased, and the details view accepts both `placeId` spellings either way.

## Address data import

A local provider serves a country only once its addresses are imported — at least
`GEOCODING["LOCAL_MIN_ADDRESSES"]` of them, so a partial load does not take the
country off Google and then answer from a handful of rows. Until then the country
falls back to Google automatically. Both registers are free and keyless.

| Country | Source | Command / Celery task |
|---|---|---|
| `cz` | ČÚZK RÚIAN monthly bulk CSV (~3M addresses) | `import_cz_addresses` |
| `sk` | ZBGIS Register adries feature service | `import_sk_addresses` |

```
python manage.py import_cz_addresses
```

Every command takes `--limit N` for testing; the CZ one takes `--url` to override
dataset auto-detection. A limited run disables pruning and writes only to a country
whose partition is still empty — it holds an arbitrary slice of the register, which
merged into a populated partition would overwrite the labels a full run disambiguated.

Each run stages the whole snapshot and merges the partition from there: only changed
rows are written, missing rows are pruned (skipped if the run collected suspiciously
few rows), and the live partition is untouched until the snapshot is complete.
Imports are idempotent, keyed on the register's own identifier, so retries and
overlapping runs are safe.

With the `[celery]` extra the same imports run as tasks, scheduled via
`CELERY_BEAT_SCHEDULE`. RÚIAN publishes monthly a few days after month
end, Register adries updates daily, harvested weekly because a full pass takes hours:

```python
CELERY_BEAT_SCHEDULE = {
    "import-cz-addresses": {
        "task": "geocoding.import_cz_addresses",
        "schedule": crontab(day_of_month=6, hour=3, minute=0),
    },
    "import-sk-addresses": {
        "task": "geocoding.import_sk_addresses",
        "schedule": crontab(day_of_week=0, hour=4, minute=0),
    },
}
```

> Run imports in a Celery worker or stable container, not a process that can be
> killed mid-run. CZ takes a few minutes; SK is paced by the remote service (1000 records per response, with pauses
> and backoff).

## Google setup

Enable **both** the *Geocoding API* and the *Places API (New)* on the key's project.
Both are billed per request — check Google's current pricing before pointing a busy
picker at them. A country whose register is imported is served for free instead.
