Recipe management and analytics web app. Django 5.2 + Django REST Framework with JWT auth, PostgreSQL, deployed on Render with Cloudflare R2 image hosting. Serves both a Django template site and a Vue 3 SPA against the same data. Lighthouse 91 (Django) / 97 (Vue).
Live demos: π΄ Django template site Β· β‘ Vue 3 SPA β both backed by this repo, same demo account: demo / example123 (Render free tier β first request after idle takes ~30 sec to wake)
Nomalyze started as a Django monolith built during a CareerFoundry full-stack course. It's been modernized in three phases:
- Foundation β Django 5.2 site with PostgreSQL, server-rendered templates, custom auto-difficulty logic, and wildcard search (
*/?β regex). - REST API + SPA cutover β Added a Django REST Framework API (JWT auth via
djangorestframework-simplejwt) alongside the original template views, then built a Vue 3 SPA frontend consuming the same backend. Sessions still drive the template site; JWT serves the SPA. OneRecipemodel, two presentation layers, no sync code. - Image pipeline overhaul β Moved recipe images off Render's ephemeral disk to Cloudflare R2 (10 GB free tier, zero egress), pre-generated three responsive variants per image (400 / 800 / 1200 widths) via
django-imagekit, and switched the variant format from JPEG q80 to WebP q72 β ~30% byte reduction at the same perceived fidelity. Lighthouse: 40 β 91 (Django) and β 97 (Vue).
This repo is the backend half. It serves both the original Django template site and the JSON API the SPA consumes.
Six endpoints, all under /api/. Recipe endpoints require a valid JWT (Authorization: Bearer <access>); auth endpoints are public.
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/token/ |
β | Obtain access + refresh token pair |
POST |
/api/auth/token/refresh/ |
β | Exchange refresh token for new access token |
GET |
/api/recipes/ |
JWT | Paginated recipe list (page size 20) |
GET |
/api/recipes/<id>/ |
JWT | Recipe detail |
GET |
/api/recipes/search/ |
JWT | Filtered search (name, ingredients, cooking time, difficulty) |
GET |
/api/recipes/search/stats/ |
JWT | Same filters as /search/, returns aggregated chart data |
# 1. Obtain tokens
curl -X POST https://nomalyze.com/api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username": "demo", "password": "example123"}'
# => { "access": "eyJβ¦", "refresh": "eyJβ¦" }
# 2. Call a protected endpoint
curl https://nomalyze.com/api/recipes/ \
-H "Authorization: Bearer eyJβ¦"Access tokens expire after 30 minutes; refresh tokens after 7 days. The SPA's Axios interceptor handles the refresh on 401 transparently β see src/api/client.ts in the frontend repo.
/api/recipes/search/ accepts any combination of:
nameβ substring match, or wildcard pattern with*/?(e.g.*berry*,pa?ta)ingredientsβ comma-separated list; AND logic (a recipe must contain all listed ingredients); each item also supports wildcardscooking_time_maxβ integer minutes, upper bounddifficultyβ one ofEasy/Medium/Intermediate/Hardshow_all=trueβ bypass filters, return everything
The recipe_image field is a SerializerMethodField that returns four URLs (one original + three resized variants) so the client can emit <img srcset> without further negotiation:
{
"id": 12,
"name": "Lentil dal",
"ingredients_list": ["red lentils", "onion", "garam masala"],
"cooking_time": 35,
"difficulty": "Hard",
"recipe_image": {
"original": "https://pub-β¦.r2.dev/images/recipes/dal.webp",
"small": "https://pub-β¦.r2.dev/CACHE/images/recipes/dal/β¦-400.webp",
"medium": "https://pub-β¦.r2.dev/CACHE/images/recipes/dal/β¦-800.webp",
"large": "https://pub-β¦.r2.dev/CACHE/images/recipes/dal/β¦-1200.webp"
}
}The Django site already has working sessions, server-rendered templates, and admin tooling. Throwing all of that away for a SPA rewrite would be expensive and offer no user-facing benefit; the goal of the SPA is faster perceived performance for the public read path, not a full re-platform.
The DRF API at /api/recipes/β¦ and /api/auth/token/β¦ lives alongside the existing template views. The SPA uses JWT (cross-origin friendly, sent via Authorization header β no CSRF dance); the template site continues to use Django sessions. Both auth systems read and write the same Recipe model β no data duplication, no sync layer. New features that benefit from interactivity (live filtering, Chart.js visualisations) ship to the Vue SPA; admin/CRUD-heavy flows stay in Django's admin.
- Stats endpoint reuses search filtering by composition, not inheritance.
RecipeSearchStatsAPIViewinstantiatesRecipeSearchAPIViewand delegates to itsget_queryset()rather than subclassing it. The stats view returns aggregated data (three differently-shaped lists for three chart types), not a paginated list of recipes, so inheritingListAPIViewwould force unhelpful machinery. Composition keeps the filter logic in one place without coupling the response shapes. - Wildcard search is translated to regex at the query layer.
*becomes.*and?becomes.; the query then uses Django's__iregexlookup. When no wildcard is present, the cheaper__icontainsis used instead so the database can keep using B-tree indexes on the simple case. Multi-ingredient search is implemented as a chain ofqs.filter(Q(...))calls, which produces AND semantics at the SQL level. - Image URLs are computed per request.
RecipeSerializer.get_recipe_imagebuilds absolute URLs only when needed (localFileSystemStoragereturns relative/media/...paths; R2 already returns absolutehttps://pub-β¦.r2.dev/...). The request object is sniffed for the host so dev and prod both produce correct URLs without hardcoding. - Pagination is global, page size 20, configured at the DRF settings level. JWT lifetimes (30 min access / 7 day refresh) are tuned for an SPA: short enough that a stolen access token has limited blast radius, long enough that the refresh dance is rare under normal use.
Recipe images live in a Cloudflare R2 bucket, written via django-storages whenever an admin saves a recipe. django-imagekit generates three resized WebP variants (quality 72, widths 400 / 800 / 1200) on save, stored in R2 alongside the original under CACHE/images/recipes/. The DRF serializer returns an object with original, small, medium, large URLs; both the Django template site and the Vue SPA emit <img srcset="..."> against those URLs so browsers pick the smallest sufficient image for the viewport and DPR.
Why R2 specifically: free tier covers 10 GB storage with zero egress fees, which fits a portfolio-scale request pattern indefinitely. Render's filesystem is ephemeral, so admin uploads previously didn't survive deploys; R2 makes uploads persistent without standing up a separate database/disk service.
Why pre-generated variants instead of an on-the-fly transformation CDN: a single set of URLs serves both the Render-hosted Django site and the Netlify-hosted Vue SPA without coupling either to a vendor-specific image-transform endpoint, and Cloudflare's image-resizing product requires a paid plan. Storage cost of variants is negligible (~4Γ per recipe, well under the free-tier ceiling).
Local development falls back to FileSystemStorage via USE_R2_STORAGE=False, so no R2 credentials are needed for runserver. The S3-compatible toggle is in settings.py; the actual R2 secrets live in Render's environment, not in this repo or in render.yaml (the four R2_* vars are marked sync: false).
- Backend: Django 5.2, Python 3.12, PostgreSQL
- API: Django REST Framework,
djangorestframework-simplejwt,django-cors-headers - Storage: Cloudflare R2 (S3-compatible) via
django-storages; responsive variants viadjango-imagekit(Pillow + WebP) - Styling (template site): Tailwind CSS 3 with a custom brand palette (orange
#f37f20, teal#6fc3aa, green#a9c57c, gold#c0a659) and Merriweather typography - Charts (template site): matplotlib renders base64 PNGs server-side (the Vue SPA replaces this with Chart.js)
- Tests: Django's built-in test runner (
manage.py test) withcoverage, run against a real PostgreSQL service in CI - Lint / format: Ruff (pyflakes, isort, line length 120)
- CI: GitHub Actions β lint β test (with coverage upload to Codecov) β security scans (Safety, Bandit) β build (Django system checks, migration validation, collectstatic)
- Deploy: Render.com (free tier) at https://nomalyze.com (custom domain) β backed by service
cf-recipe-app
- Auto-difficulty calculation β derived from cooking time + ingredient count on save (
Recipe.calculate_difficulty()). - Wildcard search β
*and?patterns translated to regex; AND logic for ingredient combinations. - Responsive image variants β three WebP sizes per recipe served via
<img srcset>. - JWT + session auth coexist β JWT for the SPA, sessions for the template site, both reading the same
Recipemodel. - Server-side charts β matplotlib base64 PNGs on the Django site; the Vue SPA replaces these with Chart.js against
/api/recipes/search/stats/. - Demo account β
demo / example123is read-only safe; data resets on each Render deploy.
Setup with Docker (recommended)
git clone https://github.com/nicovece/cf-recipe-app
cd cf-recipe-app
docker-compose up --buildRuns Django + PostgreSQL + a Tailwind watcher. Visit http://localhost:8000.
Setup without Docker
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
pnpm install # for the Tailwind watcher
./manage.sh migrate
./manage.sh createsuperuser
./manage.sh runserver
pnpm run dev # in another terminal β Tailwind watch modeTests, lint, format
./manage.sh test # full suite
cd src && coverage run --source='.' manage.py test && coverage report # suite with coverage (as CI runs it)
./manage.sh test recipes.tests.RecipeModelTest # single test class
ruff check src/
ruff format src/Environment variables (production)
| Variable | Purpose |
|---|---|
DEBUG |
False in production |
SECRET_KEY |
Django secret |
DATABASE_URL |
PostgreSQL connection string |
ALLOWED_HOSTS |
Comma-separated host list |
CORS_ALLOWED_ORIGINS |
Comma-separated origin list (SPA origin in prod) |
USE_R2_STORAGE |
True to enable R2 (False falls back to local FS) |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL |
R2 credentials (set in Render dashboard, sync: false in render.yaml) |
Maintained by: Nicola Vece
- Email: me@nicovece.com
- GitHub: @nicovece
- LinkedIn: nicovece
- Portfolio: nicovece.dev
This is a portfolio project; PRs and issues are welcome but no SLA. For questions, open an issue or email me@nicovece.com.
MIT β see LICENSE.