From e70241822167c8c1546c44bb5559141d8b2e3762 Mon Sep 17 00:00:00 2001 From: Felix Zett Date: Wed, 13 Aug 2025 21:10:04 +0200 Subject: [PATCH] Komplett neu mit GPT --- backend/Dockerfile | 2 +- backend/crud.py | 118 +++++++++++++++++++++ backend/database.py | 20 ++-- backend/main.py | 31 +++--- backend/models.py | 106 ++++++++++++++----- backend/routes/__init__.py | 0 backend/routes/items.py | 198 +++++++++++++++++++++-------------- backend/routes/tags.py | 33 ++++++ backend/routes/trip_items.py | 51 +++++++++ backend/routes/trips.py | 34 ++---- backend/schemas.py | 68 +++++++++--- docker-compose.yml | 2 + 12 files changed, 498 insertions(+), 165 deletions(-) create mode 100644 backend/crud.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/tags.py create mode 100644 backend/routes/trip_items.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 4abfe9e..62ff63d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,5 +7,5 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..d36a19a --- /dev/null +++ b/backend/crud.py @@ -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, "", "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 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index ff610a6..075e797 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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 -DATABASE_URL = "postgresql+asyncpg://postgres:postgres@db:5432/packlist" +DATABASE_URL = "postgresql+psycopg2://postgres:postgres@db:5432/postgres" -engine = create_async_engine(DATABASE_URL) -SessionLocal = sessionmaker( - bind=engine, class_=AsyncSession, expire_on_commit=False -) +engine = create_engine(DATABASE_URL, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base = declarative_base() -async def get_db(): - async with SessionLocal() as db: +# FastAPI dependency +from contextlib import contextmanager + +def get_db(): + db = SessionLocal() + try: yield db + finally: + db.close() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 010936a..cb2916a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,17 +1,22 @@ from fastapi import FastAPI -from routes import items, trips -from models import Base -from database import engine +from fastapi.middleware.cors import CORSMiddleware +from backend.database import Base, 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(trips.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"} \ No newline at end of file +app.include_router(trip_items.router) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 8ef781a..b4bcf30 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 - -Base = declarative_base() - -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 import ( + Column, String, Boolean, Date, ForeignKey, UniqueConstraint ) +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): __tablename__ = "items" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), nullable=False) - name = Column(Text, nullable=False) - tags = relationship("Tag", secondary=item_tags, back_populates="items") + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + name = Column(String, nullable=False) # z.B. "Zahnbürste" oder "{days} x Vitamin D3" + + 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): __tablename__ = "tags" + __table_args__ = (UniqueConstraint("user_id", "name"),) + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), nullable=False) - name = Column(String, nullable=False, unique=False) - items = relationship("Item", secondary=item_tags, back_populates="tags") + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + name = Column(String, nullable=False) + + 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): __tablename__ = "trips" + 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) start_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): __tablename__ = "trip_items" + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - trip_id = Column(UUID(as_uuid=True), ForeignKey("trips.id")) - item_id = Column(UUID(as_uuid=True), ForeignKey("items.id"), nullable=True) - tag = Column(String, nullable=True) # e.g. "#kristin" - name = Column(Text) - calculated_label = Column(Text) - checked = Column(Boolean, default=False) + 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=False) + name_calculated = Column(String, nullable=False) + checked = Column(Boolean, nullable=False, default=False) + tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id"), nullable=True) + + trip = relationship("Trip", back_populates="trip_items") + item = relationship("Item", back_populates="trip_items") + tag = relationship("Tag", back_populates="trip_items") diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/items.py b/backend/routes/items.py index c359197..8be1016 100644 --- a/backend/routes/items.py +++ b/backend/routes/items.py @@ -1,87 +1,123 @@ -from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from schemas import ItemCreate -from models import Item, Tag -from database import get_db -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from config import FIXED_USER_ID +from sqlalchemy.orm import Session, joinedload +from uuid import UUID +from backend.database import get_db +from backend import models +from backend.schemas import TripCreate, TripOut, TripUpdate, TripRegenerationResult +from backend.crud import generate_trip_items -router = APIRouter(prefix="/items", tags=["Items"]) +router = APIRouter(prefix="/trips", tags=["trips"]) -@router.post("/") -async def create_item(item: ItemCreate, db: AsyncSession = Depends(get_db)): - user_id = FIXED_USER_ID - - db_item = Item(id=uuid4(), name=item.name, user_id=user_id) - - 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 - 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)) +@router.get("/", response_model=list[TripOut]) +def list_trips(db: Session = Depends(get_db)): + trips = ( + db.query(models.Trip) + .options( + joinedload(models.Trip.selected_tags).joinedload(models.TripTagSelected.tag), + joinedload(models.Trip.marked_tags).joinedload(models.TripTagMarked.tag), + ) + .all() ) - db_item = result.scalar_one_or_none() - if not db_item: - raise HTTPException(status_code=404, detail="Item not found") + return [ + TripOut( + 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 { - "id": str(db_item.id), - "name": db_item.name, - "tags": [tag.name for tag in db_item.tags] + "trip_id": trip.id, + "deleted_checked_trip_item_ids": deleted_checked, + "created_trip_item_ids": created_ids, } \ No newline at end of file diff --git a/backend/routes/tags.py b/backend/routes/tags.py new file mode 100644 index 0000000..2d0bd65 --- /dev/null +++ b/backend/routes/tags.py @@ -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 \ No newline at end of file diff --git a/backend/routes/trip_items.py b/backend/routes/trip_items.py new file mode 100644 index 0000000..81beb6a --- /dev/null +++ b/backend/routes/trip_items.py @@ -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, + ) \ No newline at end of file diff --git a/backend/routes/trips.py b/backend/routes/trips.py index 344e6c1..63b14e7 100644 --- a/backend/routes/trips.py +++ b/backend/routes/trips.py @@ -2,7 +2,7 @@ import re from uuid import UUID, uuid4 from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession -from schemas import TripCreate +from schemas import TripCreate, TripItemOut, TripOut from models import Item, Trip, TripItem from database import get_db from sqlalchemy import select @@ -11,7 +11,7 @@ from config import FIXED_USER_ID router = APIRouter(prefix="/trips", tags=["Trips"]) -@router.post("/") +@router.post("/", response_model=TripOut) async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)): user_id = FIXED_USER_ID @@ -33,7 +33,7 @@ async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)): nights = days - 1 # 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() trip_items = [] @@ -73,36 +73,22 @@ async def create_trip(trip: TripCreate, db: AsyncSession = Depends(get_db)): db.add_all(trip_items) 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)): - 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() - return [ - { - "label": item.calculated_label, - "tag": item.tag, - "checked": item.checked - } for item in items - ] + return [TripItemOut.model_validate(item) for item in items] -@router.get("/") +@router.get("/", response_model=list[TripOut]) async def get_trips(db: AsyncSession = Depends(get_db)): user_id = FIXED_USER_ID result = await db.execute(select(Trip).where(Trip.user_id == user_id)) trips = result.scalars().all() - return [ - { - "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 - ] + return trips def replace_placeholders(text: str, days: int, nights: int) -> str: def replacer(match): diff --git a/backend/schemas.py b/backend/schemas.py index fa0c3ce..fa84222 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,21 +1,65 @@ +from typing import List, Optional +from uuid import UUID from datetime import date -from typing import List - from pydantic import BaseModel - -class TagCreate(BaseModel): +class TagBase(BaseModel): name: str +class TagCreate(TagBase): + pass -class ItemCreate(BaseModel): +class TagOut(TagBase): + id: UUID + class Config: + orm_mode = True + +class ItemBase(BaseModel): name: str - tag_names: List[str] +class ItemCreate(ItemBase): + tag_ids: List[UUID] = [] -class TripCreate(BaseModel): - name: str - start_date: date - end_date: date - selected_tags: List[str] - marked_tags: List[str] \ No newline at end of file +class ItemOut(ItemBase): + id: UUID + tags: List[TagOut] = [] + class Config: + orm_mode = True + +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] = [] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6682e8e..6a90846 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - ./backend:/app environment: - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/packlist + depends_on: + - db db: image: postgres:15