Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions experimenter/experimenter/experiments/api/v5/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
NimbusExperiment,
NimbusFeatureConfig,
NimbusFeatureVersion,
NimbusRolloutPhase,
NimbusVersionedSchema,
)
from experimenter.features.manifests.nimbus_fml_loader import NimbusFmlLoader
Expand Down Expand Up @@ -1994,6 +1995,39 @@ def validate(self, data):
return data


class NimbusRolloutReviewSerializer(NimbusReviewSerializer):
population_percent = serializers.DecimalField(
7,
4,
min_value=0.0,
max_value=100.0,
required=True,
error_messages={"min_value": NimbusConstants.ERROR_POPULATION_PERCENT_MIN},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

min_value=0.0 is what we want for rollouts, but you're still passing the old "must be >= 0.0001" message — it can never fire

total_enrolled_clients = serializers.IntegerField(required=False, min_value=0)
rollout_phases = serializers.PrimaryKeyRelatedField(
many=True,
required=False,
queryset=NimbusRolloutPhase.objects.all(),
)

def validate_reference_branch(self, value):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we add a comment here why we are simply returning the value for the future purpose

return value

def validate_rollout_phases(self, value):
if self.instance and not self.instance.is_rollout:
return value

phases = list(value)
if not phases:
raise serializers.ValidationError(NimbusConstants.ERROR_ROLLOUT_NO_PHASES)
if not phases[0].population_percent:
raise serializers.ValidationError(
NimbusConstants.ERROR_ROLLOUT_FIRST_PHASE_ZERO
)
return value


class LocalizationError(Exception):
"""An error that occurs during localization substitution."""

Expand Down
4 changes: 4 additions & 0 deletions experimenter/experimenter/experiments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,10 @@ class AnalysisWindow(models.TextChoices):
"You must select a feature configuration from the drop down."
)
ERROR_POPULATION_PERCENT_MIN = "Ensure this value is greater than or equal to 0.0001."
ERROR_ROLLOUT_NO_PHASES = "Add at least one rollout phase to the schedule."
ERROR_ROLLOUT_FIRST_PHASE_ZERO = (
"The first rollout phase must have a population percent greater than 0."
)
ERROR_FIREFOX_VERSION_MIN = (
"Ensure this value is less than or equal to the maximum version"
)
Expand Down
9 changes: 6 additions & 3 deletions experimenter/experimenter/experiments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2422,11 +2422,14 @@ def has_results_errors(self):
return True
return False

def get_invalid_fields_errors(self):
def get_invalid_fields_errors(self, serializer_class=None):
from experimenter.experiments.api.v5.serializers import NimbusReviewSerializer

serializer_data = NimbusReviewSerializer(self).data
serializer = NimbusReviewSerializer(self, data=serializer_data)
if serializer_class is None:
serializer_class = NimbusReviewSerializer

serializer_data = serializer_class(self).data
serializer = serializer_class(self, data=serializer_data)

errors = {}
if not serializer.is_valid():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
from experimenter.base.tests.factories import (
LocaleFactory,
)
from experimenter.experiments.api.v5.serializers import NimbusReviewSerializer
from experimenter.experiments.api.v5.serializers import (
NimbusReviewSerializer,
NimbusRolloutReviewSerializer,
)
from experimenter.experiments.constants import (
ApplicationConfig,
FirefoxLabs,
Expand All @@ -31,6 +34,7 @@
NimbusExperimentFactory,
NimbusFeatureConfigFactory,
NimbusFmlErrorDataClass,
NimbusRolloutPhaseFactory,
NimbusVersionedSchemaFactory,
)
from experimenter.openidc.tests.factories import UserFactory
Expand Down Expand Up @@ -92,6 +96,15 @@ def get_review_serializer(self, experiment):
context={"user": self.user},
)

def get_rollout_review_serializer(self, experiment):
return NimbusRolloutReviewSerializer(
experiment,
data=NimbusRolloutReviewSerializer(
experiment, context={"user": self.user}
).data,
context={"user": self.user},
)


class TestNimbusReviewSerializerSingleFeature(
GetReviewSerializerMixin, MockFmlErrorMixin, TestCase
Expand All @@ -104,6 +117,26 @@ def setUp(self):
self.user = UserFactory()
self.setup_fml_no_errors()

def create_rollout(self, **overrides):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
population_percent=0.0,
application=NimbusExperiment.Application.DESKTOP,
feature_configs=[
NimbusFeatureConfigFactory(
application=NimbusExperiment.Application.DESKTOP
)
],
is_rollout=True,
is_sticky=True,
firefox_min_version=NimbusExperiment.MIN_REQUIRED_VERSION,
**overrides,
)
for branch in experiment.treatment_branches:
branch.delete()
experiment.save()
return experiment

