Skip to content

Commit c2b9222

Browse files
committed
add feat delete and feature defaults to all users
1 parent d9818cb commit c2b9222

11 files changed

Lines changed: 470 additions & 115 deletions

File tree

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from shared.users_database_gen.sqlacodegen_models import AppUser
1+
from shared.users_database_gen.sqlacodegen_models import AppUser, FeatureFlag
22
from user_service_gen.models.feature_flag import FeatureFlag as FeatureFlagModel
33
from user_service_gen.models.user_profile import UserProfile
44

@@ -12,27 +12,36 @@ class Config:
1212
from_attributes = True
1313

1414
@classmethod
15-
def from_orm(cls, user: AppUser | None) -> UserProfile | None:
15+
def from_orm(
16+
cls,
17+
user: AppUser | None,
18+
all_flags: list[FeatureFlag] | None = None,
19+
) -> UserProfile | None:
1620
if not user:
1721
return None
1822

23+
# Always return every feature flag with its resolved value: the user's
24+
# override when set, otherwise the flag's default. A user need not be
25+
# explicitly linked to a flag to receive its default.
26+
overrides = {uff.feature_flag_id: uff.value for uff in (user.user_feature_flags or [])}
27+
features = [
28+
FeatureFlagModel(
29+
id=flag.id,
30+
name=flag.name,
31+
value_type=flag.value_type,
32+
value=overrides[flag.id] if overrides.get(flag.id) is not None else flag.default_value,
33+
)
34+
for flag in (all_flags or [])
35+
]
36+
1937
return cls(
2038
id=user.id,
2139
email=user.email,
2240
full_name=user.full_name,
2341
legacy_org_name=user.legacy_org_name,
2442
email_verified=user.email_verified,
2543
is_registered_to_receive_api_announcements=user.is_registered_to_receive_api_announcements or False,
26-
features=[
27-
FeatureFlagModel(
28-
id=uff.feature_flag.id,
29-
name=uff.feature_flag.name,
30-
value_type=uff.feature_flag.value_type,
31-
value=uff.value if uff.value is not None else uff.feature_flag.default_value,
32-
)
33-
for uff in (user.user_feature_flags or [])
34-
if uff.feature_flag.default_value is not None or uff.value is not None
35-
],
44+
features=features,
3645
created_at=user.created_at,
3746
updated_at=user.updated_at,
3847
)

