-
Notifications
You must be signed in to change notification settings - Fork 10
Feature-Flag: org/project feature flags with DB #789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
56c191f
b837b12
bce0944
435d8bc
f2e512e
0935b98
0c04860
0a66f3c
cd946ba
5758167
360edee
e0af9d6
1330333
ee920b4
593eea3
803b848
147ff6c
6e62234
3e44365
b0efcac
dcb4141
928cad3
2f6cdb8
bc55665
77235a4
148b619
f2d7c24
bc07542
a9c9d32
912cd4c
956e39e
535a65a
91c011e
a54e105
fc2180c
abe55e9
bd01a7f
ebc6ead
b0a3633
4a4b9c6
0a19fec
b4fab33
7c82585
54f856d
e011850
1e19a28
6086a0b
74d4034
f9c1a98
50e5c09
e87bcc3
fbcc36d
4757c05
25268f3
30d5909
81bac63
6feb82a
7fbf109
f9e40a4
5d3e2a1
ae4376d
9a83031
7e0b597
f388721
f68c784
7d94a2f
e37e087
96404ea
5c39c6d
48fdba4
a77ee2b
c940bb5
6bae1fb
0ca4009
809a98c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| """create feature flag table | ||
|
|
||
| Revision ID: 057 | ||
| Revises: 056 | ||
| Create Date: 2026-04-22 12:00:00.000000 | ||
|
|
||
| """ | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| from app.core.feature_flags.constants import FeatureFlag as FeatureFlagKeyEnum | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision = "057" | ||
| down_revision = "056" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade(): | ||
|
vprashrex marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add explicit These are new Python functions, and the repo guideline requires explicit parameter and return typing. Suggested fix-def upgrade():
+def upgrade() -> None:
...
-def downgrade():
+def downgrade() -> None:As per coding guidelines, "Always add type hints to all function parameters and return values in Python code." Also applies to: 148-148 🤖 Prompt for AI Agents |
||
| op.create_table( | ||
| "feature_flag", | ||
| sa.Column( | ||
| "id", | ||
| sa.Integer(), | ||
| nullable=False, | ||
| comment="Unique identifier for feature flag", | ||
| ), | ||
| sa.Column( | ||
| "key", | ||
|
vprashrex marked this conversation as resolved.
|
||
| sa.Enum("ASSESSMENT", name="featureflagkey"), | ||
| nullable=False, | ||
| comment="Feature flag key", | ||
| ), | ||
| sa.Column( | ||
| "organization_id", | ||
| sa.Integer(), | ||
| nullable=False, | ||
| comment="Organization scope for this feature flag", | ||
| ), | ||
| sa.Column( | ||
| "project_id", | ||
| sa.Integer(), | ||
| nullable=False, | ||
| comment="Project scope for this feature flag", | ||
| ), | ||
| sa.Column( | ||
| "enabled", | ||
| sa.Boolean(), | ||
| nullable=False, | ||
| comment="Whether the feature flag is enabled", | ||
| ), | ||
| sa.Column( | ||
| "inserted_at", | ||
| sa.DateTime(), | ||
| nullable=False, | ||
| comment="Timestamp when the flag row was created", | ||
| ), | ||
| sa.Column( | ||
| "updated_at", | ||
| sa.DateTime(), | ||
| nullable=False, | ||
| comment="Timestamp when the flag row was last updated", | ||
| ), | ||
| sa.ForeignKeyConstraint( | ||
| ["organization_id"], | ||
| ["organization.id"], | ||
| name="fk_feature_flag_organization_id", | ||
| ondelete="CASCADE", | ||
| ), | ||
| sa.ForeignKeyConstraint( | ||
| ["project_id"], | ||
| ["project.id"], | ||
| name="fk_feature_flag_project_id", | ||
| ondelete="CASCADE", | ||
| ), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_index( | ||
| op.f("ix_feature_flag_key"), | ||
| "feature_flag", | ||
| ["key"], | ||
| unique=False, | ||
| ) | ||
| op.create_index( | ||
| op.f("ix_feature_flag_organization_id"), | ||
| "feature_flag", | ||
| ["organization_id"], | ||
| unique=False, | ||
|
Comment on lines
+86
to
+90
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. need similar index for project_id |
||
| ) | ||
| op.create_index( | ||
| op.f("ix_feature_flag_project_id"), | ||
| "feature_flag", | ||
| ["project_id"], | ||
| unique=False, | ||
| ) | ||
| op.create_index( | ||
| "uq_feature_flag_key_org_project", | ||
| "feature_flag", | ||
| ["key", "organization_id", "project_id"], | ||
| unique=True, | ||
| ) | ||
|
|
||
| op.execute( | ||
| sa.text( | ||
| f""" | ||
| INSERT INTO feature_flag | ||
| (key, organization_id, project_id, enabled, inserted_at, updated_at) | ||
| SELECT '{FeatureFlagKeyEnum.ASSESSMENT.value}'::featureflagkey, | ||
| p.organization_id, p.id, FALSE, NOW(), NOW() | ||
| FROM project p | ||
| """ | ||
| ) | ||
| ) | ||
|
|
||
| op.execute( | ||
| sa.text( | ||
| f""" | ||
| CREATE OR REPLACE FUNCTION seed_default_feature_flag() | ||
| RETURNS trigger | ||
| LANGUAGE plpgsql | ||
| AS $$ | ||
| BEGIN | ||
| INSERT INTO feature_flag | ||
| (key, organization_id, project_id, enabled, inserted_at, updated_at) | ||
| VALUES | ||
| ('{FeatureFlagKeyEnum.ASSESSMENT.value}'::featureflagkey, | ||
| NEW.organization_id, NEW.id, FALSE, NOW(), NOW()); | ||
| RETURN NEW; | ||
| END; | ||
| $$; | ||
| """ | ||
| ) | ||
| ) | ||
| op.execute( | ||
| sa.text( | ||
| """ | ||
| CREATE TRIGGER trg_seed_default_feature_flag | ||
| AFTER INSERT ON project | ||
| FOR EACH ROW | ||
| EXECUTE FUNCTION seed_default_feature_flag(); | ||
| """ | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| def downgrade(): | ||
| op.execute("DROP TRIGGER IF EXISTS trg_seed_default_feature_flag ON project") | ||
| op.execute("DROP FUNCTION IF EXISTS seed_default_feature_flag()") | ||
| op.drop_index("uq_feature_flag_key_org_project", table_name="feature_flag") | ||
| op.drop_index(op.f("ix_feature_flag_project_id"), table_name="feature_flag") | ||
| op.drop_index(op.f("ix_feature_flag_organization_id"), table_name="feature_flag") | ||
| op.drop_index(op.f("ix_feature_flag_key"), table_name="feature_flag") | ||
| op.drop_table("feature_flag") | ||
| sa.Enum(name="featureflagkey").drop(op.get_bind()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| Create a project-scoped feature flag. Superuser only. | ||
|
|
||
| Required payload fields: | ||
| - `key` | ||
| - `organization_id` | ||
| - `project_id` | ||
| - `enabled` | ||
| - Currently supported key(s): `ASSESSMENT`. | ||
|
|
||
| Validation: | ||
| - `organization_id` must exist. | ||
| - `project_id` must exist and belong to `organization_id`. | ||
|
|
||
| Behavior: | ||
| - A flag is unique by (`key`, `organization_id`, `project_id`). | ||
| - Returns `409` if the same flag already exists. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| Delete a project-scoped feature flag. Superuser only. | ||
|
|
||
| Required payload fields: | ||
| - `key` | ||
| - `organization_id` | ||
| - `project_id` | ||
|
|
||
| Validation: | ||
| - `organization_id` must exist. | ||
| - `project_id` must exist and belong to `organization_id`. | ||
|
|
||
| Returns: | ||
| - `{"deleted": true}` on success. | ||
| - `404` if the target flag does not exist. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| List feature flag records. Superuser only. | ||
|
|
||
| Query parameters are optional: | ||
| - `key` | ||
| - `organization_id` | ||
| - `project_id` | ||
|
|
||
| `key` matches stored feature flag values (currently `ASSESSMENT`). | ||
|
|
||
| Validation: | ||
| - When `project_id` is provided, `organization_id` is required. | ||
| - If both are provided, `project_id` must belong to `organization_id`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| Update a project-scoped feature flag. Superuser only. | ||
|
|
||
| Required payload fields: | ||
| - `key` | ||
| - `organization_id` | ||
| - `project_id` | ||
| - `enabled` | ||
| - Currently supported key(s): `ASSESSMENT`. | ||
|
|
||
| Validation: | ||
| - `organization_id` must exist. | ||
| - `project_id` must exist and belong to `organization_id`. | ||
|
|
||
| Returns: | ||
| - `404` if the target flag does not exist. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| Return the current authenticated user's profile. | ||
|
|
||
| All standard user profile fields are always returned. | ||
|
|
||
| `features` is returned only when both organization and project are available for the user; otherwise it is `[]`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| from app.api.routes import ( | ||
| api_keys, | ||
| assessment as assessment_routes, | ||
| assistants, | ||
| auth, | ||
| collection_job, | ||
|
|
@@ -12,6 +13,7 @@ | |
| doc_transformation_job, | ||
| documents, | ||
| evaluations, | ||
| features, | ||
| fine_tuning, | ||
| languages, | ||
| llm, | ||
|
|
@@ -30,27 +32,29 @@ | |
| users, | ||
| utils, | ||
| ) | ||
| from app.api.routes import ( | ||
| assessment as assessment_routes, | ||
| ) | ||
| from app.core.config import settings | ||
|
|
||
| api_router = APIRouter() | ||
| api_router.include_router(api_keys.router) | ||
| api_router.include_router(assessment_routes.router) | ||
| api_router.include_router(assistants.router) | ||
| api_router.include_router(collections.router) | ||
| api_router.include_router(auth.router) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
can you make all these alphabetical to ensure same is not repeated by other developers |
||
| api_router.include_router(collection_job.router) | ||
| api_router.include_router(collections.router) | ||
| api_router.include_router(config.router) | ||
| api_router.include_router(credentials.router) | ||
| api_router.include_router(cron.router) | ||
| api_router.include_router(doc_transformation_job.router) | ||
| api_router.include_router(documents.router) | ||
| api_router.include_router(auth.router) | ||
| api_router.include_router(evaluations.router) | ||
| api_router.include_router(features.router) | ||
| api_router.include_router(fine_tuning.router) | ||
| api_router.include_router(languages.router) | ||
| api_router.include_router(llm.router) | ||
| api_router.include_router(llm_chain.router) | ||
| api_router.include_router(login.router) | ||
| api_router.include_router(model_config.router) | ||
| api_router.include_router(model_evaluation.router) | ||
| api_router.include_router(onboarding.router) | ||
| api_router.include_router(openai_conversation.router) | ||
| api_router.include_router(organization.router) | ||
|
|
@@ -60,10 +64,6 @@ | |
| api_router.include_router(user_project.router) | ||
| api_router.include_router(users.router) | ||
| api_router.include_router(utils.router) | ||
| api_router.include_router(fine_tuning.router) | ||
| api_router.include_router(model_evaluation.router) | ||
| api_router.include_router(model_config.router) | ||
| api_router.include_router(assessment_routes.router) | ||
|
|
||
| if settings.ENVIRONMENT in ["development", "testing"]: | ||
| api_router.include_router(private.router) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,14 @@ | ||
| from enum import Enum | ||
| from typing import Annotated | ||
| from fastapi import Depends, HTTPException | ||
| from enum import StrEnum | ||
|
|
||
| from fastapi import HTTPException | ||
| from sqlmodel import Session | ||
|
|
||
| from app.models import AuthContext | ||
| from app.api.deps import AuthContextDep, SessionDep | ||
| from app.core.feature_flags import FeatureFlag | ||
| from app.models import AuthContext | ||
|
|
||
|
|
||
| class Permission(str, Enum): | ||
| class Permission(StrEnum): | ||
| """Permission types for authorization checks""" | ||
|
|
||
| SUPERUSER = "require_superuser" | ||
|
|
@@ -18,7 +19,7 @@ class Permission(str, Enum): | |
| def has_permission( | ||
| auth_context: AuthContext, | ||
| permission: Permission, | ||
| session: Session | None = None, | ||
| _session: Session | None = None, | ||
| ) -> bool: | ||
| """ | ||
| Check if the auth_context has the specified permission. | ||
|
|
@@ -68,3 +69,40 @@ def permission_checker( | |
| ) | ||
|
|
||
| return permission_checker | ||
|
|
||
|
|
||
| def require_feature(flag: FeatureFlag): | ||
| """Dependency factory that gates a route behind a feature flag. | ||
|
|
||
| Returns 403 when the flag is disabled for the caller's org/project, | ||
| so callers can explicitly handle feature access denial. | ||
|
|
||
| Usage:: | ||
|
|
||
| router = APIRouter( | ||
|
Comment on lines
+75
to
+82
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cleanup formatting a bit |
||
| dependencies=[Depends(require_feature(FeatureFlag.ASSESSMENT))] | ||
| ) | ||
| """ | ||
|
|
||
| def _check_with_session(auth_context: AuthContextDep, session: SessionDep): | ||
| from app.core.feature_flags import is_enabled | ||
|
|
||
| org_id = auth_context.organization.id if auth_context.organization else None | ||
| project_id = auth_context.project.id if auth_context.project else None | ||
|
|
||
| if ( | ||
| org_id is None | ||
| or project_id is None | ||
| or not is_enabled( | ||
| session=session, | ||
| flag=flag.value, | ||
| organization_id=org_id, | ||
| project_id=project_id, | ||
| ) | ||
| ): | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail=f"Feature '{flag.value}' is not enabled for this org or project.", | ||
| ) | ||
|
Comment on lines
+87
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Distinguish "missing context" from "feature disabled" in the 403 response. When 🔧 Suggested split- if (
- org_id is None
- or project_id is None
- or not is_enabled(
- session=session,
- flag=flag.value,
- organization_id=org_id,
- project_id=project_id,
- )
- ):
+ if org_id is None or project_id is None:
+ raise HTTPException(
+ status_code=403,
+ detail="Organization and project context are required to evaluate feature flags.",
+ )
+
+ if not is_enabled(
+ session=session,
+ flag=flag.value,
+ organization_id=org_id,
+ project_id=project_id,
+ ):
raise HTTPException(
status_code=403,
detail=f"Feature '{flag.value}' is not enabled for this org or project.",
)🤖 Prompt for AI Agents |
||
|
|
||
| return _check_with_session | ||
|
Comment on lines
+74
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add return type annotations per coding guidelines. Both ✏️ Proposed annotations-def require_feature(flag: FeatureFlag):
+def require_feature(flag: FeatureFlag) -> Callable[[AuthContextDep, SessionDep], None]:
"""Dependency factory that gates a route behind a feature flag.
...
"""
- def _check_with_session(auth_context: AuthContextDep, session: SessionDep):
+ def _check_with_session(auth_context: AuthContextDep, session: SessionDep) -> None:
from app.core.feature_flags import is_enabledAdd As per coding guidelines: "Always add type hints to all function parameters and return values in Python code". 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ProjectTech4DevAI/kaapi-backend
Length of output: 3960
🏁 Script executed:
Repository: ProjectTech4DevAI/kaapi-backend
Length of output: 141
🏁 Script executed:
Repository: ProjectTech4DevAI/kaapi-backend
Length of output: 5604
Add return type hints and freeze enum values in the migration.
This migration imports a runtime application symbol (
app.core.feature_flags.constants.FeatureFlag) and uses it in SQL (lines 110, 128). Future changes to the enum's location, values, or definition will break database bootstrapping from this historical revision. Alembic migrations must be self-contained with frozen values.Additionally, both
upgrade()(line 21) anddowngrade()(line 148) functions lack return type annotations, violating the type hints requirement.Suggested fix
🤖 Prompt for AI Agents