Skip to content

Commit 356d9b8

Browse files
committed
✨ Trip sharing
1 parent 719b837 commit 356d9b8

File tree

6 files changed

+134
-4
lines changed

6 files changed

+134
-4
lines changed

backend/trip/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.11.0"
1+
__version__ = "1.12.0"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Trip share
2+
3+
Revision ID: 77027ac49c26
4+
Revises: d5fee6ec85c2
5+
Create Date: 2025-08-09 10:42:28.109690
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "77027ac49c26"
15+
down_revision = "d5fee6ec85c2"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.create_table(
22+
"tripshare",
23+
sa.Column("id", sa.Integer(), nullable=False),
24+
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
25+
sa.Column("trip_id", sa.Integer(), nullable=False),
26+
sa.ForeignKeyConstraint(
27+
["trip_id"], ["trip.id"], name=op.f("fk_tripshare_trip_id_trip"), ondelete="CASCADE"
28+
),
29+
sa.PrimaryKeyConstraint("id", name=op.f("pk_tripshare")),
30+
)
31+
with op.batch_alter_table("tripshare", schema=None) as batch_op:
32+
batch_op.create_index(batch_op.f("ix_tripshare_token"), ["token"], unique=True)
33+
34+
35+
def downgrade():
36+
with op.batch_alter_table("tripshare", schema=None) as batch_op:
37+
batch_op.drop_index(batch_op.f("ix_tripshare_token"))
38+
39+
op.drop_table("tripshare")

backend/trip/models/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class TripItemStatusEnum(str, Enum):
3939
OPTIONAL = "optional"
4040

4141

42+
class TripShareURL(BaseModel):
43+
url: str
44+
45+
4246
class LoginRegisterModel(BaseModel):
4347
username: Annotated[
4448
str,
@@ -246,6 +250,7 @@ class Trip(TripBase, table=True):
246250

247251
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
248252
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
253+
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
249254

250255

251256
class TripCreate(TripBase):
@@ -283,6 +288,7 @@ class TripRead(TripBase):
283288
image_id: int | None
284289
days: list["TripDayRead"]
285290
places: list["PlaceRead"]
291+
shared: bool
286292

287293
@classmethod
288294
def serialize(cls, obj: Trip) -> "TripRead":
@@ -294,6 +300,7 @@ def serialize(cls, obj: Trip) -> "TripRead":
294300
image_id=obj.image_id,
295301
days=[TripDayRead.serialize(day) for day in obj.days],
296302
places=[PlaceRead.serialize(place) for place in obj.places],
303+
shared=bool(obj.shares),
297304
)
298305

299306

@@ -386,3 +393,11 @@ def serialize(cls, obj: TripItem) -> "TripItemRead":
386393
status=obj.status,
387394
place=PlaceRead.serialize(obj.place) if obj.place else None,
388395
)
396+
397+
398+
class TripShare(SQLModel, table=True):
399+
id: int | None = Field(default=None, primary_key=True)
400+
token: str = Field(index=True, unique=True)
401+
402+
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
403+
trip: Trip | None = Relationship(back_populates="shares")

backend/trip/routers/settings.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ async def import_data(
236236
trip_place_id_map = {p["id"]: new_p.id for p, new_p in zip(data.get("places", []), places)}
237237
for trip in data.get("trips", []):
238238
trip_data = {
239-
key: trip[key] for key in trip.keys() if key not in {"id", "image", "image_id", "places", "days"}
239+
key: trip[key]
240+
for key in trip.keys()
241+
if key not in {"id", "image", "image_id", "places", "days", "shared"}
240242
}
241243
trip_data["user"] = current_user
242244

backend/trip/routers/trips.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
99
TripDayBase, TripDayRead, TripItem,
1010
TripItemCreate, TripItemRead, TripItemUpdate,
11-
TripPlaceLink, TripRead, TripReadBase, TripUpdate)
11+
TripPlaceLink, TripRead, TripReadBase, TripShare,
12+
TripShareURL, TripUpdate)
1213
from ..security import verify_exists_and_owns
13-
from ..utils.utils import b64img_decode, remove_image, save_image_to_file
14+
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
15+
save_image_to_file)
1416

1517
router = APIRouter(prefix="/api/trips", tags=["trips"])
1618

@@ -338,3 +340,70 @@ def delete_tripitem(
338340
session.delete(db_item)
339341
session.commit()
340342
return {}
343+
344+
345+
@router.get("/shared/{token}", response_model=TripRead)
346+
def read_shared_trip(
347+
session: SessionDep,
348+
token: str,
349+
) -> TripRead:
350+
share = session.exec(select(TripShare).where(TripShare.token == token)).first()
351+
if not share:
352+
raise HTTPException(status_code=404, detail="Not found")
353+
354+
db_trip = session.get(Trip, share.trip_id)
355+
return TripRead.serialize(db_trip)
356+
357+
358+
@router.get("/{trip_id}/share", response_model=TripShareURL)
359+
def get_shared_trip_url(
360+
session: SessionDep,
361+
trip_id: int,
362+
current_user: Annotated[str, Depends(get_current_username)],
363+
) -> TripShareURL:
364+
db_trip = session.get(Trip, trip_id)
365+
verify_exists_and_owns(current_user, db_trip)
366+
367+
share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
368+
if not share:
369+
raise HTTPException(status_code=404, detail="Not found")
370+
371+
return {"url": f"/s/t/{share.token}"}
372+
373+
374+
@router.post("/{trip_id}/share", response_model=TripShareURL)
375+
def create_shared_trip(
376+
session: SessionDep,
377+
trip_id: int,
378+
current_user: Annotated[str, Depends(get_current_username)],
379+
) -> TripShareURL:
380+
db_trip = session.get(Trip, trip_id)
381+
verify_exists_and_owns(current_user, db_trip)
382+
383+
shared = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
384+
if shared:
385+
raise HTTPException(status_code=409, detail="The resource already exists")
386+
387+
token = generate_urlsafe()
388+
trip_share = TripShare(token=token, trip_id=trip_id, user=current_user)
389+
session.add(trip_share)
390+
session.commit()
391+
return {"url": f"/s/t/{token}"}
392+
393+
394+
@router.delete("/{trip_id}/share")
395+
def delete_shared_trip(
396+
session: SessionDep,
397+
trip_id: int,
398+
current_user: Annotated[str, Depends(get_current_username)],
399+
):
400+
db_trip = session.get(Trip, trip_id)
401+
verify_exists_and_owns(current_user, db_trip)
402+
403+
db_share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
404+
if not db_share:
405+
raise HTTPException(status_code=404, detail="Not found")
406+
407+
session.delete(db_share)
408+
session.commit()
409+
return {}

backend/trip/utils/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import date
33
from io import BytesIO
44
from pathlib import Path
5+
from secrets import token_urlsafe
56
from uuid import uuid4
67

78
import httpx
@@ -14,6 +15,10 @@
1415
settings = Settings()
1516

1617

18+
def generate_urlsafe() -> str:
19+
return token_urlsafe(32)
20+
21+
1722
def generate_filename(format: str) -> str:
1823
return f"{uuid4()}.{format}"
1924

0 commit comments

Comments
 (0)