Skip to content

Commit 4e27448

Browse files
committed
Merge branch 'main' into rel
2 parents 7ebc8f5 + 31e9e05 commit 4e27448

File tree

17 files changed

+237
-52
lines changed

17 files changed

+237
-52
lines changed

CHANGELOG.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ CHANGELOG
66
.. This is included by docs/developer/changelog.rst
77
88
9+
Version v5.28.0
10+
---------------
11+
12+
This release contains an improvement to the copy ads screen that includes filtering and sorting
13+
as well as other options. It also turns on a few Postgres and Redis performance improvements
14+
notably using connection pooling on Postgres.
15+
16+
:Date: October 22, 2025
17+
18+
* @davidfischer: Improve the copy ads screen (#1083)
19+
* @davidfischer: Fix a bug with niche URL handling (#1082)
20+
* @davidfischer: Use Hiredis client for performance (#1081)
21+
* @davidfischer: Upgrade psycopg and use connection pools (#1080)
22+
* @davidfischer: Handle if clients send x-forwarded-for (#1079)
23+
24+
925
Version v5.27.0
1026
---------------
1127

adserver/analyzer/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def normalize_url(url):
3030
Currently, this means:
3131
- Removing ignored query paramters
3232
"""
33+
url = url.strip()
3334
parts = urlparse.urlparse(url)
3435

3536
query_params = urlparse.parse_qs(parts.query, keep_blank_values=True)

adserver/api/mixins.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ def initial(self, request, *args, **kwargs):
3131

3232
# Get the actual client IP address and UA (the user who will view the ad)
3333
if self.ip_field in request.data and request.data[self.ip_field]:
34-
request.ip_address = request.data[self.ip_field]
34+
ip = request.data[self.ip_field]
35+
if ip and "," in ip:
36+
# If there are multiple IPs, take the first one
37+
# The client has probably sent the X-Forwarded-For header
38+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For
39+
ip = ip.split(",")[0].strip()
40+
41+
request.ip_address = ip
3542

3643
# Geolocate the actual IP address (not the requestor's IP)
3744
# This is needed for the case of a server requesting ads on a user's behalf

adserver/api/serializers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from django.core.exceptions import ValidationError
66
from django.core.validators import URLValidator
7+
from django.core.validators import validate_ipv46_address
78
from django.utils.translation import gettext_lazy as _
89
from rest_framework import serializers
910

@@ -73,7 +74,9 @@ class AdDecisionSerializer(serializers.Serializer):
7374
)
7475

7576
# Used to pass the actual ad viewer's data for targeting purposes
76-
user_ip = serializers.IPAddressField(required=False)
77+
# The IP field purposefully isn't an IPAddressField so we can handle some invalid values
78+
# instead of rejecting them outright
79+
user_ip = serializers.CharField(required=False)
7780
user_ua = serializers.CharField(required=False)
7881

7982
# Used to specify a specific ad or campaign to show (used for debugging mostly)
@@ -124,6 +127,22 @@ def validate_url(self, url):
124127
log.warning("Invalid ad decision referring URL: %s", url)
125128
return None
126129

130+
def validate_user_ip(self, ip):
131+
if ip and "," in ip:
132+
# If there are multiple IPs, take the first one
133+
# The client has probably sent the X-Forwarded-For header
134+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For
135+
ip = ip.split(",")[0].strip()
136+
137+
# Perform basic validation of the IP address (after splitting if necessary)
138+
try:
139+
validate_ipv46_address(ip)
140+
except ValidationError:
141+
log.warning("Invalid ad decision user IP address: %s", ip)
142+
raise serializers.ValidationError("Invalid IPv4 or IPv6 address")
143+
144+
return ip
145+
127146

128147
class PublisherSerializer(serializers.HyperlinkedModelSerializer):
129148
class Meta:

adserver/forms.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ def clean_niche_urls(self):
346346
raise forms.ValidationError(
347347
_("'%(url)s' is an invalid URL."), params={"url": url}
348348
)
349-
return data
349+
return "\n".join(niche_urls)
350350

351351
def save(self, commit=True):
352352
if not self.instance.targeting_parameters:
@@ -1371,11 +1371,20 @@ def get_ads(self):
13711371
class AdvertisementCopyForm(forms.Form):
13721372
"""Used by advertisers to re-use their ads."""
13731373

1374+
MAXIMUM_ADS = 100
1375+
DEFAULT_ORDER = "-modified"
1376+
13741377
advertisements = AdvertisementMultipleChoiceField(
13751378
queryset=Advertisement.objects.none(),
13761379
required=False,
13771380
help_text=_("Copy the following advertisements"),
13781381
)
1382+
live_after_copy = forms.BooleanField(
1383+
required=False,
1384+
initial=False,
1385+
label=_("Make copied ads live"),
1386+
help_text=_("If checked, the copied ads will be live immediately"),
1387+
)
13791388

13801389
def __init__(self, *args, **kwargs):
13811390
"""Add the form helper and customize the look of the form."""
@@ -1384,14 +1393,24 @@ def __init__(self, *args, **kwargs):
13841393
else:
13851394
raise RuntimeError("'flight' is required for the ad form")
13861395

1396+
if "order" in kwargs:
1397+
order = kwargs.pop("order")
1398+
else:
1399+
order = self.DEFAULT_ORDER
1400+
13871401
super().__init__(*args, **kwargs)
13881402

1403+
# Use the passed order to sort the ads on the form
13891404
self.fields["advertisements"].queryset = (
13901405
Advertisement.objects.filter(flight__campaign=self.flight.campaign)
1391-
.order_by("-flight__start_date", "slug")
1406+
.order_by(order, "slug")
13921407
.select_related()
13931408
.prefetch_related("ad_types")
13941409
)
1410+
# Limit the choices so the form isn't *too* long
1411+
self.fields["advertisements"].choices = self.fields["advertisements"].choices[
1412+
: self.MAXIMUM_ADS
1413+
]
13951414

13961415
self.helper = FormHelper()
13971416
self.helper.attrs = {"id": "advertisements-copy"}
@@ -1403,6 +1422,10 @@ def __init__(self, *args, **kwargs):
14031422
"advertisements",
14041423
template="adserver/includes/widgets/advertisement-form-option.html",
14051424
),
1425+
Fieldset(
1426+
_("Options"),
1427+
Field("live_after_copy"),
1428+
),
14061429
css_class="my-3",
14071430
),
14081431
Submit("submit", _("Copy existing ads")),
@@ -1413,6 +1436,8 @@ def save(self):
14131436
for ad in self.cleaned_data["advertisements"]:
14141437
new_ad = ad.__copy__()
14151438
new_ad.flight = self.flight
1439+
if self.cleaned_data["live_after_copy"]:
1440+
new_ad.live = True
14161441
new_ad.save()
14171442

14181443
return len(self.cleaned_data["advertisements"])

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ <h1>{% block heading %}{% trans 'Copy advertisements' %}{% endblock heading %}</
2929
<div class="row">
3030

3131
<div class="col-md-8">
32+
33+
<form method="get">
34+
<div class="form-row">
35+
<div class="col-4">
36+
<select class="form-control form-control-sm" name="order">
37+
{% for ordering, display in ordering_options %}
38+
<option value="{{ ordering }}" {% if ordering == order %}selected{% endif %}>{{ display }}</option>
39+
{% endfor %}
40+
</select>
41+
</div>
42+
43+
<div class="col-2">
44+
<button class="btn btn-sm btn-outline-secondary" type="submit">{% trans 'Filter and order' %}</button>
45+
</div>
46+
</div> <!-- /form-row -->
47+
</form>
48+
3249
{% crispy form form.helper %}
3350
</div>
3451

adserver/tests/test_advertiser_dashboard.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ def test_flight_update_view(self):
408408
"include_keywords": "python, django",
409409
"niche_distance": 0.5,
410410
"niche_urls": "\n".join([
411-
"https://example.com/niche1/",
411+
# Make sure comments are stripped properly
412+
"https://example.com/niche1/ ",
412413
"https://example.com/niche2/",
413414
]),
414415
}
@@ -983,11 +984,22 @@ def test_ad_copy_view(self):
983984
self.assertContains(response, "Re-use your previous ads")
984985
self.assertContains(response, self.ad1.name)
985986

986-
# Perform the copy
987+
# Perform the copy - new ad isn't live by default
987988
count_ads = Advertisement.objects.all().count()
988-
response = self.client.post(url, data={"advertisements": [self.ad1.pk]})
989-
self.assertEqual(response.status_code, 302)
989+
live_ads = Advertisement.objects.filter(live=True).count()
990+
response = self.client.post(url, data={"advertisements": [self.ad1.pk]}, follow=True)
991+
self.assertEqual(response.status_code, 200)
992+
self.assertContains(response, "Successfully copied 1 ads to flight")
993+
# Ads is +1 but live ads is the same
990994
self.assertEqual(Advertisement.objects.all().count(), count_ads + 1)
995+
self.assertEqual(Advertisement.objects.filter(live=True).count(), live_ads)
996+
997+
# Copy again - new ad is live
998+
live_ads = Advertisement.objects.filter(live=True).count()
999+
response = self.client.post(url, data={"advertisements": [self.ad1.pk], "live_after_copy": True}, follow=True)
1000+
self.assertEqual(response.status_code, 200)
1001+
self.assertContains(response, "Successfully copied 1 ads to flight")
1002+
self.assertEqual(Advertisement.objects.filter(live=True).count(), live_ads + 1)
9911003

9921004
def test_deprecated_ad_type(self):
9931005
url = reverse(

adserver/tests/test_api.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,6 +1304,44 @@ def test_user_geoip_passed_ip(self):
13041304
self.assertEqual(resp["X-Adserver-Country"], "US")
13051305
self.assertEqual(resp["X-Adserver-Region"], "NY")
13061306

1307+
def test_multiple_passed_ip(self):
1308+
first_ip = "255.255.255.255"
1309+
new_ip = f"{first_ip},1.1.1.1,8.8.8.8"
1310+
data = {
1311+
"placements": self.placements,
1312+
"publisher": self.publisher1.slug,
1313+
"url": self.page_url,
1314+
"user_ip": new_ip,
1315+
"user_ua": self.user_agent,
1316+
}
1317+
with mock.patch("adserver.utils.get_geoipdb_geolocation") as get_geo:
1318+
get_geo.return_value = GeolocationData("US", "NY")
1319+
1320+
resp = self.client.post(
1321+
self.url, json.dumps(data), content_type="application/json"
1322+
)
1323+
self.assertEqual(resp.status_code, 200, resp.content)
1324+
1325+
# Check that the first IP was used
1326+
self.assertEqual(resp["X-Adserver-RealIP"], first_ip)
1327+
1328+
def test_invalid_ip(self):
1329+
ip = "255.255.256.255"
1330+
data = {
1331+
"placements": self.placements,
1332+
"publisher": self.publisher1.slug,
1333+
"url": self.page_url,
1334+
"user_ip": ip,
1335+
"user_ua": self.user_agent,
1336+
}
1337+
with mock.patch("adserver.utils.get_geoipdb_geolocation") as get_geo:
1338+
get_geo.return_value = GeolocationData(None, None)
1339+
1340+
resp = self.client.post(
1341+
self.url, json.dumps(data), content_type="application/json"
1342+
)
1343+
self.assertEqual(resp.status_code, 400)
1344+
13071345
@override_settings(ADSERVER_RECORD_VIEWS=False)
13081346
def test_record_views_false(self):
13091347
self.publisher1.record_views = False

adserver/views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,13 @@ class AdvertisementCopyView(
929929
):
930930
"""Create a copy of an existing ad."""
931931

932+
ORDERING_OPTIONS = (
933+
("-modified", "Most recent"),
934+
("-flight__start_date", "By flight start date"),
935+
("-sampled_ctr", "Top performing"),
936+
("name", "By name"),
937+
)
938+
932939
form_class = AdvertisementCopyForm
933940
model = Advertisement
934941
template_name = "adserver/advertiser/advertisement-copy.html"
@@ -963,15 +970,24 @@ def get_context_data(self, **kwargs):
963970
{
964971
"advertiser": self.advertiser,
965972
"flight": self.flight,
973+
"order": self.get_order(),
974+
"ordering_options": self.ORDERING_OPTIONS,
966975
}
967976
)
968977
return context
969978

970979
def get_form_kwargs(self):
971980
kwargs = super().get_form_kwargs()
972981
kwargs["flight"] = self.flight
982+
kwargs["order"] = self.get_order()
973983
return kwargs
974984

985+
def get_order(self):
986+
order = self.request.GET.get("order")
987+
if not order or order not in [o[0] for o in self.ORDERING_OPTIONS]:
988+
order = self.ORDERING_OPTIONS[0][0]
989+
return order
990+
975991
def get_success_url(self):
976992
return reverse(
977993
"flight_detail",

config/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@
142142
default=f"sqlite:///{DB_PATH_SQLITE}",
143143
)
144144
}
145+
# https://docs.djangoproject.com/en/dev/topics/db/transactions/#tying-transactions-to-http-requests
145146
DATABASES["default"]["ATOMIC_REQUESTS"] = True
146147
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
147148

0 commit comments

Comments
 (0)