Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add roles for advertisers and publishers #941

Merged
merged 4 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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"
davidfischer marked this conversation as resolved.
Show resolved Hide resolved


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" %}
davidfischer marked this conversation as resolved.
Show resolved Hide resolved
<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
Loading