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

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)}
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

View File

@ -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 {}

View File

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