diff --git a/README.md b/README.md index 6968811..6415655 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,25 @@ Demo content includes: 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 Copy `.env.example` to `.env` only when you want to override the local defaults: diff --git a/apps/products/access.py b/apps/products/access.py new file mode 100644 index 0000000..80c1ac7 --- /dev/null +++ b/apps/products/access.py @@ -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 diff --git a/apps/products/admin.py b/apps/products/admin.py index 2125de7..5ac830b 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -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 diff --git a/apps/products/migrations/0027_mainproduct_created_by_subproduct_created_by_and_more.py b/apps/products/migrations/0027_mainproduct_created_by_subproduct_created_by_and_more.py new file mode 100644 index 0000000..28d74e7 --- /dev/null +++ b/apps/products/migrations/0027_mainproduct_created_by_subproduct_created_by_and_more.py @@ -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')], + }, + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index 4ae09f3..b688282 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -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, diff --git a/apps/products/tests/test_access.py b/apps/products/tests/test_access.py new file mode 100644 index 0000000..95fcc6a --- /dev/null +++ b/apps/products/tests/test_access.py @@ -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()