def test_valid_experiment_with_single_feature(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
Expand Down Expand Up @@ -503,6 +536,49 @@ def test_valid_experiment_minimum_population_percent(self):
)
self.assertTrue(serializer.is_valid(), serializer.errors)

def test_rollout_serializer_allows_relaxed_fields(self):
experiment = self.create_rollout(total_enrolled_clients=0)
experiment.reference_branch.description = ""
experiment.reference_branch.save()
NimbusRolloutPhaseFactory.create(experiment=experiment, population_percent=10)
serializer = self.get_rollout_review_serializer(experiment)
self.assertTrue(serializer.is_valid(), serializer.errors)

def test_base_serializer_rejects_relaxed_rollout_fields(self):
experiment = self.create_rollout(total_enrolled_clients=0)
experiment.reference_branch.description = ""
experiment.reference_branch.save()
serializer = self.get_review_serializer(experiment)
self.assertFalse(serializer.is_valid())
self.assertIn("population_percent", serializer.errors)
self.assertIn("total_enrolled_clients", serializer.errors)
self.assertIn("reference_branch", serializer.errors)

def test_rollout_serializer_requires_phases(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
is_rollout=True,
public_description="",
)
serializer = self.get_rollout_review_serializer(experiment)
self.assertFalse(serializer.is_valid())
self.assertGreater(len(serializer.errors), 1)
self.assertEqual(
serializer.errors["rollout_phases"],
[NimbusConstants.ERROR_ROLLOUT_NO_PHASES],
)

def test_rollout_serializer_requires_nonzero_first_phase(self):
experiment = self.create_rollout()
NimbusRolloutPhaseFactory.create(experiment=experiment, population_percent=0)
NimbusRolloutPhaseFactory.create(experiment=experiment, population_percent=50)
serializer = self.get_rollout_review_serializer(experiment)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["rollout_phases"],
[NimbusConstants.ERROR_ROLLOUT_FIRST_PHASE_ZERO],
)

