Skip to content

feat(experimentation): add Experiment base model and CRUD endpoints#7591

Open
Zaimwa9 wants to merge 8 commits into
mainfrom
feat/scaffold-experimentation-models-and-cruds
Open

feat(experimentation): add Experiment base model and CRUD endpoints#7591
Zaimwa9 wants to merge 8 commits into
mainfrom
feat/scaffold-experimentation-models-and-cruds

Conversation

@Zaimwa9
Copy link
Copy Markdown
Contributor

@Zaimwa9 Zaimwa9 commented May 25, 2026

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Closes #7590

Adds the foundation Experiment model and CRUD endpoints to the experimentation app, enough to wire up the frontend wizard's Step 1 (Setup) and the experiments list page.

Model:

  • Experiment with FK to Environment and Feature, fields: name, hypothesis, status, created_at, updated_at, started_at, ended_at
  • ExperimentStatus enum: created, running, paused, completed
  • DB-level UniqueConstraint on (feature, environment) excluding completed — one active experiment per flag per environment
  • Soft delete via SoftDeleteExportableModel

Endpoints (nested under /environments/{env_key}/experiments/):

  • GET / — list, filterable by ?status=
  • POST / — create (multivariate flags only)
  • GET /{id}/ — retrieve
  • PATCH /{id}/ — update name, hypothesis, status

Validations:

  • Only multivariate features allowed
  • Feature must belong to the environment's project
  • Status transitions enforced (created→running, running→paused/completed, paused→running/completed)
  • Cannot reassign feature after creation
  • started_at auto-set on first transition to running, ended_at auto-set on completed

Other:

  • ExperimentPermission gated behind experimental_flags feature flag + environment admin
  • Audit logging for create/update/delete via RelatedObjectType.EXPERIMENT

How did you test this code?

  • New tests

    make test opts='-k "test_experiment" -n0'

@Zaimwa9 Zaimwa9 requested a review from a team as a code owner May 25, 2026 09:49
@Zaimwa9 Zaimwa9 requested review from emyller and removed request for a team May 25, 2026 09:49
@vercel
Copy link
Copy Markdown

vercel Bot commented May 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview May 25, 2026 12:37pm
flagsmith-frontend-preview Ignored Ignored Preview May 25, 2026 12:37pm
flagsmith-frontend-staging Ignored Ignored Preview May 25, 2026 12:37pm

Request Review

@Zaimwa9 Zaimwa9 requested review from gagantrivedi and removed request for emyller May 25, 2026 09:49
@github-actions github-actions Bot added api Issue related to the REST API feature New feature or request labels May 25, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

Docker builds report

Image Build Status Security report
ghcr.io/flagsmith/flagsmith-e2e:pr-7591 Finished ✅ Skipped
ghcr.io/flagsmith/flagsmith-api-test:pr-7591 Finished ✅ Skipped
ghcr.io/flagsmith/flagsmith-frontend:pr-7591 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-api:pr-7591 Finished ✅ Results
ghcr.io/flagsmith/flagsmith:pr-7591 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-private-cloud:pr-7591 Finished ✅ Results

@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels May 25, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new experimentation framework, including the Experiment model, associated API endpoints, and audit logging. Key additions include status transition logic, permissions based on feature flags, and comprehensive unit tests. Review feedback suggests moving timestamp management to model lifecycle hooks for better consistency, optimizing environment lookups in permission classes by utilizing cached data, and updating the error response status code to 409 when an active experiment already exists to align with existing patterns and handle potential race conditions.

