Add instance-level ownership
This commit is contained in:
parent
20d67f6f9f
commit
7e6bcf3e40
8 changed files with 228 additions and 1 deletions
|
@ -1,5 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_permission_codename
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from redirect.models import Redirect
|
from redirect.models import Redirect
|
||||||
|
|
||||||
|
@ -10,5 +12,17 @@ from .models import RedirectUser
|
||||||
class RedirectAdmin(admin.ModelAdmin):
|
class RedirectAdmin(admin.ModelAdmin):
|
||||||
list_display = ["short_code", "target_url"]
|
list_display = ["short_code", "target_url"]
|
||||||
|
|
||||||
|
def has_change_permission(self, request: HttpRequest, obj: Redirect = None):
|
||||||
|
opts = self.opts
|
||||||
|
codename = get_permission_codename("change", opts)
|
||||||
|
return request.user.has_perm(f"{opts.app_label}.{codename}", obj)
|
||||||
|
|
||||||
|
def has_view_permission(self, request: HttpRequest, obj: Redirect = None):
|
||||||
|
opts = self.opts
|
||||||
|
codename = get_permission_codename("view", opts)
|
||||||
|
return request.user.has_perm(
|
||||||
|
f"{opts.app_label}.{codename}", obj
|
||||||
|
) or self.has_change_permission(request, obj)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(RedirectUser, UserAdmin)
|
admin.site.register(RedirectUser, UserAdmin)
|
||||||
|
|
14
src/redirect/backends.py
Normal file
14
src/redirect/backends.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
from django.contrib.auth.backends import BaseBackend, ModelBackend
|
||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
|
||||||
|
from redirect.models import Redirect
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectBackend(ModelBackend):
|
||||||
|
def has_perm(self, user_obj: AbstractUser, perm, obj=None):
|
||||||
|
allowed = super().has_perm(user_obj, perm)
|
||||||
|
if allowed:
|
||||||
|
return allowed
|
||||||
|
if obj and isinstance(obj, Redirect):
|
||||||
|
return obj.owner == user_obj or obj.group_owner in user_obj.groups.all()
|
||||||
|
return False
|
36
src/redirect/migrations/0003_auto_20220227_2216.py
Normal file
36
src/redirect/migrations/0003_auto_20220227_2216.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-02-27 21:16
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("redirect", "0002_alter_redirect_target_url"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="group_owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to="auth.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
38
src/redirect/migrations/0004_auto_20220227_2241.py
Normal file
38
src/redirect/migrations/0004_auto_20220227_2241.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-02-27 21:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("redirect", "0003_auto_20220227_2216"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="group_owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to="auth.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
40
src/redirect/migrations/0005_auto_20220227_2307.py
Normal file
40
src/redirect/migrations/0005_auto_20220227_2307.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-02-27 22:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("redirect", "0004_auto_20220227_2241"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="group_owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Give any group full permissions over this object.The user will still at least need global view permissions for the admin to work.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to="auth.group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="redirect",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Give any user full permissions over this object. The user will still at least need global view permissions for the admin to work.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,10 +1,31 @@
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Redirect(models.Model):
|
class Redirect(models.Model):
|
||||||
short_code = models.CharField(max_length=250, blank=False, null=False, unique=True)
|
short_code = models.CharField(max_length=250, blank=False, null=False, unique=True)
|
||||||
target_url = models.URLField(max_length=2000)
|
target_url = models.URLField(max_length=2000)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Give any user full permissions over this object. "
|
||||||
|
"The user will still at least need global view permissions "
|
||||||
|
"for the admin to work.",
|
||||||
|
)
|
||||||
|
group_owner = models.ForeignKey(
|
||||||
|
Group,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="owned_redirects",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Give any group full permissions over this object."
|
||||||
|
"The user will still at least need global view permissions "
|
||||||
|
"for the admin to work.",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.short_code} => {self.target_url}"
|
return f"{self.short_code} => {self.target_url}"
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
|
from django.contrib.auth.models import AbstractUser, Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from redirect.models import Redirect, RedirectUser
|
||||||
|
|
||||||
|
|
||||||
class RedirectModelTestCase(TestCase):
|
class RedirectModelTestCase(TestCase):
|
||||||
def test_has_short_code(self):
|
def test_has_short_code(self):
|
||||||
|
@ -14,3 +18,62 @@ class RedirectModelTestCase(TestCase):
|
||||||
|
|
||||||
def test_has_target_url(self):
|
def test_has_target_url(self):
|
||||||
baker.make("Redirect", target_url="https://static.augendre.info/potain3")
|
baker.make("Redirect", target_url="https://static.augendre.info/potain3")
|
||||||
|
|
||||||
|
def test_can_miss_owner(self):
|
||||||
|
baker.make("Redirect", owner=None)
|
||||||
|
|
||||||
|
def test_can_have_owner(self):
|
||||||
|
user = RedirectUser.objects.create_user("user")
|
||||||
|
baker.make("Redirect", owner=user)
|
||||||
|
|
||||||
|
def test_can_miss_group_owner(self):
|
||||||
|
baker.make("Redirect", group_owner=None)
|
||||||
|
|
||||||
|
def test_can_have_group_owner(self):
|
||||||
|
group = Group.objects.create(name="group")
|
||||||
|
baker.make("Redirect", group_owner=group)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectModelPermissionsTestMixin:
|
||||||
|
def test_owner_can_change_object(self):
|
||||||
|
assert self.owner.has_perm("redirect.change_redirect", self.redirect)
|
||||||
|
|
||||||
|
def test_owner_can_delete_object(self):
|
||||||
|
assert self.owner.has_perm("redirect.delete_redirect", self.redirect)
|
||||||
|
|
||||||
|
def test_owner_can_view_object(self):
|
||||||
|
assert self.owner.has_perm("redirect.view_redirect", self.redirect)
|
||||||
|
|
||||||
|
def test_non_owner_cant_change_object(self):
|
||||||
|
assert not self.non_owner.has_perm("redirect.change_redirect", self.redirect)
|
||||||
|
|
||||||
|
def test_non_owner_cant_delete_object(self):
|
||||||
|
assert not self.non_owner.has_perm("redirect.delete_redirect", self.redirect)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectModelOwnerPermissionsTestCase(
|
||||||
|
RedirectModelPermissionsTestMixin, TestCase
|
||||||
|
):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner: AbstractUser = RedirectUser.objects.create_user("owner")
|
||||||
|
cls.non_owner: AbstractUser = RedirectUser.objects.create_user("rando")
|
||||||
|
content_type = ContentType.objects.get_for_model(Redirect)
|
||||||
|
permission = Permission.objects.get(
|
||||||
|
codename="view_redirect",
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
cls.non_owner.user_permissions.add(permission)
|
||||||
|
cls.redirect = baker.make("Redirect", owner=cls.owner)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectModelGroupOwnerPermissionsTestCase(
|
||||||
|
RedirectModelPermissionsTestMixin, TestCase
|
||||||
|
):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner: AbstractUser = RedirectUser.objects.create_user("owner")
|
||||||
|
cls.non_owner: AbstractUser = RedirectUser.objects.create_user("rando")
|
||||||
|
group_owner = Group.objects.create(name="group_owner")
|
||||||
|
group_owner.user_set.add(cls.owner)
|
||||||
|
cls.redirect = baker.make("Redirect", group_owner=group_owner)
|
||||||
|
|
|
@ -86,6 +86,7 @@ TEMPLATES = [
|
||||||
|
|
||||||
WSGI_APPLICATION = "shortener.wsgi.application"
|
WSGI_APPLICATION = "shortener.wsgi.application"
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = ["redirect.backends.RedirectBackend"]
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||||
|
|
Loading…
Reference in a new issue