Skip to content

Commit de4902a

Browse files
committed
Refactor hiker profile and pack APIs to enforce single-profile limit
- Removed multi-hiker profile support, enforcing a hard limit of one profile per user. - Updated profile creation logic to prevent multiple profiles and removed related functions. - Adjusted pack creation and update logic to eliminate the unused hiker_profile_id field. - Introduced a new AI review endpoint for trips, providing markdown output for easier sharing with AI assistants.
1 parent 9e66f7b commit de4902a

6 files changed

Lines changed: 420 additions & 48 deletions

File tree

‎app/api/hiker_profile.py‎

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,17 @@
77

88
from models.base import User, HikerProfile
99
from utils.auth import authenticate
10-
from utils.consts import FREE_HIKER_PROFILE_LIMIT
1110

1211
logger = logging.getLogger(__name__)
1312

1413
route = APIRouter(dependencies=[Depends(authenticate)])
1514

16-
# FREE_HIKER_PROFILE_LIMIT is read from the environment (see utils/consts.py)
17-
# and is unlimited unless set.
18-
19-
20-
def _enforce_profile_limit(user: User):
21-
"""Block non-subscribed users from exceeding the free profile allowance.
22-
23-
The first profile is always allowed, so onboarding is unaffected. Profiles
24-
that already exist over the limit are untouched.
25-
"""
26-
if user.is_subscribed:
27-
return
28-
29-
profile_count = db.session.query(HikerProfile).filter_by(
30-
user_id=user.id).count()
31-
32-
if profile_count >= FREE_HIKER_PROFILE_LIMIT:
33-
raise HTTPException(
34-
402, "Upgrade to create more hiker profiles.")
15+
# One profile per user. Multi-hiker profiles were removed from the product in
16+
# Sept 2026 (a handful of users ever assigned a pack to a second profile). The
17+
# table, the is_default column and pack.hiker_profile_id were left in place
18+
# rather than migrated; clients treat the is_default row (else the oldest) as
19+
# THE profile. The list/get/update/delete routes below stay for mobile builds
20+
# still in the wild; nothing new should be built on them.
3521

3622

3723
class HikerProfileType(BaseModel):
@@ -41,15 +27,6 @@ class HikerProfileType(BaseModel):
4127
year_of_birth: Optional[int] = None
4228
sex: Optional[str] = None
4329
body_type: Optional[str] = None
44-
is_default: bool = False
45-
46-
47-
def _clear_other_defaults(user_id: int, exclude_id: int = None):
48-
query = db.session.query(HikerProfile).filter_by(user_id=user_id, is_default=True)
49-
if exclude_id:
50-
query = query.filter(HikerProfile.id != exclude_id)
51-
for profile in query.all():
52-
profile.is_default = False
5330

5431

5532
@route.get("")
@@ -70,14 +47,12 @@ def get_profile(profile_id: int, user: User = Depends(authenticate)):
7047

7148
@route.post("", status_code=201)
7249
def create_profile(payload: HikerProfileType, user: User = Depends(authenticate)):
73-
_enforce_profile_limit(user)
74-
7550
existing_count = db.session.query(HikerProfile).filter_by(user_id=user.id).count()
7651

77-
is_default = True if existing_count == 0 else payload.is_default
78-
79-
if is_default:
80-
_clear_other_defaults(user.id)
52+
# Hard limit of one for everyone. Old mobile builds that still offer an
53+
# "Add" button surface this as their generic create-failed alert.
54+
if existing_count > 0:
55+
raise HTTPException(409, "You already have a hiker profile.")
8156

