✨ Trip multi-users - BETA
This commit is contained in:
parent
cc729722df
commit
9cbbfd9065
@ -0,0 +1,91 @@
|
||||
"""Trip multi-users
|
||||
|
||||
Revision ID: 26c89b7466f2
|
||||
Revises: 60a9bb641d8a
|
||||
Create Date: 2025-08-18 23:19:37.457354
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "26c89b7466f2"
|
||||
down_revision = "60a9bb641d8a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"tripmember",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("invited_by", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column("invited_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("joined_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("trip_id", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["invited_by"],
|
||||
["user.username"],
|
||||
name=op.f("fk_tripmember_invited_by_user"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["trip_id"], ["trip.id"], name=op.f("fk_tripmember_trip_id_trip"), ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user"], ["user.username"], name=op.f("fk_tripmember_user_user"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_tripmember")),
|
||||
)
|
||||
with op.batch_alter_table("tripchecklistitem", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f("fk_tripchecklistitem_user_user"), type_="foreignkey")
|
||||
batch_op.drop_column("user")
|
||||
|
||||
with op.batch_alter_table("trippackinglistitem", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f("fk_trippackinglistitem_user_user"), type_="foreignkey")
|
||||
batch_op.drop_column("user")
|
||||
|
||||
with op.batch_alter_table("tripitem", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f("fk_tripitem_day_id_tripday"), type_="foreignkey")
|
||||
|
||||
with op.batch_alter_table("tripday", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f("fk_tripday_user_user"), type_="foreignkey")
|
||||
batch_op.drop_column("user")
|
||||
|
||||
with op.batch_alter_table("tripitem", schema=None) as batch_op:
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_tripitem_day_id_tripday"),
|
||||
"tripday",
|
||||
["day_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("trippackinglistitem", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_trippackinglistitem_user_user"),
|
||||
"user",
|
||||
["user"],
|
||||
["username"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
with op.batch_alter_table("tripday", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_tripday_user_user"), "user", ["user"], ["username"], ondelete="CASCADE"
|
||||
)
|
||||
|
||||
with op.batch_alter_table("tripchecklistitem", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("user", sa.VARCHAR(), nullable=False))
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_tripchecklistitem_user_user"), "user", ["user"], ["username"], ondelete="CASCADE"
|
||||
)
|
||||
|
||||
op.drop_table("tripmember")
|
||||
@ -261,6 +261,7 @@ class Trip(TripBase, table=True):
|
||||
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
checklist_items: list["TripChecklistItem"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
memberships: list["TripMember"] = Relationship(back_populates="trip", cascade_delete=True)
|
||||
|
||||
|
||||
class TripCreate(TripBase):
|
||||
@ -279,6 +280,7 @@ class TripReadBase(TripBase):
|
||||
image: str | None
|
||||
image_id: int | None
|
||||
days: int
|
||||
collaborators: list["TripMemberRead"]
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, obj: Trip) -> "TripRead":
|
||||
@ -289,6 +291,7 @@ class TripReadBase(TripBase):
|
||||
image=_prefix_assets_url(obj.image.filename) if obj.image else None,
|
||||
image_id=obj.image_id,
|
||||
days=len(obj.days),
|
||||
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
|
||||
)
|
||||
|
||||
|
||||
@ -298,6 +301,7 @@ class TripRead(TripBase):
|
||||
image_id: int | None
|
||||
days: list["TripDayRead"]
|
||||
places: list["PlaceRead"]
|
||||
collaborators: list["TripMemberRead"]
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, obj: Trip) -> "TripRead":
|
||||
@ -309,17 +313,49 @@ 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],
|
||||
collaborators=[TripMemberRead.serialize(m) for m in obj.memberships],
|
||||
)
|
||||
|
||||
|
||||
class TripMember(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||
invited_by: str | None = Field(default=None, foreign_key="user.username", ondelete="SET NULL")
|
||||
invited_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
joined_at: datetime | None = None
|
||||
|
||||
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||
trip: Trip | None = Relationship(back_populates="memberships")
|
||||
|
||||
|
||||
class TripMemberCreate(BaseModel):
|
||||
user: str
|
||||
|
||||
|
||||
class TripMemberRead(BaseModel):
|
||||
user: str
|
||||
invited_by: str | None = None
|
||||
invited_at: datetime | None = None
|
||||
joined_at: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def serialize(cls, obj: TripMember) -> "TripMemberRead":
|
||||
return cls(
|
||||
user=obj.user, invited_by=obj.invited_by, invited_at=obj.invited_at, joined_at=obj.joined_at
|
||||
)
|
||||
|
||||
|
||||
class TripInvitationRead(TripReadBase):
|
||||
invited_by: str | None = None
|
||||
invited_at: datetime
|
||||
|
||||
|
||||
class TripDayBase(SQLModel):
|
||||
label: str
|
||||
|
||||
|
||||
class TripDay(TripDayBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||
|
||||
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||
trip: Trip | None = Relationship(back_populates="days")
|
||||
|
||||
@ -420,7 +456,6 @@ class TripPackingListItemBase(SQLModel):
|
||||
|
||||
class TripPackingListItem(TripPackingListItemBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
|
||||
|
||||
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
|
||||
trip: Trip | None = Relationship(back_populates="packing_items")
|
||||
|
||||
@ -8,33 +8,120 @@ from ..deps import SessionDep, get_current_username
|
||||
from ..models.models import (Image, Place, Trip, TripChecklistItem,
|
||||
TripChecklistItemCreate, TripChecklistItemRead,
|
||||
TripChecklistItemUpdate, TripCreate, TripDay,
|
||||
TripDayBase, TripDayRead, TripItem,
|
||||
TripItemCreate, TripItemRead, TripItemUpdate,
|
||||
TripPackingListItem, TripPackingListItemCreate,
|
||||
TripPackingListItemRead, TripPackingListItemUpdate,
|
||||
TripRead, TripReadBase, TripShare,
|
||||
TripShareURL, TripUpdate)
|
||||
from ..security import verify_exists_and_owns
|
||||
TripDayBase, TripDayRead, TripInvitationRead,
|
||||
TripItem, TripItemCreate, TripItemRead,
|
||||
TripItemUpdate, TripMember, TripMemberCreate,
|
||||
TripMemberRead, TripPackingListItem,
|
||||
TripPackingListItemCreate,
|
||||
TripPackingListItemRead,
|
||||
TripPackingListItemUpdate, TripRead, TripReadBase,
|
||||
TripShare, TripShareURL, TripUpdate, User)
|
||||
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
|
||||
save_image_to_file)
|
||||
save_image_to_file, utc_now)
|
||||
|
||||
router = APIRouter(prefix="/api/trips", tags=["trips"])
|
||||
|
||||
|
||||
def _get_trip_or_404(session, trip_id: int) -> Trip:
|
||||
trip = session.get(Trip, trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return trip
|
||||
|
||||
|
||||
def _trip_from_token_or_404(session, token: str) -> TripShare:
|
||||
share = session.exec(select(TripShare).where(TripShare.token == token)).first()
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return share
|
||||
|
||||
|
||||
def _trip_usernames(session, trip_id: int) -> set[str]:
|
||||
owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
|
||||
members = session.exec(select(TripMember.user).where(TripMember.trip_id == trip_id)).all()
|
||||
return {owner} | set(members)
|
||||
|
||||
|
||||
def _can_access_trip(session, trip_id: int, username: str) -> bool:
|
||||
# TODO: Optimize, if trip does not exist, trip_owner is none and we keep iterating on members despite trip not found
|
||||
trip_owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
|
||||
if trip_owner == username:
|
||||
return True
|
||||
|
||||
is_member = session.exec(
|
||||
select(TripMember.id)
|
||||
.where(TripMember.trip_id == trip_id, TripMember.user == username, TripMember.joined_at.is_not(None))
|
||||
.limit(1)
|
||||
).first()
|
||||
return bool(is_member)
|
||||
|
||||
|
||||
def _verify_trip_member(session, trip_id: int, username: str) -> None:
|
||||
if not _can_access_trip(session, trip_id, username):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
@router.get("", response_model=list[TripReadBase])
|
||||
def read_trips(
|
||||
session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||
) -> list[TripReadBase]:
|
||||
trips = session.exec(select(Trip).filter(Trip.user == current_user))
|
||||
trips = session.exec(
|
||||
select(Trip)
|
||||
.join(TripMember, isouter=True)
|
||||
.where(
|
||||
(Trip.user == current_user)
|
||||
| ((TripMember.user == current_user) & (TripMember.joined_at.is_not(None)))
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return [TripReadBase.serialize(trip) for trip in trips]
|
||||
|
||||
|
||||
@router.get("/invitations", response_model=list[TripInvitationRead])
|
||||
def read_pending_invitations(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> list[TripInvitationRead]:
|
||||
pending_inviattions = session.exec(
|
||||
select(TripMember, Trip)
|
||||
.join(Trip, Trip.id == TripMember.trip_id)
|
||||
.where(
|
||||
TripMember.user == current_user,
|
||||
TripMember.joined_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
|
||||
invitations: list[TripInvitationRead] = []
|
||||
for tm, trip in pending_inviattions:
|
||||
base = TripReadBase.serialize(trip)
|
||||
invitations.append(
|
||||
TripInvitationRead(
|
||||
**base.model_dump(),
|
||||
invited_by=tm.invited_by,
|
||||
invited_at=tm.invited_at,
|
||||
)
|
||||
)
|
||||
|
||||
return invitations
|
||||
|
||||
|
||||
@router.get("/invitations/pending", response_model=bool)
|
||||
def has_pending_invitations(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> bool:
|
||||
pending = session.exec(
|
||||
select(TripMember.id).where(TripMember.user == current_user, TripMember.joined_at.is_(None)).limit(1)
|
||||
).first()
|
||||
return bool(pending)
|
||||
|
||||
|
||||
@router.get("/{trip_id}", response_model=TripRead)
|
||||
def read_trip(
|
||||
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
|
||||
) -> TripRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
return TripRead.serialize(db_trip)
|
||||
|
||||
|
||||
@ -42,10 +129,7 @@ def read_trip(
|
||||
def create_trip(
|
||||
trip: TripCreate, session: SessionDep, current_user: Annotated[str, Depends(get_current_username)]
|
||||
) -> TripReadBase:
|
||||
new_trip = Trip(
|
||||
name=trip.name,
|
||||
user=current_user,
|
||||
)
|
||||
new_trip = Trip(name=trip.name, user=current_user)
|
||||
|
||||
if trip.image:
|
||||
image_bytes = b64img_decode(trip.image)
|
||||
@ -55,17 +139,10 @@ def create_trip(
|
||||
|
||||
image = Image(filename=filename, user=current_user)
|
||||
session.add(image)
|
||||
session.commit()
|
||||
session.flush()
|
||||
session.refresh(image)
|
||||
new_trip.image_id = image.id
|
||||
|
||||
if trip.place_ids:
|
||||
for place_id in trip.place_ids:
|
||||
db_place = session.get(Place, place_id)
|
||||
verify_exists_and_owns(current_user, db_place)
|
||||
session.add(TripPlaceLink(trip_id=new_trip.id, place_id=db_place.id))
|
||||
session.commit()
|
||||
|
||||
session.add(new_trip)
|
||||
session.commit()
|
||||
session.refresh(new_trip)
|
||||
@ -79,8 +156,8 @@ def update_trip(
|
||||
trip: TripUpdate,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived and (trip.archived is not False):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
@ -100,8 +177,8 @@ def update_trip(
|
||||
|
||||
image = Image(filename=filename, user=current_user)
|
||||
session.add(image)
|
||||
session.commit()
|
||||
session.refresh(image)
|
||||
session.flush()
|
||||
|
||||
if db_trip.image_id:
|
||||
old_image = session.get(Image, db_trip.image_id)
|
||||
@ -117,11 +194,16 @@ def update_trip(
|
||||
|
||||
place_ids = trip_data.pop("place_ids", None)
|
||||
if place_ids is not None: # Could be empty [], so 'in'
|
||||
db_trip.places.clear()
|
||||
allowed_users = _trip_usernames(session, trip_id)
|
||||
new_places = []
|
||||
for place_id in place_ids:
|
||||
db_place = session.get(Place, place_id)
|
||||
verify_exists_and_owns(current_user, db_place)
|
||||
db_trip.places.append(db_place)
|
||||
if not db_place:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if db_place.user not in allowed_users:
|
||||
raise HTTPException(status_code=403, detail="Place not accessible by trip members")
|
||||
new_places.append(db_place)
|
||||
db_trip.places = new_places
|
||||
|
||||
item_place_ids = {
|
||||
item.place.id for day in db_trip.days for item in day.items if item.place is not None
|
||||
@ -143,8 +225,8 @@ def update_trip(
|
||||
def delete_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_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
@ -171,13 +253,13 @@ def create_tripday(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripDayRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
new_day = TripDay(label=td.label, trip_id=trip_id, user=current_user)
|
||||
new_day = TripDay(label=td.label, trip_id=trip_id)
|
||||
|
||||
session.add(new_day)
|
||||
session.commit()
|
||||
@ -193,15 +275,14 @@ def update_tripday(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripDayRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_day = session.get(TripDay, day_id)
|
||||
verify_exists_and_owns(current_user, db_day)
|
||||
if db_day.trip_id != trip_id:
|
||||
if not db_day or (db_day.trip_id != trip_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
td_data = td.model_dump(exclude_unset=True)
|
||||
@ -221,15 +302,14 @@ def delete_tripday(
|
||||
day_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_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_day = session.get(TripDay, day_id)
|
||||
verify_exists_and_owns(current_user, db_day)
|
||||
if db_day.trip_id != trip_id:
|
||||
if not db_day or (db_day.trip_id != trip_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
session.delete(db_day)
|
||||
@ -245,14 +325,14 @@ def create_tripitem(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripItemRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_day = session.get(TripDay, day_id)
|
||||
if db_day.trip_id != trip_id:
|
||||
if not db_day or (db_day.trip_id != trip_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
new_item = TripItem(
|
||||
@ -266,7 +346,7 @@ def create_tripitem(
|
||||
status=item.status,
|
||||
)
|
||||
|
||||
if item.place and item.place != "":
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
@ -287,18 +367,18 @@ def update_tripitem(
|
||||
session: SessionDep,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripItemRead:
|
||||
db_trip = session.get(Trip, trip_id)
|
||||
verify_exists_and_owns(current_user, db_trip)
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_day = session.get(TripDay, day_id)
|
||||
if db_day.trip_id != trip_id:
|
||||
if not db_day or (db_day.trip_id != trip_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_item = session.get(TripItem, item_id)
|
||||
if db_item.day_id != day_id:
|
||||
if not db_item or (db_item.day_id != day_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
item_data = item.model_dump(exclude_unset=True)
|
||||
@ -327,18 +407,18 @@ def delete_tripitem(
|
||||
item_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_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.archived:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_day = session.get(TripDay, day_id)
|
||||
if db_day.trip_id != trip_id:
|
||||
if not db_day or (db_day.trip_id != trip_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
db_item = session.get(TripItem, item_id)
|
||||
if db_item.day_id != day_id:
|
||||
if not db_item or (db_item.day_id != day_id):
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
session.delete(db_item)
|
||||
@ -351,11 +431,7 @@ 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)
|
||||
db_trip = session.get(Trip, _trip_from_token_or_404(session, token).trip_id)
|
||||
return TripRead.serialize(db_trip)
|
||||
|
||||
|
||||
@ -365,8 +441,7 @@ def get_shared_trip_url(
|
||||
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)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
|
||||
if not share:
|
||||
@ -381,15 +456,14 @@ def create_shared_trip(
|
||||
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)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
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)
|
||||
trip_share = TripShare(token=token, trip_id=trip_id)
|
||||
session.add(trip_share)
|
||||
session.commit()
|
||||
return {"url": f"/s/t/{token}"}
|
||||
@ -401,8 +475,7 @@ def delete_shared_trip(
|
||||
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)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
db_share = session.exec(select(TripShare).where(TripShare.trip_id == trip_id)).first()
|
||||
if not db_share:
|
||||
@ -419,15 +492,25 @@ def read_packing_list(
|
||||
trip_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> list[TripPackingListItemRead]:
|
||||
p_items = session.exec(
|
||||
select(TripPackingListItem)
|
||||
.where(TripPackingListItem.trip_id == trip_id, TripPackingListItem.user == current_user)
|
||||
.order_by(TripPackingListItem.id.asc())
|
||||
).all()
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
p_items = session.exec(select(TripPackingListItem).where(TripPackingListItem.trip_id == trip_id))
|
||||
|
||||
return [TripPackingListItemRead.serialize(i) for i in p_items]
|
||||
|
||||
|
||||
@router.get("/shared/{token}/packing", response_model=list[TripPackingListItemRead])
|
||||
def read_shared_trip_packing_list(
|
||||
session: SessionDep,
|
||||
token: str,
|
||||
) -> list[TripPackingListItemRead]:
|
||||
p_items = session.exec(
|
||||
select(TripPackingListItem).where(
|
||||
TripPackingListItem.trip_id == _trip_from_token_or_404(session, token).trip_id
|
||||
)
|
||||
)
|
||||
return [TripPackingListItemRead.serialize(i) for i in p_items]
|
||||
|
||||
|
||||
@router.post("/{trip_id}/packing", response_model=TripPackingListItemRead)
|
||||
def create_packing_item(
|
||||
session: SessionDep,
|
||||
@ -435,11 +518,8 @@ def create_packing_item(
|
||||
data: TripPackingListItemCreate,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripPackingListItemRead:
|
||||
item = TripPackingListItem(
|
||||
**data.model_dump(),
|
||||
trip_id=trip_id,
|
||||
user=current_user,
|
||||
)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
item = TripPackingListItem(**data.model_dump(), trip_id=trip_id)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
@ -454,11 +534,10 @@ def update_packing_item(
|
||||
p_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripPackingListItemRead:
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
db_item = session.exec(
|
||||
select(TripPackingListItem).where(
|
||||
TripPackingListItem.id == p_id,
|
||||
TripPackingListItem.trip_id == trip_id,
|
||||
TripPackingListItem.user == current_user,
|
||||
TripPackingListItem.id == p_id, TripPackingListItem.trip_id == trip_id
|
||||
)
|
||||
).one_or_none()
|
||||
|
||||
@ -482,11 +561,10 @@ def delete_packing_item(
|
||||
p_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
item = session.exec(
|
||||
select(TripPackingListItem).where(
|
||||
TripPackingListItem.id == p_id,
|
||||
TripPackingListItem.trip_id == trip_id,
|
||||
TripPackingListItem.user == current_user,
|
||||
TripPackingListItem.id == p_id, TripPackingListItem.trip_id == trip_id
|
||||
)
|
||||
).one_or_none()
|
||||
|
||||
@ -504,11 +582,8 @@ def read_checklist(
|
||||
trip_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> list[TripChecklistItemRead]:
|
||||
items = session.exec(
|
||||
select(TripChecklistItem).where(
|
||||
TripChecklistItem.trip_id == trip_id, TripChecklistItem.user == current_user
|
||||
)
|
||||
)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
items = session.exec(select(TripChecklistItem).where(TripChecklistItem.trip_id == trip_id))
|
||||
return [TripChecklistItemRead.serialize(i) for i in items]
|
||||
|
||||
|
||||
@ -532,11 +607,8 @@ def create_checklist_item(
|
||||
data: TripChecklistItemCreate,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripChecklistItemRead:
|
||||
item = TripChecklistItem(
|
||||
**data.model_dump(),
|
||||
trip_id=trip_id,
|
||||
user=current_user,
|
||||
)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
item = TripChecklistItem(**data.model_dump(), trip_id=trip_id)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
@ -551,12 +623,9 @@ def update_checklist_item(
|
||||
id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripChecklistItemRead:
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
db_item = session.exec(
|
||||
select(TripChecklistItem).where(
|
||||
TripChecklistItem.id == id,
|
||||
TripChecklistItem.trip_id == trip_id,
|
||||
TripChecklistItem.user == current_user,
|
||||
)
|
||||
select(TripChecklistItem).where(TripChecklistItem.id == id, TripChecklistItem.trip_id == trip_id)
|
||||
).one_or_none()
|
||||
|
||||
if not db_item:
|
||||
@ -579,11 +648,11 @@ def delete_checklist_item(
|
||||
id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
item = session.exec(
|
||||
select(TripChecklistItem).where(
|
||||
TripChecklistItem.id == id,
|
||||
TripChecklistItem.trip_id == trip_id,
|
||||
TripChecklistItem.user == current_user,
|
||||
)
|
||||
).one_or_none()
|
||||
|
||||
@ -593,3 +662,122 @@ def delete_checklist_item(
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/{trip_id}/members", response_model=list[TripMemberRead])
|
||||
def read_trip_members(
|
||||
session: SessionDep, trip_id: int, current_user: Annotated[str, Depends(get_current_username)]
|
||||
) -> list[TripMemberRead]:
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
members: list[TripMemberRead] = []
|
||||
owner = session.exec(select(Trip.user).where(Trip.id == trip_id)).first()
|
||||
members.append(TripMemberRead(user=owner, invited_by=None, invited_at=None, joined_at=None))
|
||||
|
||||
db_members = session.exec(select(TripMember).where(TripMember.trip_id == trip_id)).all()
|
||||
members.extend(TripMemberRead.serialize(m) for m in db_members)
|
||||
return members
|
||||
|
||||
|
||||
@router.post("/{trip_id}/members", response_model=TripMemberRead)
|
||||
def invite_trip_member(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
data: TripMemberCreate,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
) -> TripMemberRead:
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if db_trip.user == data.user:
|
||||
raise HTTPException(status_code=409, detail="The resource already exists")
|
||||
|
||||
exists = session.exec(
|
||||
select(TripMember.id)
|
||||
.where(
|
||||
TripMember.trip_id == trip_id,
|
||||
TripMember.user == data.user,
|
||||
)
|
||||
.limit(1)
|
||||
).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="The resource already exists")
|
||||
|
||||
db_user = session.get(User, data.user)
|
||||
print(data.user, db_user)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
new_member = TripMember(trip_id=trip_id, user=data.user, invited_by=current_user)
|
||||
session.add(new_member)
|
||||
session.commit()
|
||||
session.refresh(new_member)
|
||||
return TripMemberRead.serialize(new_member)
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/members/{username}")
|
||||
def delete_trip_member(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
username: str,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
print("yo")
|
||||
db_trip = _get_trip_or_404(session, trip_id)
|
||||
_verify_trip_member(session, trip_id, current_user)
|
||||
|
||||
if current_user == db_trip.user and current_user == username:
|
||||
raise HTTPException(status_code=400, detail="Bad request")
|
||||
|
||||
if current_user != db_trip.user and current_user != username:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
member = session.exec(
|
||||
select(TripMember).where(
|
||||
TripMember.user == username,
|
||||
TripMember.trip_id == trip_id,
|
||||
)
|
||||
).one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
session.delete(member)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/members/accept")
|
||||
def accept_invite(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
db_member = session.exec(
|
||||
select(TripMember).where(TripMember.trip_id == trip_id, TripMember.user == current_user)
|
||||
).one_or_none()
|
||||
if not db_member:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if db_member.joined_at:
|
||||
raise HTTPException(status_code=409, detail="Already a member")
|
||||
db_member.joined_at = utc_now()
|
||||
session.add(db_member)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/members/decline")
|
||||
def decline_invite(
|
||||
session: SessionDep,
|
||||
trip_id: int,
|
||||
current_user: Annotated[str, Depends(get_current_username)],
|
||||
):
|
||||
db_member = session.exec(
|
||||
select(TripMember).where(TripMember.trip_id == trip_id, TripMember.user == current_user)
|
||||
).one_or_none()
|
||||
if not db_member:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if db_member.joined_at:
|
||||
raise HTTPException(status_code=409, detail="Already a member")
|
||||
session.delete(db_member)
|
||||
session.commit()
|
||||
return {}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import base64
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
@ -48,6 +48,10 @@ def remove_image(path: str):
|
||||
raise Exception("Error deleting image:", exc, path)
|
||||
|
||||
|
||||
def utc_now():
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def parse_str_or_date_to_date(cdate: str | date) -> date:
|
||||
if isinstance(cdate, str):
|
||||
try:
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
<div class="flex items-center gap-2 print:hidden">
|
||||
@if (!trip?.archived) {
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<p-button pTooltip="Manage users" text (click)="openMembersDialog()" icon="pi pi-users" />
|
||||
<p-button pTooltip="Share Trip" text (click)="shareDialogVisible = true" icon="pi pi-share-alt" />
|
||||
<p-button pTooltip="Archive Trip" text (click)="toggleArchiveTrip()" icon="pi pi-box" severity="warn" />
|
||||
<div class="border-l border-solid border-gray-700 h-4"></div>
|
||||
@ -616,3 +617,59 @@
|
||||
</div>
|
||||
</section>
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog header="Members" [draggable]="false" [dismissableMask]="true" [modal]="true"
|
||||
[(visible)]="membersDialogVisible" styleClass="w-[95%] md:w-[40%] lg:w-[30%]">
|
||||
<section class="p-4 max-w-full max-h-[80%] md:max-h-[600px]">
|
||||
<div class="flex justify-center">
|
||||
<p-button (click)="addMember()" icon="pi pi-plus" label="Member" text />
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100 mt-4 pb-4">
|
||||
@for (m of tripMembers; track m.user) {
|
||||
<div class="flex items-center justify-between gap-x-6 py-5">
|
||||
<div class="flex items-center min-w-0 gap-x-4">
|
||||
<div class="size-12 flex flex-none rounded-full items-center justify-center" [ngClass]="{
|
||||
'bg-red-100': !m.invited_at,
|
||||
'bg-gray-50': m.invited_at && !m.joined_at,
|
||||
'bg-blue-100': m.invited_at && m.joined_at
|
||||
}">
|
||||
<i class="pi pi-user"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 gap-x-4">
|
||||
<span class="capitalize truncate font-semibold text-gray-900">{{ m.user }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">-
|
||||
{{ currency$ | async }}</span>
|
||||
|
||||
<div class="hidden shrink-0 sm:flex sm:flex-col sm:items-end">
|
||||
@if (!m.invited_at) {
|
||||
<span
|
||||
class="text-center bg-red-100 text-red-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-red-100/85">Owner</span>
|
||||
}
|
||||
|
||||
@if (m.invited_at && !m.joined_at) {
|
||||
<span
|
||||
class="text-center bg-gray-100 text-gray-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-gray-100/85">Invited</span>
|
||||
}
|
||||
|
||||
@if (m.joined_at) {
|
||||
<span
|
||||
class="text-center bg-blue-100 text-blue-800 text-xs px-2.5 py-0.5 rounded-md group-hover:hidden dark:bg-blue-100/85">Member</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (m.invited_at) {
|
||||
<p-button text (click)="deleteMember(m.user)" icon="pi pi-trash" severity="danger" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</p-dialog>
|
||||
@ -16,6 +16,7 @@ import {
|
||||
TripStatus,
|
||||
PackingItem,
|
||||
ChecklistItem,
|
||||
TripMember,
|
||||
} from "../../types/trip";
|
||||
import { Place } from "../../types/poi";
|
||||
import {
|
||||
@ -57,6 +58,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox";
|
||||
import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component";
|
||||
import { TripCreateChecklistModalComponent } from "../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component";
|
||||
import { TripInviteMemberModalComponent } from "../../modals/trip-invite-member-modal/trip-invite-member-modal.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-trip",
|
||||
@ -106,6 +108,8 @@ export class TripComponent implements AfterViewInit {
|
||||
checklistDialogVisible = false;
|
||||
checklistItems: ChecklistItem[] = [];
|
||||
dispchecklist: ChecklistItem[] = [];
|
||||
membersDialogVisible = false;
|
||||
tripMembers: TripMember[] = [];
|
||||
|
||||
map?: L.Map;
|
||||
markerClusterGroup?: L.MarkerClusterGroup;
|
||||
@ -134,6 +138,14 @@ export class TripComponent implements AfterViewInit {
|
||||
this.openChecklist();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Members",
|
||||
icon: "pi pi-users",
|
||||
iconClass: "text-blue-500!",
|
||||
command: () => {
|
||||
this.openMembersDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: "pi pi-pencil",
|
||||
@ -1552,4 +1564,84 @@ export class TripComponent implements AfterViewInit {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openMembersDialog() {
|
||||
if (!this.trip) return;
|
||||
|
||||
if (!this.tripMembers.length)
|
||||
this.apiService
|
||||
.getTripMembers(this.trip.id)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (items) => {
|
||||
this.tripMembers = [...items];
|
||||
},
|
||||
});
|
||||
this.membersDialogVisible = true;
|
||||
}
|
||||
|
||||
addMember() {
|
||||
if (!this.trip) return;
|
||||
|
||||
const modal: DynamicDialogRef = this.dialogService.open(
|
||||
TripInviteMemberModalComponent,
|
||||
{
|
||||
header: "Invite member",
|
||||
modal: true,
|
||||
appendTo: "body",
|
||||
closable: true,
|
||||
dismissableMask: true,
|
||||
width: "40vw",
|
||||
breakpoints: {
|
||||
"1260px": "70vw",
|
||||
"600px": "90vw",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (user: string | null) => {
|
||||
if (!user) return;
|
||||
|
||||
this.apiService
|
||||
.inviteTripMember(this.trip!.id, user)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (member) => {
|
||||
this.tripMembers = [...this.tripMembers, member];
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteMember(username: string) {
|
||||
const modal = this.dialogService.open(YesNoModalComponent, {
|
||||
header: "Confirm deletion",
|
||||
modal: true,
|
||||
closable: true,
|
||||
dismissableMask: true,
|
||||
breakpoints: {
|
||||
"640px": "90vw",
|
||||
},
|
||||
data: `Delete ${username.substring(0, 50)} from Trip ?`,
|
||||
});
|
||||
|
||||
modal.onClose.pipe(take(1)).subscribe({
|
||||
next: (bool) => {
|
||||
if (!bool) return;
|
||||
this.apiService
|
||||
.deleteTripMember(this.trip!.id, username)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
const index = this.tripMembers.findIndex(
|
||||
(p) => p.user == username,
|
||||
);
|
||||
if (index > -1) this.tripMembers.splice(index, 1);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,10 @@
|
||||
<img src="favicon.png" (click)="gotoMap()" class="cursor-pointer w-24" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<p-button icon="pi pi-map" (click)="gotoMap()" text />
|
||||
<div class="relative">
|
||||
<p-button icon="pi pi-bell" (click)="toggleInvitations()" text />
|
||||
@if(hasPendingInvitations) {<div class="absolute top-1 right-2 size-2 bg-red-400 rounded-full"></div>}
|
||||
</div>
|
||||
<p-button icon="pi pi-plus" (click)="addTrip()" text />
|
||||
</div>
|
||||
</div>
|
||||
@ -19,7 +22,13 @@
|
||||
|
||||
<div class="absolute inset-0 bg-black/5 flex flex-col justify-end p-4 text-white">
|
||||
<h3 class="text-lg font-semibold line-clamp-2">{{ trip.name }}</h3>
|
||||
<p class="text-sm ">{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}</p>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<p>{{ trip.days || 0 }} {{ trip.days > 1 ? 'days' : 'day'}}</p>
|
||||
@if (trip.collaborators.length) {- <p>{{ trip.collaborators.length + 1 }} user{{ trip.collaborators.length > 0
|
||||
? 's'
|
||||
: '' }}</p>}
|
||||
</div>
|
||||
|
||||
<i
|
||||
class="pi pi-arrow-right text-xl absolute right-4 bottom-4 h-4 opacity-0 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"></i>
|
||||
@ -49,3 +58,39 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-dialog header="Invitations" [draggable]="false" [dismissableMask]="true" [modal]="true"
|
||||
[(visible)]="invitationsDialogVisible" styleClass="w-[95%] md:w-[40%] lg:w-[30%]">
|
||||
<section class="p-4 max-w-full max-h-[80%] md:max-h-[600px]">
|
||||
<div class="divide-y divide-gray-100">
|
||||
@for (inv of invitations; track inv.id) {
|
||||
<div class="flex items-center justify-between gap-x-6 py-5">
|
||||
<div class="flex items-center min-w-0 gap-x-4">
|
||||
<img [src]="inv.image" class="w-20 rounded-full object-cover">
|
||||
|
||||
<div class="flex flex-col min-w-0 gap-1">
|
||||
<div class="capitalize truncate text-md font-semibold text-gray-900">{{ inv.name }}</div>
|
||||
<div class="text-xs md:text-sm text-gray-500">On <i>{{ inv.invited_at | date: 'short'}}</i>, <i>{{
|
||||
inv.invited_by
|
||||
}}</i> invited you to collaborate </div>
|
||||
|
||||
<div class="flex justify-center md:hidden gap-4">
|
||||
<p-button text (click)="acceptInvitation(inv.id)" icon="pi pi-check" severity="success" />
|
||||
<p-button text (click)="declineInvitation(inv.id)" icon="pi pi-times" severity="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex gap-4">
|
||||
<p-button text (click)="acceptInvitation(inv.id)" icon="pi pi-check" severity="success" />
|
||||
<p-button text (click)="declineInvitation(inv.id)" icon="pi pi-times" severity="danger" />
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="text-center">
|
||||
No invitation
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</p-dialog>
|
||||
@ -2,11 +2,13 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { ApiService } from "../../services/api.service";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { SkeletonModule } from "primeng/skeleton";
|
||||
import { TripBase } from "../../types/trip";
|
||||
import { TripBase, TripInvitation } from "../../types/trip";
|
||||
import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog";
|
||||
import { TripCreateModalComponent } from "../../modals/trip-create-modal/trip-create-modal.component";
|
||||
import { Router } from "@angular/router";
|
||||
import { forkJoin, take } from "rxjs";
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { DialogModule } from "primeng/dialog";
|
||||
|
||||
interface TripBaseWithDates extends TripBase {
|
||||
from?: Date;
|
||||
@ -16,12 +18,15 @@ interface TripBaseWithDates extends TripBase {
|
||||
@Component({
|
||||
selector: "app-trips",
|
||||
standalone: true,
|
||||
imports: [SkeletonModule, ButtonModule],
|
||||
imports: [SkeletonModule, ButtonModule, DialogModule, DatePipe],
|
||||
templateUrl: "./trips.component.html",
|
||||
styleUrls: ["./trips.component.scss"],
|
||||
})
|
||||
export class TripsComponent implements OnInit {
|
||||
trips: TripBase[] = [];
|
||||
hasPendingInvitations = false;
|
||||
invitations: TripInvitation[] = [];
|
||||
invitationsDialogVisible = false;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
@ -39,6 +44,12 @@ export class TripsComponent implements OnInit {
|
||||
this.sortTrips();
|
||||
},
|
||||
});
|
||||
this.apiService
|
||||
.getHasTripsInvitations()
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (bool) => (this.hasPendingInvitations = bool),
|
||||
});
|
||||
}
|
||||
|
||||
gotoMap() {
|
||||
@ -137,4 +148,50 @@ export class TripsComponent implements OnInit {
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
toggleInvitations() {
|
||||
if (!this.invitations.length)
|
||||
this.apiService
|
||||
.getTripsInvitations()
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (items) => {
|
||||
this.invitations = [...items];
|
||||
},
|
||||
});
|
||||
this.invitationsDialogVisible = true;
|
||||
}
|
||||
|
||||
removeInvitationAndHide(trip_id: number) {
|
||||
this.invitations = this.invitations.filter((inv) => inv.id != trip_id);
|
||||
this.hasPendingInvitations = !!this.invitations.length;
|
||||
this.invitationsDialogVisible = false;
|
||||
}
|
||||
|
||||
acceptInvitation(trip_id: number) {
|
||||
this.apiService
|
||||
.acceptTripMemberInvite(trip_id)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
const index = this.invitations.findIndex((inv) => inv.id == trip_id);
|
||||
if (index > -1) {
|
||||
this.trips = [...this.trips, this.invitations[index]];
|
||||
this.sortTrips();
|
||||
}
|
||||
this.removeInvitationAndHide(trip_id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
declineInvitation(trip_id: number) {
|
||||
this.apiService
|
||||
.declineTripMemberInvite(trip_id)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.removeInvitationAndHide(trip_id);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
<div pFocusTrap class="grid items-center gap-4">
|
||||
<p-floatlabel variant="in">
|
||||
<input class="col-span-2" id="name" [formControl]="memberForm" (keypress.enter)="closeDialog()" pInputText fluid />
|
||||
<label for="name">Name</label>
|
||||
</p-floatlabel>
|
||||
|
||||
<div class="mt-4 text-right">
|
||||
<p-button (click)="closeDialog()" [disabled]="!memberForm.value">Invite</p-button>
|
||||
</div>
|
||||
@ -0,0 +1,32 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||
import { ButtonModule } from "primeng/button";
|
||||
import { DynamicDialogRef } from "primeng/dynamicdialog";
|
||||
import { FloatLabelModule } from "primeng/floatlabel";
|
||||
import { InputTextModule } from "primeng/inputtext";
|
||||
import { FocusTrapModule } from "primeng/focustrap";
|
||||
|
||||
@Component({
|
||||
selector: "app-trip-invite-member-modal",
|
||||
imports: [
|
||||
FloatLabelModule,
|
||||
InputTextModule,
|
||||
ButtonModule,
|
||||
ReactiveFormsModule,
|
||||
FocusTrapModule,
|
||||
],
|
||||
standalone: true,
|
||||
templateUrl: "./trip-invite-member-modal.component.html",
|
||||
styleUrl: "./trip-invite-member-modal.component.scss",
|
||||
})
|
||||
export class TripInviteMemberModalComponent {
|
||||
memberForm = new FormControl("");
|
||||
constructor(private ref: DynamicDialogRef) {}
|
||||
|
||||
closeDialog() {
|
||||
if (!this.memberForm.value) return;
|
||||
|
||||
// Normalize data for API POST
|
||||
this.ref.close(this.memberForm.value);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import { HttpClient, HttpHeaders } from "@angular/common/http";
|
||||
import { Category, Place } from "../types/poi";
|
||||
import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
|
||||
import { Info } from "../types/info";
|
||||
@ -11,7 +11,9 @@ import {
|
||||
Trip,
|
||||
TripBase,
|
||||
TripDay,
|
||||
TripInvitation,
|
||||
TripItem,
|
||||
TripMember,
|
||||
} from "../types/trip";
|
||||
|
||||
const NO_AUTH_HEADER = {
|
||||
@ -312,6 +314,51 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
getHasTripsInvitations(): Observable<boolean> {
|
||||
return this.httpClient.get<boolean>(
|
||||
`${this.apiBaseUrl}/trips/invitations/pending`,
|
||||
);
|
||||
}
|
||||
|
||||
getTripsInvitations(): Observable<TripInvitation[]> {
|
||||
return this.httpClient.get<TripInvitation[]>(
|
||||
`${this.apiBaseUrl}/trips/invitations`,
|
||||
);
|
||||
}
|
||||
|
||||
getTripMembers(trip_id: number): Observable<TripMember[]> {
|
||||
return this.httpClient.get<TripMember[]>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/members`,
|
||||
);
|
||||
}
|
||||
|
||||
deleteTripMember(trip_id: number, username: string): Observable<null> {
|
||||
return this.httpClient.delete<null>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/members/${username}`,
|
||||
);
|
||||
}
|
||||
|
||||
inviteTripMember(trip_id: number, user: string): Observable<TripMember> {
|
||||
return this.httpClient.post<TripMember>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/members`,
|
||||
{ user },
|
||||
);
|
||||
}
|
||||
|
||||
acceptTripMemberInvite(trip_id: number): Observable<null> {
|
||||
return this.httpClient.post<null>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/members/accept`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
declineTripMemberInvite(trip_id: number): Observable<null> {
|
||||
return this.httpClient.post<null>(
|
||||
`${this.apiBaseUrl}/trips/${trip_id}/members/decline`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
checkVersion(): Observable<string> {
|
||||
return this.httpClient.get<string>(
|
||||
`${this.apiBaseUrl}/settings/checkversion`,
|
||||
|
||||
@ -7,6 +7,7 @@ export interface TripBase {
|
||||
archived?: boolean;
|
||||
user: string;
|
||||
days: number;
|
||||
collaborators: TripMember[];
|
||||
}
|
||||
|
||||
export interface Trip {
|
||||
@ -16,6 +17,7 @@ export interface Trip {
|
||||
archived?: boolean;
|
||||
user: string;
|
||||
days: TripDay[];
|
||||
collaborators: TripMember[];
|
||||
|
||||
// POST / PUT
|
||||
places: Place[];
|
||||
@ -62,6 +64,18 @@ export interface FlattenedTripItem {
|
||||
status?: TripStatus;
|
||||
}
|
||||
|
||||
export interface TripMember {
|
||||
user: string;
|
||||
invited_by: string;
|
||||
invited_at: string;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
export interface TripInvitation extends TripBase {
|
||||
invited_by: string;
|
||||
invited_at: string;
|
||||
}
|
||||
|
||||
export interface SharedTripURL {
|
||||
url: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user