diff --git a/adserver/auth/admin.py b/adserver/auth/admin.py
index 928742e0..e184013c 100644
--- a/adserver/auth/admin.py
+++ b/adserver/auth/admin.py
@@ -6,6 +6,22 @@
from simple_history.admin import SimpleHistoryAdmin
from .models import User
+from .models import UserAdvertiserMember
+from .models import UserPublisherMember
+
+
+class UserAdvertiserInline(admin.TabularInline):
+ """For inlining the user-advertiser relationship."""
+
+ model = UserAdvertiserMember
+ raw_id_fields = ("advertiser",)
+
+
+class UserPublisherInline(admin.TabularInline):
+ """For inlining the user-publisher relationship."""
+
+ model = UserPublisherMember
+ raw_id_fields = ("publisher",)
@admin.register(User)
@@ -19,8 +35,6 @@ class UserAdmin(SimpleHistoryAdmin):
_("Ad server details"),
{
"fields": (
- "advertisers",
- "publishers",
"flight_notifications",
"notify_on_completed_flights", # DEPRECATED
)
@@ -43,6 +57,7 @@ class UserAdmin(SimpleHistoryAdmin):
{"fields": ("last_login", "updated_date", "created_date")},
),
)
+ inlines = (UserAdvertiserInline, UserPublisherInline)
list_display = (
"email",
"name",
diff --git a/adserver/auth/migrations/0009_user_advertiser_publisher_roles.py b/adserver/auth/migrations/0009_user_advertiser_publisher_roles.py
new file mode 100644
index 00000000..5f84afe6
--- /dev/null
+++ b/adserver/auth/migrations/0009_user_advertiser_publisher_roles.py
@@ -0,0 +1,70 @@
+"""
+This migration was autocreated but has been customized.
+
+See: https://docs.djangoproject.com/en/5.0/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
+"""
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('adserver', '0098_rotation_aggregation'),
+ ('adserver_auth', '0008_data_flight_notifications'),
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ state_operations=[
+ migrations.CreateModel(
+ name='UserAdvertiserMember',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ # We add this in a separate operation below
+ # ('role', models.CharField(choices=[('Admin', 'Admin'), ('Manager', 'Manager'), ('Reporter', 'Reporter')], default='Admin', max_length=100)),
+ ('advertiser', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='adserver.advertiser')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'db_table': 'adserver_auth_user_advertisers',
+ },
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='advertisers',
+ field=models.ManyToManyField(blank=True, through='adserver_auth.UserAdvertiserMember', to='adserver.advertiser'),
+ ),
+ migrations.CreateModel(
+ name='UserPublisherMember',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ # We add this in a separate operation below
+ # ('role', models.CharField(choices=[('Admin', 'Admin'), ('Manager', 'Manager'), ('Reporter', 'Reporter')], default='Admin', max_length=100)),
+ ('publisher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='adserver.publisher')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'db_table': 'adserver_auth_user_publishers',
+ },
+ ),
+ migrations.AlterField(
+ model_name='user',
+ name='publishers',
+ field=models.ManyToManyField(blank=True, through='adserver_auth.UserPublisherMember', to='adserver.publisher'),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="useradvertisermember",
+ name="role",
+ field=models.CharField(choices=[('Admin', 'Admin'), ('Manager', 'Manager'), ('Reporter', 'Reporter')], default='Admin', max_length=100),
+ ),
+ migrations.AddField(
+ model_name="userpublishermember",
+ name="role",
+ field=models.CharField(choices=[('Admin', 'Admin'), ('Manager', 'Manager'), ('Reporter', 'Reporter')], default='Admin', max_length=100),
+ ),
+ ]
diff --git a/adserver/auth/models.py b/adserver/auth/models.py
index 9d2d7889..cea8c166 100644
--- a/adserver/auth/models.py
+++ b/adserver/auth/models.py
@@ -79,8 +79,12 @@ class User(AbstractBaseUser, PermissionsMixin):
created_date = models.DateTimeField(_("create date"), auto_now_add=True)
# A user may have access to zero or more advertisers or publishers
- advertisers = models.ManyToManyField(Advertiser, blank=True)
- publishers = models.ManyToManyField(Publisher, blank=True)
+ advertisers = models.ManyToManyField(
+ Advertiser, blank=True, through="UserAdvertiserMember"
+ )
+ publishers = models.ManyToManyField(
+ Publisher, blank=True, through="UserPublisherMember"
+ )
# Notifications
flight_notifications = models.BooleanField(
@@ -146,3 +150,51 @@ def invite_user(self):
[self.email],
)
return True
+
+
+class UserAdvertiserMember(models.Model):
+ """User-Advertiser 'through' model."""
+
+ ROLE_ADMIN = "Admin"
+ ROLE_MANAGER = "Manager"
+ ROLE_REPORTER = "Reporter"
+ ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_REPORTER)
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ advertiser = models.ForeignKey(Advertiser, on_delete=models.CASCADE)
+ role = models.CharField(
+ max_length=100,
+ choices=(
+ (ROLE_ADMIN, _(ROLE_ADMIN)),
+ (ROLE_MANAGER, _(ROLE_MANAGER)),
+ (ROLE_REPORTER, _(ROLE_REPORTER)),
+ ),
+ default=ROLE_ADMIN,
+ )
+
+ class Meta:
+ db_table = "adserver_auth_user_advertisers"
+
+
+class UserPublisherMember(models.Model):
+ """User-Publisher 'through' model."""
+
+ ROLE_ADMIN = "Admin"
+ ROLE_MANAGER = "Manager"
+ ROLE_REPORTER = "Reporter"
+ ROLES = (ROLE_ADMIN, ROLE_MANAGER, ROLE_REPORTER)
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
+ role = models.CharField(
+ max_length=100,
+ choices=(
+ (ROLE_ADMIN, _(ROLE_ADMIN)),
+ (ROLE_MANAGER, _(ROLE_MANAGER)),
+ (ROLE_REPORTER, _(ROLE_REPORTER)),
+ ),
+ default=ROLE_ADMIN,
+ )
+
+ class Meta:
+ db_table = "adserver_auth_user_publishers"
diff --git a/adserver/forms.py b/adserver/forms.py
index 829cc3c5..6e3ef925 100644
--- a/adserver/forms.py
+++ b/adserver/forms.py
@@ -29,6 +29,7 @@
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
+from .auth.models import UserAdvertiserMember
from .models import Advertisement
from .models import Campaign
from .models import Flight
@@ -1322,6 +1323,14 @@ class InviteUserForm(forms.ModelForm):
without a duplicate being created.
"""
+ role = forms.ChoiceField(
+ required=True,
+ # This form works for both publishers and advertisers
+ # Currently, the roles are the same for both
+ # If that ever changes, this will need an update
+ choices=((r, r) for r in UserAdvertiserMember.ROLES),
+ )
+
def __init__(self, *args, **kwargs):
"""Add the form helper and customize the look of the form."""
super().__init__(*args, **kwargs)
@@ -1331,6 +1340,7 @@ def __init__(self, *args, **kwargs):
"",
Field("name"),
Field("email", placeholder="user@yourdomain.com"),
+ Field("role"),
css_class="my-3",
),
Submit("submit", "Send invite"),
diff --git a/adserver/mixins.py b/adserver/mixins.py
index 9a38baf0..87116da5 100644
--- a/adserver/mixins.py
+++ b/adserver/mixins.py
@@ -9,10 +9,11 @@
from django.core.paginator import Paginator
from django.db import connection
from django.db import models
-from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
+from .auth.models import UserAdvertiserMember
+from .auth.models import UserPublisherMember
from .constants import ALL_CAMPAIGN_TYPES
from .constants import CAMPAIGN_TYPES
from .models import Advertiser
@@ -34,40 +35,80 @@ class AdvertiserAccessMixin:
"""Mixin for checking advertiser access that works with the ``UserPassesTestMixin``."""
advertiser_slug_parameter = "advertiser_slug"
+ allowed_roles = (
+ UserAdvertiserMember.ROLE_ADMIN,
+ UserAdvertiserMember.ROLE_MANAGER,
+ UserAdvertiserMember.ROLE_REPORTER,
+ )
def test_func(self):
"""The user must have access on the advertiser or be staff."""
if self.request.user.is_anonymous:
return False
- advertiser = get_object_or_404(
- Advertiser, slug=self.kwargs[self.advertiser_slug_parameter]
- )
return (
self.request.user.is_staff
- or advertiser in self.request.user.advertisers.all()
+ or self.request.user.useradvertisermember_set.filter(
+ advertiser__slug=self.kwargs[self.advertiser_slug_parameter],
+ role__in=self.allowed_roles,
+ ).exists()
)
+class AdvertiserAdminAccessMixin(AdvertiserAccessMixin):
+ """Mixin for checking advertiser ADMIN role that works with the ``UserPassesTestMixin``."""
+
+ allowed_roles = (UserAdvertiserMember.ROLE_ADMIN,)
+
+
+class AdvertiserManagerAccessMixin(AdvertiserAccessMixin):
+ """Mixin for checking advertiser Manager or Admin role that works with the ``UserPassesTestMixin``."""
+
+ allowed_roles = (
+ UserAdvertiserMember.ROLE_ADMIN,
+ UserAdvertiserMember.ROLE_MANAGER,
+ )
+
+
class PublisherAccessMixin:
"""Mixin for checking publisher access that works with the ``UserPassesTestMixin``."""
publisher_slug_parameter = "publisher_slug"
+ allowed_roles = (
+ UserPublisherMember.ROLE_ADMIN,
+ UserPublisherMember.ROLE_MANAGER,
+ UserPublisherMember.ROLE_REPORTER,
+ )
def test_func(self):
"""The user must have access on the publisher or be staff."""
if self.request.user.is_anonymous:
return False
- publisher = get_object_or_404(
- Publisher, slug=self.kwargs[self.publisher_slug_parameter]
- )
return (
self.request.user.is_staff
- or publisher in self.request.user.publishers.all()
+ or self.request.user.userpublishermember_set.filter(
+ publisher__slug=self.kwargs[self.publisher_slug_parameter],
+ role__in=self.allowed_roles,
+ ).exists()
)
+class PublisherAdminAccessMixin(PublisherAccessMixin):
+ """Mixin for checking publisher ADMIN role that works with the ``UserPassesTestMixin``."""
+
+ allowed_roles = (UserPublisherMember.ROLE_ADMIN,)
+
+
+class PublisherManagerAccessMixin(PublisherAccessMixin):
+ """Mixin for checking publisher Manager or Admin role that works with the ``UserPassesTestMixin``."""
+
+ allowed_roles = (
+ UserPublisherMember.ROLE_ADMIN,
+ UserPublisherMember.ROLE_MANAGER,
+ )
+
+
class AdvertisementValidateLinkMixin:
"""
Mixin for validating the landing page returns a 200.
diff --git a/adserver/templates/adserver/advertiser/advertisement-detail.html b/adserver/templates/adserver/advertiser/advertisement-detail.html
index ba2a2c7f..8cb50ab0 100644
--- a/adserver/templates/adserver/advertiser/advertisement-detail.html
+++ b/adserver/templates/adserver/advertiser/advertisement-detail.html
@@ -2,6 +2,10 @@
{% load i18n %}
{% load static %}
{% load humanize %}
+{% load ad_extras %}
+
+
+{% advertiser_role request.user advertiser as user_advertiser_role %}
{% block title %}{% trans 'Advertisement: ' %}{{ advertisement.name }}{% endblock %}
@@ -61,9 +65,11 @@
{% block heading %}{% trans 'Advertisement: ' %}{{ advertisement.name }}{% e
+ {% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
+ {% endif %}
{% endblock content_container %}
diff --git a/adserver/templates/adserver/advertiser/flight-detail.html b/adserver/templates/adserver/advertiser/flight-detail.html
index 2cfdab0d..7364cb8d 100644
--- a/adserver/templates/adserver/advertiser/flight-detail.html
+++ b/adserver/templates/adserver/advertiser/flight-detail.html
@@ -2,6 +2,10 @@
{% load i18n %}
{% load static %}
{% load humanize %}
+{% load ad_extras %}
+
+
+{% advertiser_role request.user advertiser as user_advertiser_role %}
{% block title %}{{ flight.name }}{% endblock %}
@@ -30,10 +34,12 @@ {% block heading %}{% trans 'Flight' %}: {{ flight.name }}{% endblock headin
{% trans 'See full report' %}
+ {% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
{% if flight.auto_renew %}{% trans 'Renewing' %}{% else %}{% trans 'Automatically renew' %}{% endif %}
+ {% endif %}
{% if "adserver.change_flight" in perms %}
@@ -54,6 +60,7 @@ {% block heading %}{% trans 'Flight' %}: {{ flight.name }}{% endblock headin
{% trans 'Live ads' %}
+ {% endif %}
{% if live_ads %}
@@ -73,7 +81,13 @@ {% trans 'Live ads' %}
{% else %}
{% url 'advertisement_create' advertiser.slug flight.slug as create_ad_url %}
- {% blocktrans %}There are no live ads in this flight yet but you can create one .{% endblocktrans %}
+
+ {% if user_advertiser_role == "Reporter" %}
+ {% blocktrans %}There are no live ads in this flight yet.{% endblocktrans %}
+ {% else %}
+ {% blocktrans %}There are no live ads in this flight yet but you can create one .{% endblocktrans %}
+ {% endif %}
+
{% endif %}
diff --git a/adserver/templates/adserver/advertiser/flight-list.html b/adserver/templates/adserver/advertiser/flight-list.html
index acb441eb..939ce156 100644
--- a/adserver/templates/adserver/advertiser/flight-list.html
+++ b/adserver/templates/adserver/advertiser/flight-list.html
@@ -2,6 +2,10 @@
{% load i18n %}
{% load static %}
{% load humanize %}
+{% load ad_extras %}
+
+
+{% advertiser_role request.user advertiser as user_advertiser_role %}
{% block title %}{% trans 'Manage Advertising' %}{% endblock %}
@@ -25,10 +29,12 @@ {% block heading %}{% trans "Manage advertising flights" %}{% endblock headi
{% trans 'Create flight' %}
{% endif %}
+ {% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
{% trans 'Request a new flight' %}
+ {% endif %}
diff --git a/adserver/templates/adserver/advertiser/users.html b/adserver/templates/adserver/advertiser/users.html
index 40c236a1..d6504f01 100644
--- a/adserver/templates/adserver/advertiser/users.html
+++ b/adserver/templates/adserver/advertiser/users.html
@@ -1,6 +1,10 @@
{% extends "adserver/advertiser/overview.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
+{% load ad_extras %}
+
+
+{% advertiser_role request.user advertiser as user_advertiser_role %}
{% block title %}{% trans 'Authorized Users' %} - {{ advertiser }}{% endblock %}
@@ -15,33 +19,47 @@
{% block content_container %}
{% blocktrans %}Authorized users for {{ advertiser }}{% endblocktrans %}
- {% trans 'These are users who have access to manage ads and view reports.' %}
+ {% trans 'These are users who have access to manage ads and view reports. The role levels are:' %}
+
+ {% trans 'Admin' %}
+ {% trans 'Can invite new users to collaborate as well as all permissions below.' %}
+ {% trans 'Manager' %}
+ {% trans 'Can manage advertisements and request new flights as well as all permissions below.' %}
+ {% trans 'Reporter' %}
+ {% trans 'Can only view reports but not change any ads or flights.' %}
+
+ {% if request.user.is_staff or user_advertiser_role == "Admin" %}
{% trans 'Invite user' %}
+ {% endif %}
- {% if users %}
+ {% if members %}
{% trans 'Name' %}
{% trans 'Email' %}
+ {% trans 'Role' %}
{% trans 'Options' %}
- {% for user in users %}
+ {% for member in members %}
- {{ user.name }}
- {{ user.email }}
+ {{ member.user.name }}
+ {{ member.user.email }}
+ {{ member.role }}
- {% if request.user.id != user.id %}
- {% trans 'remove' %}
+ {% if request.user.is_staff or user_advertiser_role == "Admin" %}
+ {% if request.user.id != member.user.id %}
+ {% trans 'remove' %}
+ {% endif %}
{% endif %}
diff --git a/adserver/templates/adserver/base.html b/adserver/templates/adserver/base.html
index e7991522..68d7057e 100644
--- a/adserver/templates/adserver/base.html
+++ b/adserver/templates/adserver/base.html
@@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load i18n %}
+{% load ad_extras %}
{% block body_classes %}adserver-dashboard{% endblock body_classes %}
@@ -143,6 +144,7 @@ {{ advertiser }}
{% elif publisher %}
+ {% publisher_role request.user publisher as user_publisher_role %}
{{ publisher }}
@@ -216,12 +218,14 @@ {{ publisher }}
+ {% if request.user.is_staff or user_publisher_role == "Admin" %}
{% trans 'Settings' %}
+ {% endif %}
diff --git a/adserver/templates/adserver/dashboard.html b/adserver/templates/adserver/dashboard.html
index cf4a5bb3..0cef3900 100644
--- a/adserver/templates/adserver/dashboard.html
+++ b/adserver/templates/adserver/dashboard.html
@@ -1,6 +1,7 @@
{% extends 'adserver/base.html' %}
{% load i18n %}
+
{% block title %}{% trans 'Dashboard' %}{% endblock title %}
diff --git a/adserver/templates/adserver/publisher/fallback-ads-detail.html b/adserver/templates/adserver/publisher/fallback-ads-detail.html
index b391f808..581d1f72 100644
--- a/adserver/templates/adserver/publisher/fallback-ads-detail.html
+++ b/adserver/templates/adserver/publisher/fallback-ads-detail.html
@@ -2,6 +2,10 @@
{% load i18n %}
{% load static %}
{% load humanize %}
+{% load ad_extras %}
+
+
+{% publisher_role request.user publisher as user_publisher_role %}
{% block title %}{% trans 'Fallback ad: ' %}{{ advertisement.name }}{% endblock %}
@@ -58,9 +62,11 @@ {% block heading %}{% trans 'Fallback ad: ' %}{{ advertisement.name }}{% end
+ {% if request.user.is_staff or user_publisher_role == "Admin" or user_publisher_role == "Manager" %}
+ {% endif %}
{% endblock content_container %}
diff --git a/adserver/templates/adserver/publisher/fallback-ads-list.html b/adserver/templates/adserver/publisher/fallback-ads-list.html
index 5e5f1903..6133e1b1 100644
--- a/adserver/templates/adserver/publisher/fallback-ads-list.html
+++ b/adserver/templates/adserver/publisher/fallback-ads-list.html
@@ -1,6 +1,10 @@
{% extends "adserver/publisher/overview.html" %}
{% load humanize %}
{% load i18n %}
+{% load ad_extras %}
+
+
+{% publisher_role request.user publisher as user_publisher_role %}
{% block title %}{% trans 'Fallback ads' %} - {{ publisher }}{% endblock %}
@@ -25,10 +29,12 @@ {% trans 'Fallback ads' %}
{% trans 'Advertisements' %}
+ {% if request.user.is_staff or user_publisher_role == "Admin" or user_publisher_role == "Manager" %}
{% trans 'Create fallback ad' %}
+ {% endif %}
@@ -67,7 +73,13 @@ {% trans 'Advertisements' %}
{% else %}
- {% blocktrans %}There are no fallback ads yet but you can create one .{% endblocktrans %}
+
+ {% if user_publisher_role == "Reporter" %}
+ {% blocktrans %}There are no fallback ads yet.{% endblocktrans %}
+ {% else %}
+ {% blocktrans %}There are no fallback ads yet but you can create one .{% endblocktrans %}
+ {% endif %}
+
{% endif %}
diff --git a/adserver/templates/adserver/publisher/users.html b/adserver/templates/adserver/publisher/users.html
index 289dca04..885dd4dd 100644
--- a/adserver/templates/adserver/publisher/users.html
+++ b/adserver/templates/adserver/publisher/users.html
@@ -1,6 +1,10 @@
{% extends "adserver/publisher/overview.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
+{% load ad_extras %}
+
+
+{% publisher_role request.user publisher as user_publisher_role %}
{% block title %}{% trans 'Authorized Users' %} - {{ publisher }}{% endblock %}
@@ -15,33 +19,47 @@
{% block content_container %}
{% blocktrans %}Authorized users for {{ publisher }}{% endblocktrans %}
- {% trans 'These are users who have access to manage ads and view reports.' %}
+ {% trans 'These are users who have access to manage ads and view reports. The role levels are:' %}
+
+ {% trans 'Admin' %}
+ {% trans 'Can invite new users to collaborate, update settings, as well as all permissions below.' %}
+ {% trans 'Manager' %}
+ {% trans 'Can manage fallback ads as well as all permissions below.' %}
+ {% trans 'Reporter' %}
+ {% trans 'Can only view reports but not change anything.' %}
+
+ {% if request.user.is_staff or user_publisher_role == "Admin" %}
{% trans 'Invite user' %}
+ {% endif %}
- {% if users %}
+ {% if members %}
{% trans 'Name' %}
{% trans 'Email' %}
+ {% trans 'Role' %}
{% trans 'Options' %}
- {% for user in users %}
+ {% for member in members %}
- {{ user.name }}
- {{ user.email }}
+ {{ member.user.name }}
+ {{ member.user.email }}
+ {{ member.role }}
- {% if request.user.id != user.id %}
- {% trans 'remove' %}
+ {% if request.user.is_staff or user_publisher_role == "Admin" %}
+ {% if request.user.id != member.user.id %}
+ {% trans 'remove' %}
+ {% endif %}
{% endif %}
diff --git a/adserver/templatetags/ad_extras.py b/adserver/templatetags/ad_extras.py
index 3e48edff..f2fa7690 100644
--- a/adserver/templatetags/ad_extras.py
+++ b/adserver/templatetags/ad_extras.py
@@ -14,3 +14,55 @@ def advertisement_preview(ad, ad_type=None):
ad_type = ad.ad_types.first()
return mark_safe(ad.render_ad(ad_type, preview=True))
+
+
+@register.simple_tag
+def advertiser_role(user, advertiser):
+ """
+ Returns the users role in this advertiser or None if the user has no permissions.
+
+ Staff status is not taken into account. Caches the result on the user so future calls
+ don't involve a DB lookup.
+ """
+ if not hasattr(user, "_advertiser_roles"):
+ user._advertiser_roles = {}
+
+ if advertiser.pk in user._advertiser_roles:
+ return user._advertiser_roles[advertiser.pk]
+
+ membership = user.useradvertisermember_set.filter(
+ advertiser=advertiser,
+ ).first()
+
+ role = None
+ if membership:
+ role = membership.role
+
+ user._advertiser_roles[advertiser.pk] = role
+ return role
+
+
+@register.simple_tag
+def publisher_role(user, publisher):
+ """
+ Returns the users role in this publisher or None if the user has no permissions.
+
+ Staff status is not taken into account. Caches the result on the user so future calls
+ don't involve a DB lookup.
+ """
+ if not hasattr(user, "_publisher_roles"):
+ user._publisher_roles = {}
+
+ if publisher.pk in user._publisher_roles:
+ return user._publisher_roles[publisher.pk]
+
+ membership = user.userpublishermember_set.filter(
+ publisher=publisher,
+ ).first()
+
+ role = None
+ if membership:
+ role = membership.role
+
+ user._publisher_roles[publisher.pk] = role
+ return role
diff --git a/adserver/tests/test_advertiser_dashboard.py b/adserver/tests/test_advertiser_dashboard.py
index 9a273cc1..c12b9dd0 100644
--- a/adserver/tests/test_advertiser_dashboard.py
+++ b/adserver/tests/test_advertiser_dashboard.py
@@ -25,6 +25,7 @@
from ..tasks import daily_update_advertisers
from ..utils import get_ad_day
from .common import ONE_PIXEL_PNG_BYTES
+from ..auth.models import UserAdvertiserMember
User = get_user_model()
@@ -442,6 +443,18 @@ def test_flight_autorenew_view(self):
# Regular user - access to this advertiser
self.client.force_login(self.user)
+
+ # Make it a reporter who can't access
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Reporter"
+ member.save()
+
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
+
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Flight auto-renewal")
@@ -575,6 +588,19 @@ def test_flight_request_view(self):
self.assertTrue(resp["location"].startswith("/accounts/login/"))
# Regular user - access to this advertiser
+ self.client.force_login(self.user)
+
+ # Make it a reporter who can't access
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Reporter"
+ member.save()
+
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
+
self.client.force_login(self.user)
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@@ -697,6 +723,18 @@ def test_ad_update_view(self):
self.assertTrue(response["location"].startswith("/accounts/login/"))
self.client.force_login(self.user)
+
+ # Make it a reporter who can't access
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Reporter"
+ member.save()
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
+
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.ad1.name)
@@ -732,6 +770,18 @@ def test_ad_create_view(self):
self.assertTrue(response["location"].startswith("/accounts/login/"))
self.client.force_login(self.user)
+
+ # Make it a reporter who can't access
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Reporter"
+ member.save()
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
+
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create advertisement")
@@ -767,6 +817,18 @@ def test_ad_copy_view(self):
self.assertTrue(response["location"].startswith("/accounts/login/"))
self.client.force_login(self.user)
+
+ # Make it a reporter who can't access
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Reporter"
+ member.save()
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
+
response = self.client.get(url)
self.assertContains(response, "Re-use your previous ads")
self.assertContains(response, self.ad1.name)
@@ -828,11 +890,17 @@ def test_authorized_users(self):
self.assertEqual(response.status_code, 302)
self.assertTrue(response["location"].startswith("/accounts/login/"))
+ # Make it a manager who can't invite users
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Manager"
+ member.save()
+
self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.user.name)
self.assertContains(response, self.user.email)
+ self.assertNotContains(response, "Invite user")
self.user.advertisers.remove(self.advertiser)
@@ -885,7 +953,17 @@ def test_authorized_users_invite(self):
self.assertEqual(response.status_code, 302)
self.assertTrue(response["location"].startswith("/accounts/login/"))
+ # Make it a manager who can't invite users
+ member = UserAdvertiserMember.objects.get(user=self.user, advertiser=self.advertiser)
+ member.role = "Manager"
+ member.save()
+
self.client.force_login(self.user)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ member.role = "Admin"
+ member.save()
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@@ -893,7 +971,7 @@ def test_authorized_users_invite(self):
response = self.client.post(
url,
- data={"name": "Another User", "email": "another@example.com"},
+ data={"name": "Another User", "email": "another@example.com", "role": "Manager"},
follow=True,
)
self.assertEqual(response.status_code, 200)
@@ -914,7 +992,7 @@ def test_authorized_users_invite_existing(self):
response = self.client.post(
url,
- data={"name": name, "email": email},
+ data={"name": name, "email": email, "role": "Manager"},
follow=True,
)
self.assertEqual(response.status_code, 200)
@@ -924,7 +1002,7 @@ def test_authorized_users_invite_existing(self):
# Invite the same user again to check that the user isn't created again
response = self.client.post(
url,
- data={"name": "Yet Another User", "email": email},
+ data={"name": "Yet Another User", "email": email, "role": "Manager"},
follow=True,
)
self.assertEqual(response.status_code, 200)
diff --git a/adserver/tests/test_publisher_dashboard.py b/adserver/tests/test_publisher_dashboard.py
index 741bd4da..51dc74c3 100644
--- a/adserver/tests/test_publisher_dashboard.py
+++ b/adserver/tests/test_publisher_dashboard.py
@@ -19,6 +19,7 @@
from ..models import Publisher
from ..models import PublisherPayout
from ..tasks import daily_update_publishers
+from ..auth.models import UserPublisherMember
class TestPublisherDashboardViews(TestCase):
@@ -152,7 +153,18 @@ def test_publisher_settings(self):
self.assertEqual(resp.status_code, 302)
self.assertTrue(resp["location"].startswith("/accounts/login/"))
- self.client.force_login(self.staff_user)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher1,
+ role="Manager",
+ )
+ self.client.force_login(self.user)
+
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 403)
+
+ member.role = "Admin"
+ member.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@@ -391,15 +403,20 @@ def test_authorized_users(self):
self.assertEqual(response.status_code, 302)
self.assertTrue(response["location"].startswith("/accounts/login/"))
- self.user.publishers.add(self.publisher1)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher1,
+ role="Manager",
+ )
self.client.force_login(self.user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.user.name)
self.assertContains(response, self.user.email)
+ self.assertNotContains(response, "Invite user")
- self.user.publishers.remove(self.publisher1)
+ member.delete()
self.client.force_login(self.staff_user)
response = self.client.get(url)
@@ -453,16 +470,27 @@ def test_authorized_users_invite(self):
self.assertEqual(response.status_code, 302)
self.assertTrue(response["location"].startswith("/accounts/login/"))
- self.user.publishers.add(self.publisher1)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher1,
+ role="Manager",
+ )
+
self.client.force_login(self.user)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ member.role = "Admin"
+ member.save()
+
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Invite user to")
response = self.client.post(
url,
- data={"name": "Another User", "email": "another@example.com"},
+ data={"name": "Another User", "email": "another@example.com", "role": "Reporter"},
follow=True,
)
self.assertEqual(response.status_code, 200)
@@ -486,7 +514,7 @@ def test_authorized_users_invite_existing(self):
response = self.client.post(
url,
- data={"name": name, "email": email},
+ data={"name": name, "email": email, "role": "Reporter"},
follow=True,
)
self.assertEqual(response.status_code, 200)
@@ -496,7 +524,7 @@ def test_authorized_users_invite_existing(self):
# Invite the same user again to check that the user isn't created again
response = self.client.post(
url,
- data={"name": "Yet Another User", "email": email},
+ data={"name": "Yet Another User", "email": email, "role": "Reporter"},
follow=True,
)
self.assertEqual(response.status_code, 200)
@@ -572,11 +600,23 @@ def test_fallback_ads_list(self):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
- self.user.publishers.add(self.publisher)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher,
+ role="Manager",
+ )
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Test Ad 1")
+ self.assertContains(resp, "Create fallback ad")
+
+ member.role = "Reporter"
+ member.save()
+
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotContains(resp, "Create fallback ad")
def test_fallback_ads_detail(self):
url = reverse(
@@ -594,11 +634,23 @@ def test_fallback_ads_detail(self):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
- self.user.publishers.add(self.publisher)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher,
+ role="Manager",
+ )
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Test Ad 1")
+ self.assertContains(resp, "Edit fallback ad")
+
+ member.role = "Reporter"
+ member.save()
+
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotContains(resp, "Edit fallback ad")
def test_fallback_ads_update(self):
url = reverse(
@@ -616,7 +668,18 @@ def test_fallback_ads_update(self):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
- self.user.publishers.add(self.publisher)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher,
+ role="Reporter",
+ )
+
+ # Reporters can't edit
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@@ -653,7 +716,18 @@ def test_fallback_ads_create(self):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
- self.user.publishers.add(self.publisher)
+ member = UserPublisherMember.objects.create(
+ user=self.user,
+ publisher=self.publisher,
+ role="Reporter",
+ )
+
+ # Reporter can't create
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 403)
+
+ member.role = "Manager"
+ member.save()
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
diff --git a/adserver/views.py b/adserver/views.py
index 1de2b4e5..b415fe9a 100644
--- a/adserver/views.py
+++ b/adserver/views.py
@@ -49,6 +49,8 @@
from rest_framework.authtoken.models import Token
from user_agents import parse as parse_user_agent
+from .auth.models import UserAdvertiserMember
+from .auth.models import UserPublisherMember
from .constants import ALL_CAMPAIGN_TYPES
from .constants import CAMPAIGN_TYPES
from .constants import CLICKS
@@ -71,10 +73,14 @@
from .forms import SupportForm
from .mixins import AdvertisementValidateLinkMixin
from .mixins import AdvertiserAccessMixin
+from .mixins import AdvertiserAdminAccessMixin
+from .mixins import AdvertiserManagerAccessMixin
from .mixins import AllReportMixin
from .mixins import GeoReportMixin
from .mixins import KeywordReportMixin
from .mixins import PublisherAccessMixin
+from .mixins import PublisherAdminAccessMixin
+from .mixins import PublisherManagerAccessMixin
from .mixins import ReportQuerysetMixin
from .models import AdImpression
from .models import AdType
@@ -405,7 +411,9 @@ def get_success_url(self):
)
-class FlightSetAutoRenewView(AdvertiserAccessMixin, UserPassesTestMixin, UpdateView):
+class FlightSetAutoRenewView(
+ AdvertiserManagerAccessMixin, UserPassesTestMixin, UpdateView
+):
"""Allow advertisers to set a flight to auto-renew or not."""
form_class = FlightAutoRenewForm
@@ -515,7 +523,7 @@ def get_success_url(self):
)
-class FlightRequestView(AdvertiserAccessMixin, UserPassesTestMixin, CreateView):
+class FlightRequestView(AdvertiserManagerAccessMixin, UserPassesTestMixin, CreateView):
"""Create a new flight for an advertiser."""
form_class = FlightRequestForm
@@ -653,7 +661,7 @@ def get_object(self, queryset=None):
class AdvertisementUpdateView(
- AdvertiserAccessMixin,
+ AdvertiserManagerAccessMixin,
UserPassesTestMixin,
AdvertisementValidateLinkMixin,
UpdateView,
@@ -707,7 +715,7 @@ def get_success_url(self):
class AdvertisementCreateView(
- AdvertiserAccessMixin,
+ AdvertiserManagerAccessMixin,
UserPassesTestMixin,
AdvertisementValidateLinkMixin,
CreateView,
@@ -766,7 +774,9 @@ def get_success_url(self):
)
-class AdvertisementCopyView(AdvertiserAccessMixin, UserPassesTestMixin, FormView):
+class AdvertisementCopyView(
+ AdvertiserManagerAccessMixin, UserPassesTestMixin, FormView
+):
"""Create a copy of an existing ad."""
form_class = AdvertisementCopyForm
@@ -1544,8 +1554,8 @@ class AdvertiserAuthorizedUsersView(
):
"""Authorized users for an advertiser."""
- context_object_name = "users"
- model = get_user_model()
+ context_object_name = "members"
+ model = UserAdvertiserMember
template_name = "adserver/advertiser/users.html"
def get_context_data(self, **kwargs):
@@ -1557,11 +1567,15 @@ def get_queryset(self):
self.advertiser = get_object_or_404(
Advertiser, slug=self.kwargs.get("advertiser_slug", "")
)
- return self.advertiser.user_set.all()
+ return (
+ UserAdvertiserMember.objects.filter(advertiser=self.advertiser)
+ .select_related()
+ .all()
+ )
class AdvertiserAuthorizedUsersInviteView(
- AdvertiserAccessMixin, UserPassesTestMixin, CreateView
+ AdvertiserAdminAccessMixin, UserPassesTestMixin, CreateView
):
"""Invite additional authorized users for an advertiser."""
@@ -1577,7 +1591,15 @@ def dispatch(self, request, *args, **kwargs):
def form_valid(self, form):
result = super().form_valid(form)
- self.object.advertisers.add(self.advertiser)
+
+ # Add m2m and role
+ role = form.cleaned_data["role"]
+ UserAdvertiserMember.objects.create(
+ advertiser=self.advertiser,
+ user=self.object,
+ role=role,
+ )
+
messages.success(
self.request,
_("Successfully invited %(user)s to %(advertiser)s")
@@ -1597,7 +1619,7 @@ def get_success_url(self):
class AdvertiserAuthorizedUsersRemoveView(
- AdvertiserAccessMixin, UserPassesTestMixin, TemplateView
+ AdvertiserAdminAccessMixin, UserPassesTestMixin, TemplateView
):
"""
Remove authorized users for an advertiser.
@@ -1999,7 +2021,7 @@ def get_object(self, queryset=None):
class PublisherFallbackAdsUpdateView(
- FallbackAdsMixin, PublisherAccessMixin, UserPassesTestMixin, UpdateView
+ FallbackAdsMixin, PublisherManagerAccessMixin, UserPassesTestMixin, UpdateView
):
"""Update a fallback ad."""
@@ -2042,7 +2064,7 @@ def get_success_url(self):
class PublisherFallbackAdsCreateView(
- FallbackAdsMixin, PublisherAccessMixin, UserPassesTestMixin, CreateView
+ FallbackAdsMixin, PublisherManagerAccessMixin, UserPassesTestMixin, CreateView
):
"""Create a fallback ad."""
@@ -2088,7 +2110,7 @@ def get_success_url(self):
)
-class PublisherSettingsView(PublisherAccessMixin, UserPassesTestMixin, UpdateView):
+class PublisherSettingsView(PublisherAdminAccessMixin, UserPassesTestMixin, UpdateView):
"""Settings configuration for a publisher."""
form_class = PublisherSettingsForm
@@ -2119,7 +2141,7 @@ def get_success_url(self):
class PublisherStripeOauthConnectView(
- PublisherAccessMixin, UserPassesTestMixin, RedirectView
+ PublisherAdminAccessMixin, UserPassesTestMixin, RedirectView
):
"""Redirect the user to the correct Stripe connect URL for the publisher."""
@@ -2408,7 +2430,7 @@ def get_context_data(self, **kwargs): # pylint: disable=too-many-locals
class PublisherAuthorizedUsersView(PublisherAccessMixin, UserPassesTestMixin, ListView):
"""Authorized users for a publisher."""
- context_object_name = "users"
+ context_object_name = "members"
model = get_user_model()
template_name = "adserver/publisher/users.html"
@@ -2421,11 +2443,15 @@ def get_queryset(self):
self.publisher = get_object_or_404(
Publisher, slug=self.kwargs.get("publisher_slug", "")
)
- return self.publisher.user_set.all()
+ return (
+ UserPublisherMember.objects.filter(publisher=self.publisher)
+ .select_related()
+ .all()
+ )
class PublisherAuthorizedUsersInviteView(
- PublisherAccessMixin, UserPassesTestMixin, CreateView
+ PublisherAdminAccessMixin, UserPassesTestMixin, CreateView
):
"""Invite additional authorized users for a publisher."""
@@ -2441,7 +2467,15 @@ def dispatch(self, request, *args, **kwargs):
def form_valid(self, form):
result = super().form_valid(form)
- self.object.publishers.add(self.publisher)
+
+ # Add m2m and role
+ role = form.cleaned_data["role"]
+ UserPublisherMember.objects.create(
+ publisher=self.publisher,
+ user=self.object,
+ role=role,
+ )
+
messages.success(
self.request,
_("Successfully invited %(user)s to %(publisher)s")
@@ -2461,7 +2495,7 @@ def get_success_url(self):
class PublisherAuthorizedUsersRemoveView(
- PublisherAccessMixin, UserPassesTestMixin, TemplateView
+ PublisherAdminAccessMixin, UserPassesTestMixin, TemplateView
):
"""
Remove authorized users for a publisher.