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
+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,