Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ COMPOSE_PROFILES=local-postgres

# Docker Swarm stack deployments cannot build from source. Build/tag these locally for a
# single-node Swarm, or set them to registry images available to every Swarm node.
APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.0
NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.0
APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.1
NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.1

# Swarm ttapp-container identity. Match these to the owner of HOST_DATA_DIR on shared storage.
APP_UID=1000
Expand Down
5 changes: 4 additions & 1 deletion .vscode/SKILL-api-and-web-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ use the dedicated session factory so PostgreSQL failures can be translated into
Authenticate, decrypt, validate, verify Alembic migrations, and commit the raw payload before
returning `200 []`. When PostgreSQL or migrations are unavailable, return `503 Service
Unavailable` with `Retry-After: 30` and `Cache-Control: no-store`. Exact HTTP retries must reuse the
existing raw event rather than inserting a duplicate. Do not add a server-side buffer or MQTT path.
existing raw event rather than inserting a duplicate. Return immediately after the commit; never
call or wait for automatic trip generation, odometer calculation, retention cleanup, or the trip
processor lock in the HTTP request. The background `AutomaticTripProcessor` consumes committed rows
on its next checkpointed pass. Do not add a server-side buffer or MQTT path.

The `/api/health` endpoint is unauthenticated inside the app for container health checks;
`/api/owntracks` requires authentication.
Expand Down
3 changes: 3 additions & 0 deletions .vscode/SKILL-trip-processor.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ OwnTracks ingestion is HTTP-only. The endpoint commits raw messages directly to
returns `200 []` only after acceptance. When PostgreSQL or migrations are unavailable, it returns a
retryable `503` so the OwnTracks mobile app retains and resends its own queue. Exact HTTP retries
must not create duplicate raw rows. There is no server-side buffer, replay worker, or MQTT path.
The HTTP request must not invoke or wait for trip processing or `_PROCESSING_LOCK`; the background
`AutomaticTripProcessor` exclusively performs trip generation, odometer updates, backfills, and
retention after ingestion has returned.

---

Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ backward-compatibility handling.
PostgreSQL or migrations are unavailable so OwnTracks retains and retries its own HTTP queue.
- Returns `200 []` only after PostgreSQL accepts the payload. Exact HTTP retries reuse the existing
raw event instead of inserting it twice.
- Returns immediately after the payload commit without waiting for automatic trip generation,
odometer calculation, retention cleanup, or the trip processor lock. The background
`AutomaticTripProcessor` owns all derived processing on its next pass.

**[login_failures.py](trip_tracker/services/login_failures.py)** — Web login audit logging
- Stores structured PostgreSQL records for successful and failed web UI login attempts and emits
Expand Down Expand Up @@ -335,6 +338,8 @@ dates. Manual trips are not restricted by those indexes.
- After authentication, decryption, and validation, verify Alembic migrations and attempt the
PostgreSQL write. Return `200 []` only after the commit succeeds. Return `503`,
`Retry-After: 30`, and `Cache-Control: no-store` when PostgreSQL or migrations are unavailable.
- Do not call or wait for `run_automatic_trip_processing()` from the OwnTracks request. Once raw
storage commits, return promptly and let the background processor consume the checkpointed rows.
- The OwnTracks mobile app is the only outage queue. Do not add a server-side SQLite queue, replay
worker, MQTT subscriber, or queue storage volume.
- Preserve retry idempotency: an exact resent HTTP event must not create a second raw event row.
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
# Changelog

## 1.5.0 - Unreleased
## 1.5.1 - 09.25.2026

### Added
- Added regression coverage proving encrypted location, waypoint, duplicate retry, and sequential
backlog requests persist successfully without invoking automatic trip processing in the HTTP
request.

### Changed
- Changed OwnTracks HTTP ingestion to return `200 []` immediately after PostgreSQL commits the
validated payload, leaving trip generation, odometer updates, backfills, and retention cleanup
to the existing background processor.
- Bumped the Trip Tracker package and deployment image version to 1.5.1.