Comment on lines +89 to +110
class Experiment(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc]
environment = models.ForeignKey(
Environment,
on_delete=models.CASCADE,
related_name="experiments",
)
feature = models.ForeignKey(
"features.Feature",
on_delete=models.CASCADE,
related_name="experiments",
)
name = models.CharField(max_length=255)
hypothesis = models.TextField()
status = models.CharField(
max_length=50,
choices=ExperimentStatus.choices,
default=ExperimentStatus.CREATED,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
started_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for automatically setting started_at and ended_at timestamps based on status transitions is currently implemented in the serializer's update method. This logic is better suited for the model layer using django-lifecycle hooks, which are already being used in this model. This ensures data consistency regardless of how the model is updated (e.g., via the Django shell, admin, or background tasks).

class Experiment(LifecycleModelMixin, SoftDeleteExportableModel):  # type: ignore[misc]
    environment = models.ForeignKey(
        Environment,
        on_delete=models.CASCADE,
        related_name="experiments",
    )
    feature = models.ForeignKey(
        "features.Feature",
        on_delete=models.CASCADE,
        related_name="experiments",
    )
    name = models.CharField(max_length=255)
    hypothesis = models.TextField()
    status = models.CharField(
        max_length=50,
        choices=ExperimentStatus.choices,
        default=ExperimentStatus.CREATED,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    started_at = models.DateTimeField(null=True, blank=True)
    ended_at = models.DateTimeField(null=True, blank=True)

    @hook(
        BEFORE_UPDATE,
        when="status",
        was=ExperimentStatus.CREATED,
        is_now=ExperimentStatus.RUNNING,
    )
    def set_started_at(self) -> None:
        if not self.started_at:
            self.started_at = timezone.now()

    @hook(BEFORE_UPDATE, when="status", is_now=ExperimentStatus.COMPLETED)
    def set_ended_at(self) -> None:
        self.ended_at = timezone.now()

Comment on lines +31 to +34
try:
environment = Environment.objects.get(
api_key=view.kwargs.get("environment_api_key")
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This permission class performs a redundant database query to fetch the environment. Since ExperimentViewSet inherits from NestedEnvironmentViewSet, the environment is already fetched and cached by the view. You can reuse it by calling view._get_environment().

        try:
            environment = view._get_environment()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad bot! Don't access private method

Comment on lines +112 to +123
if (
Experiment.objects.filter(
feature=feature,
environment=environment,
)
.exclude(status=ExperimentStatus.COMPLETED)
.exists()
):
return Response(
{"detail": "An active experiment already exists for this feature."},
status=400,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This manual check for an existing active experiment is prone to a race condition. While there is a database-level UniqueConstraint, a concurrent request could pass this check and then cause an IntegrityError during perform_create, resulting in a 500 response. It is safer to catch the IntegrityError or use a 409 Conflict status code for consistency with other similar endpoints in this file (like WarehouseConnectionViewSet).

Suggested change
if (
Experiment.objects.filter(
feature=feature,
environment=environment,
)
.exclude(status=ExperimentStatus.COMPLETED)
.exists()
):
return Response(
{"detail": "An active experiment already exists for this feature."},
status=400,
)
if (
Experiment.objects.filter(
feature=feature,
environment=environment,
)
.exclude(status=ExperimentStatus.COMPLETED)
.exists()
):
return Response(
{"detail": "An active experiment already exists for this feature."},
status=409,
)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  38.9 seconds
commit  b27797d
info  🔄 Run: #16970 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  36.8 seconds
commit  b27797d
info  🔄 Run: #16970 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  39.8 seconds
commit  b27797d
info  🔄 Run: #16970 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  41.2 seconds
commit  b27797d
info  🔄 Run: #16970 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  38.6 seconds
commit  43b0897
info  🔄 Run: #16971 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  43.5 seconds
commit  43b0897
info  🔄 Run: #16971 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  38.4 seconds
commit  43b0897
info  🔄 Run: #16971 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  2 passed

Details

stats  2 tests across 2 suites
duration  1 minute, 7 seconds
commit  43b0897
info  🔄 Run: #16971 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  39.3 seconds
commit  5d55695
info  🔄 Run: #16972 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  42.4 seconds
commit  5d55695
info  🔄 Run: #16972 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  47.8 seconds
commit  5d55695
info  🔄 Run: #16972 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

failed  2 failed

Details

stats  2 tests across 2 suites
duration  1 minute, 13 seconds
commit  5d55695
info  📦 Artifacts: View test results and HTML report
🔄 Run: #16972 (attempt 1)

Failed tests

firefox › tests/environment-permission-test.pw.ts › Environment Permission Tests › Environment-level permissions control access to features, identities, and segments @enterprise
firefox › tests/project-permission-test.pw.ts › Project Permission Tests › Project-level permissions control access to features, environments, audit logs, and segments @enterprise

### Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  33.1 seconds
commit  4269a8a
info  🔄 Run: #16973 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  42.2 seconds
commit  4269a8a
info  🔄 Run: #16973 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  51.2 seconds
commit  4269a8a
info  🔄 Run: #16973 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  2 passed

Details

stats  2 tests across 2 suites
duration  55.3 seconds
commit  4269a8a
info  🔄 Run: #16973 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  42.5 seconds
commit  52fa070
info  🔄 Run: #16974 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  37.9 seconds
commit  52fa070
info  🔄 Run: #16974 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  32.6 seconds
commit  52fa070
info  🔄 Run: #16974 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  42.8 seconds
commit  52fa070
info  🔄 Run: #16974 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  39.1 seconds
commit  683fc69
info  🔄 Run: #16980 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  1 passed

Details

stats  1 test across 1 suite
duration  39.4 seconds
commit  683fc69
info  🔄 Run: #16980 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  32.4 seconds
commit  683fc69
info  🔄 Run: #16980 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  3 passed

Details

stats  3 tests across 3 suites
duration  41.4 seconds
commit  683fc69
info  🔄 Run: #16980 (attempt 1)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 25, 2026

Visual Regression

19 screenshots compared. See report for details.
View full report

@codecov
Copy link
Copy Markdown

codecov Bot commented May 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.51%. Comparing base (7d3b541) to head (683fc69).

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #7591    +/-   ##
========================================
  Coverage   98.51%   98.51%            
========================================
  Files        1436     1439     +3     
  Lines       54363    54646   +283     
========================================
+ Hits        53553    53836   +283     
  Misses        810      810            

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels May 25, 2026
@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels May 25, 2026
@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels May 25, 2026
Comment thread api/experimentation/serializers.py Outdated
)
view = self.context.get("view")
if view:
environment: Environment = view._get_environment()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can pass environment explicitly via get_serializer_context in the view: context["environment"] = self.get_environment_from_request(), then read self.context["environment"] in the serializer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 683fc69

Comment thread api/experimentation/serializers.py Outdated

def validate_status(self, status: str) -> str:
if self.instance is None:
if status != ExperimentStatus.CREATED:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep this field private and add actions on the view to act on experiment, e.g: starting, pausing and ending(mostly because we will need to add more logic to those endpoints)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the action in 683fc69, good call

@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels May 25, 2026
@Zaimwa9 Zaimwa9 requested a review from gagantrivedi May 25, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experimentation Form - Models and CRUD

2 participants