Skip to content

Commit ffe2fcf

Browse files
committed
feat(nimbus): create update leading branch image forms
1 parent 7038988 commit ffe2fcf

File tree

7 files changed

+263
-8
lines changed

7 files changed

+263
-8
lines changed

experimenter/experimenter/nimbus_ui/forms.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ def get_changelog_message(self) -> str:
5050

5151
def save(self, *args, **kwargs):
5252
experiment = super().save(*args, **kwargs)
53+
54+
if type(experiment) is NimbusBranchScreenshot:
55+
experiment = self.instance.branch.experiment
56+
5357
generate_nimbus_changelog(
5458
experiment, self.request.user, self.get_changelog_message()
5559
)
@@ -1771,3 +1775,24 @@ def save(self, commit=True):
17711775

17721776
def get_changelog_message(self):
17731777
return f"{self.request.user} updated collaborators"
1778+
1779+
1780+
class BranchLeadingScreenshotForm(NimbusChangeLogFormMixin, forms.ModelForm):
1781+
image = forms.ImageField(
1782+
required=False,
1783+
widget=forms.FileInput(attrs={"class": "form-control"}),
1784+
)
1785+
description = forms.CharField(
1786+
required=False,
1787+
widget=forms.TextInput(attrs={"class": "form-control"}),
1788+
)
1789+
1790+
class Meta:
1791+
model = NimbusBranchScreenshot
1792+
fields = ["image", "description"]
1793+
1794+
def get_changelog_message(self):
1795+
return (
1796+
f"{self.request.user} updated leading screenshot for "
1797+
f"{self.instance.branch.slug} branch"
1798+
)

experimenter/experimenter/nimbus_ui/templates/common/branch_card.html

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
{% load nimbus_extras %}
22

3-
<div class="d-flex gap-4">
3+
<div class="d-flex gap-3">
44
{% with shot=branch.screenshots.first %}
55
{% if shot and shot.image %}
6-
<img src="{{ shot.image.url }}"
7-
alt="{{ shot.description|default:branch.name }}"
8-
class="border border-secondary-subtle rounded w-50"
9-
style="height: 120px;
10-
object-fit: cover" />
6+
<div class="position-relative border border-secondary-subtle rounded w-50 h-100">
7+
<img src="{{ shot.image.url }}"
8+
alt="{{ shot.description|default:branch.name }}"
9+
class="rounded w-100"
10+
style="height: 120px;
11+
object-fit: cover" />
12+
<button type="button"
13+
data-bs-toggle="modal"
14+
data-bs-target="#{{ experiment_slug }}-{{ branch.slug }}-image-edit"
15+
class="position-absolute border-0 opacity-75 bg-secondary-subtle top-0 end-0 p-2 m-1 rounded lh-1">
16+
<i class="fa-solid fa-up-right-and-down-left-from-center text-muted"></i>
17+
</button>
18+
</div>
1119
{% else %}
12-
<div class="border border-secondary-subtle rounded text-center d-flex align-items-center justify-content-center w-50"
20+
<div class="position-relative border border-secondary-subtle rounded text-center d-flex align-items-center justify-content-center w-50"
1321
style="height: 120px">
1422
<small class="text-muted">No screenshot</small>
23+
<button type="button"
24+
data-bs-toggle="modal"
25+
data-bs-target="#{{ experiment_slug }}-{{ branch.slug }}-image-edit"
26+
class="position-absolute border-0 opacity-75 bg-secondary-subtle top-0 end-0 p-2 m-1 rounded lh-1">
27+
<i class="fa-solid fa-up-right-and-down-left-from-center text-muted"></i>
28+
</button>
1529
</div>
1630
{% endif %}
1731
{% endwith %}
@@ -28,3 +42,63 @@ <h6 class="mt-2">{{ branch.name }}</h6>
2842
<p class="text-muted">{{ branch.description|truncatechars:100 }}</p>
2943
</div>
3044
</div>
45+
<div class="modal fade"
46+
id="{{ experiment_slug }}-{{ branch.slug }}-image-edit"
47+
tabindex="-1"
48+
aria-labelledby="{{ experiment_slug }}-{{ branch.slug }}-image-edit"
49+
aria-hidden="true">
50+
<div class="modal-dialog modal-dialog-centered modal-lg">
51+
<div class="modal-content p-4">
52+
<div class="modal-header border-0">
53+
<button type="button"
54+
class="btn-close"
55+
data-bs-dismiss="modal"
56+
aria-label="Close"></button>
57+
</div>
58+
<div class="modal-body">
59+
<form method="post"
60+
id="branch-screenshot-form-{{ experiment_slug }}-{{ branch.slug }}"
61+
hx-encoding="multipart/form-data"
62+
hx-post="{% url "nimbus-ui-branch-leading-screenshot-upload" slug=experiment_slug branch_slug=branch.slug %}"
63+
class="accordion">
64+
{% csrf_token %}
65+
{% if screenshot_form.instance.pk and screenshot_form.instance.image %}
66+
<div class="position-relative border border-secondary-subtle rounded h-100 mb-3">
67+
<img src="{{ screenshot_form.instance.image.url }}"
68+
alt="{{ screenshot_form.instance.description }}"
69+
class="w-100 rounded">
70+
</div>
71+
{% else %}
72+
<div class="border border-secondary-subtle rounded mb-3">
73+
<small class="text-muted d-block p-5 text-center">No screenshot uploaded</small>
74+
</div>
75+
{% endif %}
76+
{{ screenshot_form.image }}
77+
{% if screenshot_form.instance.image %}
78+
<div class="accordion-item border border-1 rounded mt-4">
79+
<button class="accordion-button shadow-none bg-transparent text-body"
80+
type="button"
81+
data-bs-toggle="collapse"
82+
data-bs-target="#screenshot-form-{{ experiment_slug }}-{{ branch.slug }}"
83+
aria-expanded="true"
84+
aria-controls="screenshot-form-{{ experiment_slug }}-{{ branch.slug }}">
85+
<div class="d-flex flex-column align-items-start">
86+
<h6 class="mb-1">Add image description</h6>
87+
<small class="text-muted">Add a short description so people who can't see the image know what it shows.</small>
88+
</div>
89+
</button>
90+
<div id="screenshot-form-{{ experiment_slug }}-{{ branch.slug }}"
91+
class="accordion-collapse collapse show">
92+
<div class="accordion-body pt-0">{{ screenshot_form.description }}</div>
93+
</div>
94+
</div>
95+
{% endif %}
96+
<div class="d-flex justify-content-end gap-2 mt-4">
97+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
98+
<button type="submit" class="btn btn-primary">Save changes</button>
99+
</div>
100+
</form>
101+
</div>
102+
</div>
103+
</div>
104+
</div>

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/results-new-inner.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,12 @@ <h5>Hypothesis</h5>
8989
<div class="row row-cols-2 row-cols-xxl-3 g-4">
9090
{% for branch in branch_data %}
9191
<div class="col">
92-
{% include "common/branch_card.html" with branch=branch %}
92+
{% for branch_slug, branch_leading_screenshot_form in branch_leading_screenshot_forms.items %}
93+
{% if branch_slug == branch.slug %}
94+
{% include "common/branch_card.html" with branch=branch experiment_slug=experiment.slug screenshot_form=branch_leading_screenshot_form %}
9395

96+
{% endif %}
97+
{% endfor %}
9498
</div>
9599
{% endfor %}
96100
</div>

experimenter/experimenter/nimbus_ui/tests/test_forms.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
ApproveEndExperimentForm,
4242
ApproveUpdateRolloutForm,
4343
AudienceForm,
44+
BranchLeadingScreenshotForm,
4445
BranchScreenshotCreateForm,
4546
BranchScreenshotDeleteForm,
4647
CancelEndEnrollmentForm,
@@ -4411,3 +4412,49 @@ def test_feature_unsubscribe_form_removes_subscriber(self):
44114412
self.assertTrue(form.is_valid())
44124413
form.save()
44134414
self.assertNotIn(self.request.user, feature_config.subscribers.all())
4415+
4416+
4417+
class ResultsEditBranchLeadingImageTests(RequestFormTestCase):
4418+
def test_edit_leading_image(self):
4419+
application = NimbusExperiment.Application.DESKTOP
4420+
experiment = NimbusExperimentFactory.create_with_lifecycle(
4421+
NimbusExperimentFactory.Lifecycles.CREATED,
4422+
application=application,
4423+
)
4424+
experiment.branches.all().delete()
4425+
experiment.changes.all().delete()
4426+
4427+
reference_branch = NimbusBranchFactory.create(experiment=experiment, ratio=1)
4428+
experiment.reference_branch = reference_branch
4429+
experiment.save()
4430+
4431+
reference_screenshot = reference_branch.screenshots.first()
4432+
4433+
image_bytes = io.BytesIO()
4434+
image = Image.new("RGB", (10, 10), color="red")
4435+
image.save(image_bytes, format="PNG")
4436+
image_bytes.seek(0)
4437+
dummy_image = SimpleUploadedFile(
4438+
"test.png", image_bytes.read(), content_type="image/png"
4439+
)
4440+
4441+
form = BranchLeadingScreenshotForm(
4442+
instance=reference_screenshot,
4443+
data={
4444+
"description": "Updated control screenshot",
4445+
},
4446+
files={
4447+
"image": dummy_image,
4448+
},
4449+
request=self.request,
4450+
)
4451+
4452+
form.save()
4453+
experiment.refresh_from_db()
4454+
4455+
self.assertEqual(
4456+
experiment.reference_branch.screenshots.get(
4457+
id=reference_screenshot.id
4458+
).description,
4459+
"Updated control screenshot",
4460+
)

experimenter/experimenter/nimbus_ui/tests/test_views.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import datetime
2+
import io
23
import json
34
from unittest.mock import patch
45

56
from django.conf import settings
7+
from django.core.files.uploadedfile import SimpleUploadedFile
68
from django.test import TestCase
79
from django.urls import reverse
810
from parameterized import parameterized
11+
from PIL import Image
912

1013
from experimenter.base.tests.factories import (
1114
CountryFactory,
@@ -3028,6 +3031,62 @@ def test_get_redirects_to_next_step(self, current_url, next_url, data):
30283031
)
30293032

30303033

3034+
@mock_valid_outcomes
3035+
class TestResultsEditBranchImagesView(AuthTestCase):
3036+
def test_upload_updates_screenshot(self):
3037+
experiment = NimbusExperimentFactory.create(owner=self.user)
3038+
branch = NimbusBranchFactory.create(experiment=experiment, slug="t1")
3039+
3040+
url = reverse(
3041+
"nimbus-ui-branch-leading-screenshot-upload",
3042+
kwargs={"slug": experiment.slug, "branch_slug": branch.slug},
3043+
)
3044+
3045+
image_bytes = io.BytesIO()
3046+
image = Image.new("RGB", (10, 10), color="red")
3047+
image.save(image_bytes, format="PNG")
3048+
image_bytes.seek(0)
3049+
dummy_image = SimpleUploadedFile(
3050+
"test.png", image_bytes.read(), content_type="image/png"
3051+
)
3052+
3053+
response = self.client.post(url, {"image": dummy_image})
3054+
3055+
self.assertEqual(response.status_code, 200)
3056+
3057+
branch.refresh_from_db()
3058+
shot = branch.screenshots.first()
3059+
self.assertIsNotNone(shot)
3060+
3061+
def test_upload_creates_screenshot(self):
3062+
experiment = NimbusExperimentFactory.create(owner=self.user)
3063+
experiment.branches.all().delete()
3064+
3065+
branch = NimbusBranchFactory.create(experiment=experiment, slug="t1")
3066+
branch.screenshots.all().delete()
3067+
3068+
url = reverse(
3069+
"nimbus-ui-branch-leading-screenshot-upload",
3070+
kwargs={"slug": experiment.slug, "branch_slug": branch.slug},
3071+
)
3072+
3073+
image_bytes = io.BytesIO()
3074+
image = Image.new("RGB", (10, 10), color="red")
3075+
image.save(image_bytes, format="PNG")
3076+
image_bytes.seek(0)
3077+
dummy_image = SimpleUploadedFile(
3078+
"test.png", image_bytes.read(), content_type="image/png"
3079+
)
3080+
3081+
response = self.client.post(url, {"image": dummy_image})
3082+
3083+
self.assertEqual(response.status_code, 200)
3084+
3085+
branch.refresh_from_db()
3086+
shot = branch.screenshots.first()
3087+
self.assertIsNotNone(shot)
3088+
3089+
30313090
@mock_valid_outcomes
30323091
class TestResultsView(AuthTestCase):
30333092
def test_render_to_response(self):

experimenter/experimenter/nimbus_ui/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
BranchDeleteView,
1111
BranchesPartialUpdateView,
1212
BranchesUpdateView,
13+
BranchLeadingScreenshotView,
1314
BranchScreenshotCreateView,
1415
BranchScreenshotDeleteView,
1516
CancelEndEnrollmentView,
@@ -298,4 +299,9 @@
298299
BranchScreenshotDeleteView.as_view(),
299300
name="nimbus-ui-delete-branch-screenshot",
300301
),
302+
re_path(
303+
r"^(?P<slug>[\w-]+)/branches/(?P<branch_slug>[\w-]+)/leading-screenshot/",
304+
BranchLeadingScreenshotView.as_view(),
305+
name="nimbus-ui-branch-leading-screenshot-upload",
306+
),
301307
]

experimenter/experimenter/nimbus_ui/views.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
ApproveEndExperimentForm,
3737
ApproveUpdateRolloutForm,
3838
AudienceForm,
39+
BranchLeadingScreenshotForm,
3940
BranchScreenshotCreateForm,
4041
BranchScreenshotDeleteForm,
4142
CancelEndEnrollmentForm,
@@ -671,6 +672,38 @@ class ApproveUpdateRolloutView(StatusUpdateView):
671672
form_class = ApproveUpdateRolloutForm
672673

673674

675+
class BranchLeadingScreenshotView(
676+
NimbusExperimentViewMixin,
677+
RequestFormMixin,
678+
UpdateView,
679+
):
680+
form_class = BranchLeadingScreenshotForm
681+
682+
def get_branch(self):
683+
return (
684+
self.get_object().branches.filter(slug=self.kwargs.get("branch_slug")).first()
685+
)
686+
687+
def get_form_kwargs(self):
688+
kwargs = super().get_form_kwargs()
689+
690+
if branch := self.get_branch():
691+
if screenshot := branch.screenshots.first():
692+
kwargs["instance"] = screenshot
693+
else:
694+
# create an unsaved placeholder instance of the correct model
695+
screenshot_model = branch.screenshots.model
696+
kwargs["instance"] = screenshot_model(branch=branch)
697+
698+
return kwargs
699+
700+
def form_valid(self, form):
701+
form.save()
702+
response = HttpResponse()
703+
response.headers["HX-Refresh"] = "true"
704+
return response
705+
706+
674707
class NewResultsView(NimbusExperimentViewMixin, DetailView):
675708
template_name = "nimbus_experiments/results-new.html"
676709

@@ -710,6 +743,13 @@ def get_context_data(self, **kwargs):
710743
analysis_basis, selected_segment, selected_reference_branch
711744
)
712745

746+
branch_leading_screenshot_forms = {
747+
branch.slug: BranchLeadingScreenshotForm(instance=branch.screenshots.first())
748+
for branch in experiment.branches.all()
749+
}
750+
751+
context["branch_leading_screenshot_forms"] = branch_leading_screenshot_forms
752+
713753
relative_metric_changes = {}
714754

715755
all_metrics = experiment.get_metric_data(

0 commit comments

Comments
 (0)