### Fixed
- Fixed OwnTracks Android requests timing out while waiting for the automatic trip processor or
its global lock, which caused retried messages to block the phone's queued location updates.

## 1.5.0 - 07.27.2026

### Added
- Added a focused 1.5.0 upgrade guide covering the coordinated database, PostgreSQL role, Docker
Expand Down
14 changes: 7 additions & 7 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,11 @@ Swarm does not build images during `docker stack deploy`, does not support Compo
does not preserve the normal Compose loopback-only nginx port binding. The Swarm stack therefore
uses image tags and overlay networking. The `Build and publish Swarm images` GitHub workflow
publishes the app and nginx images to GHCR with the package version, `latest`, and an immutable
commit-SHA tag. For v1.5.0, use:
commit-SHA tag. For v1.5.1, use:

```bash
APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.0
NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.0
APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.1
NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.1
```

If the GHCR packages are private, configure GHCR registry credentials in Portainer or authenticate
Expand All @@ -356,17 +356,17 @@ needed variables in the shell before deploying.
Remote PostgreSQL Swarm deployment:

```bash
export APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.0
export NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.0
export APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.1
export NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.1
export DATABASE_URL=postgresql+psycopg://triptracker:url_encoded_password@central-db-host:5432/trip_tracker
docker stack deploy -c docker-stack.yml trip-tracker
```

Bundled PostgreSQL Swarm deployment:

```bash
export APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.0
export NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.0
export APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.1
export NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.1
export DATABASE_URL=postgresql+psycopg://triptracker:your-db-password@postgres:5432/trip_tracker
docker stack deploy -c docker-stack.yml -c docker-stack.local-postgres.yml trip-tracker
```
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ stores them in PostgreSQL, lets you review and edit generated waypoint work trip
monthly mileage and expense PDF logs.

Upgrading an existing deployment from 1.4.4 or earlier requires coordinated database, storage,
service, and image-name changes. Follow [UPGRADE-1.5.md](UPGRADE-1.5.md) before deploying 1.5.0.
service, and image-name changes. Follow [UPGRADE-1.5.md](UPGRADE-1.5.md) before deploying 1.5.1.

## Current Scope

