diff --git a/backend/trip/__init__.py b/backend/trip/__init__.py index f84c53b..b518f6e 100644 --- a/backend/trip/__init__.py +++ b/backend/trip/__init__.py @@ -1 +1 @@ -__version__ = "1.11.0" +__version__ = "1.12.0" diff --git a/backend/trip/alembic/versions/77027ac49c26_trip_share.py b/backend/trip/alembic/versions/77027ac49c26_trip_share.py new file mode 100644 index 0000000..861b2dd --- /dev/null +++ b/backend/trip/alembic/versions/77027ac49c26_trip_share.py @@ -0,0 +1,39 @@ +"""Trip share + +Revision ID: 77027ac49c26 +Revises: d5fee6ec85c2 +Create Date: 2025-08-09 10:42:28.109690 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "77027ac49c26" +down_revision = "d5fee6ec85c2" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "tripshare", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("trip_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["trip_id"], ["trip.id"], name=op.f("fk_tripshare_trip_id_trip"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tripshare")), + ) + with op.batch_alter_table("tripshare", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_tripshare_token"), ["token"], unique=True) + + +def downgrade(): + with op.batch_alter_table("tripshare", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_tripshare_token")) + + op.drop_table("tripshare") diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py index f4dcdc9..e53022c 100644 --- a/backend/trip/models/models.py +++ b/backend/trip/models/models.py @@ -39,6 +39,10 @@ class TripItemStatusEnum(str, Enum): OPTIONAL = "optional" +class TripShareURL(BaseModel): + url: str + + class LoginRegisterModel(BaseModel): username: Annotated[ str, @@ -246,6 +250,7 @@ class Trip(TripBase, table=True): places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink) days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True) + shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True) class TripCreate(TripBase): @@ -283,6 +288,7 @@ class TripRead(TripBase): image_id: int | None days: list["TripDayRead"] places: list["PlaceRead"] + shared: bool @classmethod def serialize(cls, obj: Trip) -> "TripRead": @@ -294,6 +300,7 @@ class TripRead(TripBase): image_id=obj.image_id, days=[TripDayRead.serialize(day) for day in obj.days], places=[PlaceRead.serialize(place) for place in obj.places], + shared=bool(obj.shares), ) @@ -386,3 +393,11 @@ class TripItemRead(TripItemBase): status=obj.status, place=PlaceRead.serialize(obj.place) if obj.place else None, ) + + +class TripShare(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + token: str = Field(index=True, unique=True) + + trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE") + trip: Trip | None = Relationship(back_populates="shares") diff --git a/backend/trip/routers/settings.py b/backend/trip/routers/settings.py index 0685538..82cf81d 100644 --- a/backend/trip/routers/settings.py +++ b/backend/trip/routers/settings.py @@ -236,7 +236,9 @@ async def import_data( trip_place_id_map = {p["id"]: new_p.id for p, new_p in zip(data.get("places", []), places)} for trip in data.get("trips", []): trip_data = { - key: trip[key] for key in trip.keys() if key not in {"id", "image", "image_id", "places", "days"} + key: trip[key] + for key in trip.keys() + if key not in {"id", "image", "image_id", "places", "days", "shared"} } trip_data["user"] = current_user diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py index 7e9e55f..d612c38 100644 --- a/backend/trip/routers/trips.py +++ b/backend/trip/routers/trips.py @@ -8,9 +8,11 @@ from ..deps import SessionDep, get_current_username from ..models.models import (Image, Place, Trip, TripCreate, TripDay, TripDayBase, TripDayRead, TripItem, TripItemCreate, TripItemRead, TripItemUpdate, - TripPlaceLink, TripRead, TripReadBase, TripUpdate) + TripPlaceLink, TripRead, TripReadBase, TripShare, + TripShareURL, TripUpdate) from ..security import verify_exists_and_owns -from ..utils.utils import b64img_decode, remove_image, save_image_to_file +from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image, + save_image_to_file) router = APIRouter(prefix="/api/trips", tags=["trips"]) @@ -338,3 +340,70 @@ def delete_tripitem( session.delete(db_item) session.commit() return {} + + +@router.get("/shared/{token}", response_model=TripRead) +def read_shared_trip( + session: SessionDep, + token: str, +) -> TripRead: + share = session.exec(select(TripShare).where(TripShare.token == token)).first() + if not share: + raise HTTPException(status_code=404, detail="Not found") + + db_trip = session.get(Trip, share.trip_id) + return TripRead.serialize(db_trip) + + +@router.get("/{trip_id}/share", response_model=TripShareURL) +def get_shared_trip_url( + session: SessionDep, + trip_id: int, + current_user: Annotated[str, Depends(get_current_username)], +) -> TripShareURL: + db_trip = session.get(Trip, trip_id) + verify_exists_and_owns(current_user, db_trip) + + share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() + if not share: + raise HTTPException(status_code=404, detail="Not found") + + return {"url": f"/s/t/{share.token}"} + + +@router.post("/{trip_id}/share", response_model=TripShareURL) +def create_shared_trip( + session: SessionDep, + trip_id: int, + current_user: Annotated[str, Depends(get_current_username)], +) -> TripShareURL: + db_trip = session.get(Trip, trip_id) + verify_exists_and_owns(current_user, db_trip) + + shared = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() + if shared: + raise HTTPException(status_code=409, detail="The resource already exists") + + token = generate_urlsafe() + trip_share = TripShare(token=token, trip_id=trip_id, user=current_user) + session.add(trip_share) + session.commit() + return {"url": f"/s/t/{token}"} + + +@router.delete("/{trip_id}/share") +def delete_shared_trip( + session: SessionDep, + trip_id: int, + current_user: Annotated[str, Depends(get_current_username)], +): + db_trip = session.get(Trip, trip_id) + verify_exists_and_owns(current_user, db_trip) + + db_share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first() + if not db_share: + raise HTTPException(status_code=404, detail="Not found") + + session.delete(db_share) + session.commit() + return {} diff --git a/backend/trip/utils/utils.py b/backend/trip/utils/utils.py index fb642ed..7d4ddb4 100644 --- a/backend/trip/utils/utils.py +++ b/backend/trip/utils/utils.py @@ -2,6 +2,7 @@ import base64 from datetime import date from io import BytesIO from pathlib import Path +from secrets import token_urlsafe from uuid import uuid4 import httpx @@ -14,6 +15,10 @@ from ..config import Settings settings = Settings() +def generate_urlsafe() -> str: + return token_urlsafe(32) + + def generate_filename(format: str) -> str: return f"{uuid4()}.{format}"