Trip sharing

This commit is contained in:
itskovacs 2025-08-11 18:48:44 +02:00
parent 719b837978
commit 356d9b8a59
6 changed files with 134 additions and 4 deletions

View File

@ -1 +1 @@
__version__ = "1.11.0" __version__ = "1.12.0"

View File

@ -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")

View File

@ -39,6 +39,10 @@ class TripItemStatusEnum(str, Enum):
OPTIONAL = "optional" OPTIONAL = "optional"
class TripShareURL(BaseModel):
url: str
class LoginRegisterModel(BaseModel): class LoginRegisterModel(BaseModel):
username: Annotated[ username: Annotated[
str, str,
@ -246,6 +250,7 @@ class Trip(TripBase, table=True):
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink) places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True) days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
class TripCreate(TripBase): class TripCreate(TripBase):
@ -283,6 +288,7 @@ class TripRead(TripBase):
image_id: int | None image_id: int | None
days: list["TripDayRead"] days: list["TripDayRead"]
places: list["PlaceRead"] places: list["PlaceRead"]
shared: bool
@classmethod @classmethod
def serialize(cls, obj: Trip) -> "TripRead": def serialize(cls, obj: Trip) -> "TripRead":
@ -294,6 +300,7 @@ class TripRead(TripBase):
image_id=obj.image_id, image_id=obj.image_id,
days=[TripDayRead.serialize(day) for day in obj.days], days=[TripDayRead.serialize(day) for day in obj.days],
places=[PlaceRead.serialize(place) for place in obj.places], places=[PlaceRead.serialize(place) for place in obj.places],
shared=bool(obj.shares),
) )
@ -386,3 +393,11 @@ class TripItemRead(TripItemBase):
status=obj.status, status=obj.status,
place=PlaceRead.serialize(obj.place) if obj.place else None, 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")

View File

@ -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)} 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", []): for trip in data.get("trips", []):
trip_data = { 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 trip_data["user"] = current_user

View File

@ -8,9 +8,11 @@ from ..deps import SessionDep, get_current_username
from ..models.models import (Image, Place, Trip, TripCreate, TripDay, from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
TripDayBase, TripDayRead, TripItem, TripDayBase, TripDayRead, TripItem,
TripItemCreate, TripItemRead, TripItemUpdate, TripItemCreate, TripItemRead, TripItemUpdate,
TripPlaceLink, TripRead, TripReadBase, TripUpdate) TripPlaceLink, TripRead, TripReadBase, TripShare,
TripShareURL, TripUpdate)
from ..security import verify_exists_and_owns 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"]) router = APIRouter(prefix="/api/trips", tags=["trips"])
@ -338,3 +340,70 @@ def delete_tripitem(
session.delete(db_item) session.delete(db_item)
session.commit() session.commit()
return {} 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 {}

View File

@ -2,6 +2,7 @@ import base64
from datetime import date from datetime import date
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from secrets import token_urlsafe
from uuid import uuid4 from uuid import uuid4
import httpx import httpx
@ -14,6 +15,10 @@ from ..config import Settings
settings = Settings() settings = Settings()
def generate_urlsafe() -> str:
return token_urlsafe(32)
def generate_filename(format: str) -> str: def generate_filename(format: str) -> str:
return f"{uuid4()}.{format}" return f"{uuid4()}.{format}"