Expand All @@ -16,6 +16,8 @@ service, and image-name changes. Follow [UPGRADE-1.5.md](UPGRADE-1.5.md) before
- OwnTracks HTTP endpoint at `/api/owntracks` and Recorder-compatible `/api/pub`.
- Retry-safe HTTP ingestion that returns `503 Service Unavailable` while PostgreSQL is unavailable
so the OwnTracks mobile app retains and resends its own queued messages after recovery.
- Fast OwnTracks acknowledgements that return `200 []` immediately after PostgreSQL commits the
message; automatic trip generation and odometer processing continue in the background.
- OwnTracks waypoint transition model used to turn leave/enter events into work trips, with
location updates between those events used as the primary trip distance.
- Manual current-odometer entry from the Diagnostics page, with the Manual Odometer card showing
Expand Down Expand Up @@ -141,8 +143,8 @@ Docker Swarm deployments use [docker-stack.yml](docker-stack.yml) instead of `do
Swarm cannot build images, use Compose profiles, or keep the normal Compose loopback-only port
binding. The `Build and publish Swarm images` GitHub workflow publishes versioned, `latest`, and
commit-SHA app and nginx images to GHCR. Set `APP_IMAGE` to
`ghcr.io/cosmicc/trip-tracker-app:1.5.0` and `NGINX_IMAGE` to
`ghcr.io/cosmicc/trip-tracker-nginx:1.5.0` through Portainer or the shell, and deploy the base
`ghcr.io/cosmicc/trip-tracker-app:1.5.1` and `NGINX_IMAGE` to
`ghcr.io/cosmicc/trip-tracker-nginx:1.5.1` through Portainer or the shell, and deploy the base
stack for remote PostgreSQL. Add
[docker-stack.local-postgres.yml](docker-stack.local-postgres.yml) only when the bundled
PostgreSQL service should be part of the Swarm stack. In Swarm, configure the Cloudflare Tunnel
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.5.0
1.5.1
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "trip-tracker"
version = "1.5.0"
version = "1.5.1"
description = "OwnTracks trip tracker with PostgreSQL storage and monthly mileage and expense PDFs."
readme = "README.md"
requires-python = ">=3.12"
Expand Down
102 changes: 101 additions & 1 deletion tests/test_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from trip_tracker.app import app
from trip_tracker.config import Settings
from trip_tracker.database import get_db
from trip_tracker.models import Base, OwnTracksLocation
from trip_tracker.models import Base, OwnTracksLocation, Site


def _test_client_session() -> tuple[TestClient, sessionmaker[Session]]:
Expand Down Expand Up @@ -72,6 +72,12 @@ def _encrypted_owntracks_payload(payload: dict, secret: str) -> dict:
}


def _fail_if_trip_processing_runs(*args, **kwargs) -> None:
"""Fail when the HTTP ingestion path invokes derived trip processing."""

raise AssertionError("HTTP ingestion must not invoke automatic trip processing")


def test_owntracks_endpoint_requires_basic_auth_and_encrypted_payload(monkeypatch) -> None:
settings = Settings(
database_url="sqlite://",
Expand Down Expand Up @@ -154,6 +160,11 @@ def test_owntracks_endpoint_uses_dedicated_database_session(monkeypatch) -> None
automatic_trip_processing_enabled=False,
)
_patch_settings(monkeypatch, settings)
monkeypatch.setattr(
"trip_tracker.services.owntracks._run_trip_processing",
_fail_if_trip_processing_runs,
raising=False,
)
client, session_factory = _test_client_session()
payload = {
"_type": "location",
Expand All @@ -172,6 +183,7 @@ def test_owntracks_endpoint_uses_dedicated_database_session(monkeypatch) -> None
)

assert response.status_code == 200
assert response.json() == []
assert response.headers["Cache-Control"] == "no-store"
assert "X-Trip-Tracker-OwnTracks-Buffered" not in response.headers
with session_factory() as db:
Expand All @@ -180,6 +192,49 @@ def test_owntracks_endpoint_uses_dedicated_database_session(monkeypatch) -> None
app.dependency_overrides.clear()


def test_owntracks_waypoint_endpoint_persists_without_trip_processing(monkeypatch) -> None:
settings = Settings(
database_url="sqlite://",
owntracks_username="owntracks",
owntracks_password="owntracks-password",
owntracks_encryption_key="owntracks-secret",
automatic_trip_processing_enabled=False,
)
_patch_settings(monkeypatch, settings)
monkeypatch.setattr(
"trip_tracker.services.owntracks._run_trip_processing",
_fail_if_trip_processing_runs,
raising=False,
)
client, session_factory = _test_client_session()
payload = {
"_type": "waypoint",
"desc": "Client Warehouse",
"lat": 42.3314,
"lon": -83.0458,
"rad": 75,
"rid": "warehouse-1",
"tst": int(datetime(2026, 6, 30, 12, 0, tzinfo=UTC).timestamp()),
}
encrypted_payload = _encrypted_owntracks_payload(payload, settings.owntracks_encryption_key)
try:
response = client.post(
"/api/owntracks",
json=encrypted_payload,
auth=("owntracks", "owntracks-password"),
)

assert response.status_code == 200
assert response.json() == []
with session_factory() as db:
site = db.scalar(select(Site).where(Site.owntracks_region_id == "warehouse-1"))
assert site is not None
assert site.name == "Client Warehouse"
assert site.radius_m == 75
finally:
app.dependency_overrides.clear()


def test_owntracks_endpoint_returns_retryable_503_when_database_is_offline(monkeypatch) -> None:
settings = Settings(
database_url="sqlite://",
Expand Down Expand Up @@ -303,6 +358,51 @@ def test_owntracks_exact_http_retry_is_not_stored_twice(monkeypatch) -> None:
app.dependency_overrides.clear()


def test_owntracks_sequential_backlog_persists_without_inline_processing(monkeypatch) -> None:
settings = Settings(
database_url="sqlite://",
owntracks_username="owntracks",
owntracks_password="owntracks-password",
owntracks_encryption_key="owntracks-secret",
automatic_trip_processing_enabled=False,
)
_patch_settings(monkeypatch, settings)
monkeypatch.setattr(
"trip_tracker.services.owntracks._run_trip_processing",
_fail_if_trip_processing_runs,
raising=False,
)
client, session_factory = _test_client_session()
first_timestamp = int(datetime(2026, 6, 30, 12, 0, tzinfo=UTC).timestamp())
try:
for offset in range(50):
encrypted_payload = _encrypted_owntracks_payload(
{
"_type": "location",
"lat": 42.3314 + (offset / 100_000),
"lon": -83.0458,
"tst": first_timestamp + offset,
"tid": "IP",
"topic": "owntracks/ian/phone",
},
settings.owntracks_encryption_key,
)

response = client.post(
"/api/owntracks",
json=encrypted_payload,
auth=("owntracks", "owntracks-password"),
)

assert response.status_code == 200
assert response.json() == []

with session_factory() as db:
assert db.scalar(select(func.count(OwnTracksLocation.id))) == 50
finally:
app.dependency_overrides.clear()


def test_non_owntracks_api_requires_separate_bearer_key(monkeypatch) -> None:
settings = Settings(database_url="sqlite://", web_api_key="web-api-secret")
_patch_settings(monkeypatch, settings)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_docker_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ def test_swarm_stack_has_optional_local_postgres_overlay() -> None:
)
assert "POSTGRES_DATA_VOLUME=trip-tracker-postgres-data" in env_text
assert "trip-tracker-internal" in local_postgres_text
assert "APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.0" in env_text
assert "NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.0" in env_text
assert "APP_IMAGE=ghcr.io/cosmicc/trip-tracker-app:1.5.1" in env_text
assert "NGINX_IMAGE=ghcr.io/cosmicc/trip-tracker-nginx:1.5.1" in env_text
assert "APP_UID=1000" in env_text
assert "APP_GID=100" in env_text

Expand Down
7 changes: 6 additions & 1 deletion tests/test_owntracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from trip_tracker.models import Base, OwnTracksLocation, Site, Trip
from trip_tracker.services.owntracks import parse_owntracks_location, process_owntracks_payload
from trip_tracker.services.trip_processor import run_automatic_trip_processing


def _session() -> Session:
Expand Down Expand Up @@ -84,7 +85,7 @@ def test_process_owntracks_location_with_region_does_not_create_waypoint() -> No
assert before_receive <= received_at <= after_receive


def test_process_owntracks_payload_automatically_creates_trip() -> None:
def test_process_owntracks_payload_defers_trip_creation_to_background_processor() -> None:
db = _session()
day = datetime(2030, 1, 1, 13, 0, tzinfo=UTC)
db.add_all(
Expand Down Expand Up @@ -135,6 +136,10 @@ def test_process_owntracks_payload_automatically_creates_trip() -> None:
).encode("utf-8"),
)

assert list(db.scalars(select(Trip))) == []

run_automatic_trip_processing(db, now=day + timedelta(minutes=40))

trips = list(db.scalars(select(Trip).order_by(Trip.started_at.asc())))
client_a = db.scalar(select(Site).where(Site.name == "Client A"))
client_b = db.scalar(select(Site).where(Site.name == "Client B"))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -1364,7 +1364,7 @@ def test_install_assets_stay_available_when_web_login_is_enabled(monkeypatch) ->
assert manifest["scope"] == "/"
assert manifest_response.headers["cache-control"] == "no-store"
assert {icon["purpose"] for icon in manifest["icons"]} == {"any", "maskable"}
assert "/static/icons/trip-tracker-icon-512.png?v=1.5.0-brand1" in {
assert "/static/icons/trip-tracker-icon-512.png?v=1.5.1-brand1" in {
icon["src"] for icon in manifest["icons"]
}

Expand Down
2 changes: 1 addition & 1 deletion trip_tracker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__all__ = ["__version__"]

__version__ = "1.5.0"
__version__ = "1.5.1"
Loading
Loading