Skip to content

Commit dd4bf34

Browse files
authored
Feature Flags: dedupe & basic superadmin interface (#3170)
Re-implements #3152 a little more simply with fewer features. See original #3152 for more context and discussion.
1 parent 8828056 commit dd4bf34

File tree

16 files changed

+493
-7
lines changed

16 files changed

+493
-7
lines changed

backend/btrixcloud/models.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
# pylint: disable=invalid-name, too-many-lines
6-
6+
from __future__ import annotations
77
from datetime import datetime
88
from enum import Enum, IntEnum
99
from uuid import UUID
@@ -13,7 +13,7 @@
1313
import math
1414
import os
1515

16-
from typing import Optional, List, Dict, Union, Literal, Any, get_args
16+
from typing import Optional, List, Dict, Self, Union, Literal, Any, get_args, get_origin
1717
from typing_extensions import Annotated
1818

1919
from pydantic import (
@@ -22,6 +22,8 @@
2222
HttpUrl as HttpUrlNonStr,
2323
AnyHttpUrl as AnyHttpUrlNonStr,
2424
EmailStr as CasedEmailStr,
25+
create_model,
26+
model_validator,
2527
validate_email,
2628
RootModel,
2729
BeforeValidator,
@@ -2227,6 +2229,110 @@ class UserOut(UserOutNoId):
22272229
is_superuser: bool = False
22282230

22292231

2232+
# ============================================================================
2233+
# Feature Flags
2234+
# ============================================================================
2235+
2236+
2237+
class ValidatedFeatureFlags(BaseModel):
2238+
"""Base class for feature flags with validation."""
2239+
2240+
@model_validator(mode="after")
2241+
def validate_all_fields(self) -> Self:
2242+
"""Ensure all fields have descriptions and are bools."""
2243+
missing_descriptions = []
2244+
non_bool_fields = []
2245+
2246+
for field_name, field_info in self.model_fields.items():
2247+
# Check for missing descriptions
2248+
if not field_info.description:
2249+
missing_descriptions.append(field_name)
2250+
2251+
# Check if field type is bool (handles Annotated[bool, ...])
2252+
annotation = field_info.annotation
2253+
if get_origin(annotation) is Annotated:
2254+
actual_type = get_args(annotation)[0]
2255+
else:
2256+
actual_type = annotation
2257+
2258+
if actual_type is not bool:
2259+
non_bool_fields.append(f"{field_name} (type: {actual_type})")
2260+
2261+
if missing_descriptions:
2262+
raise ValueError(
2263+
f"The following fields are missing descriptions: {', '.join(missing_descriptions)}"
2264+
)
2265+
2266+
if non_bool_fields:
2267+
raise ValueError(
2268+
f"The following fields must be bool type: {', '.join(non_bool_fields)}"
2269+
)
2270+
2271+
return self
2272+
2273+
2274+
def make_feature_flags_partial(model_cls: type[BaseModel]) -> type[BaseModel]:
2275+
"""Return a partial model for feature flags without validation inheritance.
2276+
2277+
This creates a model where all fields are optional (bool | None) but doesn't
2278+
inherit from the original model to avoid the ValidatedFeatureFlags validator
2279+
that checks for exact bool types.
2280+
"""
2281+
new_fields = {}
2282+
2283+
for f_name, f_info in model_cls.model_fields.items():
2284+
f_dct = f_info.asdict() # type: ignore
2285+
2286+
# Create a new field that's bool | None with the same description
2287+
new_fields[f_name] = (
2288+
Annotated[
2289+
(
2290+
bool | None,
2291+
Field(description=f_dct.get("description"), default=None), # type: ignore
2292+
)
2293+
],
2294+
None,
2295+
)
2296+
2297+
return create_model( # type: ignore
2298+
f"{model_cls.__name__}Partial",
2299+
**new_fields,
2300+
)
2301+
2302+
2303+
# ============================================================================
2304+
# Feature Flags - Edit here
2305+
# ============================================================================
2306+
2307+
2308+
class FeatureFlags(ValidatedFeatureFlags):
2309+
"""Feature flags for an organization"""
2310+
2311+
dedupeEnabled: bool = Field(
2312+
description="Enable deduplication options for an org. Intended for beta-testing dedupe.",
2313+
default=False,
2314+
)
2315+
2316+
2317+
# ============================================================================
2318+
2319+
2320+
FeatureFlagsPartial = make_feature_flags_partial(FeatureFlags)
2321+
2322+
2323+
class FeatureFlagStats(BaseModel):
2324+
"""Output model for feature flags"""
2325+
2326+
model_config = ConfigDict(use_attribute_docstrings=True)
2327+
2328+
name: str
2329+
2330+
description: str
2331+
2332+
count: int
2333+
"""Number of organizations that have this feature flag enabled."""
2334+
2335+
22302336
# ============================================================================
22312337
# ORGS
22322338
# ============================================================================
@@ -2354,6 +2460,8 @@ class OrgOut(BaseMongoModel):
23542460
publicDescription: str = ""
23552461
publicUrl: str = ""
23562462

2463+
featureFlags: FeatureFlags = FeatureFlags()
2464+
23572465

23582466
# ============================================================================
23592467
class Organization(BaseMongoModel):
@@ -2417,6 +2525,8 @@ class Organization(BaseMongoModel):
24172525
publicDescription: Optional[str] = None
24182526
publicUrl: Optional[str] = None
24192527

2528+
featureFlags: FeatureFlags = FeatureFlags()
2529+
24202530
def is_owner(self, user):
24212531
"""Check if user is owner"""
24222532
return self._is_auth(user, UserRole.OWNER)

backend/btrixcloud/orgs.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Literal,
2323
AsyncGenerator,
2424
Any,
25+
cast,
2526
)
2627

2728
from motor.motor_asyncio import (
@@ -43,6 +44,9 @@
4344
RUNNING_STATES,
4445
WAITING_STATES,
4546
BaseCrawl,
47+
FeatureFlagStats,
48+
FeatureFlags,
49+
FeatureFlagsPartial,
4650
Organization,
4751
PlansResponse,
4852
StorageRef,
@@ -770,6 +774,45 @@ async def update_quotas(
770774
print(f"Error updating organization quotas: {e}")
771775
raise HTTPException(status_code=500, detail=str(e)) from e
772776

777+
async def update_feature_flags(
778+
self,
779+
org: Organization,
780+
feature_flags: FeatureFlagsPartial, # type: ignore
781+
session: AsyncIOMotorClientSession | None = None,
782+
):
783+
"""Update organization feature flag"""
784+
update = {"$set": {}}
785+
for feature, enabled in feature_flags.model_dump(exclude_none=True).items(): # type: ignore
786+
update["$set"][f"featureFlags.{feature}"] = enabled
787+
await self.orgs.find_one_and_update({"_id": org.id}, update, session=session)
788+
789+
async def get_feature_flags(
790+
self,
791+
session: AsyncIOMotorClientSession | None = None,
792+
):
793+
"""Get feature flags, with their metadata and org counts."""
794+
# get number of organizations that have each feature flag enabled
795+
counts = await self.orgs.aggregate(
796+
[
797+
{"$match": {"featureFlags": {"$exists": True, "$ne": {}}}},
798+
{"$project": {"flags": {"$objectToArray": "$featureFlags"}}},
799+
{"$unwind": "$flags"},
800+
{"$match": {"flags.v": True}},
801+
{"$group": {"_id": "$flags.k", "count": {"$sum": 1}}},
802+
{"$sort": {"_id": 1}},
803+
],
804+
session=session,
805+
).to_list(None)
806+
org_counts = {count["_id"]: count["count"] for count in counts}
807+
return [
808+
FeatureFlagStats(
809+
name=name,
810+
count=org_counts.get(name, 0),
811+
description=cast(str, flag.description),
812+
)
813+
for name, flag in FeatureFlags.model_fields.items()
814+
]
815+
773816
async def update_event_webhook_urls(
774817
self, org: Organization, urls: OrgWebhookUrls
775818
) -> bool:
@@ -1759,6 +1802,34 @@ async def update_proxies(
17591802

17601803
return {"updated": True}
17611804

1805+
@router.post(
1806+
"/feature-flags", tags=["organizations"], response_model=UpdatedResponse
1807+
)
1808+
async def update_feature_flags(
1809+
feature_flags: FeatureFlagsPartial, # type: ignore
1810+
org: Organization = Depends(org_owner_dep),
1811+
user: User = Depends(user_dep),
1812+
):
1813+
if not user.is_superuser:
1814+
raise HTTPException(status_code=403, detail="Not Allowed")
1815+
1816+
await ops.update_feature_flags(org, feature_flags)
1817+
1818+
return {"updated": True}
1819+
1820+
@app.get(
1821+
"/orgs/feature-flags",
1822+
tags=["organizations"],
1823+
response_model=list[FeatureFlagStats],
1824+
)
1825+
async def get_feature_flags(
1826+
user: User = Depends(user_dep),
1827+
):
1828+
if not user.is_superuser:
1829+
raise HTTPException(status_code=403, detail="Not Allowed")
1830+
1831+
return await ops.get_feature_flags()
1832+
17621833
@router.post("/read-only", tags=["organizations"], response_model=UpdatedResponse)
17631834
async def update_read_only(
17641835
update: OrgReadOnlyUpdate,

backend/test/test_org.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ def test_get_org_crawler(crawler_auth_headers, default_org_id):
5656
assert data.get("users") == {}
5757

5858

59+
def test_get_org_feature_flags(crawler_auth_headers, default_org_id):
60+
"""feature flags should be available for all users"""
61+
r = requests.get(
62+
f"{API_PREFIX}/orgs/{default_org_id}", headers=crawler_auth_headers
63+
)
64+
assert r.status_code == 200
65+
data = r.json()
66+
assert data["id"] == default_org_id
67+
feature_flags = data["featureFlags"]
68+
assert feature_flags == {
69+
"dedupeEnabled": False,
70+
}
71+
72+
# List endpoint
73+
r = requests.get(f"{API_PREFIX}/orgs", headers=crawler_auth_headers)
74+
assert r.status_code == 200
75+
data = r.json()
76+
for org in data["items"]:
77+
feature_flags = org["featureFlags"]
78+
assert feature_flags == {
79+
"dedupeEnabled": False,
80+
}
81+
82+
5983
def test_update_org_crawling_defaults(admin_auth_headers, default_org_id):
6084
r = requests.post(
6185
f"{API_PREFIX}/orgs/{default_org_id}/defaults/crawling",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Feature Flags
2+
3+
## Introduction
4+
5+
Feature flags are a powerful tool for managing and controlling the release of new features in a software application. They allow developers to enable or disable specific features at runtime without deploying new releases, making it easier to test and deploy new features in a controlled manner.
6+
7+
## Implementation
8+
9+
In Browsertrix, feature flags are implemented as an object stored on the `Organization` model. This object contains a set of key-value pairs, where the key is the name of the feature flag and the value is a boolean indicating whether the feature is enabled or disabled.
10+
11+
### Consuming Feature Flags
12+
13+
On the back-end, feature flags are available through the `featureFlags` property on an `Organization` instance:
14+
15+
```python
16+
def do_something(org: Organization):
17+
if org.featureFlags.newFeature:
18+
# do something
19+
```
20+
21+
On the front-end, you can access feature flags using the `featureFlags.has` and `featureFlags.excludes` method on any element inheriting from `BtrixElement`:
22+
23+
```typescript
24+
class MyElement extends BtrixElement {
25+
render() {
26+
if (this.featureFlags.has("newFeature")) {
27+
// render new feature
28+
}
29+
// or
30+
if (this.featureFlags.excludes("newFeature")) {
31+
// render old feature
32+
}
33+
}
34+
}
35+
```
36+
37+
### Adding a New Feature Flag
38+
39+
There are a few steps to adding a new feature flag:
40+
41+
1. In `backend/btrixcloud/models.py`, add a new field to the `FeatureFlags` model:
42+
43+
```python
44+
class FeatureFlags(ValidatedFeatureFlags):
45+
# ...
46+
newFeature: bool = Field(
47+
description="Detailed description of the feature flag. It should explain what the flag does and why it is needed.",
48+
default=False,
49+
)
50+
```
51+
52+
3. In `frontend/src/types/featureFlags.ts`, add a new value to the `FeatureFlags` type:
53+
54+
```typescript
55+
export const featureFlagSchema = z.enum([
56+
// ...
57+
"newFeature",
58+
]);
59+
```
60+
61+
## Best Practices
62+
63+
- **Keep feature flags simple**: Feature flags should be simple and easy to understand.
64+
- **Document feature flags**: Feature flags must be documented using the `description` property in the `Field` definition.
65+
- **Intend to remove feature flags**: Feature flags should be intended to be removed after a certain period of time. They shouldn't be used for things like gating features based on subscription levels or user roles.
66+
67+
Feature flags as they're currently implemented in Browsertrix are intended to be used for experimental/beta features that are not yet ready for production. They should be used sparingly and only when necessary; they're not designed to be used as circuit breakers or ops/permission toggles at this stage.

frontend/docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ nav:
9999
- develop/local-dev-setup.md
100100
- develop/docs.md
101101
- develop/emails.md
102+
- develop/feature-flags.md
102103
- UI Development:
103104
- develop/frontend-dev.md
104105
- develop/ui/components.md

frontend/src/__mocks__/api/orgs/[id].js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,5 @@ export default {
153153
enablePublicProfile: false,
154154
publicDescription: "This is an example org.",
155155
publicUrl: "https://example.com",
156+
featureFlags: {},
156157
};

frontend/src/classes/BtrixElement.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ export class BtrixElement extends TailwindElement {
3939
protected get org() {
4040
return this.appState.org;
4141
}
42+
43+
protected readonly featureFlags = this.appState.featureFlags;
4244
}

0 commit comments

Comments
 (0)