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
+19
View File
@@ -52,6 +52,25 @@ Demo content includes:
Content created or edited by an admin is preserved on later container restarts. Content created or edited by an admin is preserved on later container restarts.
## Project permissions in Django Admin
Published projects remain visible to everyone on the public website. Project
permissions apply only inside Django Admin. Users with Staff status can open the
admin panel; by default, each staff user can view and edit only main projects
and sub-projects they created. Main and sub-projects have independent access.
A superuser can configure a user under **Admin → Users → [user]**:
- **Project access policy**: own projects (default), no projects by default,
view all, or view and edit all.
- **Project access overrides**: choose one main project or sub-project per row
and set no access, view only, or view and edit. An override wins over the
default and the all-projects policy.
Project articles and videos inherit the permissions of their parent project.
Existing projects without a recorded creator remain accessible to superusers;
assign access overrides for other staff users as needed.
## Optional configuration ## Optional configuration
Copy `.env.example` to `.env` only when you want to override the local defaults: Copy `.env.example` to `.env` only when you want to override the local defaults:
+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 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.admin_video import video_admin_preview
from apps.core.video import VIDEO_ADMIN_FIELDSET 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 ( from .models import (
Article, ArticleCitation, ArticlePerson, ArticleSection, MainProduct, 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 extra = 0
fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order") fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order")
ordering = ("order",) ordering = ("order",)
@@ -27,21 +115,21 @@ class ArticlePersonInline(PersonInline):
model = ArticlePerson model = ArticlePerson
class ArticleCitationInline(admin.TabularInline): class ArticleCitationInline(ProjectInlineAccessMixin, admin.TabularInline):
model = ArticleCitation model = ArticleCitation
extra = 1 extra = 1
fields = ("text", "read_more_text", "url", "order") fields = ("text", "read_more_text", "url", "order")
ordering = ("order",) ordering = ("order",)
class ArticleSectionInline(admin.TabularInline): class ArticleSectionInline(ProjectInlineAccessMixin, admin.TabularInline):
model = ArticleSection model = ArticleSection
extra = 1 extra = 1
fields = ("title", "value_format", "value", "read_more_value", "order") fields = ("title", "value_format", "value", "read_more_value", "order")
ordering = ("order",) ordering = ("order",)
class SubProductArticleInline(admin.StackedInline): class SubProductArticleInline(ProjectInlineAccessMixin, admin.StackedInline):
model = Article model = Article
fk_name = "sub_product" fk_name = "sub_product"
extra = 0 extra = 0
@@ -50,7 +138,7 @@ class SubProductArticleInline(admin.StackedInline):
show_change_link = True show_change_link = True
class MainProductArticleInline(admin.StackedInline): class MainProductArticleInline(ProjectInlineAccessMixin, admin.StackedInline):
model = Article model = Article
fk_name = "main_product" fk_name = "main_product"
extra = 0 extra = 0
@@ -59,7 +147,7 @@ class MainProductArticleInline(admin.StackedInline):
show_change_link = True show_change_link = True
class ProductVideoInline(admin.StackedInline): class ProductVideoInline(ProjectInlineAccessMixin, admin.StackedInline):
model = ProductVideo model = ProductVideo
fk_name = "main_product" fk_name = "main_product"
extra = 0 extra = 0
@@ -82,7 +170,7 @@ class ProductVideoInline(admin.StackedInline):
ordering = ("order",) ordering = ("order",)
class SubProductInline(admin.StackedInline): class SubProductInline(ProjectInlineAccessMixin, admin.StackedInline):
model = SubProduct model = SubProduct
extra = 0 extra = 0
fields = ("name", "slug", "short_description", "read_more_short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active") 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 show_change_link = True
prepopulated_fields = {"slug": ("name",)} 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) @admin.register(MainProduct)
class MainProductAdmin(admin.ModelAdmin): class MainProductAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at") list_display = ("name", "created_by", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
list_filter = ("is_active", "show_on_homepage") list_filter = ("is_active", "show_on_homepage")
search_fields = ("name", "description") search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline] inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline]
fieldsets = ( fieldsets = (
( (
@@ -117,8 +207,23 @@ class MainProductAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}), ("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 model = ProductVideo
fk_name = "sub_product" fk_name = "sub_product"
extra = 0 extra = 0
@@ -142,10 +247,11 @@ class SubProductVideoInline(admin.StackedInline):
@admin.register(SubProduct) @admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin): class SubProductAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ( list_display = (
"name", "name",
"main_product", "main_product",
"created_by",
"show_on_homepage", "show_on_homepage",
"homepage_order", "homepage_order",
"order", "order",
@@ -155,7 +261,6 @@ class SubProductAdmin(admin.ModelAdmin):
list_filter = ("is_active", "main_product", "show_on_homepage") list_filter = ("is_active", "main_product", "show_on_homepage")
search_fields = ("name", "description", "main_product__name") search_fields = ("name", "description", "main_product__name")
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",) raw_id_fields = ("main_product",)
inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline] inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline]
fieldsets = ( fieldsets = (
@@ -177,13 +282,21 @@ class SubProductAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}), ("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) @admin.register(Article)
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "parent", "order", "created_at") list_display = ("title", "parent", "order", "created_at")
list_filter = ("main_product", "sub_product__main_product") list_filter = ("main_product", "sub_product__main_product")
search_fields = ("title", "description", "main_product__name", "sub_product__name") search_fields = ("title", "description", "main_product__name", "sub_product__name")
list_editable = ("order",)
raw_id_fields = ("main_product", "sub_product") raw_id_fields = ("main_product", "sub_product")
inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline] inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline]
fieldsets = ( fieldsets = (
@@ -193,6 +306,10 @@ class ArticleAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order",)}), ("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") @admin.display(description="Parent")
def parent(self, obj): def parent(self, obj):
if obj.main_product_id: if obj.main_product_id:
@@ -203,10 +320,9 @@ class ArticleAdmin(admin.ModelAdmin):
@admin.register(ProductVideo) @admin.register(ProductVideo)
class ProductVideoAdmin(admin.ModelAdmin): class ProductVideoAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "parent", "video_source", "video_size", "order", "is_active") list_display = ("title", "parent", "video_source", "video_size", "order", "is_active")
list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product") 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") search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name")
raw_id_fields = ("main_product", "sub_product") raw_id_fields = ("main_product", "sub_product")
readonly_fields = ("video_preview",) readonly_fields = ("video_preview",)
@@ -216,6 +332,10 @@ class ProductVideoAdmin(admin.ModelAdmin):
("Settings", {"fields": ("order", "is_active")}), ("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") @admin.display(description="Parent")
def parent(self, obj): def parent(self, obj):
if obj.main_product_id: if obj.main_product_id:
@@ -230,13 +350,16 @@ class ProductVideoAdmin(admin.ModelAdmin):
@admin.register(ArticleSection) @admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin): class ArticleSectionAdmin(ProjectAdminAccessMixin, admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order") list_display = ("title", "article", "value_format", "order")
search_fields = ("title", "value", "article__title") search_fields = ("title", "value", "article__title")
list_editable = ("order",)
raw_id_fields = ("article",) raw_id_fields = ("article",)
fieldsets = ( fieldsets = (
(None, {"fields": ("article", "title")}), (None, {"fields": ("article", "title")}),
("Content", {"fields": ("value_format", "value", "read_more_value")}), ("Content", {"fields": ("value_format", "value", "read_more_value")}),
("Settings", {"fields": ("order",)}), ("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 import os
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@@ -60,6 +61,10 @@ RELEASE_CHANNEL_LABELS = {
} }
class MainProduct(models.Model): 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) name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True) slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300) 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}) return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
class SubProduct(models.Model): 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( main_product = models.ForeignKey(
MainProduct, MainProduct,
on_delete=models.CASCADE, 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): class Article(models.Model):
main_product = models.ForeignKey( main_product = models.ForeignKey(
MainProduct, 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()