teoat commited on
Commit
87a43ef
·
verified ·
1 Parent(s): 0f4d613

Upload app/routers/feature_flags.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app/routers/feature_flags.py +394 -0
app/routers/feature_flags.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature Flags API Router
3
+ Provides secure admin endpoints for managing feature flags
4
+ """
5
+
6
+ import logging
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, status
10
+ from pydantic import BaseModel, Field
11
+ from sqlalchemy.orm import Session
12
+
13
+ from app.services.infrastructure.rbac_service import require_admin
14
+ from core.database import User, get_db
15
+ from core.feature_flags.models import FeatureFlag
16
+ from core.feature_flags.service import feature_flag_service
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # ===== PYDANTIC SCHEMAS =====
24
+
25
+
26
+ class FeatureFlagBase(BaseModel):
27
+ """Base feature flag schema"""
28
+
29
+ name: str = Field(..., min_length=1, max_length=100, description="Feature flag name")
30
+ description: Optional[str] = Field(None, max_length=500, description="Feature flag description")
31
+ enabled: bool = Field(default=False, description="Whether the flag is enabled")
32
+ rollout_percentage: int = Field(default=0, ge=0, le=100, description="Rollout percentage (0-100)")
33
+ target_users: Optional[List[str]] = Field(default=None, description="List of target user IDs")
34
+ target_contexts: Optional[Dict[str, Any]] = Field(default=None, description="Targeting rules/context")
35
+
36
+
37
+ class FeatureFlagCreate(FeatureFlagBase):
38
+ """Schema for creating a feature flag"""
39
+
40
+ pass
41
+
42
+
43
+ class FeatureFlagUpdate(BaseModel):
44
+ """Schema for updating a feature flag"""
45
+
46
+ description: Optional[str] = Field(None, max_length=500)
47
+ enabled: Optional[bool] = None
48
+ rollout_percentage: Optional[int] = Field(None, ge=0, le=100)
49
+ target_users: Optional[List[str]] = None
50
+ target_contexts: Optional[Dict[str, Any]] = None
51
+
52
+
53
+ class FeatureFlagResponse(FeatureFlagBase):
54
+ """Response schema for feature flag"""
55
+
56
+ flag_id: str
57
+ created_at: str
58
+ updated_at: str
59
+ disabled_at: Optional[str] = None
60
+ disabled_reason: Optional[str] = None
61
+
62
+ class Config:
63
+ from_attributes = True
64
+
65
+
66
+ class FeatureFlagListResponse(BaseModel):
67
+ """Response for listing feature flags"""
68
+
69
+ flags: List[FeatureFlagResponse]
70
+ total: int
71
+ enabled_count: int
72
+
73
+
74
+ class PublicFeatureFlagResponse(BaseModel):
75
+ """Public response schema for feature flags (no sensitive data)"""
76
+
77
+ name: str
78
+ enabled: bool
79
+ rollout_percentage: int
80
+
81
+ class Config:
82
+ from_attributes = True
83
+
84
+
85
+ class PublicFeatureFlagsResponse(BaseModel):
86
+ """Response for public feature flags endpoint"""
87
+
88
+ flags: Dict[str, bool]
89
+ timestamp: str
90
+
91
+
92
+ # ===== PUBLIC ENDPOINTS (NO AUTH REQUIRED) =====
93
+
94
+
95
+ @router.get("/public", response_model=PublicFeatureFlagsResponse)
96
+ async def get_public_feature_flags(
97
+ user_id: Optional[str] = None,
98
+ db: Session = Depends(get_db),
99
+ ) -> PublicFeatureFlagsResponse:
100
+ """
101
+ Get feature flags for public/frontend consumption (NO AUTH REQUIRED)
102
+
103
+ This endpoint is used by the frontend to check feature flags without authentication.
104
+ It evaluates rollout percentages and returns a simple enabled/disabled status.
105
+
106
+ Args:
107
+ user_id: Optional user identifier for percentage-based rollout
108
+
109
+ Returns:
110
+ Dictionary of feature flag names to their enabled status
111
+ """
112
+ import hashlib
113
+ from datetime import datetime
114
+
115
+ try:
116
+ # Get all non-disabled flags
117
+ flags = db.query(FeatureFlag).filter(FeatureFlag.disabled_at.is_(None)).all()
118
+
119
+ result = {}
120
+
121
+ for flag in flags:
122
+ # Simple enabled/disabled check
123
+ if not flag.enabled:
124
+ result[flag.name] = False
125
+ continue
126
+
127
+ # Check rollout percentage
128
+ if flag.rollout_percentage >= 100:
129
+ result[flag.name] = True
130
+ elif flag.rollout_percentage <= 0:
131
+ result[flag.name] = False
132
+ elif user_id:
133
+ # Consistent hash-based rollout
134
+ hash_value = int(hashlib.md5(f"{flag.name}:{user_id}".encode()).hexdigest()[:8], 16)
135
+ percentage = (hash_value % 100) / 100.0
136
+ result[flag.name] = percentage <= (flag.rollout_percentage / 100.0)
137
+ else:
138
+ # No user_id provided, use random for percentage
139
+ result[flag.name] = flag.rollout_percentage >= 50 # Conservative default
140
+
141
+ return PublicFeatureFlagsResponse(flags=result, timestamp=datetime.now().isoformat())
142
+
143
+ except Exception as e:
144
+ # Log error but don't expose details to public
145
+ logger.error(f"Error getting public feature flags: {e}")
146
+ return PublicFeatureFlagsResponse(flags={}, timestamp=datetime.now().isoformat())
147
+
148
+
149
+ # ===== FEATURE FLAG ENDPOINTS =====
150
+
151
+
152
+ @router.post("/", response_model=FeatureFlagResponse, status_code=status.HTTP_201_CREATED)
153
+ async def create_feature_flag(
154
+ flag_data: FeatureFlagCreate,
155
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
156
+ db: Session = Depends(get_db),
157
+ ) -> FeatureFlagResponse:
158
+ """
159
+ Create a new feature flag.
160
+
161
+ **Requires admin role.**
162
+
163
+ This endpoint allows administrators to create new feature flags
164
+ for controlling application behavior and gradual feature rollouts.
165
+ """
166
+ try:
167
+ # Check if flag with this name already exists
168
+ existing = db.query(FeatureFlag).filter(FeatureFlag.name == flag_data.name).first()
169
+ if existing:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_409_CONFLICT, detail=f"Feature flag '{flag_data.name}' already exists"
172
+ )
173
+
174
+ # Create the feature flag
175
+ flag = feature_flag_service.create_flag(
176
+ name=flag_data.name,
177
+ description=flag_data.description,
178
+ enabled=flag_data.enabled,
179
+ rollout_percentage=flag_data.rollout_percentage,
180
+ target_users=flag_data.target_users,
181
+ target_contexts=flag_data.target_contexts,
182
+ created_by=current_user.id if hasattr(current_user, "id") else "system",
183
+ )
184
+
185
+ return FeatureFlagResponse.from_orm(flag)
186
+
187
+ except Exception as e:
188
+ raise HTTPException(
189
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create feature flag: {str(e)}"
190
+ )
191
+
192
+
193
+ @router.put("/{flag_name}", response_model=FeatureFlagResponse)
194
+ async def update_feature_flag(
195
+ flag_name: str,
196
+ flag_update: FeatureFlagUpdate,
197
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
198
+ db: Session = Depends(get_db),
199
+ ) -> FeatureFlagResponse:
200
+ """
201
+ Update an existing feature flag.
202
+
203
+ **Requires admin role.**
204
+
205
+ This endpoint allows administrators to modify feature flag settings,
206
+ including enabling/disabling features and adjusting rollout percentages.
207
+ """
208
+ try:
209
+ # Check if flag exists
210
+ existing = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
211
+ if not existing:
212
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature flag '{flag_name}' not found")
213
+
214
+ # Update the flag
215
+ success = feature_flag_service.update_flag(
216
+ flag_name=flag_name,
217
+ updates={k: v for k, v in flag_update.dict(exclude_unset=True).items() if v is not None},
218
+ updated_by=current_user.id if hasattr(current_user, "id") else "system",
219
+ )
220
+
221
+ if not success:
222
+ raise HTTPException(
223
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update feature flag '{flag_name}'"
224
+ )
225
+
226
+ # Get updated flag
227
+ updated_flag = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
228
+ return FeatureFlagResponse.from_orm(updated_flag)
229
+
230
+ except HTTPException:
231
+ raise
232
+ except Exception as e:
233
+ raise HTTPException(
234
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update feature flag: {str(e)}"
235
+ )
236
+
237
+
238
+ @router.delete("/{flag_name}", status_code=status.HTTP_204_NO_CONTENT)
239
+ async def delete_feature_flag(
240
+ flag_name: str,
241
+ disabled_reason: Optional[str] = None,
242
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
243
+ db: Session = Depends(get_db),
244
+ ):
245
+ """
246
+ Delete (disable) a feature flag.
247
+
248
+ **Requires admin role.**
249
+
250
+ This endpoint allows administrators to permanently disable feature flags.
251
+ The flag is marked as disabled rather than deleted to maintain audit trail.
252
+ """
253
+ try:
254
+ # Check if flag exists and is not already disabled
255
+ existing = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
256
+ if not existing:
257
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature flag '{flag_name}' not found")
258
+
259
+ if existing.disabled_at:
260
+ raise HTTPException(
261
+ status_code=status.HTTP_400_BAD_REQUEST, detail=f"Feature flag '{flag_name}' is already disabled"
262
+ )
263
+
264
+ # Delete (disable) the flag
265
+ success = feature_flag_service.delete_flag(
266
+ flag_name=flag_name,
267
+ disabled_reason=disabled_reason or "Disabled by admin",
268
+ disabled_by=current_user.id if hasattr(current_user, "id") else "system",
269
+ )
270
+
271
+ if not success:
272
+ raise HTTPException(
273
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete feature flag '{flag_name}'"
274
+ )
275
+
276
+ except HTTPException:
277
+ raise
278
+ except Exception as e:
279
+ raise HTTPException(
280
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete feature flag: {str(e)}"
281
+ )
282
+
283
+
284
+ @router.get("/", response_model=FeatureFlagListResponse)
285
+ async def list_feature_flags(
286
+ enabled_only: bool = False,
287
+ skip: int = 0,
288
+ limit: int = 100,
289
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
290
+ db: Session = Depends(get_db),
291
+ ) -> FeatureFlagListResponse:
292
+ """
293
+ List all feature flags.
294
+
295
+ **Requires admin role.**
296
+
297
+ Returns a paginated list of all feature flags with summary statistics.
298
+ """
299
+ try:
300
+ query = db.query(FeatureFlag)
301
+
302
+ if enabled_only:
303
+ query = query.filter(FeatureFlag.enabled, FeatureFlag.disabled_at.is_(None))
304
+
305
+ total = query.count()
306
+ flags = query.offset(skip).limit(limit).all()
307
+
308
+ enabled_count = sum(1 for f in flags if f.enabled and not f.disabled_at)
309
+
310
+ return FeatureFlagListResponse(
311
+ flags=[FeatureFlagResponse.from_orm(flag) for flag in flags], total=total, enabled_count=enabled_count
312
+ )
313
+
314
+ except Exception as e:
315
+ raise HTTPException(
316
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list feature flags: {str(e)}"
317
+ )
318
+
319
+
320
+ @router.get("/{flag_name}", response_model=FeatureFlagResponse)
321
+ async def get_feature_flag(
322
+ flag_name: str,
323
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
324
+ db: Session = Depends(get_db),
325
+ ) -> FeatureFlagResponse:
326
+ """
327
+ Get a specific feature flag by name.
328
+
329
+ **Requires admin role.**
330
+ """
331
+ try:
332
+ flag = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
333
+
334
+ if not flag:
335
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature flag '{flag_name}' not found")
336
+
337
+ return FeatureFlagResponse.from_orm(flag)
338
+
339
+ except HTTPException:
340
+ raise
341
+ except Exception as e:
342
+ raise HTTPException(
343
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get feature flag: {str(e)}"
344
+ )
345
+
346
+
347
+ @router.post("/{flag_name}/toggle", response_model=FeatureFlagResponse)
348
+ async def toggle_feature_flag(
349
+ flag_name: str,
350
+ current_user: User = Depends(require_admin), # 🔴 ADMIN REQUIRED
351
+ db: Session = Depends(get_db),
352
+ ) -> FeatureFlagResponse:
353
+ """
354
+ Toggle a feature flag on/off.
355
+
356
+ **Requires admin role.**
357
+
358
+ This is a convenience endpoint for quickly enabling/disabling flags.
359
+ """
360
+ try:
361
+ # Check if flag exists
362
+ existing = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
363
+ if not existing:
364
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature flag '{flag_name}' not found")
365
+
366
+ if existing.disabled_at:
367
+ raise HTTPException(
368
+ status_code=status.HTTP_400_BAD_REQUEST,
369
+ detail=f"Feature flag '{flag_name}' is disabled and cannot be toggled",
370
+ )
371
+
372
+ # Toggle the flag
373
+ new_state = not existing.enabled
374
+ success = feature_flag_service.update_flag(
375
+ flag_name=flag_name,
376
+ updates={"enabled": new_state},
377
+ updated_by=current_user.id if hasattr(current_user, "id") else "system",
378
+ )
379
+
380
+ if not success:
381
+ raise HTTPException(
382
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to toggle feature flag '{flag_name}'"
383
+ )
384
+
385
+ # Get updated flag
386
+ updated_flag = db.query(FeatureFlag).filter(FeatureFlag.name == flag_name).first()
387
+ return FeatureFlagResponse.from_orm(updated_flag)
388
+
389
+ except HTTPException:
390
+ raise
391
+ except Exception as e:
392
+ raise HTTPException(
393
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to toggle feature flag: {str(e)}"
394
+ )