feat: scope project access to Django Admin

This commit is contained in:
mohamad
2026-10-29 04:39:49 +08:00
parent 9a8551ae23
commit 6fd236f518
6 changed files with 489 additions and 20 deletions
+58
View File
@@ -0,0 +1,58 @@
from django.db.models import Q
from .models import MainProduct, ProjectAccess, ProjectAccessPolicy, SubProduct
def _mode(user):
try:
return user.project_access_policy.mode
except ProjectAccessPolicy.DoesNotExist:
return ProjectAccessPolicy.OWN
def accessible_projects(user, model, *, edit=False):
"""Return projects visible/editable by a user; explicit overrides win."""
if model not in (MainProduct, SubProduct):
raise ValueError("Expected a main project or sub-project model")
if not user.is_authenticated or not user.is_active:
return model.objects.none()
if user.is_superuser:
return model.objects.all()
field = "main_product" if model is MainProduct else "sub_product"
overrides = ProjectAccess.objects.filter(user=user, **{f"{field}__isnull": False})
allowed_levels = (ProjectAccess.EDIT,) if edit else (ProjectAccess.VIEW, ProjectAccess.EDIT)
permitted = overrides.filter(level__in=allowed_levels)
excluded = overrides.exclude(level__in=allowed_levels)
mode = _mode(user)
base = Q(created_by=user) if mode != ProjectAccessPolicy.NONE else Q(pk__in=[])
if mode == ProjectAccessPolicy.ALL_EDIT or (mode == ProjectAccessPolicy.ALL_VIEW and not edit):
base = Q(pk__isnull=False)
return model.objects.filter(base | Q(pk__in=permitted.values_list(f"{field}_id", flat=True))).exclude(
pk__in=excluded.values_list(f"{field}_id", flat=True)
).distinct()
def can_access_project(user, project, *, edit=False):
if project is None:
return False
return accessible_projects(user, type(project), edit=edit).filter(pk=project.pk).exists()
def can_create_project(user):
return user.is_authenticated and user.is_active and (
user.is_superuser or _mode(user) != ProjectAccessPolicy.NONE
)
def project_for_related(obj):
if isinstance(obj, (MainProduct, SubProduct)):
return obj
if getattr(obj, "sub_product_id", None):
return obj.sub_product
if getattr(obj, "main_product_id", None):
return obj.main_product
if getattr(obj, "article_id", None):
return project_for_related(obj.article)
return None
+143 -20
View File
@@ -1,15 +1,103 @@
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.db.models import Q
from apps.core.admin_video import video_admin_preview
from apps.core.video import VIDEO_ADMIN_FIELDSET
from .access import accessible_projects, can_access_project, can_create_project, project_for_related
from .models import (
Article, ArticleCitation, ArticlePerson, ArticleSection, MainProduct,
MainProductPerson, ProductVideo, SubProduct, SubProductPerson,
MainProductPerson, ProductVideo, ProjectAccess, ProjectAccessPolicy,
SubProduct, SubProductPerson,
)
class PersonInline(admin.StackedInline):
class ProjectAccessPolicyInline(admin.StackedInline):
model = ProjectAccessPolicy
extra = 1
max_num = 1
fields = ("mode",)
class ProjectAccessInline(admin.TabularInline):
model = ProjectAccess
extra = 1
fields = ("main_product", "sub_product", "level")
verbose_name = "Project access override"
verbose_name_plural = "Project access overrides (choose one project per row)"
User = get_user_model()
admin.site.unregister(User)
@admin.register(User)
class ProjectUserAdmin(UserAdmin):
inlines = (ProjectAccessPolicyInline, ProjectAccessInline)
def get_inlines(self, request, obj=None):
return self.inlines if request.user.is_superuser else ()
class ProjectInlineAccessMixin:
def has_view_permission(self, request, obj=None):
return request.user.is_staff and (obj is None or can_access_project(request.user, project_for_related(obj)))
def has_change_permission(self, request, obj=None):
return request.user.is_staff and (obj is None or can_access_project(request.user, project_for_related(obj), edit=True))
def has_add_permission(self, request, obj=None):
return request.user.is_staff and (obj is None or can_access_project(request.user, project_for_related(obj), edit=True))
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class ProjectAdminAccessMixin:
def has_module_permission(self, request):
return request.user.is_staff
def get_queryset(self, request):
queryset = super().get_queryset(request)
main = accessible_projects(request.user, MainProduct)
sub = accessible_projects(request.user, SubProduct)
if self.model is MainProduct:
return queryset.filter(pk__in=main)
if self.model is SubProduct:
return queryset.filter(pk__in=sub)
if self.model in (Article, ProductVideo):
return queryset.filter(Q(main_product__in=main) | Q(sub_product__in=sub))
if self.model is ArticleSection:
return queryset.filter(Q(article__main_product__in=main) | Q(article__sub_product__in=sub))
return queryset.none()
def has_view_permission(self, request, obj=None):
return request.user.is_staff and (obj is None or can_access_project(request.user, project_for_related(obj)))
def has_change_permission(self, request, obj=None):
return request.user.is_staff and (obj is None or can_access_project(request.user, project_for_related(obj), edit=True))
def has_add_permission(self, request):
return request.user.is_staff and can_create_project(request.user)
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "main_product":
kwargs["queryset"] = accessible_projects(request.user, MainProduct, edit=True)
elif db_field.name == "sub_product":
kwargs["queryset"] = accessible_projects(request.user, SubProduct, edit=True)
elif db_field.name == "article":
main = accessible_projects(request.user, MainProduct, edit=True)
sub = accessible_projects(request.user, SubProduct, edit=True)
kwargs["queryset"] = Article.objects.filter(Q(main_product__in=main) | Q(sub_product__in=sub))
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class PersonInline(ProjectInlineAccessMixin, admin.StackedInline):
extra = 0
fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order")
ordering = ("order",)
@@ -27,21 +115,21 @@ class ArticlePersonInline(PersonInline):
model = ArticlePerson
class ArticleCitationInline(admin.TabularInline):
class ArticleCitationInline(ProjectInlineAccessMixin, admin.TabularInline):
model = ArticleCitation
extra = 1
fields = ("text", "read_more_text", "url", "order")
ordering = ("order",)
class ArticleSectionInline(admin.TabularInline):
class ArticleSectionInline(ProjectInlineAccessMixin, admin.TabularInline):
model = ArticleSection
extra = 1
fields = ("title", "value_format", "value", "read_more_value", "order")
ordering = ("order",)
class SubProductArticleInline(admin.StackedInline):
class SubProductArticleInline(ProjectInlineAccessMixin, admin.StackedInline):
model = Article
fk_name = "sub_product"
extra = 0
@@ -50,7 +138,7 @@ class SubProductArticleInline(admin.StackedInline):
show_change_link = True
class MainProductArticleInline(admin.StackedInline):
class MainProductArticleInline(ProjectInlineAccessMixin, admin.StackedInline):
model = Article
fk_name = "main_product"
extra = 0
@@ -59,7 +147,7 @@ class MainProductArticleInline(admin.StackedInline):
show_change_link = True
class ProductVideoInline(admin.StackedInline):
class ProductVideoInline(ProjectInlineAccessMixin, admin.StackedInline):
model = ProductVideo
fk_name = "main_product"
extra = 0
@@ -82,7 +170,7 @@ class ProductVideoInline(admin.StackedInline):
ordering = ("order",)
class SubProductInline(admin.StackedInline):
class SubProductInline(ProjectInlineAccessMixin, admin.StackedInline):
model = SubProduct
extra = 0
fields = ("name", "slug", "short_description", "read_more_short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
@@ -90,14 +178,16 @@ class SubProductInline(admin.StackedInline):
show_change_link = True
prepopulated_fields = {"slug": ("name",)}
def get_queryset(self, request):
return super().get_queryset(request).filter(pk__in=accessible_projects(request.user, SubProduct, edit=True))
@admin.register(MainProduct)
class MainProductAdmin(admin.ModelAdmin):
list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
class MainProductAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("name", "created_by", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
list_filter = ("is_active", "show_on_homepage")
search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline]
fieldsets = (
(
@@ -117,8 +207,23 @@ class MainProductAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}),
)
def save_model(self, request, obj, form, change):
if not change and not obj.created_by_id:
obj.created_by = request.user
super().save_model(request, obj, form, change)
class SubProductVideoInline(admin.StackedInline):
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for deleted in formset.deleted_objects:
deleted.delete()
for instance in instances:
if isinstance(instance, SubProduct) and not instance.created_by_id:
instance.created_by = request.user
instance.save()
formset.save_m2m()
class SubProductVideoInline(ProjectInlineAccessMixin, admin.StackedInline):
model = ProductVideo
fk_name = "sub_product"
extra = 0
@@ -142,10 +247,11 @@ class SubProductVideoInline(admin.StackedInline):
@admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin):
class SubProductAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = (
"name",
"main_product",
"created_by",
"show_on_homepage",
"homepage_order",
"order",
@@ -155,7 +261,6 @@ class SubProductAdmin(admin.ModelAdmin):
list_filter = ("is_active", "main_product", "show_on_homepage")
search_fields = ("name", "description", "main_product__name")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",)
inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline]
fieldsets = (
@@ -177,13 +282,21 @@ class SubProductAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}),
)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
return (*fields, "main_product") if obj and not request.user.is_superuser else fields
def save_model(self, request, obj, form, change):
if not change and not obj.created_by_id:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
class ArticleAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "parent", "order", "created_at")
list_filter = ("main_product", "sub_product__main_product")
search_fields = ("title", "description", "main_product__name", "sub_product__name")
list_editable = ("order",)
raw_id_fields = ("main_product", "sub_product")
inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline]
fieldsets = (
@@ -193,6 +306,10 @@ class ArticleAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order",)}),
)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
return (*fields, "main_product", "sub_product") if obj and not request.user.is_superuser else fields
@admin.display(description="Parent")
def parent(self, obj):
if obj.main_product_id:
@@ -203,10 +320,9 @@ class ArticleAdmin(admin.ModelAdmin):
@admin.register(ProductVideo)
class ProductVideoAdmin(admin.ModelAdmin):
class ProductVideoAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "parent", "video_source", "video_size", "order", "is_active")
list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product")
list_editable = ("order", "is_active")
search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name")
raw_id_fields = ("main_product", "sub_product")
readonly_fields = ("video_preview",)
@@ -216,6 +332,10 @@ class ProductVideoAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}),
)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
return (*fields, "main_product", "sub_product") if obj and not request.user.is_superuser else fields
@admin.display(description="Parent")
def parent(self, obj):
if obj.main_product_id:
@@ -230,13 +350,16 @@ class ProductVideoAdmin(admin.ModelAdmin):
@admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin):
class ArticleSectionAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order")
search_fields = ("title", "value", "article__title")
list_editable = ("order",)
raw_id_fields = ("article",)
fieldsets = (
(None, {"fields": ("article", "title")}),
("Content", {"fields": ("value_format", "value", "read_more_value")}),
("Settings", {"fields": ("order",)}),
)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
return (*fields, "article") if obj and not request.user.is_superuser else fields
@@ -0,0 +1,49 @@
# Generated by Django 5.2.16 on 2026-10-28 20:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0026_alter_articleperson_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='mainproduct',
name='created_by',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_main_projects', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='subproduct',
name='created_by',
field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_sub_projects', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='ProjectAccessPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mode', models.CharField(choices=[('own', 'Own projects only'), ('none', 'No projects by default'), ('all_view', 'View all projects'), ('all_edit', 'View and edit all projects')], default='own', max_length=12)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_access_policy', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ProjectAccess',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('level', models.CharField(choices=[('none', 'No access'), ('view', 'View only'), ('edit', 'View and edit')], default='view', max_length=4)),
('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='products.mainproduct')),
('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='products.subproduct')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_access_grants', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Project access override',
'verbose_name_plural': 'Project access overrides',
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), _connector='OR'), name='project_access_exactly_one_project'), models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('user', 'main_product'), name='unique_user_main_project_access'), models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('user', 'sub_product'), name='unique_user_sub_project_access')],
},
),
]
+59
View File
@@ -1,5 +1,6 @@
import os
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@@ -60,6 +61,10 @@ RELEASE_CHANNEL_LABELS = {
}
class MainProduct(models.Model):
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="created_main_projects", editable=False,
)
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300)
@@ -98,6 +103,10 @@ class MainProduct(models.Model):
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
class SubProduct(models.Model):
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="created_sub_projects", editable=False,
)
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
@@ -148,6 +157,56 @@ class SubProduct(models.Model):
},
)
class ProjectAccessPolicy(models.Model):
OWN = "own"
NONE = "none"
ALL_VIEW = "all_view"
ALL_EDIT = "all_edit"
MODE_CHOICES = [
(OWN, "Own projects only"),
(NONE, "No projects by default"),
(ALL_VIEW, "View all projects"),
(ALL_EDIT, "View and edit all projects"),
]
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="project_access_policy")
mode = models.CharField(max_length=12, choices=MODE_CHOICES, default=OWN)
def __str__(self):
return f"{self.user}: {self.get_mode_display()}"
class ProjectAccess(models.Model):
NONE = "none"
VIEW = "view"
EDIT = "edit"
LEVEL_CHOICES = [(NONE, "No access"), (VIEW, "View only"), (EDIT, "View and edit")]
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="project_access_grants")
main_product = models.ForeignKey(MainProduct, on_delete=models.CASCADE, null=True, blank=True, related_name="access_grants")
sub_product = models.ForeignKey(SubProduct, on_delete=models.CASCADE, null=True, blank=True, related_name="access_grants")
level = models.CharField(max_length=4, choices=LEVEL_CHOICES, default=VIEW)
class Meta:
verbose_name = "Project access override"
verbose_name_plural = "Project access overrides"
constraints = [
models.CheckConstraint(
check=(models.Q(main_product__isnull=False, sub_product__isnull=True)
| models.Q(main_product__isnull=True, sub_product__isnull=False)),
name="project_access_exactly_one_project",
),
models.UniqueConstraint(fields=["user", "main_product"], condition=models.Q(main_product__isnull=False), name="unique_user_main_project_access"),
models.UniqueConstraint(fields=["user", "sub_product"], condition=models.Q(sub_product__isnull=False), name="unique_user_sub_project_access"),
]
def clean(self):
super().clean()
if (self.main_product_id is None) == (self.sub_product_id is None):
raise ValidationError("Select exactly one main project or sub-project.")
def __str__(self):
return f"{self.user} → {self.main_product or self.sub_product}: {self.get_level_display()}"
class Article(models.Model):
main_product = models.ForeignKey(
MainProduct,
+161
View File
@@ -0,0 +1,161 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.urls import reverse
from apps.products.access import accessible_projects, can_access_project
from apps.products.models import (
Article, MainProduct, ProjectAccess, ProjectAccessPolicy, SubProduct,
)
class ProjectAdminAccessTests(TestCase):
def setUp(self):
User = get_user_model()
self.owner = User.objects.create_user(username="owner", password="password123", is_staff=True)
self.other = User.objects.create_user(username="other", password="password123", is_staff=True)
self.admin = User.objects.create_superuser(username="superadmin", password="password123")
self.main = MainProduct.objects.create(
created_by=self.owner, name="Private Alpha", slug="private-alpha",
short_description="Alpha summary", description="Alpha details",
)
self.sub = SubProduct.objects.create(
created_by=self.owner, main_product=self.main, name="Private Module",
slug="private-module", short_description="Module summary", description="Module details",
)
self.article = Article.objects.create(main_product=self.main, title="Alpha Article", description="Article body")
def test_public_website_keeps_published_projects_visible_to_everyone(self):
self.assertContains(self.client.get(reverse("products:overview")), self.main.name)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
self.assertContains(self.client.get(self.sub.get_absolute_url()), self.sub.name)
self.client.force_login(self.other)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
self.assertContains(self.client.get(self.sub.get_absolute_url()), self.sub.name)
def test_default_admin_access_is_creator_only(self):
self.assertTrue(can_access_project(self.owner, self.main, edit=True))
self.assertTrue(can_access_project(self.owner, self.sub, edit=True))
self.assertFalse(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.sub))
self.client.force_login(self.other)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertNotEqual(
self.client.get(reverse("admin:products_mainproduct_change", args=[self.main.pk])).status_code,
200,
)
def test_no_projects_mode_removes_default_owner_access(self):
ProjectAccessPolicy.objects.create(user=self.owner, mode=ProjectAccessPolicy.NONE)
self.assertFalse(can_access_project(self.owner, self.main))
self.assertFalse(can_access_project(self.owner, self.sub))
self.client.force_login(self.owner)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertNotEqual(
self.client.get(reverse("admin:products_mainproduct_add")).status_code, 200
)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
def test_selected_view_and_edit_grants_are_independent(self):
main_grant = ProjectAccess.objects.create(
user=self.other, main_product=self.main, level=ProjectAccess.VIEW
)
self.assertTrue(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.main, edit=True))
self.assertFalse(can_access_project(self.other, self.sub))
self.client.force_login(self.other)
change_url = reverse("admin:products_mainproduct_change", args=[self.main.pk])
response = self.client.get(change_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "View Main Project")
self.assertEqual(self.client.post(change_url, {"name": "Tampered"}).status_code, 403)
self.main.refresh_from_db()
self.assertEqual(self.main.name, "Private Alpha")
main_grant.level = ProjectAccess.EDIT
main_grant.save(update_fields=["level"])
self.assertTrue(can_access_project(self.other, self.main, edit=True))
self.assertEqual(self.client.get(change_url).status_code, 200)
self.assertFalse(can_access_project(self.other, self.sub, edit=True))
def test_all_projects_modes_and_explicit_denials(self):
policy = ProjectAccessPolicy.objects.create(user=self.other, mode=ProjectAccessPolicy.ALL_VIEW)
self.assertTrue(can_access_project(self.other, self.main))
self.assertFalse(can_access_project(self.other, self.main, edit=True))
policy.mode = ProjectAccessPolicy.ALL_EDIT
policy.save(update_fields=["mode"])
self.assertTrue(can_access_project(self.other, self.main, edit=True))
self.assertTrue(can_access_project(self.other, self.sub, edit=True))
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.NONE)
self.assertFalse(can_access_project(self.other, self.main))
self.assertTrue(can_access_project(self.other, self.sub, edit=True))
self.assertTrue(can_access_project(self.admin, self.main, edit=True))
def test_explicit_view_override_can_remove_owners_edit_access(self):
ProjectAccess.objects.create(user=self.owner, main_product=self.main, level=ProjectAccess.VIEW)
self.assertTrue(can_access_project(self.owner, self.main))
self.assertFalse(can_access_project(self.owner, self.main, edit=True))
self.assertTrue(can_access_project(self.owner, self.sub, edit=True))
def test_subproject_grant_does_not_grant_parent_or_siblings_in_admin(self):
ProjectAccess.objects.create(user=self.other, sub_product=self.sub, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
self.assertContains(
self.client.get(reverse("admin:products_subproduct_changelist")), self.sub.name
)
self.assertNotContains(
self.client.get(reverse("admin:products_mainproduct_changelist")), self.main.name
)
self.assertContains(self.client.get(self.main.get_absolute_url()), self.main.name)
def test_related_article_admin_uses_parent_permission(self):
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
change_url = reverse("admin:products_article_change", args=[self.article.pk])
self.assertEqual(self.client.get(change_url).status_code, 200)
self.assertEqual(self.client.post(change_url, {"title": "Tampered"}).status_code, 403)
def test_main_edit_does_not_expose_subproject_without_sub_edit_permission(self):
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.EDIT)
ProjectAccess.objects.create(user=self.other, sub_product=self.sub, level=ProjectAccess.VIEW)
self.client.force_login(self.other)
response = self.client.get(reverse("admin:products_mainproduct_change", args=[self.main.pk]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'value="private-module"')
sub_url = reverse("admin:products_subproduct_change", args=[self.sub.pk])
self.assertContains(self.client.get(sub_url), "View Sub-project")
self.assertEqual(self.client.post(sub_url, {"name": "Tampered"}).status_code, 403)
def test_admin_user_form_has_global_and_per_project_controls(self):
self.client.force_login(self.admin)
response = self.client.get(reverse("admin:auth_user_change", args=[self.other.pk]))
self.assertContains(response, "project_access_policy-0-mode")
self.assertContains(response, "project_access_grants-0-level")
def test_admin_project_creation_records_creator(self):
self.client.force_login(self.other)
response = self.client.post(reverse("admin:products_mainproduct_add"), {
"name": "Admin Created", "slug": "admin-created", "short_description": "Summary",
"description": "Details", "description_format": "plain", "order": 0,
"homepage_order": 0,
"people-TOTAL_FORMS": 0, "people-INITIAL_FORMS": 0,
"articles-TOTAL_FORMS": 0, "articles-INITIAL_FORMS": 0,
"videos-TOTAL_FORMS": 0, "videos-INITIAL_FORMS": 0,
"sub_products-TOTAL_FORMS": 0, "sub_products-INITIAL_FORMS": 0,
})
self.assertEqual(response.status_code, 302)
self.assertEqual(MainProduct.objects.get(slug="admin-created").created_by, self.other)
def test_override_must_target_one_project_and_cannot_duplicate(self):
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other).full_clean()
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other, main_product=self.main, sub_product=self.sub).full_clean()
ProjectAccess.objects.create(user=self.other, main_product=self.main, level=ProjectAccess.VIEW)
with self.assertRaises(ValidationError):
ProjectAccess(user=self.other, main_product=self.main, level=ProjectAccess.EDIT).full_clean()