-
Notifications
You must be signed in to change notification settings - Fork 70
Expand file tree
/
Copy pathmodels.py
More file actions
3084 lines (2560 loc) · 101 KB
/
models.py
File metadata and controls
3084 lines (2560 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Core models for the ad server."""
import datetime
import html
import logging
import math
import re
from collections import Counter
import bleach
import djstripe.models as djstripe_models
import pytz
import stripe
import uuid_utils.compat as uuid
from django.conf import settings
from django.core.cache import cache
from django.core.cache import caches
from django.core.files.images import get_image_dimensions
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.db import IntegrityError
from django.db import models
from django.db import transaction
from django.db.models.constraints import UniqueConstraint
from django.template import engines
from django.template.loader import get_template
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.html import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from django_extensions.db.models import TimeStampedModel
from djstripe.enums import InvoiceStatus
from djstripe.models import Invoice
from simple_history.models import HistoricalRecords
from user_agents import parse
from .constants import CAMPAIGN_TYPES
from .constants import CLICKS
from .constants import DECISIONS
from .constants import FLIGHT_AUTO_RENEW_PAYMENT_OPTIONS
from .constants import FLIGHT_STATE_CURRENT
from .constants import FLIGHT_STATE_PAST
from .constants import FLIGHT_STATE_UPCOMING
from .constants import IMPRESSION_TYPES
from .constants import OFFERS
from .constants import PAID
from .constants import PAID_CAMPAIGN
from .constants import PAYOUT_GITHUB
from .constants import PAYOUT_OPENCOLLECTIVE
from .constants import PAYOUT_PAYPAL
from .constants import PAYOUT_STATUS
from .constants import PAYOUT_STRIPE
from .constants import PENDING
from .constants import PUBLISHER_PAYOUT_METHODS
from .constants import VIEWS
from .utils import COUNTRY_DICT
from .utils import anonymize_ip_address
from .utils import cached_method
from .utils import calculate_ctr
from .utils import generate_absolute_url
from .utils import get_ad_day
from .utils import get_client_country
from .utils import get_client_id
from .utils import get_client_ip
from .utils import get_client_user_agent
from .utils import get_domain_from_url
from .utils import is_proxy_ip
from .validators import TargetingParametersValidator
from .validators import TopicPricingValidator
from .validators import TrafficFillValidator
log = logging.getLogger(__name__) # noqa
def default_flight_end_date():
return datetime.date.today() + datetime.timedelta(days=30)
class IndestructibleQuerySet(models.QuerySet):
"""A queryset object without the delete option."""
def delete(self):
"""Always raises ``IntegrityError``."""
raise IntegrityError
class IndestructibleManager(models.Manager):
"""A model manager that generates ``IndestructibleQuerySets``."""
def get_queryset(self):
return IndestructibleQuerySet(self.model, using=self._db)
class IndestructibleModel(models.Model):
"""A model that disallows the delete method or deleting at the queryset level."""
objects = IndestructibleManager()
def delete(self, using=None, keep_parents=False):
"""Raises `IntegrityError` unless the model also has a `can_be_deleted()` method and it returns True."""
if hasattr(self, "can_be_deleted") and self.can_be_deleted():
return super().delete(using=using, keep_parents=keep_parents)
raise IntegrityError
class Meta:
abstract = True
class Topic(TimeStampedModel, models.Model):
"""Topics able to be targeted by the ad server."""
# Topics are cached for 30m
# We don't want to repeatedly hit the database for data that rarely changes
CACHE_KEY = "keyword-topic-mapping"
CACHE_TIMEOUT = 60 * 30
name = models.CharField(max_length=255)
slug = models.SlugField(_("Slug"), max_length=200, unique=True)
selectable = models.BooleanField(
default=False,
help_text=_("Whether advertisers can select this region for new flights"),
)
def __str__(self):
"""String representation."""
return self.name
@classmethod
def load_from_cache(cls):
"""Load keyword and topic mappings from the cache or database."""
topics = caches[settings.CACHE_LOCAL_ALIAS].get(cls.CACHE_KEY)
if not topics:
topics = cls._load_db()
return topics
@classmethod
def _load_db(cls):
"""Load keyword and topic mappings from database and cache it."""
# topic -> list of keyword slugs
topics = {}
for topic in Topic.objects.all().prefetch_related():
topics[topic.slug] = [kw.slug for kw in topic.keywords.all()]
caches[settings.CACHE_LOCAL_ALIAS].set(
cls.CACHE_KEY, value=topics, timeout=cls.CACHE_TIMEOUT
)
return topics
class Keyword(TimeStampedModel, models.Model):
"""A keyword for a topic on the ad server."""
slug = models.SlugField(_("Slug"), max_length=200, unique=True)
topics = models.ManyToManyField(
Topic,
blank=True,
related_name="keywords",
)
def __str__(self):
"""String representation."""
return self.slug
class Region(TimeStampedModel, models.Model):
"""A region able to be targeted by the ad server."""
# Regions are cached for 30m
# We don't want to repeatedly hit the database for data that rarely changes
CACHE_KEY = "country-region-mapping"
CACHE_TIMEOUT = 60 * 30
NON_OVERLAPPING_REGIONS = (
"us-ca",
"western-europe",
"eastern-europe",
"aus-nz",
"wider-apac",
"latin-america",
"africa",
"south-asia",
"exclude",
"global", # This does overlap but is only used when none of the others apply
)
name = models.CharField(max_length=255)
slug = models.SlugField(_("Slug"), max_length=200, unique=True)
selectable = models.BooleanField(
default=False,
help_text=_("Whether advertisers can select this region for new flights"),
)
prices = models.JSONField(
_("Topic prices"),
blank=True,
null=True,
validators=[TopicPricingValidator()],
help_text=_("Topic pricing matrix for this region"),
)
# Lower order takes precedence
# When mapping country to a single region, the lowest order region is returned
order = models.PositiveSmallIntegerField(
_("Order"),
default=0,
help_text=_(
"When mapping country to a single region, the lowest order region is returned."
),
)
def __str__(self):
"""String representation."""
return self.name
@classmethod
def load_from_cache(cls):
"""Load country to region mappings from the cache or database."""
regions = caches[settings.CACHE_LOCAL_ALIAS].get(cls.CACHE_KEY)
if not regions:
regions = cls._load_db()
return regions
@classmethod
def _load_db(cls):
"""Load country to region mapping from the DB and cache it."""
# Maps region (in order) -> list of countries
regions = {}
for region in (
Region.objects.all().order_by("order").prefetch_related("countryregion_set")
):
countries = [cr.country.code for cr in region.countryregion_set.all()]
regions[region.slug] = countries
caches[settings.CACHE_LOCAL_ALIAS].set(
cls.CACHE_KEY, value=regions, timeout=cls.CACHE_TIMEOUT
)
return regions
@classmethod
def get_region_from_country_code(cls, country):
"""Get the *nonoverlapping* region slug from the country code. Returns "other" if no others apply."""
regions = cls.load_from_cache()
for slug in regions:
if slug not in cls.NON_OVERLAPPING_REGIONS:
continue
if country in regions[slug]:
return slug
return "global"
@staticmethod
def get_pricing():
pricing = {}
for region in Region.objects.filter(selectable=True):
if region.prices:
pricing[region.slug] = region.prices
return pricing
class CountryRegion(TimeStampedModel, models.Model):
"""Countries in targeted regions."""
country = CountryField()
region = models.ForeignKey(
Region,
on_delete=models.CASCADE,
)
def __str__(self):
"""String representation."""
return f"{self.country.name}"
class Publisher(TimeStampedModel, IndestructibleModel):
"""
A publisher that displays advertising from the ad server.
A publisher represents a site or collection of sites that displays advertising.
Advertisers can opt-in to displaying ads on different publishers.
An example of a publisher would be Read the Docs, our first publisher.
"""
name = models.CharField(_("Name"), max_length=200)
slug = models.SlugField(_("Publisher Slug"), max_length=200, unique=True)
revenue_share_percentage = models.FloatField(
default=70.0,
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text=_("Percentage of advertising revenue shared with this publisher"),
)
default_keywords = models.CharField(
_("Default keywords"),
max_length=250,
help_text=_("A CSV of default keywords for this property. Used for targeting."),
default="",
blank=True,
)
allow_api_keywords = models.BooleanField(
default=True,
help_text=_(
"Whether to allow the ad API/client to send its own keywords for targeting."
),
)
# If this is blank, all domains are allowed
allowed_domains = models.CharField(
_("Allowed domains"),
max_length=1024,
help_text=_(
"A space separated list of domains where the publisher's ads can appear"
),
default="",
blank=True,
)
unauthed_ad_decisions = models.BooleanField(
default=True,
help_text=_(
"Whether this publisher allows unauthenticated ad decision API requests (eg. JSONP)"
),
)
disabled = models.BooleanField(
default=False,
help_text=_("Completely disable this publisher"),
)
# Use this for publishers with unoptimized/unsupported mobile placements
# This essentially means the network is "not buying" the publisher's mobile traffic
ignore_mobile_traffic = models.BooleanField(
default=False,
help_text=_("If true, no ads will be served to this publisher's mobile users"),
)
saas = models.BooleanField(
default=False,
help_text=_(
"This published is configured as a SaaS customer. They will be billed by usage instead of paid out."
),
)
# Default to False so that we can use this as an "approved" flag for publishers
allow_paid_campaigns = models.BooleanField(_("Allow paid campaigns"), default=False)
allow_affiliate_campaigns = models.BooleanField(
_("Allow affiliate campaigns"), default=False
)
allow_community_campaigns = models.BooleanField(
_("Allow community campaigns"),
default=True,
help_text="These are unpaid campaigns that support non-profit projects in our community. Shown only when no paid ads are available",
)
allow_house_campaigns = models.BooleanField(
_("Allow house campaigns"),
default=True,
help_text="These are ads for EthicalAds itself. Shown only when no paid ads are available.",
)
daily_cap = models.DecimalField(
_("Daily maximum earn cap"),
max_digits=8,
decimal_places=2,
default=None,
blank=True,
null=True,
help_text=_(
"A daily maximum this publisher can earn after which only unpaid ads are shown."
),
)
allow_multiple_placements = models.BooleanField(
default=False,
help_text=_("Can this publisher have multiple placements on the same pageview"),
)
# Payout information
skip_payouts = models.BooleanField(
_("Skip payouts"),
default=False,
help_text=_(
"Enable this to temporarily disable payouts. They will be processed again once you uncheck this."
),
)
payout_method = models.CharField(
max_length=100,
choices=PUBLISHER_PAYOUT_METHODS,
blank=True,
null=True,
default=None,
help_text=_("How this publisher wants to get paid"),
)
send_bid_rate = models.BooleanField(
default=False,
help_text=_("Return the bid CPM/CPC with the decision API"),
)
djstripe_account = models.ForeignKey(
djstripe_models.Account,
verbose_name=_("Stripe connected account"),
on_delete=models.SET_NULL,
blank=True,
null=True,
default=None,
)
# Deprecated - migrate to `stripe_account`
stripe_connected_account_id = models.CharField(
_("Stripe connected account ID"),
max_length=200,
blank=True,
null=True,
default=None,
)
open_collective_name = models.CharField(
_("Open Collective name"), max_length=200, blank=True, null=True, default=None
)
paypal_email = models.EmailField(
_("PayPal email address"), blank=True, null=True, default=None
)
github_sponsors_name = models.CharField(
_("GitHub sponsors name"),
max_length=200,
blank=True,
null=True,
default=None,
)
# Record additional details to the offer for this publisher
# This can be used for publishers where their traffic needs more scrutiny
record_offer_details = models.BooleanField(
default=False,
help_text=_("Record additional offer details for this publisher"),
)
# This overrides settings.ADSERVER_RECORD_VIEWS for a specific publisher
# Details of each ad view are written to the database.
record_views = models.BooleanField(
default=False,
help_text=_("Record each ad view from this publisher to the database"),
)
record_placements = models.BooleanField(
default=False, help_text=_("Record placement impressions for this publisher")
)
# This defaults to False, so publishers have to ask for it.
render_pixel = models.BooleanField(
default=False,
help_text=_(
"Render ethical-pixel in ad templates. This is needed for users not using the ad client."
),
)
cache_ads = models.BooleanField(
default=True,
help_text=_(
"Cache this publishers ad requests. Disable for special cases (eg. SaaS users)"
),
)
cache_ads_duration = models.PositiveIntegerField(
default=0,
help_text=_(
"If cache_ads is True and duration > 0, "
"use a custom duration instead of settings.ADSERVER_STICKY_DECISION_DURATION."
),
)
# Denormalized fields
sampled_ctr = models.FloatField(
default=0.0,
help_text=_(
"A periodically calculated CTR from a sample of ads on this publisher."
),
)
history = HistoricalRecords()
class Meta:
ordering = ("name",)
permissions = [
("staff_publisher_fields", "Can view staff publisher fields in reports"),
]
def __str__(self):
"""Simple override."""
return self.name
def get_absolute_url(self):
return reverse("publisher_report", kwargs={"publisher_slug": self.slug})
@property
def keywords(self):
"""
Parses database keywords and ensures consistency.
- Lowercases all tags
- Converts underscores to hyphens
- Slugifies tags
- Removes empty tags
Similar logic to RTD ``readthedocs.projects.tag_utils.rtd_parse_tags``.
"""
if self.default_keywords:
return_keywords = []
keyword_list = self.default_keywords.split(",")
for keyword in keyword_list:
keyword = keyword.lower().replace("_", "-")
keyword = slugify(keyword)
if keyword:
return_keywords.append(keyword)
return return_keywords
return []
@property
def daily_earn_cache_key(self):
today = get_ad_day().date()
return f"daily-earn::{self.slug}::{today:%Y-%m-%d}"
def allowed_domains_as_list(self):
return self.allowed_domains.split()
def total_payout_sum(self):
"""The total amount ever paid out to this publisher."""
total = self.payouts.filter(status=PAID).aggregate(
total=models.Sum("amount", output_field=models.DecimalField())
)["total"]
if total:
return total
return 0
def payout_url(self):
if self.payout_method == PAYOUT_STRIPE and self.djstripe_account.id:
return f"https://dashboard.stripe.com/connect/accounts/{self.djstripe_account.id}"
if self.payout_method == PAYOUT_OPENCOLLECTIVE and self.open_collective_name:
return f"https://opencollective.com/{self.open_collective_name}"
if self.payout_method == PAYOUT_PAYPAL and self.paypal_email:
return "https://www.paypal.com/myaccount/transfer/homepage/pay"
if self.payout_method == PAYOUT_GITHUB and self.github_sponsors_name:
return f"https://github.com/sponsors/{self.github_sponsors_name}"
return ""
def get_daily_earn(self):
"""Get how much this publisher has earned today."""
cache_key = self.daily_earn_cache_key
return cache.get(cache_key, default=0.0)
def increment_daily_earn(self, delta):
"""Increment how much this publisher has earned today."""
cache_key = self.daily_earn_cache_key
try:
cache.incr(cache_key, delta=delta)
except ValueError:
# Cache key doesn't exist for today
cache.set(cache_key, value=delta, timeout=60 * 60 * 24)
class PublisherGroup(TimeStampedModel):
"""Group of publishers that can be targeted by advertiser's campaigns."""
name = models.CharField(
_("Name"), max_length=200, help_text=_("Visible to advertisers")
)
slug = models.SlugField(_("Publisher group slug"), max_length=200, unique=True)
publishers = models.ManyToManyField(
Publisher,
related_name="publisher_groups",
blank=True,
help_text=_("A group of publishers that can be targeted by advertisers"),
)
default_enabled = models.BooleanField(
default=False,
help_text=_(
"Whether this publisher group is enabled on new campaigns by default"
),
)
history = HistoricalRecords()
class Meta:
ordering = ("name",)
def __str__(self):
"""Simple override."""
return self.name
class Advertiser(TimeStampedModel, IndestructibleModel):
"""An advertiser who buys advertising from the ad server."""
name = models.CharField(_("Name"), max_length=200)
slug = models.SlugField(_("Advertiser Slug"), max_length=200, unique=True)
advertiser_logo = models.ImageField(
_("Advertiser Logo"),
upload_to="advertiser_logos/%Y/%m/",
blank=True,
null=True,
help_text=_(
"The logo for the advertiser. Returned in ad responses. Recommended size 200x200."
),
)
# Publisher specific advertiser account
publisher = models.OneToOneField(
Publisher,
null=True,
blank=True,
default=None,
on_delete=models.PROTECT,
help_text=_("Used for advertiser accounts associated with a publisher"),
)
djstripe_customer = models.ForeignKey(
djstripe_models.Customer,
verbose_name=_("Stripe Customer"),
on_delete=models.SET_NULL,
blank=True,
null=True,
default=None,
)
# Deprecated - will migration to `customer`
stripe_customer_id = models.CharField(
_("Stripe Customer ID"), max_length=200, blank=True, null=True, default=None
)
history = HistoricalRecords()
class Meta:
ordering = ("name",)
permissions = [
("staff_advertiser_fields", "Can view staff advertiser fields in reports"),
]
def __str__(self):
"""Simple override."""
return self.name
def get_absolute_url(self):
return reverse("advertiser_main", kwargs={"advertiser_slug": self.slug})
class Campaign(TimeStampedModel, IndestructibleModel):
"""
A collection of advertisements (:py:class:`~Advertisement`) from the same advertiser.
A campaign is typically made up of one or more :py:class:`~Flight` which are themselves
groups of advertisements including details common among the ads.
Campaigns have a campaign type which distinguishes paid, house and community ads.
Since campaigns contain important historical data around tracking how we bill
and report to customers, they cannot be deleted once created.
"""
name = models.CharField(_("Name"), max_length=200)
slug = models.SlugField(_("Campaign Slug"), max_length=200, unique=True)
advertiser = models.ForeignKey(
Advertiser,
related_name="campaigns",
on_delete=models.PROTECT,
help_text=_("The advertiser for this campaign."),
)
publisher_groups = models.ManyToManyField(
PublisherGroup,
blank=True,
help_text=_(
"Ads for this campaign are eligible for display on publishers in any of these groups"
),
)
campaign_type = models.CharField(
_("Campaign Type"),
max_length=20,
choices=CAMPAIGN_TYPES,
default=PAID_CAMPAIGN,
help_text=_(
"Most campaigns are paid but ad server admins can configure other lower priority campaign types."
),
)
exclude_publishers = models.ManyToManyField(
Publisher,
blank=True,
help_text=_("Ads for this campaign will not be shown on these publishers"),
)
# Deprecated and no longer used. Will be removed in future releases
publishers = models.ManyToManyField(
Publisher,
related_name="campaigns",
blank=True,
help_text=_(
"Ads for this campaign are eligible for display on these publishers"
),
)
history = HistoricalRecords()
class Meta:
ordering = ("name",)
def __str__(self):
"""Simple override."""
return self.name
def ad_count(self):
return Advertisement.objects.filter(flight__campaign=self).count()
def allowed_ad_types(self, exclude_deprecated=False):
"""Get the valid ad types for this campaign."""
queryset = AdType.objects.filter(
models.Q(publisher_groups=None)
| models.Q( # Global ad types across all publishers
publisher_groups__id__in=self.publisher_groups.all().values("pk")
)
)
if exclude_deprecated:
queryset = queryset.exclude(deprecated=True)
return queryset.distinct()
def total_value(self):
"""Calculate total cost/revenue for all ads/flights in this campaign."""
# Check for a cached value that would come from an annotated queryset
if hasattr(self, "campaign_total_value"):
return self.campaign_total_value or 0.0
aggregation = Flight.objects.filter(campaign=self).aggregate(
total_value=models.Sum(
(models.F("total_clicks") * models.F("cpc"))
+ (models.F("total_views") * models.F("cpm") / 1000.0),
output_field=models.FloatField(),
)
)["total_value"]
return aggregation or 0.0
def publisher_group_display(self):
"""Helper function to display publisher groups if the selected set isn't the default."""
default_pub_groups = [
pg.name
for pg in PublisherGroup.objects.filter(default_enabled=True).order_by(
"name"
)
]
campaign_pub_groups = [pg.name for pg in self.publisher_groups.order_by("name")]
if default_pub_groups != campaign_pub_groups:
return campaign_pub_groups
return None
class Flight(TimeStampedModel, IndestructibleModel):
"""
A flight is a collection of :py:class:`~Advertisement` objects.
Effectively a flight is a single "ad buy". So if an advertiser wants to
buy $2000 worth of ads at $2 CPC and run 5 variations, they would have 5
:py:class:`~Advertisement` objects in a single :py:class:`~Flight`.
Flights are associated with a :py:class:`~Campaign` and so they have a
single advertiser.
At this level, we control:
* Sold clicks (maximum clicks across all ads in this flight)
* CPC/CPM which could be 0
* Targeting parameters (programming language, geo, etc)
* Start and end date (the end date is a soft target)
* Whether the flight is live or not
Since flights contain important historical data around tracking how we bill
and report to customers, they cannot be deleted once created.
"""
HIGHEST_PRIORITY_MULTIPLIER = 1000000
LOWEST_PRIORITY_MULTIPLIER = 1
# The pacing interval in seconds used to pace flights evenly.
# For example, with a 1000 click flight over 10 days,
# there will be 10 intervals (each 1 day) and we'll aim for 100 clicks per day
# Shorter intervals (eg. an hour) spread flights more evenly across geographies
DEFAULT_PACING_INTERVAL = 60 * 60 # 1 hour
name = models.CharField(_("Name"), max_length=200)
slug = models.SlugField(_("Flight Slug"), max_length=200, unique=True)
flight_logo = models.ImageField(
_("Flight Logo"),
upload_to="flight_logos/%Y/%m/",
blank=True,
null=True,
help_text=_(
"Overrides the advertiser logo for this specific flight. Recommended size 200x200."
),
)
start_date = models.DateField(
_("Start Date"),
default=datetime.date.today,
db_index=True,
help_text=_("This flight will not be shown before this date"),
)
end_date = models.DateField(
_("End Date"),
default=default_flight_end_date,
help_text=_("The estimated end date for the flight"),
)
hard_stop = models.BooleanField(
_("Hard stop"),
default=False,
help_text=_(
"The flight will be stopped on the end date even if not completely fulfilled"
),
)
auto_renew = models.BooleanField(
_("Automatically renew when complete"),
default=False,
)
auto_renew_payment_method = models.CharField(
_("Auto renewal payment method"),
max_length=100,
choices=FLIGHT_AUTO_RENEW_PAYMENT_OPTIONS,
default=None,
blank=True,
null=True,
)
live = models.BooleanField(_("Live"), default=False)
priority_multiplier = models.IntegerField(
_("Priority Multiplier"),
default=LOWEST_PRIORITY_MULTIPLIER,
validators=[
MinValueValidator(LOWEST_PRIORITY_MULTIPLIER),
MaxValueValidator(HIGHEST_PRIORITY_MULTIPLIER),
],
help_text="Multiplies chance of showing this flight's ads [{},{}]".format(
LOWEST_PRIORITY_MULTIPLIER, HIGHEST_PRIORITY_MULTIPLIER
),
)
# CPC
cpc = models.DecimalField(
_("Cost Per Click"), max_digits=5, decimal_places=2, default=0
)
sold_clicks = models.PositiveIntegerField(_("Sold Clicks"), default=0)
# CPM
cpm = models.DecimalField(
_("Cost Per 1k Impressions"), max_digits=5, decimal_places=2, default=0
)
sold_impressions = models.PositiveIntegerField(_("Sold Impressions"), default=0)
campaign = models.ForeignKey(
Campaign, related_name="flights", on_delete=models.PROTECT
)
targeting_parameters = models.JSONField(
_("Targeting parameters"),
blank=True,
null=True,
validators=[TargetingParametersValidator()],
)
pacing_interval = models.PositiveIntegerField(
default=DEFAULT_PACING_INTERVAL,
)
prioritize_ads_ctr = models.BooleanField(
_("Prioritize ads by CTR"),
default=True,
help_text=_(
"If true, the ad server will automatically show ads with higher CTRs "
"at a higher rate than ads with lower CTRs."
),
)
daily_cap = models.DecimalField(
_("Daily maximum spend cap"),
max_digits=8,
decimal_places=2,
default=None,
blank=True,
null=True,
help_text=_("A daily maximum this flight can spend."),
)
# Denormalized fields
total_views = models.PositiveIntegerField(
default=0, help_text=_("Views across all ads in this flight")
)
total_clicks = models.PositiveIntegerField(
default=0, help_text=_("Clicks across all ads in this flight")
)
# We store nightly the top 20 publishers/countries/regions for each flight
# and the percentage they have filled this flight
# eg.
# {
# "publishers": {"publisher1": 0.1, "publisher2": 0.05},
# "countries": {"US": 0.1, "CA": 0.05, "DE": 0.05},
# "regions": {"us-ca": 0.25, "eu": 0.5},
# }
traffic_fill = models.JSONField(
_("Traffic fill"),
blank=True,
null=True,
default=None,
validators=[TrafficFillValidator()],
)
# If set, any publisher, country, or region whose `traffic_fill` exceeds the cap
# will not be eligible to show on this campaign until they're below the cap.
# Format is the same as `traffic_fill` but this is set manually
traffic_cap = models.JSONField(
_("Traffic cap"),
blank=True,
null=True,
default=None,
validators=[TrafficFillValidator()],
)
# Connect to Stripe invoice data
# There can be multiple invoices for a flight
# (say a 3 month flight billed monthly)
# and an invoice can cover multiple flights
invoices = models.ManyToManyField(
djstripe_models.Invoice,
verbose_name=_("Stripe invoices"),
blank=True,
)
discount = models.ForeignKey(
djstripe_models.Coupon,
on_delete=models.SET_NULL,
blank=True,
null=True,
default=None,
)
history = HistoricalRecords()
class Meta:
ordering = ("name",)
def __str__(self):
"""Simple override."""
return self.name
@property
def included_countries(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("include_countries", [])
@property
def included_state_provinces(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("include_state_provinces", [])
@property
def included_metro_codes(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("include_metro_codes", [])
@property
def excluded_countries(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("exclude_countries", [])
@property
def included_regions(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("include_regions", [])
@property
def excluded_regions(self):
if not self.targeting_parameters:
return []
return self.targeting_parameters.get("exclude_regions", [])
@property
def included_topics(self):
if not self.targeting_parameters: