Skip to content

p95max/dealerfinder

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

255 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

DealerFinder

🧱 System Overview

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

βš™οΈ Tech Stack

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


πŸš€ Local Development (Docker)

The project is started via Docker Compose from the docker/ directory.

1. Prepare environment

cp .env.example .env

⚠️ Required: before running the project, you must provide valid API keys in .env:

  • Google APIs (Places API, Maps JS, OAuth)
  • AI provider API key (for summaries)
  • Cloudflare Turnstile

During development, OpenAI gpt-4o-mini was 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.

2. Run project

docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up --build

3. Open in browser

http://localhost:8000

Notes

  • Uses development overrides (docker-compose.dev.yml)
  • Includes: Django + PostgreSQL + Redis + Celery + Nginx
  • Hot reload enabled for development

πŸ€– AI

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


πŸ—οΈ Architecture

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

πŸ“ Project Structure

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/

πŸ” Request Flow

Search flow: cache-first β†’ Google Places (on miss) β†’ filtering β†’ optional AI enrichment.

Details: docs/request_flow.md


🧠 Core Components

Service Layer (apps/dealers/services/)

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

AI Layer (apps/dealers/ai/)

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)

Cache Strategy

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

Data Normalization

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"
}

πŸ—„οΈ Data Model

Core entities:

  • Dealer
  • SearchCache
  • User
  • Favorite

Details: docs/data_model.md


Filtering

Search is restricted to German cities. Requests for other countries are rejected.

  1. radius β€” radius in km (allowed: 1, 5, 10, 20, 30, 50, 100, 200, 300; default: 20)
  2. rating + reviews β€” weighted score: rating * log1p(reviews) (confidence-adjusted)
  3. open_now β€” currently open
  4. weekends β€” open on weekends
  5. types β€” dealer/car showroom only (via types field, not by name)
  6. contacts β€” has phone or website

Filters and sorting are applied in-memory (filter_and_sort_dealers()) after retrieval from cache.

Ranking

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.


⚑ Performance

  • 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), only done/failed cached
  • Geocoding cached for 30 days
  • Pagination: 20 results per page
  • DB indexes: city, lat, lng, last_synced_at

Auth

  • 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

πŸ’Έ Cost Control

  • 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

Rate Limiting

Search Quota

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

AI Quota

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.


πŸ” Anti-abuse

Cloudflare Turnstile (backend verification via siteverify):

  • Login (via LoginGateMiddleware + google_oauth_start view)
  • Account deletion
  • Contact form

ContactThrottleMiddleware (Django cache):

  • Anonymous: 3 POST / 10 min (by IP)
  • Authenticated: 5 POST / 10 min (by user.pk)

⭐ Favorites

Authenticated users only.

  • Favorite β€” snapshot of dealer data at the time of addition
  • POST /favorites/add/ β€” get_or_create by (user, place_id)
  • POST /favorites/remove/<place_id>/
  • POST /favorites/clear/
  • GET /favorites/ β€” list
  • In search results: is_favorite flag in context

πŸ“¬ Contact

  • 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

πŸ”Ž Search Discovery

  • Popular cities: PopularSearch incremented 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.

πŸ“± Frontend

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).


πŸ”§ Deployment

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.


Observability

  • 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 / Privacy

  • Cookie consent banner for third-party services
  • Consent stored in session
  • /datenschutz/, /impressum/, /agb/ β€” static legal pages (TemplateView)

Testing

See testing.md

  • pytest-django + pytest-mock
  • Test settings: SQLite in-memory, locmem.LocMemCache
  • conftest.py: autouse fixture overrides CACHES to locmem

Datenschutz / GDPR

See legal_pages.md


πŸ“„ License

This project is licensed under the MIT License β€” see the LICENSE file for details.


Contacts

Author: Maksym Petrykin

Email: [email protected]

Telegram: @max_p95

About

Django-based web app for searching car dealers with cache-first architecture, Google Places integration, and optional AI-based data enrichment.

Topics

Resources

Stars

Watchers

Forks

Contributors