✨ Trip sharing
This commit is contained in:
parent
719b837978
commit
356d9b8a59
@ -1 +1 @@
|
|||||||
__version__ = "1.11.0"
|
__version__ = "1.12.0"
|
||||||
|
|||||||
39
backend/trip/alembic/versions/77027ac49c26_trip_share.py
Normal file
39
backend/trip/alembic/versions/77027ac49c26_trip_share.py
Normal 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")
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user