api/src/user_service/impl/users_api_impl.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from middleware.request_context import get_request_context
2626
from shared.database.users_database import with_users_db_session
2727
from shared.db_models.app_user_impl import AppUserImpl
28-
from shared.users_database_gen.sqlacodegen_models import AppUser, UserFeatureFlag
28+
from shared.users_database_gen.sqlacodegen_models import AppUser, FeatureFlag, UserFeatureFlag
2929
from user_service_gen.apis.users_api_base import BaseUsersApi
3030
from user_service_gen.models.create_notification_subscription_request import (
3131
CreateNotificationSubscriptionRequest,
@@ -82,7 +82,8 @@ def get_user(self, db_session=None) -> UserProfile:
8282
db_session.add(user)
8383
db_session.flush()
8484

85-
return AppUserImpl.from_orm(user)
85+
all_flags = db_session.query(FeatureFlag).filter(FeatureFlag.disabled.is_(False)).order_by(FeatureFlag.id).all()
86+
return AppUserImpl.from_orm(user, all_flags)
8687

8788
@with_users_db_session
8889
def update_user(self, update_user_request: UpdateUserRequest, db_session=None) -> UserProfile:
@@ -115,7 +116,8 @@ def update_user(self, update_user_request: UpdateUserRequest, db_session=None) -
115116
user.updated_at = datetime.now(timezone.utc)
116117
db_session.flush()
117118

118-
return AppUserImpl.from_orm(user)
119+
all_flags = db_session.query(FeatureFlag).filter(FeatureFlag.disabled.is_(False)).order_by(FeatureFlag.id).all()
120+
return AppUserImpl.from_orm(user, all_flags)
119121

120122
# ── Subscription stubs — implemented in a follow-up issue ────────────────
121123

api/tests/unittest/user_service/test_users_api_impl.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,25 @@ def _make_user(**kwargs) -> AppUser:
3131
return user
3232

3333

34-
def _mock_query_first(session, return_value):
35-
"""Make session.query().options().filter_by().first() return return_value."""
36-
mock_q = MagicMock()
37-
mock_q.options.return_value = mock_q
38-
mock_q.filter_by.return_value = mock_q
39-
mock_q.first.return_value = return_value
40-
session.query.return_value = mock_q
41-
return mock_q
34+
def _mock_query_first(session, return_value, flags=None):
35+
"""Dispatch session.query per model.
36+
37+
AppUser query → .options().filter_by().first() returns `return_value`.
38+
FeatureFlag query → .order_by().all() returns `flags` (default []).
39+
"""
40+
flags = flags if flags is not None else []
41+
user_q = MagicMock()
42+
user_q.options.return_value = user_q
43+
user_q.filter_by.return_value = user_q
44+
user_q.first.return_value = return_value
45+
46+
flags_q = MagicMock()
47+
flags_q.filter.return_value = flags_q
48+
flags_q.order_by.return_value = flags_q
49+
flags_q.all.return_value = flags
50+
51+
session.query.side_effect = lambda model: (flags_q if model is FeatureFlag else user_q)
52+
return user_q
4253

4354

4455
def _set_context(user_id="uid-123", user_email="user@example.com", is_guest=False):
@@ -101,6 +112,35 @@ def test_guest(self):
101112
self.mock_session.add.assert_not_called()
102113
self.assertEqual(result.id, "uid-123")
103114

115+
def test_returns_active_flags_with_resolved_values(self):
116+
flag = FeatureFlag(
117+
id="beta_editor",
118+
name="Beta Editor",
119+
value_type="boolean",
120+
default_value=False,
121+
disabled=False,
122+
created_at=FIXED_NOW,
123+
)
124+
user = _make_user()
125+
_mock_query_first(self.mock_session, user, flags=[flag])
126+
_set_context()
127+
128+
result = self.api.get_user(db_session=self.mock_session)
129+
130+
self.assertEqual([f.id for f in result.features], ["beta_editor"])
131+
self.assertEqual(result.features[0].value, False)
132+
133+
def test_disabled_flags_filtered_out_of_query(self):
134+
user = _make_user()
135+
_mock_query_first(self.mock_session, user, flags=[])
136+
_set_context()
137+
138+
self.api.get_user(db_session=self.mock_session)
139+
140+
# The user-facing query must exclude disabled flags.
141+
flags_q = self.mock_session.query(FeatureFlag)
142+
flags_q.filter.assert_called()
143+
104144

105145
class TestUpdateUserMe(unittest.TestCase):
106146
def setUp(self):
@@ -239,7 +279,7 @@ def test_from_orm_maps_all_fields(self):
239279
self.assertEqual(profile.created_at, now)
240280
self.assertEqual(profile.updated_at, now)
241281

242-
def test_from_orm_includes_feature_flags(self):
282+
def test_from_orm_resolves_user_override_and_default(self):
243283
now = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
244284
flag = FeatureFlag(
245285
id="beta_editor",
@@ -249,34 +289,44 @@ def test_from_orm_includes_feature_flags(self):
249289
default_value=False,
250290
created_at=now,
251291
)
252-
user_flag = UserFeatureFlag(user_id="uid-2", feature_flag_id=flag.id, assigned_at=now)
292+
user_flag = UserFeatureFlag(user_id="uid-2", feature_flag_id=flag.id, value=True, assigned_at=now)
253293
user_flag.feature_flag = flag
254294
user = AppUser(id="uid-2", email="b@b.com", created_at=now, updated_at=now)
255295
user.user_feature_flags = [user_flag]
256296

257-
profile = AppUserImpl.from_orm(user)
297+
profile = AppUserImpl.from_orm(user, [flag])
258298

259299
self.assertEqual(len(profile.features), 1)
260300
self.assertEqual(profile.features[0].id, "beta_editor")
261301
self.assertEqual(profile.features[0].name, "Beta Editor")
262302
self.assertEqual(profile.features[0].value_type, "boolean")
263-
self.assertEqual(profile.features[0].value, False)
303+
# User override (True) wins over the default (False)
304+
self.assertEqual(profile.features[0].value, True)
264305

265-
def test_from_orm_empty_feature_flags(self):
306+
def test_from_orm_returns_all_flags_with_defaults_when_user_has_none(self):
266307
now = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
308+
flag = FeatureFlag(
309+
id="beta_editor",
310+
name="Beta Editor",
311+
value_type="boolean",
312+
default_value=False,
313+
created_at=now,
314+
)
267315
user = AppUser(id="uid-3", email="c@b.com", created_at=now, updated_at=now)
268316
user.user_feature_flags = []
269317

270-
profile = AppUserImpl.from_orm(user)
318+
profile = AppUserImpl.from_orm(user, [flag])
271319

272-
self.assertEqual(profile.features, [])
320+
# Flag is returned even though the user has no override; value is the default
321+
self.assertEqual(len(profile.features), 1)
322+
self.assertEqual(profile.features[0].id, "beta_editor")
323+
self.assertEqual(profile.features[0].value, False)
273324

274-
def test_from_orm_none_feature_flags_treated_as_empty(self):
325+
def test_from_orm_empty_when_no_flags_exist(self):
275326
now = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
276327
user = AppUser(id="uid-4", email="d@b.com", created_at=now, updated_at=now)
277-
# Simulate a user where feature_flags hasn't been loaded (set to empty list as selectinload would return)
278328
user.user_feature_flags = []
279329

280-
profile = AppUserImpl.from_orm(user)
330+
profile = AppUserImpl.from_orm(user, [])
281331

282332
self.assertEqual(profile.features, [])

docs/OperationsAPI.yaml

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,8 @@ paths:
568568
description: Unauthorized.
569569
"409":
570570
description: A feature flag with this ID already exists.
571+
"422":
572+
description: default_value does not match the declared value_type.
571573
/v1/operations/feature-flags/{id}:
572574
put:
573575
summary: Update a feature flag
@@ -601,6 +603,32 @@ paths:
601603
description: Unauthorized.
602604
"404":
603605
description: Feature flag not found.
606+
"422":
607+
description: default_value does not match the declared value_type.
608+
delete:
609+
summary: Delete a feature flag
610+
description: >
611+
Deletes a feature flag definition. All user assignments for this flag are removed as well (cascading delete).
612+
613+
operationId: deleteFeatureFlag
614+
tags:
615+
- "users"
616+
security:
617+
- ApiKeyAuth: []
618+
parameters:
619+
- name: id
620+
in: path
621+
required: true
622+
schema:
623+
type: string
624+
description: The feature flag slug ID.
625+
responses:
626+
"204":
627+
description: Feature flag deleted.
628+
"401":
629+
description: Unauthorized.
630+
"404":
631+
description: Feature flag not found.
604632
components:
605633
schemas:
606634
Redirect:
@@ -1905,6 +1933,7 @@ components:
19051933
example: vp
19061934
description: >
19071935
The type of realtime entry:
1936+
19081937
* vp - vehicle positions
19091938
* tu - trip updates
19101939
* sa - service alerts
@@ -2029,6 +2058,7 @@ components:
20292058
example: vp
20302059
description: >
20312060
The type of realtime entry:
2061+
20322062
* vp - vehicle positions
20332063
* tu - trip updates
20342064
* sa - service alerts
@@ -2116,6 +2146,7 @@ components:
21162146
x-operation: true
21172147
description: >
21182148
Describes status of the Feed. Should be one of
2149+
21192150
* `active` Feed should be used in public trip planners.
21202151
* `deprecated` Feed is explicitly deprecated and should not be used in public trip planners.
21212152
* `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information.
@@ -2133,6 +2164,7 @@ components:
21332164
x-operation: true
21342165
description: >
21352166
Describes data type of a feed. Should be one of
2167+
21362168
* `gtfs` GTFS feed.
21372169
* `gtfs_rt` GTFS-RT feed.
21382170
* `gbfs` GBFS feed.
@@ -2220,6 +2252,12 @@ components:
22202252
default_value:
22212253
$ref: "#/components/schemas/FeatureFlagValue"
22222254
description: The flag's global default value.
2255+
disabled:
2256+
type: boolean
2257+
description: >
2258+
When true, the flag is hidden from the user-facing API. It remains visible and manageable through the Operations API.
2259+
2260+
default: false
22232261
CreateFeatureFlagRequest:
22242262
x-operation: true
22252263
type: object
@@ -2247,6 +2285,12 @@ components:
22472285
default_value:
22482286
$ref: "#/components/schemas/FeatureFlagValue"
22492287
description: The flag's global default value.
2288+
disabled:
2289+
type: boolean
2290+
description: >
2291+
When true, the flag is hidden from the user-facing API. It remains visible and manageable through the Operations API.
2292+
2293+
default: false
22502294
UpdateFeatureFlagRequest:
22512295
x-operation: true
22522296
type: object
@@ -2262,6 +2306,11 @@ components:
22622306
default_value:
22632307
$ref: "#/components/schemas/FeatureFlagValue"
22642308
description: Updated global default value. value_type cannot be changed.
2309+
disabled:
2310+
type: boolean
2311+
description: >
2312+
When true, the flag is hidden from the user-facing API. It remains visible and manageable through the Operations API.
2313+
22652314
OperationUserProfile:
22662315
x-operation: true
22672316
type: object
@@ -2290,7 +2339,9 @@ components:
22902339
example: "Acme Transit"
22912340
features:
22922341
type: array
2293-
description: Feature flags currently assigned to this user.
2342+
description: >
2343+
All feature flags with their resolved state for this user. Every flag is returned (including those the user has no explicit override for); user_value holds the per-user override when set, otherwise the flag's default_value applies.
2344+
22942345
items:
22952346
$ref: "#/components/schemas/UserFeatureFlagState"
22962347
default: []

docs/UserServiceAPI.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,10 @@ components:
259259
default: false
260260
features:
261261
type: array
262-
description: Feature flags currently active for this user.
262+
description: >
263+
All active feature flags for this user. Each flag's value is resolved to the
264+
user's override when set, otherwise the global default. Disabled flags are not
265+
included.
263266
items:
264267
$ref: "#/components/schemas/FeatureFlag"
265268
default: []

functions-python/operations_api/src/feeds_operations/impl/models/operation_feature_flag_impl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ def from_orm(cls, flag: FeatureFlag | None) -> OperationFeatureFlag | None:
3535
created_at=flag.created_at,
3636
value_type=flag.value_type,
3737
default_value=flag.default_value,
38+
disabled=flag.disabled,
3839
)

functions-python/operations_api/src/feeds_operations/impl/models/operation_user_profile_impl.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from feeds_operations.impl.models.user_feature_flag_state_impl import (
1919
UserFeatureFlagStateImpl,
2020
)
21-
from shared.users_database_gen.sqlacodegen_models import AppUser
21+
from shared.users_database_gen.sqlacodegen_models import AppUser, FeatureFlag
2222

2323

2424
class OperationUserProfileImpl(OperationUserProfile):
@@ -28,16 +28,26 @@ class Config:
2828
from_attributes = True
2929

3030
@classmethod
31-
def from_orm(cls, user: AppUser | None) -> OperationUserProfile | None:
31+
def from_orm(
32+
cls,
33+
user: AppUser | None,
34+
all_flags: list[FeatureFlag] | None = None,
35+
) -> OperationUserProfile | None:
3236
if not user:
3337
return None
38+
# Always return every feature flag with its default; overlay the user's
39+
# value where an override exists. Users need not be linked to a flag.
40+
overrides = {
41+
uff.feature_flag_id: uff.value for uff in (user.user_feature_flags or [])
42+
}
43+
features = [
44+
UserFeatureFlagStateImpl.from_flag(flag, overrides.get(flag.id))
45+
for flag in (all_flags or [])
46+
]
3447
return cls(
3548
id=user.id,
3649
email=user.email,
3750
full_name=user.full_name,
3851
legacy_org_name=user.legacy_org_name,
39-
features=[
40-
UserFeatureFlagStateImpl.from_orm(uff)
41-
for uff in (user.user_feature_flags or [])
42-
],
52+
features=features,
4353
)

0 commit comments

Comments
 (0)