From 7869ba99311265c9f0bc90f860f3d60423f8abcb Mon Sep 17 00:00:00 2001 From: itskovacs Date: Sun, 21 Sep 2025 15:11:12 +0200 Subject: [PATCH] :sparkles: Trip: item image and gpx --- .../versions/8775a65d510f_tripitem_image.py | 37 +++++++++++++ .../versions/8e12410a0b8e_tripitem_gpx.py | 33 ++++++++++++ backend/trip/models/models.py | 13 ++++- backend/trip/routers/trips.py | 52 +++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 backend/trip/alembic/versions/8775a65d510f_tripitem_image.py create mode 100644 backend/trip/alembic/versions/8e12410a0b8e_tripitem_gpx.py diff --git a/backend/trip/alembic/versions/8775a65d510f_tripitem_image.py b/backend/trip/alembic/versions/8775a65d510f_tripitem_image.py new file mode 100644 index 0000000..3420166 --- /dev/null +++ b/backend/trip/alembic/versions/8775a65d510f_tripitem_image.py @@ -0,0 +1,37 @@ +"""TripItem image + +Revision ID: 8775a65d510f +Revises: 7e331b851cb7 +Create Date: 2025-09-20 20:01:43.884115 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8775a65d510f" +down_revision = "7e331b851cb7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tripitem", schema=None) as batch_op: + batch_op.add_column(sa.Column("image_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key( + batch_op.f("fk_tripitem_image_id_image"), "image", ["image_id"], ["id"], ondelete="CASCADE" + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tripitem", schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f("fk_tripitem_image_id_image"), type_="foreignkey") + batch_op.drop_column("image_id") + + # ### end Alembic commands ### diff --git a/backend/trip/alembic/versions/8e12410a0b8e_tripitem_gpx.py b/backend/trip/alembic/versions/8e12410a0b8e_tripitem_gpx.py new file mode 100644 index 0000000..02da630 --- /dev/null +++ b/backend/trip/alembic/versions/8e12410a0b8e_tripitem_gpx.py @@ -0,0 +1,33 @@ +"""TripItem GPX + +Revision ID: 8e12410a0b8e +Revises: 8775a65d510f +Create Date: 2025-09-20 19:21:18.294547 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8e12410a0b8e" +down_revision = "8775a65d510f" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tripitem", schema=None) as batch_op: + batch_op.add_column(sa.Column("gpx", sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("tripitem", schema=None) as batch_op: + batch_op.drop_column("gpx") + + # ### end Alembic commands ### diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py index 6416b42..7bb5eff 100644 --- a/backend/trip/models/models.py +++ b/backend/trip/models/models.py @@ -75,6 +75,7 @@ class Image(ImageBase, table=True): categories: list["Category"] = Relationship(back_populates="image") places: list["Place"] = Relationship(back_populates="image") trips: list["Trip"] = Relationship(back_populates="image") + tripitems: list["TripItem"] = Relationship(back_populates="image") class UserBase(SQLModel): @@ -199,7 +200,6 @@ class Place(PlaceBase, table=True): class PlaceCreate(PlaceBase): image: str | None = None category_id: int - gpx: str | None = None class PlacesCreate(PlaceBase): @@ -393,6 +393,7 @@ class TripItemBase(SQLModel): price: float | None = None lng: float | None = None status: TripItemStatusEnum | None = None + gpx: str | None = None @field_validator("time", mode="before") def pad_mm_if_needed(cls, value: str) -> str: @@ -407,6 +408,9 @@ class TripItem(TripItemBase, table=True): place_id: int | None = Field(default=None, foreign_key="place.id") place: Place | None = Relationship(back_populates="trip_items") + image_id: int | None = Field(default=None, foreign_key="image.id", ondelete="CASCADE") + image: Image | None = Relationship(back_populates="tripitems") + day_id: int = Field(foreign_key="tripday.id", ondelete="CASCADE") day: TripDay | None = Relationship(back_populates="items") @@ -414,6 +418,7 @@ class TripItem(TripItemBase, table=True): class TripItemCreate(TripItemBase): place: int | None = None status: TripItemStatusEnum | None = None + image: str | None = None class TripItemUpdate(TripItemBase): @@ -422,6 +427,7 @@ class TripItemUpdate(TripItemBase): place: int | None = None day_id: int | None = None status: TripItemStatusEnum | None = None + image: str | None = None class TripItemRead(TripItemBase): @@ -429,6 +435,8 @@ class TripItemRead(TripItemBase): place: PlaceRead | None day_id: int status: TripItemStatusEnum | None + image: str | None + image_id: int | None @classmethod def serialize(cls, obj: TripItem) -> "TripItemRead": @@ -443,6 +451,9 @@ class TripItemRead(TripItemBase): day_id=obj.day_id, status=obj.status, place=PlaceRead.serialize(obj.place) if obj.place else None, + image=_prefix_assets_url(obj.image.filename) if obj.image else None, + image_id=obj.image_id, + gpx=obj.gpx, ) diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py index d1475fa..979b2b2 100644 --- a/backend/trip/routers/trips.py +++ b/backend/trip/routers/trips.py @@ -346,6 +346,18 @@ def create_tripitem( status=item.status, ) + if item.image: + image_bytes = b64img_decode(item.image) + filename = save_image_to_file(image_bytes, 0) + if not filename: + raise HTTPException(status_code=400, detail="Bad request") + + image = Image(filename=filename, user=current_user) + session.add(image) + session.flush() + session.refresh(image) + new_item.image_id = image.id + if item.place is not None: place_in_trip = any(place.id == item.place for place in db_trip.places) if not place_in_trip: @@ -382,6 +394,46 @@ def update_tripitem( raise HTTPException(status_code=400, detail="Bad request") item_data = item.model_dump(exclude_unset=True) + # TODO: Optimize logic; image=data: parse / image=none: remove / no image key: pass + if "image" in item_data: # no image key: pass + image_b64 = item_data.pop("image", None) # image=data: parse + if image_b64: + try: + image_bytes = b64img_decode(image_b64) + except Exception: + raise HTTPException(status_code=400, detail="Bad request") + + filename = save_image_to_file(image_bytes, 0) + if not filename: + raise HTTPException(status_code=400, detail="Bad request") + + image = Image(filename=filename, user=current_user) + session.add(image) + session.flush() + session.refresh(image) + + if db_item.image_id: + old_image = session.get(Image, db_item.image_id) + try: + remove_image(old_image.filename) + session.delete(old_image) + db_item.image_id = None + session.refresh(db_item) + except Exception: + raise HTTPException(status_code=400, detail="Bad request") + + db_item.image_id = image.id + + else: # image=none: remove if previous + if getattr(db_item, "image_id", None): + old_image = session.get(Image, db_item.image_id) + try: + remove_image(old_image.filename) + session.delete(old_image) + db_item.image_id = None + session.refresh(db_item) + except Exception: + raise HTTPException(status_code=400, detail="Bad request") place_id = item_data.pop("place", None) db_item.place_id = place_id