Public Access
feat(backend): wire exclude_recipe_ids, verify MealPlan votes schema, add image generation service
- api/meal_plans.py: /regenerate now passes exclude_recipe_ids into generate_meal_plan - planner/generate.py: filter recipe_dicts by exclude_recipe_ids set - image_generation.py: OpenAI gpt-image-1 client with prompt building, b64_json handling - main.py: StaticFiles mount at /static for generated images - admin.py: POST /api/admin/trigger-images endpoint for batch generation - scripts/generate_images.py: CLI for batch image generation - docker-compose.yml + nginx: volume mounts for static/images persistence - Verify MealPlanItem.votes ↔ MealPlanVote relationship is correct; no model bug exists
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env*
|
||||||
|
tests/
|
||||||
|
.pytest_cache/
|
||||||
@@ -189,6 +189,27 @@ def get_stats(db: Session = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger-images", status_code=200)
|
||||||
|
def trigger_images(
|
||||||
|
limit: int = 10,
|
||||||
|
missing_only: bool = True,
|
||||||
|
force: bool = False,
|
||||||
|
recipe_id: Optional[UUID] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.services.image_generation import generate_images_batch
|
||||||
|
|
||||||
|
recipe_ids = [str(recipe_id)] if recipe_id else None
|
||||||
|
result = generate_images_batch(
|
||||||
|
db,
|
||||||
|
recipe_ids=recipe_ids,
|
||||||
|
missing_only=missing_only,
|
||||||
|
force=force,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger-discovery", status_code=200)
|
@router.post("/trigger-discovery", status_code=200)
|
||||||
def trigger_discovery(
|
def trigger_discovery(
|
||||||
family_profile_id: UUID,
|
family_profile_id: UUID,
|
||||||
|
|||||||
@@ -92,9 +92,6 @@ def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> Gen
|
|||||||
if payload.relax_max_meal_cost is not None:
|
if payload.relax_max_meal_cost is not None:
|
||||||
config = replace(config, max_meal_cost=payload.relax_max_meal_cost)
|
config = replace(config, max_meal_cost=payload.relax_max_meal_cost)
|
||||||
|
|
||||||
# exclude_recipe_ids accepted for forward-compat but not yet honored.
|
|
||||||
# See plan §Open items.
|
|
||||||
|
|
||||||
db.query(MealPlan).filter(
|
db.query(MealPlan).filter(
|
||||||
MealPlan.family_profile_id == payload.family_profile_id,
|
MealPlan.family_profile_id == payload.family_profile_id,
|
||||||
MealPlan.week_start_date == payload.week_start_date,
|
MealPlan.week_start_date == payload.week_start_date,
|
||||||
@@ -106,6 +103,7 @@ def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> Gen
|
|||||||
family_id=payload.family_profile_id,
|
family_id=payload.family_profile_id,
|
||||||
week_start_date=payload.week_start_date,
|
week_start_date=payload.week_start_date,
|
||||||
config=config,
|
config=config,
|
||||||
|
exclude_recipe_ids=set(payload.exclude_recipe_ids) if payload.exclude_recipe_ids else None,
|
||||||
)
|
)
|
||||||
items = (
|
items = (
|
||||||
db.query(MealPlanItem)
|
db.query(MealPlanItem)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -14,6 +15,9 @@ app = FastAPI(
|
|||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Serve generated recipe images from local filesystem.
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check(db: Session = Depends(get_db)):
|
def health_check(db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""OpenAI DALL-E image generation service for recipes.
|
||||||
|
|
||||||
|
Downloads generated images to `backend/static/images/` and returns
|
||||||
|
relative paths suitable for `Recipe.image_url`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import Recipe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Directory where generated images are persisted.
|
||||||
|
STATIC_IMAGES_DIR = Path(__file__).resolve().parent.parent.parent / "static" / "images"
|
||||||
|
PUBLIC_URL_PREFIX = "/static/images/"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(recipe: Recipe) -> str:
|
||||||
|
"""Create a vivid, appetising DALL-E prompt from a recipe."""
|
||||||
|
name = recipe.name or "A delicious dish"
|
||||||
|
ingredients = recipe.ingredients or []
|
||||||
|
ingredient_names = [str(ing.get("name", "")) for ing in ingredients if ing.get("name")]
|
||||||
|
ingredient_str = ", ".join(ingredient_names[:6]) if ingredient_names else "fresh ingredients"
|
||||||
|
tags = recipe.cuisine_tags or []
|
||||||
|
tag_str = f", {', '.join(tags)} style" if tags else ""
|
||||||
|
return (
|
||||||
|
f"Professional food photography of {name}{tag_str}. "
|
||||||
|
f"Served on a clean white ceramic plate with natural lighting, "
|
||||||
|
f"shallow depth of field. Vibrant colours, appetising. "
|
||||||
|
f"Ingredients visible: {ingredient_str}. "
|
||||||
|
f"No text, no watermark."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitise_filename(recipe_name: str) -> str:
|
||||||
|
"""Return a filesystem-safe lowercase slug."""
|
||||||
|
keep = recipe_name.lower()
|
||||||
|
for ch in " /'\"?!:\\|;#":
|
||||||
|
keep = keep.replace(ch, "_")
|
||||||
|
return keep.strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_api_key() -> Optional[str]:
|
||||||
|
if settings.AI_IMAGE_PROVIDER and settings.AI_IMAGE_PROVIDER.lower() != "openai":
|
||||||
|
return None
|
||||||
|
return settings.AI_IMAGE_API_KEY or os.environ.get("OPENAI_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image_for_recipe(
|
||||||
|
recipe: Recipe,
|
||||||
|
db: Optional[Session] = None,
|
||||||
|
size: str = "1024x1024",
|
||||||
|
quality: str = "standard", # or "hd"
|
||||||
|
force: bool = False,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Generate an image for *recipe* using DALL-E 3.
|
||||||
|
|
||||||
|
Returns the public static path (e.g. ``/static/images/caprese_pasta_a1b2.png``)
|
||||||
|
or ``None`` if generation is disabled / fails.
|
||||||
|
|
||||||
|
If *force* is ``False`` and the recipe already has an ``image_url`` that
|
||||||
|
looks like a local static path, the existing image is returned immediately.
|
||||||
|
"""
|
||||||
|
if not settings.AI_IMAGE_ENABLED:
|
||||||
|
logger.info("AI_IMAGE_ENABLED=false; skipping image generation for %s", recipe.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_key = _openai_api_key()
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("No OpenAI API key configured; skipping image generation.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
slug = _sanitise_filename(recipe.name)
|
||||||
|
digest_id = hashlib.sha256(str(recipe.id).encode()).hexdigest()[:8]
|
||||||
|
out_name = f"{slug}_{digest_id}.png"
|
||||||
|
out_path = STATIC_IMAGES_DIR / out_name
|
||||||
|
|
||||||
|
# Reuse existing image unless forced.
|
||||||
|
if not force and out_path.exists():
|
||||||
|
logger.debug("Image already exists for %s", recipe.name)
|
||||||
|
return f"{PUBLIC_URL_PREFIX}{out_name}"
|
||||||
|
|
||||||
|
if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"):
|
||||||
|
return recipe.image_url
|
||||||
|
|
||||||
|
prompt = _build_prompt(recipe)
|
||||||
|
|
||||||
|
model = os.environ.get("AI_IMAGE_MODEL", "gpt-image-1")
|
||||||
|
logger.info("Generating image for '%s' via %s …", recipe.name, model)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"size": size,
|
||||||
|
"n": 1,
|
||||||
|
}
|
||||||
|
# gpt-image-* uses low/medium/high/auto; dall-e uses standard/hd
|
||||||
|
if not model.startswith("gpt-image"):
|
||||||
|
payload["quality"] = quality
|
||||||
|
else:
|
||||||
|
# Map dall-e quality names to gpt-image quality names
|
||||||
|
quality_map = {"standard": "medium", "hd": "high"}
|
||||||
|
payload["quality"] = quality_map.get(quality, "auto")
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.openai.com/v1/images/generations",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json=payload,
|
||||||
|
timeout=120.0,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.error("Network error generating image for '%s': %s", recipe.name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"DALL-E API error for '%s': HTTP %s – %s",
|
||||||
|
recipe.name,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = resp.json()["data"][0]
|
||||||
|
if "b64_json" in data:
|
||||||
|
image_bytes = base64.b64decode(data["b64_json"])
|
||||||
|
elif "url" in data:
|
||||||
|
image_url = data["url"]
|
||||||
|
img_resp = httpx.get(image_url, timeout=30.0)
|
||||||
|
if img_resp.status_code != 200:
|
||||||
|
logger.error("Failed downloading image for '%s': HTTP %s", recipe.name, img_resp.status_code)
|
||||||
|
return None
|
||||||
|
image_bytes = img_resp.content
|
||||||
|
else:
|
||||||
|
raise KeyError("no image data (url or b64_json)")
|
||||||
|
except (KeyError, IndexError) as exc:
|
||||||
|
logger.error("Unexpected DALL-E response for '%s': %s", recipe.name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
STATIC_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(image_bytes)
|
||||||
|
public_path = f"{PUBLIC_URL_PREFIX}{out_name}"
|
||||||
|
|
||||||
|
# Persist on recipe row if a DB session was provided.
|
||||||
|
if db is not None:
|
||||||
|
recipe.image_url = public_path
|
||||||
|
db.add(recipe)
|
||||||
|
db.commit()
|
||||||
|
logger.info("Image saved for '%s' -> %s", recipe.name, public_path)
|
||||||
|
|
||||||
|
return public_path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images_batch(
|
||||||
|
db: Session,
|
||||||
|
recipe_ids: Optional[list[str]] = None,
|
||||||
|
missing_only: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> dict:
|
||||||
|
"""Batch-generate images for recipes.
|
||||||
|
|
||||||
|
Returns a dict::
|
||||||
|
{
|
||||||
|
"total": <int>,
|
||||||
|
"succeeded": <int>,
|
||||||
|
"failed": <int>,
|
||||||
|
"skipped": <int>,
|
||||||
|
"results": {"recipe_id": "image_url | null", …},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
query = db.query(Recipe)
|
||||||
|
|
||||||
|
if recipe_ids:
|
||||||
|
query = query.filter(Recipe.id.in_(recipe_ids))
|
||||||
|
|
||||||
|
if missing_only:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Recipe.image_url.is_(None),
|
||||||
|
Recipe.image_url == "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes = query.limit(limit).all()
|
||||||
|
|
||||||
|
total = len(recipes)
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
skipped = 0
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for recipe in recipes:
|
||||||
|
recipe_id = str(recipe.id)
|
||||||
|
# Skip if already has local image unless forced.
|
||||||
|
if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"):
|
||||||
|
skipped += 1
|
||||||
|
results[recipe_id] = {"status": "skipped", "url": recipe.image_url}
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = generate_image_for_recipe(recipe, db=db, force=force)
|
||||||
|
if url:
|
||||||
|
succeeded += 1
|
||||||
|
results[recipe_id] = {"status": "ok", "url": url}
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
results[recipe_id] = {"status": "failed", "url": None}
|
||||||
|
|
||||||
|
time.sleep(2) # rate-limiting courtesy between requests
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped": skipped,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ def generate_meal_plan(
|
|||||||
week_start_date: date,
|
week_start_date: date,
|
||||||
config: PlannerConfig = DEFAULT,
|
config: PlannerConfig = DEFAULT,
|
||||||
today: Optional[date] = None,
|
today: Optional[date] = None,
|
||||||
|
exclude_recipe_ids: Optional[Set[UUID]] = None,
|
||||||
) -> GenerationResult:
|
) -> GenerationResult:
|
||||||
today = today or date.today()
|
today = today or date.today()
|
||||||
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
|
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
|
||||||
@@ -107,6 +108,7 @@ def generate_meal_plan(
|
|||||||
raise ValueError(f"family_profile {family_id} not found")
|
raise ValueError(f"family_profile {family_id} not found")
|
||||||
|
|
||||||
recipes = db.query(Recipe).all()
|
recipes = db.query(Recipe).all()
|
||||||
|
exclude_set = exclude_recipe_ids or set()
|
||||||
recipe_dicts = [
|
recipe_dicts = [
|
||||||
{
|
{
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@@ -120,6 +122,7 @@ def generate_meal_plan(
|
|||||||
"servings": r.servings or 4,
|
"servings": r.servings or 4,
|
||||||
}
|
}
|
||||||
for r in recipes
|
for r in recipes
|
||||||
|
if r.id not in exclude_set
|
||||||
]
|
]
|
||||||
|
|
||||||
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}
|
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# utils package
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Batch-generate recipe images using OpenAI DALL-E 3.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/generate_images.py --limit 10 --missing-only
|
||||||
|
python scripts/generate_images.py --recipe-id <uuid>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure imports resolve regardless of cwd.
|
||||||
|
_ = sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from app.database import get_db as _get_db
|
||||||
|
from app.services.image_generation import generate_images_batch
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate recipe images via DALL-E 3")
|
||||||
|
parser.add_argument("--limit", type=int, default=10, help="Max recipes to process")
|
||||||
|
parser.add_argument("--missing-only", action="store_true", default=True, help="Only recipes without images")
|
||||||
|
parser.add_argument("--force", action="store_true", default=False, help="Overwrite existing images")
|
||||||
|
parser.add_argument("--recipe-id", type=str, default=None, help="Generate for a single recipe")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# FastAPI DB dependency is a generator; consume it.
|
||||||
|
# Ensure project-root .env is discoverable before importing config.
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
os.environ.setdefault("DATABASE_URL", os.environ.get("DATABASE_URL", ""))
|
||||||
|
os.environ.setdefault("AI_IMAGE_ENABLED", os.environ.get("AI_IMAGE_ENABLED", "true"))
|
||||||
|
os.environ.setdefault("AI_IMAGE_API_KEY", os.environ.get("AI_IMAGE_API_KEY", ""))
|
||||||
|
os.environ.setdefault("OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY", ""))
|
||||||
|
|
||||||
|
db_gen = _get_db()
|
||||||
|
db = next(db_gen)
|
||||||
|
|
||||||
|
recipe_ids = [args.recipe_id] if args.recipe_id else None
|
||||||
|
|
||||||
|
result = generate_images_batch(
|
||||||
|
db,
|
||||||
|
recipe_ids=recipe_ids,
|
||||||
|
missing_only=args.missing_only,
|
||||||
|
force=args.force,
|
||||||
|
limit=args.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Total: {result['total']}")
|
||||||
|
print(f"Succeeded: {result['succeeded']}")
|
||||||
|
print(f"Failed: {result['failed']}")
|
||||||
|
print(f"Skipped: {result['skipped']}")
|
||||||
|
for rid, info in result["results"].items():
|
||||||
|
print(f" {rid}: {info['status']} – {info['url'] or '—'}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -19,6 +19,8 @@ services:
|
|||||||
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
||||||
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
|
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
|
||||||
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
|
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
|
||||||
|
- AI_IMAGE_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||||
@@ -35,6 +37,8 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
volumes:
|
||||||
|
- ./backend/static:/app/static:rw
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build:
|
build:
|
||||||
@@ -53,6 +57,8 @@ services:
|
|||||||
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
||||||
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
|
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
|
||||||
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
|
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
|
||||||
|
- AI_IMAGE_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||||
@@ -64,6 +70,8 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./backend/static:/app/static:rw
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -98,6 +106,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
- ./backend/static:/app/static:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
Binary file not shown.
@@ -21,6 +21,13 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
set $backend_upstream http://backend:8000;
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
set $frontend_upstream http://frontend:80;
|
set $frontend_upstream http://frontend:80;
|
||||||
proxy_pass $frontend_upstream;
|
proxy_pass $frontend_upstream;
|
||||||
|
|||||||
Reference in New Issue
Block a user