diff --git a/backend/Dockerfile b/backend/Dockerfile index 62ff63d..49c736b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,11 +1,13 @@ + FROM python:3.11-slim WORKDIR /app -COPY requirements.txt . +COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY . . -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +EXPOSE 8000 +CMD [ "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] diff --git a/backend/crud.py b/backend/crud.py index 21ddd4c..5b652a8 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -1,10 +1,11 @@ + from __future__ import annotations -from typing import Iterable, List, Optional, Tuple +from typing import List, Optional, Tuple from uuid import UUID as UUID_t from datetime import date -import re, ast, operator +import re, ast from sqlalchemy.orm import Session, joinedload -import models +from backend import models ALLOWED_NAMES = {"days", "nights"} ALLOWED_NODES = ( @@ -74,7 +75,7 @@ def generate_trip_items( trip: models.Trip, selected_tag_ids: List[UUID_t], marked_tag_ids: List[UUID_t], -) -> Tuple[List[models.TripItem], List[UUID_t]]: +) -> Tuple[List[UUID_t], 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 @@ -94,12 +95,8 @@ def generate_trip_items( 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) + per_tags = sorted(list(intersection)) if (marked_set and intersection) else [None] for tag_id in per_tags: calc = render_name(it.name, trip.start_date, trip.end_date) @@ -115,4 +112,4 @@ def generate_trip_items( created_ids.append(ti.id) db.flush() - return created_ids, deleted_checked \ No newline at end of file + return created_ids, deleted_checked diff --git a/backend/database.py b/backend/database.py index 075e797..d718c8e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,3 +1,4 @@ + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base @@ -7,12 +8,9 @@ engine = create_engine(DATABASE_URL, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base = declarative_base() -# FastAPI dependency -from contextlib import contextmanager - def get_db(): db = SessionLocal() try: yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/backend/main.py b/backend/main.py index 8a67dda..7ca6453 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,8 @@ -from uuid import UUID + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from database import Base, engine -from routes import items, tags, trips, trip_items, dev_seed - +from backend.database import Base, engine +from backend.routes import items, tags, trips, trip_items, dev_seed # Create tables (for MVP without Alembic) Base.metadata.create_all(bind=engine) @@ -22,4 +21,4 @@ app.include_router(tags.router) app.include_router(items.router) app.include_router(trips.router) app.include_router(trip_items.router) -app.include_router(dev_seed.router) \ No newline at end of file +app.include_router(dev_seed.router) diff --git a/backend/models.py b/backend/models.py index a38d8e6..9cbf915 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,15 +1,12 @@ + import uuid -from sqlalchemy import ( - Column, String, Boolean, Date, ForeignKey, UniqueConstraint -) +from sqlalchemy import Column, String, Boolean, Date, ForeignKey, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship -from database import Base - +from backend.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) @@ -17,10 +14,8 @@ class User(Base): 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), ForeignKey("users.id"), nullable=False) name = Column(String, nullable=False) # z.B. "Zahnbürste" oder "{days} x Vitamin D3" @@ -29,10 +24,9 @@ class Item(Base): 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"),) + __table_args__ = (UniqueConstraint("user_id", "name", name="uq_tag_user_name"),) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) @@ -44,20 +38,16 @@ class Tag(Base): 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), ForeignKey("users.id"), nullable=False) name = Column(String) @@ -69,36 +59,30 @@ class Trip(Base): 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", 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) + tag_id = Column(UUID(as_uuid=True), ForeignKey("tags.id"), nullable=True) # null = gemeinsames Item trip = relationship("Trip", back_populates="trip_items") item = relationship("Item", back_populates="trip_items") diff --git a/backend/requirements.txt b/backend/requirements.txt index c30fe25..0712569 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,6 @@ -fastapi -uvicorn[standard] -sqlalchemy -asyncpg -psycopg2-binary -pydantic -python-dotenv +fastapi==0.115.0 +uvicorn==0.30.6 +SQLAlchemy==2.0.34 +psycopg2-binary==2.9.9 +pydantic==1.10.17 diff --git a/backend/routes/dev_seed.py b/backend/routes/dev_seed.py index 7c860d6..c5cf6c6 100644 --- a/backend/routes/dev_seed.py +++ b/backend/routes/dev_seed.py @@ -1,63 +1,103 @@ + from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -import uuid -from database import get_db -import models - -FIXED_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") # Fixed UUID for demo user +from uuid import uuid4 +from datetime import date, timedelta +from backend.database import get_db +from backend import models +from backend.crud import generate_trip_items router = APIRouter(tags=["dev"]) @router.get("/dev/seed") -def seed_data(db: Session = Depends(get_db)): - user_id = FIXED_USER_ID - # Create demo user if not exists - user = db.query(models.User).filter(models.User.id == user_id).first() +def dev_seed(db: Session = Depends(get_db)): + + # Create demo user + user = db.query(models.User).first() if not user: - user = models.User(id=user_id, name="Demo User") + user = models.User(id=uuid4(), name="Demo") db.add(user) db.flush() # Tags - tags = ["jari", "kristin", "felix", "auto", "sommer"] - tag_objs = [] - for t in tags: - tag_obj = models.Tag(id=uuid.uuid4(), user_id=user_id, name=t) - db.add(tag_obj) - tag_objs.append(tag_obj) + tag_names = ["jari", "kristin", "felix", "auto", "sommer"] + name_to_tag = {} + for name in tag_names: + existing = db.query(models.Tag).filter(models.Tag.user_id==user.id, models.Tag.name==name).first() + if existing: + name_to_tag[name] = existing + else: + t = models.Tag(id=uuid4(), user_id=user.id, name=name) + db.add(t) + db.flush() + name_to_tag[name] = t + + # Items (based on your original example) + + items = [ + ("Kinderwagen", ["jari"]), + ("Babyschale mit Sonnenschutz", ["jari", "auto"]), + ("Alle Schnuller", ["jari"]), + ("Sonnencreme", ["sommer"]), + ("Ladekabel Handy", []), + ("Ladekabel Mac", ["kristin"]), + ("Sonnenbrillen", ["sommer"]), + ("Schlafsack für {nights} Nächte", ["jari"]), + ("{days} x Vitamin D3", ["jari"]), + ("{days * 10} Windeln", ["jari"]), + ("{days} Unterhosen", ["felix", "kristin"]), + ("Badesachen", ["felix", "kristin"]), + ] + + for name, tags in items: + existing = db.query(models.Item).filter(models.Item.user_id==user.id, models.Item.name==name).first() + if existing: + item = existing + else: + item = models.Item(id=uuid4(), user_id=user.id, name=name) + db.add(item) + db.flush() + + # link tags + for tag_name in tags: + tag = name_to_tag[tag_name] + link = db.query(models.ItemTag).filter_by(item_id=item.id, tag_id=tag.id).first() + if not link: + db.add(models.ItemTag(item_id=item.id, tag_id=tag.id)) db.flush() - # Items - items_data = [ - ("Badesachen", ["sommer", "auto"]), - ("Sonnencreme", ["sommer"]), - ("Ladegerät", []), - ("Zelt", ["auto"]), - ("Schlafsack", []), - ("Strandspielzeug", ["sommer", "jari"]), - ] - for name, tag_names in items_data: - item = models.Item(id=uuid.uuid4(), user_id=user_id, name=name) - db.add(item) - db.flush() - for tag_name in tag_names: - tag_id = next(t.id for t in tag_objs if t.name == tag_name) - db.add(models.ItemTag(item_id=item.id, tag_id=tag_id)) - - selected_tags = [t for t in tag_objs if t.name in ["jari", "kristin", "auto", "sommer"]] - marked_tags = [t for t in tag_objs if t.name in ["kristin", "felix"]] + # Demo trip trip = models.Trip( - id=uuid.uuid4(), - user_id=user_id, + id=uuid4(), + user_id=user.id, name="Ostsee August 2025", - start_date="2025-08-01", - end_date="2025-08-14", - selected_tags=selected_tags, - marked_tags=marked_tags, + start_date=date(2025, 8, 18), + end_date=date(2025, 8, 20), ) db.add(trip) + db.flush() + + selected = [name_to_tag[n].id for n in ["jari", "felix", "kristin", "auto"]] + marked = [name_to_tag[n].id for n in ["kristin", "felix"]] + + for tid in selected: + db.add(models.TripTagSelected(trip_id=trip.id, tag_id=tid)) + + for tid in marked: + db.add(models.TripTagMarked(trip_id=trip.id, tag_id=tid)) + + db.flush() + + created_ids, _ = generate_trip_items(db, trip=trip, selected_tag_ids=selected, marked_tag_ids=marked) db.commit() - return {"message": "Seed data created", "user_id": str(user_id)} + + return { + "user_id": str(user.id), + "trip_id": str(trip.id), + "selected_tag_ids": [str(x) for x in selected], + "marked_tag_ids": [str(x) for x in marked], + "created_trip_item_ids": [str(x) for x in created_ids], + } \ No newline at end of file diff --git a/backend/routes/items.py b/backend/routes/items.py index d794944..971ba97 100644 --- a/backend/routes/items.py +++ b/backend/routes/items.py @@ -1,9 +1,10 @@ + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session, joinedload from uuid import UUID -from database import get_db -import models -from schemas import ItemCreate, ItemOut +from backend.database import get_db +from backend import models +from backend.schemas import ItemCreate, ItemOut router = APIRouter(prefix="/items", tags=["items"]) @@ -60,4 +61,4 @@ def delete_item(item_id: UUID, db: Session = Depends(get_db)): if not item: raise HTTPException(status_code=404, detail="Item not found") db.delete(item) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/routes/tags.py b/backend/routes/tags.py index df516c3..70a4a74 100644 --- a/backend/routes/tags.py +++ b/backend/routes/tags.py @@ -1,14 +1,13 @@ + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from uuid import UUID -from database import get_db -import models -from schemas import TagCreate, TagOut +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() @@ -16,7 +15,6 @@ def list_tags(db: Session = Depends(get_db)): @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 @@ -30,4 +28,4 @@ def create_tag(payload: TagCreate, db: Session = Depends(get_db)): db.add(tag) db.commit() db.refresh(tag) - return tag \ No newline at end of file + return tag diff --git a/backend/routes/trip_items.py b/backend/routes/trip_items.py index 458fff2..9744033 100644 --- a/backend/routes/trip_items.py +++ b/backend/routes/trip_items.py @@ -1,9 +1,10 @@ + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session, joinedload from uuid import UUID -from database import get_db -import models -from schemas import TripItemOut +from backend.database import get_db +from backend import models +from backend.schemas import TripItemOut router = APIRouter(prefix="/trip-items", tags=["trip-items"]) @@ -48,4 +49,4 @@ def toggle_trip_item(trip_item_id: UUID, db: Session = Depends(get_db)): 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 c6a85e8..f3c7fb1 100644 --- a/backend/routes/trips.py +++ b/backend/routes/trips.py @@ -1,10 +1,11 @@ + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session, joinedload from uuid import UUID -from database import get_db -import models -from schemas import TripCreate, TripOut, TripUpdate, TripRegenerationResult -from crud import generate_trip_items +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="/trips", tags=["trips"]) @@ -120,4 +121,4 @@ def reconfigure_trip(trip_id: UUID, payload: TripUpdate, db: Session = Depends(g "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/schemas.py b/backend/schemas.py index fa84222..76db648 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,3 +1,4 @@ + from typing import List, Optional from uuid import UUID from datetime import date @@ -62,4 +63,4 @@ class TripItemOut(BaseModel): 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 + created_trip_item_ids: List[UUID] = [] diff --git a/docker-compose.yml b/docker-compose.yml index 6a90846..293b1c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,27 @@ -version: "3.9" +version: "3.9" services: + db: + image: postgres:15-alpine + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data backend: - build: ./backend + build: + context: ./backend + dockerfile: Dockerfile + depends_on: + - db ports: - "8000:8000" volumes: - - ./backend:/app + - ./:/app environment: - - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/packlist - depends_on: - - db - - db: - image: postgres:15 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: packlist - volumes: - - db_data:/var/lib/postgresql/data - + - PYTHONUNBUFFERED=1 volumes: db_data: