Skip to content

Commit

Permalink
Add roles for advertisers and publishers
Browse files Browse the repository at this point in the history
- Add 3 permission levels for advertisers and publishers
- The migration will default everybody to the highest permission
- Role permissions are checked in the UI and in the backend
- Admin users can change publisher settings, and invite users.
- Managers can edit things
- Reporters can't change anything.
  • Loading branch information
davidfischer committed Nov 15, 2024
1 parent 795a0bf commit 90a4a76
Show file tree
Hide file tree
Showing 18 changed files with 574 additions and 63 deletions.
19 changes: 17 additions & 2 deletions adserver/auth/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -19,8 +35,6 @@ class UserAdmin(SimpleHistoryAdmin):
_("Ad server details"),
{
"fields": (
"advertisers",
"publishers",
"flight_notifications",
"notify_on_completed_flights", # DEPRECATED
)
Expand All @@ -43,6 +57,7 @@ class UserAdmin(SimpleHistoryAdmin):
{"fields": ("last_login", "updated_date", "created_date")},
),
)
inlines = (UserAdvertiserInline, UserPublisherInline)
list_display = (
"email",
"name",
Expand Down
70 changes: 70 additions & 0 deletions adserver/auth/migrations/0009_user_advertiser_publisher_roles.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
56 changes: 54 additions & 2 deletions adserver/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
10 changes: 10 additions & 0 deletions adserver/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -1331,6 +1340,7 @@ def __init__(self, *args, **kwargs):
"",
Field("name"),
Field("email", placeholder="[email protected]"),
Field("role"),
css_class="my-3",
),
Submit("submit", "Send invite"),
Expand Down
59 changes: 50 additions & 9 deletions adserver/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -61,9 +65,11 @@ <h1>{% block heading %}{% trans 'Advertisement: ' %}{{ advertisement.name }}{% e
</div>

<div class="row mt-5">
{% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
<div class="col">
<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>
</div>
{% endif %}
</div>

{% endblock content_container %}
16 changes: 15 additions & 1 deletion adserver/templates/adserver/advertiser/flight-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -30,10 +34,12 @@ <h1>{% block heading %}{% trans 'Flight' %}: {{ flight.name }}{% endblock headin
<span class="fa fa-bar-chart mr-1" aria-hidden="true"></span>
<span>{% trans 'See full report' %}</span>
</a>
{% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
<a title="{% if flight.auto_renew %}{% trans 'Your flight will renew when complete.' %}{% else %}{% trans 'Flights that automatically renew receive an additional 10% discount' %}{% endif %}" data-toggle="tooltip" data-placement="left" href="{% url 'flight_auto_renew' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-info" role="button" aria-pressed="true">
<span class="fa fa-refresh mr-1" aria-hidden="true"></span>
<span>{% if flight.auto_renew %}{% trans 'Renewing' %}{% else %}{% trans 'Automatically renew' %}{% endif %}</span>
</a>
{% endif %}
{% if "adserver.change_flight" in perms %}
<a href="{% url 'flight_update' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-info" role="button" aria-pressed="true">
<span class="fa fa-lock mr-1" aria-hidden="true"></span>
Expand All @@ -54,6 +60,7 @@ <h1>{% block heading %}{% trans 'Flight' %}: {{ flight.name }}{% endblock headin
<h5 class="col-md-8">{% trans 'Live ads' %}</h5>

<aside class="col-md-4 text-right">
{% if request.user.is_staff or user_advertiser_role == "Admin" or user_advertiser_role == "Manager" %}
<a href="{% url 'advertisement_create' advertiser.slug flight.slug %}" class="btn btn-sm btn-outline-primary mb-3" role="button" aria-pressed="true">
<span class="fa fa-plus mr-1" aria-hidden="true"></span>
<span>{% trans 'Create advertisement' %}</span>
Expand All @@ -63,6 +70,7 @@ <h5 class="col-md-8">{% trans 'Live ads' %}</h5>
<span>{% trans 'Copy existing ads' %}</span>
</a>
</aside>
{% endif %}
</div>

{% if live_ads %}
Expand All @@ -73,7 +81,13 @@ <h5 class="col-md-8">{% trans 'Live ads' %}</h5>
</div>
{% else %}
{% url 'advertisement_create' advertiser.slug flight.slug as create_ad_url %}
<p class="text-center">{% blocktrans %}There are no live ads in this flight yet but you can <a href="{{ create_ad_url }}">create one</a>.{% endblocktrans %}</p>
<p class="text-center">
{% if user_advertiser_role == "Reporter" %}
<span>{% blocktrans %}There are no live ads in this flight yet.{% endblocktrans %}</span>
{% else %}
<span>{% blocktrans %}There are no live ads in this flight yet but you can <a href="{{ create_ad_url }}">create one</a>.{% endblocktrans %}</span>
{% endif %}
</p>
{% endif %}


Expand Down
Loading

0 comments on commit 90a4a76

Please sign in to comment.