DealerFinder is a server-rendered Django application for searching car dealers in Germany.
The system is optimized for fast and predictable search with minimal external API dependency via a cache-first approach.
The primary external dependency is the Google Places API; the majority of requests are served from cache. Cached data has a TTL and is automatically refreshed on cache miss or expiry.
- Search is restricted to German cities (validated via Geocoding API)
- Authentication via Google OAuth only
- Anti-abuse: Cloudflare Turnstile + quotas (Redis) + throttling
Backend: Python 3.12, Django 6.x (FBV), PostgreSQL 18, Redis 7, Celery 5 Frontend: Django Templates, Bootstrap 5.3, Vanilla JS External APIs: Google Places API (New), Google Geocoding API, Geolocation API, Google OAuth, OpenAI API, Telegram Bot API Infra: Docker Compose v2, Gunicorn (2 workers), Nginx, Redis 7
The project is started via Docker Compose from the docker/ directory.
cp .env.example .env- Google APIs (Places API, Maps JS, OAuth)
- AI provider API key (for summaries)
- Cloudflare Turnstile
During development, OpenAI
gpt-4o-miniwas used as the AI provider for generating summaries. The system is provider-agnostic and can be switched to any compatible AI API.
β οΈ Without these keys, core functionality (search, AI summaries, anti-bot protection) will not work.
docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up --buildhttp://localhost:8000
- Uses development overrides (
docker-compose.dev.yml) - Includes: Django + PostgreSQL + Redis + Celery + Nginx
- Hot reload enabled for development
AI summary is an optional data enrichment layer. It has no effect on search, filtering, or result ranking.
- Async generation via Celery + OpenAI
- Optional, can be disabled via feature flag
- Used only for UX enrichment
Details: docs/ai_architecture.md
Client (Browser)
β
Middleware (RequestLoggingMiddleware β ThrottleMiddleware β LoginGateMiddleware)
β
Django Views (FBV)
β
Service Layer
βββ Search services
βββ AI module
β βββ payload/query layer
β βββ enqueue logic
β βββ generation service
β βββ Redis cache
β βββ Redis locks
β βββ quotas / rate limits
βββ PostgreSQL
βββ Redis
βββ Google Places API
βββ OpenAI API
dealerfinder/
βββ manage.py
βββ .env.example
βββ pyproject.toml
β
βββ docker/
β βββ Dockerfile
β βββ docker-compose.yml
β βββ docker-compose.dev.yml
β βββ entrypoint.sh
β βββ nginx.conf
β
βββ config/
β βββ celery.py
β βββ urls.py
β βββ settings/
β βββ base.py
β βββ dev.py
β βββ prod.py
β βββ test.py
β
βββ apps/
β βββ contact/
β β βββ forms.py
β β βββ middleware.py # ContactThrottleMiddleware
β β βββ models.py # ContactMessage
β β βββ services.py # Telegram notify + email fallback
β β βββ urls.py
β β βββ views.py
β β
β βββ core/
β β βββ middleware.py # RequestLoggingMiddleware, ClientIPMiddleware
β β βββ urls.py
β β βββ views.py # home, about, health, legal pages
β β
β βββ dealers/
β β βββ admin.py
β β βββ models.py # Dealer, DealerAiSummary, SearchCache, PopularSearch, UserSearchHistory
β β βββ tasks.py # generate_dealer_ai_summary_task (Celery)
β β βββ urls.py
β β βββ views.py
β β βββ ai/
β β β βββ prompts.py # prompt builders
β β β βββ parsers.py # JSON parsing + schema validation
β β β βββ cache.py # Redis cache for AI summary payloads
β β β βββ enqueue.py # enqueue_ai_summaries_for_dealers()
β β β βββ locks.py # Redis dedup lock for AI generation
β β β βββ queries.py # attach_ai_summaries_to_dealers(), payload builders
β β β βββ quotas.py
β β β βββ system_quota.py
β β β βββ rate_limits.py
β β β βββ service.py # generate_ai_summary_for_dealer(), freshness/retry helpers
β β βββ management/
β β β βββ commands/
β β β βββ warm_search_cache.py
β β β βββ process_pending_ai_summaries.py
β β β βββ purge_expired_search_cache.py
β β βββ services/
β β βββ dealer_service.py # orchestration: cache β Google β normalize β store
β β βββ search_cache.py # read/write SearchCache (TTL from CACHE_TTL_HOURS)
β β βββ distance_service.py # haversine
β β βββ geocoding_service.py # German city validation, 30-day cache
β β βββ google_places.py # Text Search + Place Details API
β β βββ google_places_cache_service.py # Redis cache for Place Details
β β βββ google_places_lock_service.py # Redis lock for Place Details deduplication
β β βββ search_tracking_service.py # PopularSearch, UserSearchHistory, anon history
β β
β βββ users/
β βββ admin.py
β βββ middleware.py # ThrottleMiddleware, LoginGateMiddleware, OAuthStartProtectionMiddleware
β βββ models.py # User, Favorite
β βββ urls.py
β βββ views.py
β βββ services/
β βββ quota_service.py # search quota (Redis)
β βββ ai_quota_service.py # AI quota (Redis)
β
βββ common/
β βββ exceptions.py # AiClientError
β βββ redis.py # raw redis_client
β βββ services/
β βββ feature_flags.py # Redis-backed feature flags
β βββ rate_limiter.py # RedisRateLimiter (ZSET sliding window)
β
βββ integrations/
β βββ google_oauth.py
β βββ telegram.py
β βββ email_notifications.py
β βββ ai_client.py # is transport-only and does not contain dealer-specific business rules.
β βββ turnstile.py
β
βββ utils/
β βββ build_cities.py # one-time: generates static/data/cities_de.json
β βββ logging.py # JsonFormatter
β βββ http.py # _get_client_ip()
β
βββ templates/
βββ static/
β βββ css/
β βββ js/
β βββ data/
β βββ cities_de.json # ~3500 German cities for autocomplete
βββ tests/
Search flow: cache-first β Google Places (on miss) β filtering β optional AI enrichment.
Details: docs/request_flow.md
| Module | Responsibility |
|---|---|
dealer_service.py |
Orchestration: cache β Google β normalize β sync_dealers_to_db |
search_cache.py |
Read/write SearchCache (TTL = CACHE_TTL_HOURS) |
google_places.py |
Text Search + Place Details API, global daily cap |
google_places_cache_service.py |
Redis cache for Place Details (PLACE_DETAILS_CACHE_TTL_SECONDS) |
google_places_lock_service.py |
Redis lock for deduplicating concurrent Place Details requests |
geocoding_service.py |
German city validation, reverse geocode, 30-day cache |
distance_service.py |
Haversine distance to user |
search_tracking_service.py |
PopularSearch, UserSearchHistory, anon history |
| Module | Responsibility |
|---|---|
enqueue.py |
enqueue_ai_summaries_for_dealers() β creates/updates DealerAiSummary, dispatches Celery tasks |
service.py |
generate_ai_summary_for_dealer() β OpenAI call, result persistence; freshness/retry helpers |
queries.py |
attach_ai_summaries_to_dealers(), get_dealer_ai_summary_payload(), generate_dealer_ai_summary_payload() |
cache.py |
Redis payload cache (ai_summary:{place_id}, TTL AI_SUMMARY_CACHE_TTL_SECONDS) |
locks.py |
Redis NX lock for generation deduplication (lock:ai_summary:{place_id}) |
rate_limits.py |
AI rate limits (per-minute via RedisRateLimiter) |
| Parameter | Value |
|---|---|
| Type | read-through |
| Cache key | dealers:{city}:{radius_int} |
| TTL | CACHE_TTL_HOURS (default 72h) |
| Storage | SearchCache (PostgreSQL) |
| Filters | applied in-memory after cache hit/miss |
| HIT | return without Google API call |
| MISS | Google API β normalize β SearchCache.update_or_create |
normalize() in dealer_service.py β internal format:
{
"place_id": "str",
"name": "str",
"address": "str",
"lat": "float",
"lng": "float",
"rating": "float | None",
"reviews": "int | None",
"phone": "str | None",
"website": "str | None",
"open_now": "bool",
"has_weekend": "bool"
}Core entities:
- Dealer
- SearchCache
- User
- Favorite
Details: docs/data_model.md
Search is restricted to German cities. Requests for other countries are rejected.
- radius β radius in km (allowed: 1, 5, 10, 20, 30, 50, 100, 200, 300; default: 20)
- rating + reviews β weighted score:
rating * log1p(reviews)(confidence-adjusted) - open_now β currently open
- weekends β open on weekends
- types β dealer/car showroom only (via
typesfield, not by name) - contacts β has phone or website
Filters and sorting are applied in-memory (filter_and_sort_dealers()) after retrieval from cache.
Sort modes: score (weighted rating Γ log1p reviews), rating, reviews, distance (if user coordinates provided). Permissive filtering: if hours/weekend data is missing β the dealer is not excluded.
- Cache-first, TTL configurable via
CACHE_TTL_HOURS(default 72h) - FieldMask β only required fields from Google
- Place Details β Redis cache
PLACE_DETAILS_CACHE_TTL_SECONDS(default 24h) + Redis lock (deduplication) - AI summary payload β Redis cache
AI_SUMMARY_CACHE_TTL_SECONDS(default 6h), onlydone/failedcached - Geocoding cached for 30 days
- Pagination: 20 results per page
- DB indexes:
city,lat,lng,last_synced_at
- Single auth method β Google OAuth (
django-allauth) LoginGateMiddlewareβ blocks direct access to/accounts/google/login/without passing Turnstile- On first login β mandatory AGB/Datenschutz acceptance (
terms_accepted) - Anonymous quota: Redis-backed by IP + day bucket
- Quota consumed in search flow/service layer
- Anonymous session: UX search history and consent state
- Account deletion β cascading delete of all user data (GDPR)
β οΈ Re-authentication via the same Google account after deletion creates a new User β fix before prod
- Cache is the primary tool (
CACHE_TTL_HOURS=72) - FieldMask on all Google requests
- Global daily cap:
MAX_GOOGLE_CALLS_PER_DAY=500β when reached, service operates from cache only - Quota consumed only on cache MISS
- Place Details cached in Redis (parallel requests deduplicated via lock)
- Geocoding cached for 30 days
- AI:
MAX_AI_SUMMARIES_PER_DAY=200, per-user/IP quotas - Cache management via management commands:
warm_search_cache,purge_expired_search_cache
| Type | Limit | Storage |
|---|---|---|
| Anonymous | ANON_DAILY_LIMIT (default 5) / day |
Redis quota:anon:{ip}:{date}, TTL until midnight |
| Free | FREE_DAILY_LIMIT (default 15) / day |
Redis quota:user:{pk}:{date} |
| Premium | PREMIUM_DAILY_LIMIT (default 50) / day |
same |
| Type | Limit | Storage |
|---|---|---|
| Anonymous | ANON_AI_DAILY_LIMIT (default 3) / day |
Redis quota:anon_ai:{ip}:{date} |
| Free | FREE_AI_DAILY_LIMIT (default 15) / day |
Redis quota:ai:user:{pk}:{date} |
| Premium | PREMIUM_AI_DAILY_LIMIT (default 50) / day |
same |
Throttling: SEARCH_THROTTLE_RATE=8 requests/minute per user (user.pk) or per IP. Sliding 60s window, Django cache (Redis).
AI rate limit: per-minute via RedisRateLimiter (common/services/rate_limiter.py) β ZSET sliding window.
Global Google cap: MAX_GOOGLE_CALLS_PER_DAY=500, counter in Redis, reset at midnight.
Cloudflare Turnstile (backend verification via siteverify):
- Login (via
LoginGateMiddleware+google_oauth_startview) - Account deletion
- Contact form
ContactThrottleMiddleware (Django cache):
- Anonymous: 3 POST / 10 min (by IP)
- Authenticated: 5 POST / 10 min (by
user.pk)
Authenticated users only.
Favoriteβ snapshot of dealer data at the time of additionPOST /favorites/add/βget_or_createby(user, place_id)POST /favorites/remove/<place_id>/POST /favorites/clear/GET /favorites/β list- In search results:
is_favoriteflag in context
GET/POST /contact/β form (name, email, message)- Turnstile verification on POST
- Saved to
ContactMessage - Telegram notification on each new message
- Email fallback if Telegram is unavailable; notification failure does not break the request
ContactThrottleMiddlewareβ see Anti-abuse
- Popular cities:
PopularSearchincremented on every search. Top 10 on home and search views. - Search history: authenticated users β
UserSearchHistory(20 entries); anonymous βsession["search_history_cities"](8 cities, LIFO). build_search_discovery_context(request)β single context assembly point for home and search views.
Mobile-first: dealer list β main screen, map β secondary, filters β offcanvas.
City autocomplete from static/data/cities_de.json (~3500 entries, generated by utils/build_cities.py).
services:
web # Django + Gunicorn (2 workers)
db # PostgreSQL 18
redis # Redis 7
nginx # reverse proxy + static files
celery_worker # Celery worker (concurrency=2)- Web healthcheck via
/health/ - DB and Redis healthchecks; variables read from
.env
entrypoint.sh: wait-for-redis β migrate β collectstatic β create superuser β configure Google SocialApp β start.
- Structured JSON logging (
utils/logging.pyβJsonFormatter) - Request-level logs via
RequestLoggingMiddleware(path, method, status, duration_ms, user_id, client_ip) - Domain events:
search_started,dealer_search_executed,search_quota_denied,ai_summary_task_dispatched,health_check_completed, etc. - Health endpoint:
/health/β DB + Redis checks, 200/503
- Cookie consent banner for third-party services
- Consent stored in session
/datenschutz/,/impressum/,/agb/β static legal pages (TemplateView)
See testing.md
pytest-django+pytest-mock- Test settings: SQLite in-memory,
locmem.LocMemCache conftest.py: autouse fixture overrides CACHES to locmem
See legal_pages.md
This project is licensed under the MIT License β see the LICENSE file for details.
Author: Maksym Petrykin
Email: [email protected]
Telegram: @max_p95