Komplett neu mit GPT
This commit is contained in:
parent
269bfaab2c
commit
e702418221
12 changed files with 498 additions and 165 deletions
|
|
@ -7,5 +7,5 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
|
|
|
||||||
118
backend/crud.py
Normal file
118
backend/crud.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Iterable, List, Optional, Tuple
|
||||||
|
from uuid import UUID as UUID_t
|
||||||
|
from datetime import date
|
||||||
|
import re, ast, operator
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from backend import models
|
||||||
|
|
||||||
|
ALLOWED_NAMES = {"days", "nights"}
|
||||||
|
ALLOWED_NODES = (
|
||||||
|
ast.Expression, ast.BinOp, ast.UnaryOp, ast.Num, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv,
|
||||||
|
ast.Mod, ast.Pow, ast.USub, ast.Load, ast.Name, ast.Constant, ast.Call
|
||||||
|
)
|
||||||
|
|
||||||
|
# Very small safe-eval for placeholders like {days * 10}
|
||||||
|
|
||||||
|
def _safe_eval(expr: str, ctx: dict) -> str:
|
||||||
|
node = ast.parse(expr, mode="eval")
|
||||||
|
for n in ast.walk(node):
|
||||||
|
if not isinstance(n, ALLOWED_NODES):
|
||||||
|
raise ValueError("disallowed expression")
|
||||||
|
if isinstance(n, ast.Name) and n.id not in ALLOWED_NAMES:
|
||||||
|
raise ValueError("unknown name")
|
||||||
|
if isinstance(n, ast.Call):
|
||||||
|
raise ValueError("calls not allowed")
|
||||||
|
val = eval(compile(node, "<expr>", "eval"), {"__builtins__": {}}, ctx)
|
||||||
|
return str(int(val)) if isinstance(val, (int, float)) and float(val).is_integer() else str(val)
|
||||||
|
|
||||||
|
_placeholder_re = re.compile(r"\{([^{}]+)\}")
|
||||||
|
|
||||||
|
def render_name(name_template: str, start: Optional[date], end: Optional[date]) -> str:
|
||||||
|
if not name_template:
|
||||||
|
return ""
|
||||||
|
days = nights = 0
|
||||||
|
if start and end:
|
||||||
|
days = (end - start).days + 1
|
||||||
|
nights = max(days - 1, 0)
|
||||||
|
ctx = {"days": days, "nights": nights}
|
||||||
|
|
||||||
|
def repl(m):
|
||||||
|
expr = m.group(1).strip()
|
||||||
|
try:
|
||||||
|
return _safe_eval(expr, ctx)
|
||||||
|
except Exception:
|
||||||
|
# if not evaluable, leave placeholder as-is
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
return _placeholder_re.sub(repl, name_template)
|
||||||
|
|
||||||
|
|
||||||
|
def items_for_trip(db: Session, user_id: UUID_t, selected_tag_ids: List[UUID_t]) -> List[models.Item]:
|
||||||
|
# Items ohne Tags (immer) + Items, die irgendeinen der selected_tags besitzen
|
||||||
|
q = (
|
||||||
|
db.query(models.Item)
|
||||||
|
.options(joinedload(models.Item.tags).joinedload(models.ItemTag.tag))
|
||||||
|
.filter(models.Item.user_id == user_id)
|
||||||
|
)
|
||||||
|
items = q.all()
|
||||||
|
|
||||||
|
selected_set = set(selected_tag_ids)
|
||||||
|
result: List[models.Item] = []
|
||||||
|
for it in items:
|
||||||
|
item_tag_ids = {link.tag_id for link in it.tags}
|
||||||
|
if not item_tag_ids:
|
||||||
|
result.append(it)
|
||||||
|
elif selected_set & item_tag_ids:
|
||||||
|
result.append(it)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def generate_trip_items(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
trip: models.Trip,
|
||||||
|
selected_tag_ids: List[UUID_t],
|
||||||
|
marked_tag_ids: List[UUID_t],
|
||||||
|
) -> Tuple[List[models.TripItem], List[UUID_t]]:
|
||||||
|
"""Regeneriert TripItems für einen Trip. Löscht alte, legt neue an.
|
||||||
|
Gibt (created_ids, deleted_checked_ids) zurück."""
|
||||||
|
# Sammle bestehende checked Items, falls sie verschwinden
|
||||||
|
deleted_checked: List[UUID_t] = []
|
||||||
|
|
||||||
|
# Lösche alle existierenden TripItems und merke checked, die wegfallen
|
||||||
|
for ti in list(trip.trip_items):
|
||||||
|
if ti.checked:
|
||||||
|
deleted_checked.append(ti.id)
|
||||||
|
db.delete(ti)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
items = items_for_trip(db, trip.user_id, selected_tag_ids)
|
||||||
|
|
||||||
|
created_ids: List[UUID_t] = []
|
||||||
|
marked_set = set(marked_tag_ids)
|
||||||
|
|
||||||
|
for it in items:
|
||||||
|
item_tag_ids = {link.tag_id for link in it.tags}
|
||||||
|
# bestimmen, ob dupliziert werden soll
|
||||||
|
intersection = item_tag_ids & marked_set
|
||||||
|
if marked_set and intersection:
|
||||||
|
per_tags = sorted(list(intersection))
|
||||||
|
else:
|
||||||
|
per_tags = [None] # gemeinsames Item (oder keine marked match)
|
||||||
|
|
||||||
|
for tag_id in per_tags:
|
||||||
|
calc = render_name(it.name, trip.start_date, trip.end_date)
|
||||||
|
ti = models.TripItem(
|
||||||
|
trip_id=trip.id,
|
||||||
|
item_id=it.id,
|
||||||
|
name_calculated=calc,
|
||||||
|
checked=False,
|
||||||
|
tag_id=tag_id,
|
||||||
|
)
|
||||||
|
db.add(ti)
|
||||||
|
db.flush()
|
||||||
|
created_ids.append(ti.id)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
return created_ids, deleted_checked
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/packlist"
|
DATABASE_URL = "postgresql+psycopg2://postgres:postgres@db:5432/postgres"
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL)
|
engine = create_engine(DATABASE_URL, future=True)
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
bind=engine, class_=AsyncSession, expire_on_commit=False
|
|
||||||
)
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
async def get_db():
|
# FastAPI dependency
|
||||||
async with SessionLocal() as db:
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
yield db
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from routes import items, trips
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from models import Base
|
from backend.database import Base, engine
|
||||||
from database import engine
|
from backend.routes import items, tags, trips, trip_items
|
||||||
|
|
||||||
app = FastAPI()
|
# Create tables (for MVP without Alembic)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Packlist API (MVP)")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(tags.router)
|
||||||
app.include_router(items.router)
|
app.include_router(items.router)
|
||||||
app.include_router(trips.router)
|
app.include_router(trips.router)
|
||||||
|
app.include_router(trip_items.router)
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup():
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def read_root():
|
|
||||||
return {"status": "running"}
|
|
||||||
|
|
@ -1,51 +1,105 @@
|
||||||
from sqlalchemy import Column, String, Boolean, Date, ForeignKey, Table, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, ARRAY
|
|
||||||
from sqlalchemy.orm import relationship, declarative_base
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from sqlalchemy import (
|
||||||
Base = declarative_base()
|
Column, String, Boolean, Date, ForeignKey, UniqueConstraint
|
||||||
|
|
||||||
item_tags = Table(
|
|
||||||
"item_tags",
|
|
||||||
Base.metadata,
|
|
||||||
Column("item_id", UUID(as_uuid=True), ForeignKey("items.id")),
|
|
||||||
Column("tag_id", UUID(as_uuid=True), ForeignKey("tags.id")),
|
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
|
items = relationship("Item", back_populates="user", cascade="all, delete")
|
||||||
|
tags = relationship("Tag", back_populates="user", cascade="all, delete")
|
||||||
|
trips = relationship("Trip", back_populates="user", cascade="all, delete")
|
||||||
|
|
||||||
|
|
||||||
class Item(Base):
|
class Item(Base):
|
||||||
__tablename__ = "items"
|
__tablename__ = "items"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
name = Column(Text, nullable=False)
|
name = Column(String, nullable=False) # z.B. "Zahnbürste" oder "{days} x Vitamin D3"
|
||||||
tags = relationship("Tag", secondary=item_tags, back_populates="items")
|
|
||||||
|
user = relationship("User", back_populates="items")
|
||||||
|
tags = relationship("ItemTag", back_populates="item", cascade="all, delete")
|
||||||
|
trip_items = relationship("TripItem", back_populates="item", cascade="all, delete")
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base):
|
||||||
__tablename__ = "tags"
|
__tablename__ = "tags"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "name"),)
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
name = Column(String, nullable=False, unique=False)
|
name = Column(String, nullable=False)
|
||||||
items = relationship("Item", secondary=item_tags, back_populates="tags")
|
|
||||||
|
user = relationship("User", back_populates="tags")
|
||||||
|
items = relationship("ItemTag", back_populates="tag", cascade="all, delete")
|
||||||
|
trip_selected_tags = relationship("TripTagSelected", back_populates="tag", cascade="all, delete")
|
||||||
|
trip_marked_tags = relationship("TripTagMarked", back_populates="tag", cascade="all, delete")
|
||||||
|
trip_items = relationship("TripItem", back_populates="tag")
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTag(Base):
|
||||||
|
__tablename__ = "item_tags"
|
||||||
|
|
||||||
|
item_id = Column(UUID(as_uuid=True), ForeignKey("items.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
|
||||||
|
item = relationship("Item", back_populates="tags")
|
||||||
|
tag = relationship("Tag", back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
class Trip(Base):
|
class Trip(Base):
|
||||||
__tablename__ = "trips"
|
__tablename__ = "trips"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
name = Column(String)
|
name = Column(String)
|
||||||
start_date = Column(Date)
|
start_date = Column(Date)
|
||||||
end_date = Column(Date)
|
end_date = Column(Date)
|
||||||
selected_tags = Column(ARRAY(String))
|
|
||||||
marked_tags = Column(ARRAY(String))
|
user = relationship("User", back_populates="trips")
|
||||||
|
selected_tags = relationship("TripTagSelected", back_populates="trip", cascade="all, delete")
|
||||||
|
marked_tags = relationship("TripTagMarked", back_populates="trip", cascade="all, delete")
|
||||||
|
trip_items = relationship("TripItem", back_populates="trip", cascade="all, delete")
|
||||||
|
|
||||||
|
|
||||||
|
class TripTagSelected(Base):
|
||||||
|
__tablename__ = "trip_tag_selected"
|
||||||
|
|
||||||
|
trip_id = Column(UUID(as_uuid=True), ForeignKey("trips.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id"), primary_key=True)
|
||||||
|
|
||||||
|
trip = relationship("Trip", back_populates="selected_tags")
|
||||||
|
tag = relationship("Tag", back_populates="trip_selected_tags")
|
||||||
|
|
||||||
|
|
||||||
|
class TripTagMarked(Base):
|
||||||
|
__tablename__ = "trip_tag_marked"
|
||||||
|
|
||||||
|
trip_id = Column(UUID(as_uuid=True), ForeignKey("trips.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id"), primary_key=True)
|
||||||
|
|
||||||
|
trip = relationship("Trip", back_populates="marked_tags")
|
||||||
|
tag = relationship("Tag", back_populates="trip_marked_tags")
|
||||||
|
|
||||||
|
|
||||||
class TripItem(Base):
|
class TripItem(Base):
|
||||||
__tablename__ = "trip_items"
|
__tablename__ = "trip_items"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
trip_id = Column(UUID(as_uuid=True), ForeignKey("trips.id"))
|
trip_id = Column(UUID(as_uuid=True), ForeignKey("trips.id", ondelete="CASCADE"), nullable=False)
|
||||||
item_id = Column(UUID(as_uuid=True), ForeignKey("items.id"), nullable=True)
|
item_id = Column(UUID(as_uuid=True), ForeignKey("items.id"), nullable=False)
|
||||||
tag = Column(String, nullable=True) # e.g. "#kristin"
|
name_calculated = Column(String, nullable=False)
|
||||||
name = Column(Text)
|
checked = Column(Boolean, nullable=False, default=False)
|
||||||
calculated_label = Column(Text)
|
tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id"), nullable=True)
|
||||||
checked = Column(Boolean, default=False)
|
|
||||||
|
trip = relationship("Trip", back_populates="trip_items")
|
||||||
|
item = relationship("Item", back_populates="trip_items")
|
||||||
|
tag = relationship("Tag", back_populates="trip_items")
|
||||||
|
|
|
||||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
|
|
@ -1,87 +1,123 @@
|
||||||
from uuid import uuid4
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from schemas import ItemCreate
|
from uuid import UUID
|
||||||
from models import Item, Tag
|
from backend.database import get_db
|
||||||
from database import get_db
|
from backend import models
|
||||||
from sqlalchemy import select
|
from backend.schemas import TripCreate, TripOut, TripUpdate, TripRegenerationResult
|
||||||
from sqlalchemy.orm import selectinload
|
from backend.crud import generate_trip_items
|
||||||
from config import FIXED_USER_ID
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/items", tags=["Items"])
|
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||||
|
|
||||||
@router.post("/")
|
@router.get("/", response_model=list[TripOut])
|
||||||
async def create_item(item: ItemCreate, db: AsyncSession = Depends(get_db)):
|
def list_trips(db: Session = Depends(get_db)):
|
||||||
user_id = FIXED_USER_ID
|
trips = (
|
||||||
|
db.query(models.Trip)
|
||||||
db_item = Item(id=uuid4(), name=item.name, user_id=user_id)
|
.options(
|
||||||
|
joinedload(models.Trip.selected_tags).joinedload(models.TripTagSelected.tag),
|
||||||
tags = []
|
joinedload(models.Trip.marked_tags).joinedload(models.TripTagMarked.tag),
|
||||||
for tag_name in item.tag_names:
|
)
|
||||||
result = await db.execute(select(Tag).where(Tag.name == tag_name, Tag.user_id == user_id))
|
.all()
|
||||||
tag = result.scalar_one_or_none()
|
|
||||||
if not tag:
|
|
||||||
tag = Tag(id=uuid4(), name=tag_name, user_id=user_id)
|
|
||||||
db.add(tag)
|
|
||||||
tags.append(tag)
|
|
||||||
|
|
||||||
db_item.tags = tags
|
|
||||||
db.add(db_item)
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "item created", "item_id": str(db_item.id)}
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def read_items(db: AsyncSession = Depends(get_db)):
|
|
||||||
user_id = FIXED_USER_ID
|
|
||||||
result = await db.execute(select(Item).where(Item.user_id == user_id).options(selectinload(Item.tags)))
|
|
||||||
items = result.scalars().all()
|
|
||||||
return {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": str(item.id),
|
|
||||||
"name": item.name,
|
|
||||||
"tags": [tag.name for tag in item.tags]
|
|
||||||
}
|
|
||||||
for item in items
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.put("/{item_id}")
|
|
||||||
async def update_item(item_id: str, item: ItemCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
user_id = FIXED_USER_ID
|
|
||||||
|
|
||||||
result = await db.execute(select(Item).where(Item.id == item_id, Item.user_id == user_id).options(selectinload(Item.tags)))
|
|
||||||
db_item = result.scalar_one_or_none()
|
|
||||||
if not db_item:
|
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
|
||||||
|
|
||||||
db_item.name = item.name
|
|
||||||
|
|
||||||
# Update tags
|
|
||||||
tags = []
|
|
||||||
for tag_name in item.tag_names:
|
|
||||||
result = await db.execute(select(Tag).where(Tag.name == tag_name, Tag.user_id == user_id))
|
|
||||||
tag = result.scalar_one_or_none()
|
|
||||||
if not tag:
|
|
||||||
tag = Tag(id=uuid4(), name=tag_name, user_id=user_id)
|
|
||||||
db.add(tag)
|
|
||||||
tags.append(tag)
|
|
||||||
db_item.tags = tags
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"status": "item updated", "item_id": str(db_item.id)}
|
|
||||||
|
|
||||||
@router.get("/{item_id}")
|
|
||||||
async def get_item(item_id: str, db: AsyncSession = Depends(get_db)):
|
|
||||||
user_id = FIXED_USER_ID
|
|
||||||
result = await db.execute(
|
|
||||||
select(Item).where(Item.id == item_id, Item.user_id == user_id).options(selectinload(Item.tags))
|
|
||||||
)
|
)
|
||||||
db_item = result.scalar_one_or_none()
|
return [
|
||||||
if not db_item:
|
TripOut(
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
id=t.id,
|
||||||
|
name=t.name,
|
||||||
|
start_date=t.start_date,
|
||||||
|
end_date=t.end_date,
|
||||||
|
selected_tags=[st.tag for st in t.selected_tags],
|
||||||
|
marked_tags=[mt.tag for mt in t.marked_tags],
|
||||||
|
)
|
||||||
|
for t in trips
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/", response_model=TripOut)
|
||||||
|
def create_trip(payload: TripCreate, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(models.User).first()
|
||||||
|
if not user:
|
||||||
|
from uuid import uuid4
|
||||||
|
user = models.User(id=uuid4(), name="Demo")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
trip = models.Trip(user_id=user.id, name=payload.name, start_date=payload.start_date, end_date=payload.end_date)
|
||||||
|
db.add(trip)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# attach selected & marked
|
||||||
|
if payload.selected_tag_ids:
|
||||||
|
for tid in payload.selected_tag_ids:
|
||||||
|
db.add(models.TripTagSelected(trip_id=trip.id, tag_id=tid))
|
||||||
|
if payload.marked_tag_ids:
|
||||||
|
for tid in payload.marked_tag_ids:
|
||||||
|
db.add(models.TripTagMarked(trip_id=trip.id, tag_id=tid))
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# generate items per rules
|
||||||
|
created_ids, _ = generate_trip_items(
|
||||||
|
db,
|
||||||
|
trip=trip,
|
||||||
|
selected_tag_ids=payload.selected_tag_ids,
|
||||||
|
marked_tag_ids=payload.marked_tag_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# reload with relationships
|
||||||
|
trip = (
|
||||||
|
db.query(models.Trip)
|
||||||
|
.options(
|
||||||
|
joinedload(models.Trip.selected_tags).joinedload(models.TripTagSelected.tag),
|
||||||
|
joinedload(models.Trip.marked_tags).joinedload(models.TripTagMarked.tag),
|
||||||
|
)
|
||||||
|
.get(trip.id)
|
||||||
|
)
|
||||||
|
return TripOut(
|
||||||
|
id=trip.id,
|
||||||
|
name=trip.name,
|
||||||
|
start_date=trip.start_date,
|
||||||
|
end_date=trip.end_date,
|
||||||
|
selected_tags=[st.tag for st in trip.selected_tags],
|
||||||
|
marked_tags=[mt.tag for mt in trip.marked_tags],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/{trip_id}/reconfigure", response_model=TripRegenerationResult)
|
||||||
|
def reconfigure_trip(trip_id: UUID, payload: TripUpdate, db: Session = Depends(get_db)):
|
||||||
|
trip = db.get(models.Trip, trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(status_code=404, detail="Trip not found")
|
||||||
|
|
||||||
|
# update base fields
|
||||||
|
if payload.name is not None:
|
||||||
|
trip.name = payload.name
|
||||||
|
if payload.start_date is not None:
|
||||||
|
trip.start_date = payload.start_date
|
||||||
|
if payload.end_date is not None:
|
||||||
|
trip.end_date = payload.end_date
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# update selected/marked join tables if provided
|
||||||
|
if payload.selected_tag_ids is not None:
|
||||||
|
# replace all
|
||||||
|
db.query(models.TripTagSelected).filter_by(trip_id=trip.id).delete()
|
||||||
|
for tid in payload.selected_tag_ids:
|
||||||
|
db.add(models.TripTagSelected(trip_id=trip.id, tag_id=tid))
|
||||||
|
if payload.marked_tag_ids is not None:
|
||||||
|
db.query(models.TripTagMarked).filter_by(trip_id=trip.id).delete()
|
||||||
|
for tid in payload.marked_tag_ids:
|
||||||
|
db.add(models.TripTagMarked(trip_id=trip.id, tag_id=tid))
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# read back lists
|
||||||
|
sel_ids = [row.tag_id for row in db.query(models.TripTagSelected).filter_by(trip_id=trip.id).all()]
|
||||||
|
mrk_ids = [row.tag_id for row in db.query(models.TripTagMarked).filter_by(trip_id=trip.id).all()]
|
||||||
|
|
||||||
|
created_ids, deleted_checked = generate_trip_items(
|
||||||
|
db, trip=trip, selected_tag_ids=sel_ids, marked_tag_ids=mrk_ids
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"id": str(db_item.id),
|
"trip_id": trip.id,
|
||||||
"name": db_item.name,
|
"deleted_checked_trip_item_ids": deleted_checked,
|
||||||
"tags": [tag.name for tag in db_item.tags]
|
"created_trip_item_ids": created_ids,
|
||||||
}
|
}
|
||||||
33
backend/routes/tags.py
Normal file
33
backend/routes/tags.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from uuid import UUID
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend import models
|
||||||
|
from backend.schemas import TagCreate, TagOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||||
|
|
||||||
|
FIXED_USER_ID = None # will be created on seed; or set in your auth layer
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[TagOut])
|
||||||
|
def list_tags(db: Session = Depends(get_db)):
|
||||||
|
tags = db.query(models.Tag).all()
|
||||||
|
return tags
|
||||||
|
|
||||||
|
@router.post("/", response_model=TagOut)
|
||||||
|
def create_tag(payload: TagCreate, db: Session = Depends(get_db)):
|
||||||
|
# For MVP: attach to first user (or create one)
|
||||||
|
user = db.query(models.User).first()
|
||||||
|
if not user:
|
||||||
|
from uuid import uuid4
|
||||||
|
user = models.User(id=uuid4(), name="Demo")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
existing = db.query(models.Tag).filter(models.Tag.user_id == user.id, models.Tag.name == payload.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Tag already exists")
|
||||||
|
tag = models.Tag(user_id=user.id, name=payload.name)
|
||||||
|
db.add(tag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tag)
|
||||||
|
return tag
|
||||||
51
backend/routes/trip_items.py
Normal file
51
backend/routes/trip_items.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from uuid import UUID
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend import models
|
||||||
|
from backend.schemas import TripItemOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trip-items", tags=["trip-items"])
|
||||||
|
|
||||||
|
@router.get("/by-trip/{trip_id}", response_model=list[TripItemOut])
|
||||||
|
def list_trip_items(trip_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
trip = db.get(models.Trip, trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(status_code=404, detail="Trip not found")
|
||||||
|
items = (
|
||||||
|
db.query(models.TripItem)
|
||||||
|
.options(joinedload(models.TripItem.tag))
|
||||||
|
.filter(models.TripItem.trip_id == trip_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
TripItemOut(
|
||||||
|
id=ti.id,
|
||||||
|
trip_id=ti.trip_id,
|
||||||
|
item_id=ti.item_id,
|
||||||
|
name_calculated=ti.name_calculated,
|
||||||
|
checked=ti.checked,
|
||||||
|
tag=ti.tag,
|
||||||
|
) for ti in items
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/{trip_item_id}/toggle", response_model=TripItemOut)
|
||||||
|
def toggle_trip_item(trip_item_id: UUID, db: Session = Depends(get_db)):
|
||||||
|
ti = (
|
||||||
|
db.query(models.TripItem)
|
||||||
|
.options(joinedload(models.TripItem.tag))
|
||||||
|
.get(trip_item_id)
|
||||||
|
)
|
||||||
|
if not ti:
|
||||||
|
raise HTTPException(status_code=404, detail="TripItem not found")
|
||||||
|
ti.checked = not ti.checked
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ti)
|
||||||
|
return TripItemOut(
|
||||||
|
id=ti.id,
|
||||||
|
trip_id=ti.trip_id,
|
||||||
|
item_id=ti.item_id,
|
||||||
|
name_calculated=ti.name_calculated,
|
||||||
|
checked=ti.checked,
|
||||||
|
tag=ti.tag,
|
||||||
|
)
|
||||||
|
|
@ -2,7 +2,7 @@ import re
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from schemas import TripCreate
|
from schemas import TripCreate, TripItemOut, TripOut
|
||||||
from models import Item, Trip, TripItem
|
from models import Item, Trip, TripItem
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -11,7 +11,7 @@ from config import FIXED_USER_ID
|
||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["Trips"])
|
router = APIRouter(prefix="/trips", tags=["Trips"])
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/", response_model=TripOut)
|
||||||
async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)):
|
async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)):
|
||||||
user_id = FIXED_USER_ID
|
user_id = FIXED_USER_ID
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)):
|
||||||
nights = days - 1
|
nights = days - 1
|
||||||
|
|
||||||
# relevante Items
|
# relevante Items
|
||||||
result = await db.execute(select(Item).where(Item.user_id == user_id))
|
result = await db.execute(select(Item).where(Item.user_id == user_id).options(selectinload(Item.tags)))
|
||||||
all_items = result.scalars().all()
|
all_items = result.scalars().all()
|
||||||
|
|
||||||
trip_items = []
|
trip_items = []
|
||||||
|
|
@ -73,36 +73,22 @@ async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
|
||||||
db.add_all(trip_items)
|
db.add_all(trip_items)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "trip created", "trip_id": str(db_trip.id)}
|
# return {"status": "trip created", "trip_id": str(db_trip.id)}
|
||||||
|
return db_trip
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{trip_id}/items")
|
@router.get("/{trip_id}/items", response_model=list[TripItemOut])
|
||||||
async def get_trip_items(trip_id: UUID, db: AsyncSession = Depends(get_db)):
|
async def get_trip_items(trip_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(TripItem).where(TripItem.trip_id == trip_id))
|
result = await db.execute(select(TripItem).where(TripItem.trip_id == trip_id).options(selectinload(TripItem.item)))
|
||||||
items = result.scalars().all()
|
items = result.scalars().all()
|
||||||
return [
|
return [TripItemOut.model_validate(item) for item in items]
|
||||||
{
|
|
||||||
"label": item.calculated_label,
|
|
||||||
"tag": item.tag,
|
|
||||||
"checked": item.checked
|
|
||||||
} for item in items
|
|
||||||
]
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/", response_model=list[TripOut])
|
||||||
async def get_trips(db: AsyncSession = Depends(get_db)):
|
async def get_trips(db: AsyncSession = Depends(get_db)):
|
||||||
user_id = FIXED_USER_ID
|
user_id = FIXED_USER_ID
|
||||||
result = await db.execute(select(Trip).where(Trip.user_id == user_id))
|
result = await db.execute(select(Trip).where(Trip.user_id == user_id))
|
||||||
trips = result.scalars().all()
|
trips = result.scalars().all()
|
||||||
return [
|
return trips
|
||||||
{
|
|
||||||
"id": str(trip.id),
|
|
||||||
"name": trip.name,
|
|
||||||
"start_date": trip.start_date,
|
|
||||||
"end_date": trip.end_date,
|
|
||||||
"selected_tags": trip.selected_tags,
|
|
||||||
"marked_tags": trip.marked_tags
|
|
||||||
} for trip in trips
|
|
||||||
]
|
|
||||||
|
|
||||||
def replace_placeholders(text: str, days: int, nights: int) -> str:
|
def replace_placeholders(text: str, days: int, nights: int) -> str:
|
||||||
def replacer(match):
|
def replacer(match):
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,65 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class TagBase(BaseModel):
|
||||||
class TagCreate(BaseModel):
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
class TagCreate(TagBase):
|
||||||
|
pass
|
||||||
|
|
||||||
class ItemCreate(BaseModel):
|
class TagOut(TagBase):
|
||||||
|
id: UUID
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
class ItemBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
tag_names: List[str]
|
|
||||||
|
|
||||||
|
class ItemCreate(ItemBase):
|
||||||
|
tag_ids: List[UUID] = []
|
||||||
|
|
||||||
class TripCreate(BaseModel):
|
class ItemOut(ItemBase):
|
||||||
name: str
|
id: UUID
|
||||||
start_date: date
|
tags: List[TagOut] = []
|
||||||
end_date: date
|
class Config:
|
||||||
selected_tags: List[str]
|
orm_mode = True
|
||||||
marked_tags: List[str]
|
|
||||||
|
class TripBase(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
|
||||||
|
class TripCreate(TripBase):
|
||||||
|
selected_tag_ids: List[UUID] = []
|
||||||
|
marked_tag_ids: List[UUID] = []
|
||||||
|
|
||||||
|
class TripUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
selected_tag_ids: Optional[List[UUID]] = None
|
||||||
|
marked_tag_ids: Optional[List[UUID]] = None
|
||||||
|
|
||||||
|
class TripOut(TripBase):
|
||||||
|
id: UUID
|
||||||
|
selected_tags: List[TagOut]
|
||||||
|
marked_tags: List[TagOut]
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
class TripItemOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
trip_id: UUID
|
||||||
|
item_id: UUID
|
||||||
|
name_calculated: str
|
||||||
|
checked: bool
|
||||||
|
tag: Optional[TagOut] = None
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
class TripRegenerationResult(BaseModel):
|
||||||
|
trip_id: UUID
|
||||||
|
deleted_checked_trip_item_ids: List[UUID] = []
|
||||||
|
created_trip_item_ids: List[UUID] = []
|
||||||
|
|
@ -9,6 +9,8 @@ services:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/packlist
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/packlist
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue