Skip to content

Commit 97e1b87

Browse files
committed
Merge branch 'main' into rel
2 parents 4e27448 + 217218b commit 97e1b87

File tree

11 files changed

+237
-8
lines changed

11 files changed

+237
-8
lines changed

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ CHANGELOG
66
.. This is included by docs/developer/changelog.rst
77
88
9+
Version v5.29.0
10+
---------------
11+
12+
This release makes pgpool optional for Postgres connection pooling.
13+
We saw some instability with pgpool with Celery (but not on the webs)
14+
and also didn't really see any performance benefits from it.
15+
We also added a change to allow deleting ads that have never been shown.
16+
17+
:Date: October 23, 2025
18+
19+
* @davidfischer: Make pgpool optional (#1086)
20+
* @davidfischer: Allow ad deletion (restrictions apply) (#1084)
21+
22+
923
Version v5.28.0
1024
---------------
1125

adserver/models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ class IndestructibleModel(models.Model):
103103
objects = IndestructibleManager()
104104

105105
def delete(self, using=None, keep_parents=False):
106-
"""Always raises ``IntegrityError``."""
106+
"""Raises `IntegrityError` unless the model also has a `can_be_deleted()` method and it returns True."""
107+
if hasattr(self, "can_be_deleted") and self.can_be_deleted():
108+
return super().delete(using=using, keep_parents=keep_parents)
107109
raise IntegrityError
108110

109111
class Meta:
@@ -1854,6 +1856,11 @@ def __str__(self):
18541856
"""Simple override."""
18551857
return self.name
18561858

1859+
def can_be_deleted(self):
1860+
"""Check if this advertisement can be deleted."""
1861+
# An advertisement can be deleted if it has never been offered
1862+
return not AdImpression.objects.filter(advertisement=self).exists()
1863+
18571864
def get_absolute_url(self):
18581865
return reverse(
18591866
"advertisement_detail",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends "adserver/advertiser/overview.html" %}
2+
{% load i18n %}
3+
{% load static %}
4+
{% load humanize %}
5+
6+
7+
{% block title %}{% trans 'Remove advertisement' %}{% endblock %}
8+
9+
10+
{% block breadcrumbs %}
11+
{{ block.super }}
12+
<li class="breadcrumb-item"><a href="{% url 'flight_list' advertiser.slug %}">{% trans 'Flights' %}</a></li>
13+
<li class="breadcrumb-item"><a href="{% url 'flight_detail' advertiser.slug flight.slug %}">{{ flight.name }}</a></li>
14+
<li class="breadcrumb-item"><a href="{% url 'advertisement_detail' advertiser.slug flight.slug advertisement.slug %}">{{ advertisement.name }}</a></li>
15+
<li class="breadcrumb-item active">{% trans 'Remove advertisement' %}</li>
16+
{% endblock breadcrumbs %}
17+
18+
19+
{% block content_container %}
20+
21+
<section>
22+
23+
<h1>{% block heading %}{% blocktrans with ad_name=advertisement.name %}Remove {{ ad_name }}?{% endblocktrans %}{% endblock heading %}</h1>
24+
<p>{% blocktrans %}Only ads that have never been shown can be deleted.{% endblocktrans %}</p>
25+
26+
<div class="row">
27+
28+
<div class="col-md">
29+
30+
{% for ad_type in advertisement.ad_types.all %}
31+
{% include "adserver/includes/ad-preview.html" %}
32+
{% endfor %}
33+
34+
<form method="post">
35+
{% csrf_token %}
36+
<input type="submit" class="btn btn-danger" value="{% trans "Remove advertisement" %}">
37+
</form>
38+
</div>
39+
40+
</div>
41+
42+
</section>
43+
44+
{% endblock content_container %}

adserver/templates/adserver/advertiser/advertisement-detail.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ <h1>{% block heading %}{% trans 'Advertisement: ' %}{{ advertisement.name }}{% e
6767
{% if has_advertiser_edit_permission %}
6868
<div class="col">
6969
<a href="{% url 'advertisement_update' advertiser.slug advertisement.flight.slug advertisement.slug %}" class="btn btn-outline-primary" role="button" aria-pressed="true">{% trans 'Edit advertisement' %}</a>
70+
{% if advertisement.can_be_deleted %}
71+
<a href="{% url 'advertisement_remove' advertiser.slug advertisement.flight.slug advertisement.slug %}" class="btn btn-outline-danger" role="button" aria-pressed="true" title="{% trans 'Only advertisements that have never been shown can be removed. You will have an opportunity to confirm.' %}">{% trans 'Remove advertisement' %}</a>
72+
{% endif %}
7073
</div>
7174
{% endif %}
7275
</div>

adserver/tests/test_advertiser_dashboard.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,87 @@ def test_ad_create_view(self):
897897
Advertisement.objects.filter(flight=self.flight, name="New Name").exists()
898898
)
899899

900+
def test_ad_delete_view(self):
901+
url = reverse(
902+
"advertisement_remove",
903+
kwargs={
904+
"advertiser_slug": self.advertiser.slug,
905+
"flight_slug": self.flight.slug,
906+
"advertisement_slug": self.ad1.slug,
907+
},
908+
)
909+
910+
# Anonymous - no access
911+
response = self.client.get(url)
912+
self.assertEqual(response.status_code, 302)
913+
self.assertTrue(response["location"].startswith("/accounts/login/"))
914+
915+
self.client.force_login(self.user)
916+
917+
# Make it a reporter who can't access
918+
member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
919+
member.role = UserAdvertiserMember.ROLE_REPORTER
920+
member.save()
921+
922+
response = self.client.get(url)
923+
self.assertEqual(response.status_code, 403)
924+
925+
member.role = UserAdvertiserMember.ROLE_MANAGER
926+
member.save()
927+
928+
response = self.client.get(url)
929+
self.assertEqual(response.status_code, 200)
930+
self.assertContains(response, "Remove advertisement")
931+
932+
# Can remove ad1 - no impressions
933+
response = self.client.post(url, data={}, follow=True)
934+
self.assertEqual(response.status_code, 200)
935+
self.assertContains(response, "Successfully removed advertisement")
936+
937+
self.assertFalse(
938+
Advertisement.objects.filter(pk=self.ad1.pk).exists()
939+
)
940+
941+
def test_ad_delete_view_fail(self):
942+
url = reverse(
943+
"advertisement_remove",
944+
kwargs={
945+
"advertiser_slug": self.advertiser.slug,
946+
"flight_slug": self.flight.slug,
947+
"advertisement_slug": self.ad1.slug,
948+
},
949+
)
950+
951+
self.client.force_login(self.user)
952+
member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
953+
member.role = UserAdvertiserMember.ROLE_MANAGER
954+
member.save()
955+
956+
response = self.client.get(url)
957+
self.assertEqual(response.status_code, 200)
958+
self.assertContains(response, "Remove advertisement")
959+
960+
# Add an offer to ad1 so it can't be removed
961+
request = self.factory.get("/")
962+
self.ad1.offer_ad(
963+
request=request,
964+
publisher=self.publisher,
965+
ad_type_slug=self.ad_type1,
966+
div_id="foo",
967+
keywords=None,
968+
)
969+
970+
response = self.client.get(url)
971+
self.assertEqual(response.status_code, 404)
972+
973+
# Can't remove ad1 with a POST either
974+
response = self.client.post(url, data={})
975+
self.assertEqual(response.status_code, 404)
976+
977+
self.assertTrue(
978+
Advertisement.objects.filter(pk=self.ad1.pk).exists()
979+
)
980+
900981
def test_ad_bulk_create_view(self):
901982
url = reverse(
902983
"advertisement_bulk_create",

adserver/tests/test_models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ class TestProtectedModels(BaseAdModelsTestCase):
2828
"""Test that models extending IndestructibleModel can't be deleted"""
2929

3030
def test_delete_model(self):
31-
self.assertRaises(IntegrityError, self.ad1.delete)
31+
self.ad1.delete() # Shouldn't raise because ads without impressions can be deleted
3232
self.assertRaises(IntegrityError, self.campaign.delete)
3333
self.assertRaises(IntegrityError, self.flight.delete)
3434

35+
request = self.factory.get("/")
36+
self.ad2.offer_ad(
37+
request=request,
38+
publisher=self.publisher,
39+
ad_type_slug=self.text_ad_type,
40+
div_id="foo",
41+
keywords=None,
42+
)
43+
44+
# Now that ad2 has an offer, it can't be deleted
45+
self.assertRaises(IntegrityError, self.ad2.delete)
46+
3547
def test_queryset(self):
3648
self.assertRaises(IntegrityError, Advertisement.objects.all().delete)
3749
self.assertRaises(IntegrityError, Flight.objects.all().delete)

adserver/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .views import AdvertisementCopyView
1212
from .views import AdvertisementCreateView
1313
from .views import AdvertisementDetailView
14+
from .views import AdvertisementRemoveView
1415
from .views import AdvertisementUpdateView
1516
from .views import AdvertiserAuthorizedUsersInviteView
1617
from .views import AdvertiserAuthorizedUsersRemoveView
@@ -270,6 +271,11 @@
270271
AdvertisementUpdateView.as_view(),
271272
name="advertisement_update",
272273
),
274+
path(
275+
r"advertiser/<slug:advertiser_slug>/flights/<slug:flight_slug>/advertisements/<slug:advertisement_slug>/remove/",
276+
AdvertisementRemoveView.as_view(),
277+
name="advertisement_remove",
278+
),
273279
path(
274280
r"advertiser/<slug:advertiser_slug>/users/",
275281
AdvertiserAuthorizedUsersView.as_view(),

adserver/views.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,63 @@ def get_success_url(self):
778778
)
779779

780780

781+
class AdvertisementRemoveView(
782+
AdvertiserManagerAccessMixin, UserPassesTestMixin, DeleteView
783+
):
784+
"""View to delete an ad that has *never* been used."""
785+
786+
model = Advertisement
787+
template_name = "adserver/advertiser/advertisement-delete.html"
788+
789+
def dispatch(self, request, *args, **kwargs):
790+
self.advertiser = get_object_or_404(
791+
Advertiser, slug=self.kwargs["advertiser_slug"]
792+
)
793+
self.flight = get_object_or_404(
794+
Flight,
795+
slug=self.kwargs["flight_slug"],
796+
campaign__advertiser=self.advertiser,
797+
)
798+
self.advertisement = get_object_or_404(
799+
Advertisement,
800+
flight=self.flight,
801+
slug=self.kwargs["advertisement_slug"],
802+
)
803+
return super().dispatch(request, *args, **kwargs)
804+
805+
def form_valid(self, form):
806+
result = super().form_valid(form)
807+
messages.info(self.request, _("Successfully removed advertisement"))
808+
return result
809+
810+
def get_success_url(self):
811+
return reverse(
812+
"flight_detail",
813+
kwargs={
814+
"advertiser_slug": self.advertiser.slug,
815+
"flight_slug": self.flight.slug,
816+
},
817+
)
818+
819+
def get_object(self, queryset=None):
820+
# can_be_deleted checks if there are any impressions or clicks for this ad
821+
if not self.advertisement or not self.advertisement.can_be_deleted():
822+
log.warning("Ad isn't eligible for deletion: %s", self.advertisement)
823+
raise Http404
824+
return self.advertisement
825+
826+
def get_context_data(self, **kwargs):
827+
context = super().get_context_data(**kwargs)
828+
context.update(
829+
{
830+
"advertiser": self.advertiser,
831+
"flight": self.flight,
832+
"advertisement": self.advertisement,
833+
}
834+
)
835+
return context
836+
837+
781838
class AdvertisementBulkCreateView(
782839
AdvertiserManagerAccessMixin,
783840
UserPassesTestMixin,

config/settings/production.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,18 @@
4040
# --------------------------------------------------------------------------
4141
DATABASES["default"] = env.db() # Raises ImproperlyConfigured if DATABASE_URL not set
4242

43+
# Enable database connection pooling
44+
# https://docs.djangoproject.com/en/dev/ref/databases/#connection-pool
45+
USE_DB_POOLING = env.bool("USE_DB_POOLING", default=False)
4346
for db in ("default", "replica"):
4447
if db in DATABASES:
45-
# Enable database connection pooling
46-
# https://docs.djangoproject.com/en/dev/ref/databases/#connection-pool
4748
if "OPTIONS" not in DATABASES[db]:
4849
DATABASES[db]["OPTIONS"] = {}
49-
if DATABASES[db]["ENGINE"] == "django.db.backends.postgresql":
50+
51+
if (
52+
DATABASES[db]["ENGINE"] == "django.db.backends.postgresql"
53+
and USE_DB_POOLING
54+
):
5055
DATABASES[db]["OPTIONS"]["pool"] = True
5156
else:
5257
# CONN_MAX_AGE should be 0 when using connection pooling

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)