chore: initial project setup with backend, frontend, and infrastructure
Add complete NexaPantry application structure including: - Docker Compose configuration with PostgreSQL, Redis, FastAPI backend, worker, frontend and Caddy - Environment configuration template with database, auth, and service settings - GitHub Actions CI workflow for backend/frontend linting, testing, auditing and Docker builds - AGPL-3.0 license and comprehensive README with setup, development, and security documentation - Backend
This commit is contained in:
16
backend/app/services/audit.py
Normal file
16
backend/app/services/audit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import AuditLog, User
|
||||
|
||||
|
||||
def audit(db: Session, actor: User | None, action: str, target_type: str | None = None, target_id: str | None = None, metadata: dict | None = None) -> None:
|
||||
db.add(
|
||||
AuditLog(
|
||||
actor_user_id=actor.id if actor else None,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
)
|
||||
|
||||
70
backend/app/services/mail.py
Normal file
70
backend/app/services/mail.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import decrypt_secret, encrypt_secret
|
||||
from app.models.entities import MailSetting
|
||||
from app.schemas.common import MailSettingsIn, MailSettingsOut
|
||||
|
||||
|
||||
def get_mail_settings(db: Session) -> MailSetting:
|
||||
settings = db.get(MailSetting, 1)
|
||||
if not settings:
|
||||
settings = MailSetting(id=1)
|
||||
db.add(settings)
|
||||
db.flush()
|
||||
return settings
|
||||
|
||||
|
||||
def serialize_mail_settings(settings: MailSetting) -> MailSettingsOut:
|
||||
return MailSettingsOut(
|
||||
smtp_host=settings.smtp_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
smtp_user=settings.smtp_user,
|
||||
has_password=bool(settings.smtp_password_encrypted),
|
||||
use_tls=settings.use_tls,
|
||||
use_starttls=settings.use_starttls,
|
||||
sender_address=settings.sender_address,
|
||||
sender_name=settings.sender_name,
|
||||
)
|
||||
|
||||
|
||||
def update_mail_settings(db: Session, payload: MailSettingsIn) -> MailSetting:
|
||||
settings = get_mail_settings(db)
|
||||
settings.smtp_host = payload.smtp_host
|
||||
settings.smtp_port = payload.smtp_port
|
||||
settings.smtp_user = payload.smtp_user
|
||||
if payload.smtp_password is not None:
|
||||
settings.smtp_password_encrypted = encrypt_secret(payload.smtp_password)
|
||||
settings.use_tls = payload.use_tls
|
||||
settings.use_starttls = payload.use_starttls
|
||||
settings.sender_address = str(payload.sender_address) if payload.sender_address else None
|
||||
settings.sender_name = payload.sender_name
|
||||
return settings
|
||||
|
||||
|
||||
def send_mail(db: Session, to: str, subject: str, body: str) -> None:
|
||||
settings = get_mail_settings(db)
|
||||
if not settings.smtp_host or not settings.sender_address:
|
||||
raise RuntimeError("SMTP is not configured")
|
||||
message = EmailMessage()
|
||||
message["From"] = f"{settings.sender_name} <{settings.sender_address}>"
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
password = decrypt_secret(settings.smtp_password_encrypted)
|
||||
client_cls = smtplib.SMTP_SSL if settings.use_tls and not settings.use_starttls else smtplib.SMTP
|
||||
with client_cls(settings.smtp_host, settings.smtp_port, timeout=20) as smtp:
|
||||
if settings.use_starttls:
|
||||
smtp.starttls()
|
||||
if settings.smtp_user and password:
|
||||
smtp.login(settings.smtp_user, password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
def invite_body(token: str) -> str:
|
||||
link = f"{get_settings().instance_url.rstrip('/')}/accept-invite?token={token}"
|
||||
return f"Welcome to NexaPantry.\n\nOpen this invitation link to set your password:\n{link}\n\nThe link expires automatically."
|
||||
|
||||
50
backend/app/services/notifications.py
Normal file
50
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import Home, HomeMembership, Notification, Product
|
||||
from app.services.mail import send_mail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_expiry_notifications(db: Session) -> int:
|
||||
count = 0
|
||||
homes = db.scalars(select(Home)).all()
|
||||
today = date.today()
|
||||
for home in homes:
|
||||
deadline = today + timedelta(days=home.expiry_warning_days)
|
||||
products = db.scalars(
|
||||
select(Product).where(Product.home_id == home.id, Product.expires_at <= deadline)
|
||||
).all()
|
||||
if not products:
|
||||
continue
|
||||
memberships = db.scalars(select(HomeMembership).where(HomeMembership.home_id == home.id)).all()
|
||||
for membership in memberships:
|
||||
prefs = membership.notification_preferences or {}
|
||||
if prefs.get("in_app", True):
|
||||
db.add(
|
||||
Notification(
|
||||
user_id=membership.user_id,
|
||||
home_id=home.id,
|
||||
title="NexaPantry expiry warning",
|
||||
body=f"{len(products)} products expire soon in {home.name}.",
|
||||
kind="expiry",
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
if prefs.get("email", False):
|
||||
try:
|
||||
send_mail(db, membership.user.email, "NexaPantry expiry warning", f"{len(products)} products expire soon in {home.name}.")
|
||||
except Exception:
|
||||
logger.exception("Expiry e-mail delivery failed for user %s", membership.user_id)
|
||||
continue
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def mark_read(db: Session, notification: Notification) -> Notification:
|
||||
notification.read_at = datetime.now(UTC)
|
||||
return notification
|
||||
51
backend/app/services/products.py
Normal file
51
backend/app/services/products.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import date
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entities import Home, Product
|
||||
|
||||
|
||||
def expiry_status(product: Product, home: Home) -> str:
|
||||
if not product.expires_at:
|
||||
return "ok"
|
||||
today = date.today()
|
||||
if product.expires_at <= today:
|
||||
return "expired"
|
||||
if (product.expires_at - today).days <= home.expiry_warning_days:
|
||||
return "soon"
|
||||
return "ok"
|
||||
|
||||
|
||||
class ProductLookup(Protocol):
|
||||
async def by_barcode(self, barcode: str) -> dict | None:
|
||||
...
|
||||
|
||||
|
||||
class OpenFoodFactsLookup(ProductLookup):
|
||||
async def by_barcode(self, barcode: str) -> dict | None:
|
||||
url = f"https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.get(url)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
data = response.json()
|
||||
product = data.get("product")
|
||||
if not product:
|
||||
return None
|
||||
return {
|
||||
"name": product.get("product_name") or product.get("generic_name") or "",
|
||||
"brand": product.get("brands"),
|
||||
"category": (product.get("categories_tags") or ["Other"])[0].replace("en:", ""),
|
||||
"image_url": product.get("image_front_small_url") or product.get("image_url"),
|
||||
"barcode": barcode,
|
||||
}
|
||||
|
||||
|
||||
def low_stock_products(db: Session, home_id: str) -> list[Product]:
|
||||
return [
|
||||
product
|
||||
for product in db.query(Product).filter(Product.home_id == home_id).all()
|
||||
if product.quantity <= product.min_quantity
|
||||
]
|
||||
44
backend/app/services/recipes.py
Normal file
44
backend/app/services/recipes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from app.models.entities import Product
|
||||
|
||||
RECIPES = [
|
||||
{
|
||||
"id": "tomato-pasta",
|
||||
"name": {"de": "Tomaten-Pasta", "en": "Tomato pasta"},
|
||||
"ingredients": ["tomato", "tomate", "pasta", "nudeln", "cheese", "käse"],
|
||||
"steps": {"de": ["Nudeln kochen", "Tomaten anbraten", "Mit Käse servieren"], "en": ["Cook pasta", "Warm tomatoes", "Serve with cheese"]},
|
||||
},
|
||||
{
|
||||
"id": "omelette",
|
||||
"name": {"de": "Gemüse-Omelett", "en": "Vegetable omelette"},
|
||||
"ingredients": ["egg", "ei", "cheese", "käse", "pepper", "paprika", "milk", "milch"],
|
||||
"steps": {"de": ["Eier verquirlen", "Gemüse anbraten", "Stocken lassen"], "en": ["Whisk eggs", "Saute vegetables", "Let it set"]},
|
||||
},
|
||||
{
|
||||
"id": "rice-bowl",
|
||||
"name": {"de": "Reis-Bowl", "en": "Rice bowl"},
|
||||
"ingredients": ["rice", "reis", "beans", "bohnen", "corn", "mais", "yogurt", "joghurt"],
|
||||
"steps": {"de": ["Reis erhitzen", "Toppings ergänzen", "Mit Sauce servieren"], "en": ["Warm rice", "Add toppings", "Serve with sauce"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def suggest(products: list[Product], language: str) -> list[dict]:
|
||||
names = " ".join([p.name.lower() for p in products])
|
||||
expiring = {p.id for p in products if p.expires_at}
|
||||
suggestions: list[dict] = []
|
||||
for recipe in RECIPES:
|
||||
matches = [i for i in recipe["ingredients"] if i in names]
|
||||
if not matches:
|
||||
continue
|
||||
score = len(matches) + min(len(expiring), 3)
|
||||
suggestions.append(
|
||||
{
|
||||
"id": recipe["id"],
|
||||
"name": recipe["name"].get(language, recipe["name"]["en"]),
|
||||
"matchedIngredients": sorted(set(matches)),
|
||||
"score": score,
|
||||
"steps": recipe["steps"].get(language, recipe["steps"]["en"]),
|
||||
}
|
||||
)
|
||||
return sorted(suggestions, key=lambda item: item["score"], reverse=True)
|
||||
|
||||
Reference in New Issue
Block a user