8257
profile = HikerProfile(
8358
user_id=user.id,
@@ -87,7 +62,7 @@ def create_profile(payload: HikerProfileType, user: User = Depends(authenticate)
8762
year_of_birth=payload.year_of_birth,
8863
sex=payload.sex,
8964
body_type=payload.body_type,
90-
is_default=is_default,
65+
is_default=True,
9166
)
9267

9368
try:
@@ -108,7 +83,6 @@ class HikerProfileUpdateType(BaseModel):
10883
year_of_birth: Optional[int] = None
10984
sex: Optional[str] = None
11085
body_type: Optional[str] = None
111-
is_default: Optional[bool] = None
11286

11387

11488
@route.put("/{profile_id}")
@@ -120,9 +94,6 @@ def update_profile(profile_id: int, payload: HikerProfileUpdateType, user: User
12094

12195
fields = payload.dict(exclude_none=True)
12296

123-
if fields.get("is_default"):
124-
_clear_other_defaults(user.id, exclude_id=profile.id)
125-
12697
for key, value in fields.items():
12798
setattr(profile, key, value)
12899

‎app/api/pack.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from fastapi import APIRouter, Depends, HTTPException
44
from fastapi_sqlalchemy import db
55
from pydantic import BaseModel
6-
from typing import List, Optional
6+
from typing import List
77

88
from models.base import User, Pack, PackItem, Trip
99
from utils.auth import authenticate
@@ -126,7 +126,6 @@ class PackItemType(BaseModel):
126126
class PackType(BaseModel):
127127
title: str
128128
trip_id: int = None
129-
hiker_profile_id: Optional[int] = None
130129
items: List[PackItemType] = None
131130

132131

@@ -135,7 +134,7 @@ def create_pack(pack: PackType, user: User = Depends(authenticate)):
135134
_require_own_trip(user, pack.trip_id)
136135
_enforce_pack_limit(user, pack.trip_id)
137136

138-
new_pack = Pack(title=pack.title, trip_id=pack.trip_id, hiker_profile_id=pack.hiker_profile_id, user_id=user.id)
137+
new_pack = Pack(title=pack.title, trip_id=pack.trip_id, user_id=user.id)
139138

140139
try:
141140
db.session.add(new_pack)
@@ -185,7 +184,6 @@ def update_pack(id: int, payload: PackType, user: User = Depends(authenticate)):
185184
try:
186185
pack.title = payload.title
187186
pack.trip_id = new_trip_id
188-
pack.hiker_profile_id = payload.hiker_profile_id
189187
pack.items = [
190188
PackItem(pack_id=pack.id,
191189
item_id=item.item_id,

‎app/api/trip.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import uuid as uuid_module
44

55
from fastapi import APIRouter, Depends, HTTPException
6+
from fastapi.responses import Response
67
from fastapi_sqlalchemy import db
78
from pydantic import BaseModel
89
from sqlalchemy.orm import joinedload
910

1011
from models.base import User, Trip, Pack, PackItem
1112
from tasks.enrich_trip import enrich_trip
13+
from utils.ai_review import build_ai_review_markdown
1214
from utils.auth import authenticate
1315
from utils.utils import clone_model
1416

@@ -104,6 +106,46 @@ def fetch_meta(trip_id: str):
104106
}
105107

106108

109+
def _resolve_trip(trip_id: str) -> Trip:
110+
"""Look up a trip by public uuid or numeric id, as the share URLs do."""
111+
try:
112+
uuid_val = uuid_module.UUID(trip_id)
113+
trip = db.session.query(Trip).filter_by(uuid=uuid_val).first()
114+
except ValueError:
115+
try:
116+
trip = db.session.query(Trip).filter_by(id=int(trip_id)).first()
117+
except (ValueError, TypeError):
118+
raise HTTPException(400, "Invalid trip identifier.")
119+
120+
if not trip or trip.removed:
121+
raise HTTPException(404, "Trip not found.")
122+
return trip
123+
124+
125+
@route.get("/{trip_id}/ai-review")
126+
def fetch_ai_review(trip_id: str):
127+
"""Trip + packs + totals as one markdown document for pasting into an AI
128+
assistant. Same visibility as /info and /meta (anyone with the link).
129+
Backs the "Copy for AI" button on the public pack page.
130+
"""
131+
trip = _resolve_trip(trip_id)
132+
133+
user = db.session.query(User.unit_distance,
134+
User.unit_temperature).filter_by(id=trip.user_id).first()
135+
if not user:
136+
raise HTTPException(404, "Trip owner not found.")
137+
138+
packs = db.session.query(Pack).filter_by(trip_id=trip.id).order_by(Pack.id).all()
139+
public_url = f"https://packstack.io/pack/{trip.uuid or trip.id}"
140+
markdown = build_ai_review_markdown(trip, packs, user, public_url=public_url)
141+
142+
return Response(
143+
content=markdown,
144+
media_type="text/markdown; charset=utf-8",
145+
headers={"Cache-Control": "no-store"},
146+
)
147+
148+
107149
@route.get("/sitemap")
108150
def get_sitemap():
109151
trips = db.session.query(Trip.id, Trip.title, Trip.updated_at).filter_by(
@@ -226,7 +268,9 @@ def clone(trip_id: int, user: User = Depends(authenticate)):
226268
# it was grandfathered in.
227269
packs = db.session.query(Pack).filter_by(trip_id=trip.id).all()
228270
for pack in packs:
229-
cloned_pack_data = clone_model(pack, ['trip_id'])
271+
# hiker_profile_id is a dead column (single-profile product); don't
272+
# carry stale assignments into the clone.
273+
cloned_pack_data = clone_model(pack, ['trip_id', 'hiker_profile_id'])
230274
cloned_pack = Pack(**cloned_pack_data, trip_id=cloned_trip.id)
231275
db.session.add(cloned_pack)
232276
db.session.flush()

0 commit comments

Comments
 (0)