def test_invalid_experiment_treatment_branch_requires_description(self):
experiment = NimbusExperimentFactory.create_with_lifecycle(
NimbusExperimentFactory.Lifecycles.CREATED,
Expand Down
81 changes: 81 additions & 0 deletions experimenter/experimenter/nimbus_ui/new/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.views.generic import DetailView
from django.views.generic.edit import UpdateView

from experimenter.experiments.api.v5.serializers import NimbusRolloutReviewSerializer
from experimenter.experiments.models import NimbusExperiment, Tag
from experimenter.nimbus_ui.filtersets import (
TagSearchFilterSet,
Expand Down Expand Up @@ -94,7 +95,86 @@ def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)


class RolloutSetupProgressMixin:
SETUP_SECTIONS = {
"Overview": {*RolloutOverviewForm.Meta.fields},
"Risks": {*RolloutRisksForm.Meta.fields},
"Features": {*RolloutFeaturesForm.Meta.fields},
"Audience": {*RolloutAudienceForm.Meta.fields},
}

SECTION_CARD_IDS = {
"Overview": "overview",
"Risks": "risks",
"Features": "rollout-features",
"Audience": "audience",
}

NON_FIELD_ISSUE_CARDS = {
"rollout_phases": {
"section": "Rollout schedule",
"card_id": "schedule",
"label": "Rollout Schedule",
},
}
Comment on lines +99 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SETUP_SECTIONS, SECTION_CARD_IDS, NON_FIELD_ISSUE_CARDS
All keyed by section, so easy to fall out of sync. Could we merge into one structure so a new section is added in one place?


def flatten_errors(self, messages):
if isinstance(messages, dict):
return [m for value in messages.values() for m in self.flatten_errors(value)]
if isinstance(messages, (list, tuple)):
return [m for value in messages for m in self.flatten_errors(value)]
return [str(messages)]

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
field_errors = self.object.get_invalid_fields_errors(
serializer_class=NimbusRolloutReviewSerializer
)
error_keys = set(field_errors)

tracked_fields = set()
for fields in self.SETUP_SECTIONS.values():
tracked_fields.update(fields)
tracked_fields.update(self.NON_FIELD_ISSUE_CARDS)
tracked_fields.update(error_keys)

invalid_tracked = error_keys & tracked_fields
context["setup_completion_percent"] = round(
100 * (len(tracked_fields) - len(invalid_tracked)) / len(tracked_fields)
)

Comment on lines +135 to +145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

tracked_fields.update(error_keys) adds every erroring field into the denominator, which causes two issues:

error_keys & tracked_fields then always equals error_keys, so the & never filters anything (could just be len(error_keys)).
The total keeps changing — an unexpected error grows the denominator, so "100%" isn't a stable baseline.
Can we keep the denominator fixed to the known fields and only count broken ones?

tracked_fields = set()
for fields in self.SETUP_SECTIONS.values():
tracked_fields.update(fields)
tracked_fields.update(self.NON_FIELD_ISSUE_CARDS)
# don't add error_keys — keep total fixed

broken = error_keys & tracked_fields
percent = round(100 * (len(tracked_fields) - len(broken)) / len(tracked_fields))

One decision for us to make first: should an unexpected error drag the progress bar down, or just show up in the issues list without affecting the percent? With the fixed denominator it does the latter. Right now test_setup_progress_untracked_issue_lowers_completion expects the former — so let's agree on the intended behavior and make the code + test match. My vote is the bar tracks only known setup fields, and unknown errors just appear as issues.

field_to_section = {
field: section
for section, fields in self.SETUP_SECTIONS.items()
for field in fields
}
issues = []
for field, messages in field_errors.items():
if field in self.NON_FIELD_ISSUE_CARDS:
meta = self.NON_FIELD_ISSUE_CARDS[field]
section = meta["section"]
card_id = meta["card_id"]
label = meta["label"]
else:
section = field_to_section.get(field, "Other")
card_id = self.SECTION_CARD_IDS.get(section)
label = field.replace("_", " ").title()
issues.append(
{
"section": section,
"card_id": card_id,
"label": label,
"messages": self.flatten_errors(messages),
}
)

context["setup_issues"] = issues
context["setup_issues_count"] = len(issues)
return context


class NimbusRolloutDetailView(
RolloutSetupProgressMixin,
NimbusExperimentViewMixin,
CloneExperimentFormMixin,
DetailView,
Expand All @@ -118,6 +198,7 @@ def get_context_data(self, **kwargs):


class NewCardUpdateView(
RolloutSetupProgressMixin,
NimbusExperimentViewMixin,
RequestFormMixin,
UpdateRedirectViewMixin,
Expand Down
24 changes: 24 additions & 0 deletions experimenter/experimenter/nimbus_ui/static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ const cleanupOrphanedModalBackdrops = () => {
}
};

const setupSetupIssueNav = () => {
document.addEventListener("click", function (event) {
const trigger = event.target.closest("[data-setup-issue-target]");
if (!trigger) {
return;
}

const cardId = trigger.getAttribute("data-setup-issue-target");
const card = document.getElementById(`rollout-card-${cardId}`);
if (!card) {
return;
}
const scrollToCard = () =>
card.scrollIntoView({ behavior: "smooth", block: "start" });

if (trigger.closest(".modal")) {
setTimeout(scrollToCard, 300);
} else {
scrollToCard();
}
});
};
setupSetupIssueNav();

const setupHTMXLoadingOverlay = () => {
document.addEventListener("htmx:beforeRequest", function () {
const loadingOverlay = document.querySelector("#htmx-loading-overlay");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% block card %}{% endblock %}
{% include "new/common/sidebar_setup_refresh.html" %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<div id="sidebar-setup-issues-modal"
{% if oob %}hx-swap-oob="true"{% endif %}>
{% if setup_issues_count %}
<div class="modal fade" id="setupIssuesModal" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5 d-flex align-items-center gap-2">Rollout Issues Detected</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body bg-body-tertiary d-flex flex-column gap-2">
{% for issue in setup_issues %}
<div class="rounded-2 p-3 small"
style="background-color: var(--bs-warning-bg-subtle);
border: 1px solid var(--bs-warning-border-subtle)">
<button type="button"
class="rollout-card-toggle btn btn-link small p-0 w-100 d-flex align-items-center gap-2 text-black text-decoration-none collapsed"
data-bs-toggle="collapse"
data-bs-target="#setupIssue{{ forloop.counter }}">
<i class="fa-solid fa-triangle-exclamation"
style="color: var(--bs-orange)"></i>
<span class="flex-grow-1 small text-start fw-semibold">{{ issue.label }}</span>
<i class="fa-solid fa-chevron-right chevron-icon"
style="font-size: 11px"></i>
</button>
<div class="collapse ms-4" id="setupIssue{{ forloop.counter }}">
{% for message in issue.messages %}<div>{{ message }}</div>{% endfor %}
{% if issue.card_id %}
<button type="button"
class="btn btn-sm mt-2"
style="background-color: var(--bs-warning-border-subtle);
border: 1px solid var(--bs-warning-border-subtle)"
data-bs-dismiss="modal"
data-setup-issue-target="{{ issue.card_id }}">
Go to {{ issue.section }}
<i class="fa-solid fa-arrow-right ms-1" style="font-size: 10px;"></i>
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
Loading
Loading