Skip to content

Commit 3cfd33c

Browse files
committed
✨ Trip: packing list
1 parent d8cb828 commit 3cfd33c

File tree

10 files changed

+476
-2
lines changed

10 files changed

+476
-2
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Trip Packing list
2+
3+
Revision ID: 1181ac441ce5
4+
Revises: 77027ac49c26
5+
Create Date: 2025-08-16 11:35:34.870999
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 = "1181ac441ce5"
15+
down_revision = "77027ac49c26"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.create_table(
22+
"trippackinglistitem",
23+
sa.Column("text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
24+
sa.Column("qt", sa.Integer(), nullable=True),
25+
sa.Column(
26+
"category",
27+
sa.Enum("CLOTHES", "TOILETRIES", "TECH", "DOCUMENTS", "OTHER", name="packinglistcategoryenum"),
28+
nullable=True,
29+
),
30+
sa.Column("packed", sa.Boolean(), nullable=True),
31+
sa.Column("id", sa.Integer(), nullable=False),
32+
sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
33+
sa.Column("trip_id", sa.Integer(), nullable=False),
34+
sa.ForeignKeyConstraint(
35+
["trip_id"], ["trip.id"], name=op.f("fk_trippackinglistitem_trip_id_trip"), ondelete="CASCADE"
36+
),
37+
sa.ForeignKeyConstraint(
38+
["user"], ["user.username"], name=op.f("fk_trippackinglistitem_user_user"), ondelete="CASCADE"
39+
),
40+
sa.PrimaryKeyConstraint("id", name=op.f("pk_trippackinglistitem")),
41+
)
42+
43+
44+
def downgrade():
45+
op.drop_table("trippackinglistitem")

backend/trip/models/models.py

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

4141

42+
class PackingListCategoryEnum(str, Enum):
43+
CLOTHES = "clothes"
44+
TOILETRIES = "toiletries"
45+
TECH = "tech"
46+
DOCUMENTS = "documents"
47+
OTHER = "other"
48+
49+
4250
class TripShareURL(BaseModel):
4351
url: str
4452

@@ -251,6 +259,7 @@ class Trip(TripBase, table=True):
251259
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
252260
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
253261
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
262+
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
254263

255264

256265
class TripCreate(TripBase):
@@ -401,3 +410,39 @@ class TripShare(SQLModel, table=True):
401410

402411
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
403412
trip: Trip | None = Relationship(back_populates="shares")
413+
414+
415+
class TripPackingListItemBase(SQLModel):
416+
text: str | None = None
417+
qt: int | None = None
418+
category: PackingListCategoryEnum | None = None
419+
packed: bool | None = None
420+
421+
422+
class TripPackingListItem(TripPackingListItemBase, table=True):
423+
id: int | None = Field(default=None, primary_key=True)
424+
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
425+
426+
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
427+
trip: Trip | None = Relationship(back_populates="packing_items")
428+
429+
430+
class TripPackingListItemCreate(TripPackingListItemBase):
431+
packed: bool = False
432+
433+
434+
class TripPackingListItemUpdate(TripPackingListItemBase): ...
435+
436+
437+
class TripPackingListItemRead(TripPackingListItemBase):
438+
id: int
439+
440+
@classmethod
441+
def serialize(cls, obj: "TripPackingListItem") -> "TripPackingListItemRead":
442+
return cls(
443+
id=obj.id,
444+
text=obj.text,
445+
qt=obj.qt,
446+
category=obj.category,
447+
packed=obj.packed,
448+
)

backend/trip/routers/trips.py

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
99
TripDayBase, TripDayRead, TripItem,
1010
TripItemCreate, TripItemRead, TripItemUpdate,
11-
TripPlaceLink, TripRead, TripReadBase, TripShare,
12-
TripShareURL, TripUpdate)
11+
TripPackingListItem, TripPackingListItemCreate,
12+
TripPackingListItemRead,
13+
TripPackingListItemUpdate, TripPlaceLink,
14+
TripRead, TripReadBase, TripShare, TripShareURL,
15+
TripUpdate)
1316
from ..security import verify_exists_and_owns
1417
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
1518
save_image_to_file)
@@ -407,3 +410,88 @@ def delete_shared_trip(
407410
session.delete(db_share)
408411
session.commit()
409412
return {}
413+
414+
415+
@router.get("/{trip_id}/packing", response_model=list[TripPackingListItemRead])
416+
def read_packing_list(
417+
session: SessionDep,
418+
trip_id: int,
419+
current_user: Annotated[str, Depends(get_current_username)],
420+
) -> list[TripPackingListItemRead]:
421+
p_items = session.exec(
422+
select(TripPackingListItem)
423+
.where(TripPackingListItem.trip_id == trip_id, TripPackingListItem.user == current_user)
424+
.order_by(TripPackingListItem.id.asc())
425+
).all()
426+
427+
return [TripPackingListItemRead.serialize(i) for i in p_items]
428+
429+
430+
@router.post("/{trip_id}/packing", response_model=TripPackingListItemRead)
431+
def create_packing_item(
432+
session: SessionDep,
433+
trip_id: int,
434+
data: TripPackingListItemCreate,
435+
current_user: Annotated[str, Depends(get_current_username)],
436+
) -> TripPackingListItemRead:
437+
item = TripPackingListItem(
438+
**data.model_dump(),
439+
trip_id=trip_id,
440+
user=current_user,
441+
)
442+
session.add(item)
443+
session.commit()
444+
session.refresh(item)
445+
return TripPackingListItemRead.serialize(item)
446+
447+
448+
@router.put("/{trip_id}/packing/{p_id}", response_model=TripPackingListItemRead)
449+
def update_packing_item(
450+
session: SessionDep,
451+
p_item: TripPackingListItemUpdate,
452+
trip_id: int,
453+
p_id: int,
454+
current_user: Annotated[str, Depends(get_current_username)],
455+
) -> TripPackingListItemRead:
456+
db_item = session.exec(
457+
select(TripPackingListItem).where(
458+
TripPackingListItem.id == p_id,
459+
TripPackingListItem.trip_id == trip_id,
460+
TripPackingListItem.user == current_user,
461+
)
462+
).one_or_none()
463+
464+
if not db_item:
465+
raise HTTPException(status_code=404, detail="Not found")
466+
467+
item_data = p_item.model_dump(exclude_unset=True)
468+
for key, value in item_data.items():
469+
setattr(db_item, key, value)
470+
471+
session.add(db_item)
472+
session.commit()
473+
session.refresh(db_item)
474+
return TripPackingListItemRead.serialize(db_item)
475+
476+
477+
@router.delete("/{trip_id}/packing/{p_id}")
478+
def delete_packing_item(
479+
session: SessionDep,
480+
trip_id: int,
481+
p_id: int,
482+
current_user: Annotated[str, Depends(get_current_username)],
483+
):
484+
item = session.exec(
485+
select(TripPackingListItem).where(
486+
TripPackingListItem.id == p_id,
487+
TripPackingListItem.trip_id == trip_id,
488+
TripPackingListItem.user == current_user,
489+
)
490+
).one_or_none()
491+
492+
if not item:
493+
raise HTTPException(status_code=404, detail="Not found")
494+
495+
session.delete(item)
496+
session.commit()
497+
return {}

src/src/app/components/trip/trip.component.html

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ <h1 class="font-medium tracking-tight text-2xl truncate">{{ trip?.name }}</h1>
2121
<p-button pTooltip="Delete Trip" text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" />
2222
<p-button pTooltip="Edit Trip" text (click)="editTrip()" icon="pi pi-pencil" />
2323
<div class="border-l border-solid border-gray-700 h-4"></div>
24+
<p-button pTooltip="Packing list" tooltipPosition="left" text (click)="openPackingList()" icon="pi pi-briefcase"
25+
severity="help" />
2426
</div>
2527

2628
<div class="flex md:hidden">
@@ -551,3 +553,38 @@ <h1 class="font-semibold tracking-tight text-xl">Watchlist</h1>
551553
</ng-container>
552554
}
553555
</p-dialog>
556+
557+
<p-dialog header="Packing list" [draggable]="false" [dismissableMask]="true" [modal]="true"
558+
[(visible)]="packingDialogVisible" styleClass="w-[95%] md:w-[70%] lg:w-[50%]">
559+
<section class="md:max-w-3/4 md:mx-auto max-h-[80%] md:max-h-[600px]">
560+
<div class="flex justify-center">
561+
<p-button (click)="addPackingItem()" icon="pi pi-plus" label="Add item" text />
562+
</div>
563+
564+
<div class="grid gap-2 mt-4 pb-4">
565+
@for (c of dispPackingList | keyvalue; track c.key) {
566+
<div class="mt-4 text-md font-semibold capitalize">{{ c.key }}</div>
567+
568+
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
569+
@for (item of c.value; track item.id) {
570+
<div class="relative group flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5">
571+
<label [for]="item.id" [class.line-through]="item.packed"
572+
class="flex items-center gap-2 w-full cursor-pointer">
573+
<p-checkbox (onChange)="onCheckPackingItem($event, item.id)" [binary]="true" [inputId]="item.id.toString()"
574+
[(ngModel)]="item.packed" />
575+
<div class="pr-6 md:pr-0 truncate select-none flex-1">
576+
@if (item.qt) {<span class="text-gray-400 mr-0.5">{{ item.qt }}</span>}
577+
<span>{{ item.text }}</span>
578+
</div>
579+
</label>
580+
<div
581+
class="md:opacity-0 absolute right-0 top-1/2 -translate-y-1/2 md:group-hover:opacity-100 bg-white md:bg-gray-100 rounded">
582+
<p-button size="small" text icon="pi pi-trash" (click)="deletePackingItem(item)" severity="danger" />
583+
</div>
584+
</div>
585+
}
586+
</div>
587+
}
588+
</div>
589+
</section>
590+
</p-dialog>

0 commit comments

Comments
 (0)