feat: scaffold Django multi-tenant project with 5 of 9 apps
Phase 1 scaffolding: config/, core/, base models, AES-256-GCM phone encryption, enums mirror apps.tenant: Tenant + Domain (django-tenants) apps.org: 11 models (OrgUnit hierarchy, Staff, audit logs) apps.account: 4 models (UserAccount as AUTH_USER_MODEL, login/password tracking) apps.permission: 7 models (RBAC + overrides + datascope + append-only changelog) apps.region: 5 models (District, BusinessArea, MetroLine, MetroStation, School) All migrations generated, manage.py check passes
This commit is contained in:
18
apps/permission/models/__init__.py
Normal file
18
apps/permission/models/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from apps.permission.models.permission_def import PermissionDef
|
||||
from apps.permission.models.role import Role, RolePermission
|
||||
from apps.permission.models.staff_perm import (
|
||||
PermissionChangeLog,
|
||||
StaffDataScope,
|
||||
StaffPermissionOverride,
|
||||
StaffRole,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PermissionChangeLog",
|
||||
"PermissionDef",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"StaffDataScope",
|
||||
"StaffPermissionOverride",
|
||||
"StaffRole",
|
||||
]
|
||||
46
apps/permission/models/permission_def.py
Normal file
46
apps/permission/models/permission_def.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
|
||||
from core.enums import PermissionModule, PermissionValueType
|
||||
from core.models.base import TimeStampedModel
|
||||
|
||||
|
||||
class PermissionDef(TimeStampedModel):
|
||||
code = models.CharField(max_length=150, unique=True)
|
||||
module = models.CharField(max_length=50, choices=PermissionModule.choices)
|
||||
sub_module = models.CharField(max_length=50, blank=True, default="")
|
||||
group_name = models.CharField(max_length=100)
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True, default="")
|
||||
value_type = models.CharField(max_length=20, choices=PermissionValueType.choices)
|
||||
scope_choices = models.JSONField(default=list, blank=True)
|
||||
integer_min = models.IntegerField(null=True, blank=True)
|
||||
integer_max = models.IntegerField(null=True, blank=True)
|
||||
default_value = models.JSONField(default=dict)
|
||||
max_allowed_categories = ArrayField(
|
||||
models.CharField(max_length=50),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_deprecated = models.BooleanField(default=False)
|
||||
version = models.PositiveIntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
db_table = "permission_defs"
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["module", "sub_module", "sort_order"],
|
||||
name="idx_perm_defs_module",
|
||||
condition=models.Q(is_active=True),
|
||||
),
|
||||
models.Index(
|
||||
fields=["is_active"],
|
||||
name="idx_perm_defs_active",
|
||||
condition=models.Q(is_active=True),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.code} ({self.value_type})"
|
||||
91
apps/permission/models/role.py
Normal file
91
apps/permission/models/role.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import PermissionRoleCategory
|
||||
from core.models.base import SoftDeleteModel, TimeStampedModel
|
||||
|
||||
|
||||
class Role(SoftDeleteModel):
|
||||
name = models.CharField(max_length=100)
|
||||
category = models.CharField(max_length=30, choices=PermissionRoleCategory.choices)
|
||||
description = models.TextField(blank=True, default="")
|
||||
template_role = models.ForeignKey(
|
||||
"fonrey_permission.Role",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="derived_roles",
|
||||
)
|
||||
is_system_builtin = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="permission_roles_created",
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="permission_roles_updated",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "roles"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name"],
|
||||
name="uq_roles_name_active",
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["category"],
|
||||
name="idx_roles_category",
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
),
|
||||
models.Index(fields=["template_role"], name="idx_roles_template"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.category})"
|
||||
|
||||
|
||||
class RolePermission(TimeStampedModel):
|
||||
role = models.ForeignKey(
|
||||
"fonrey_permission.Role",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="permissions",
|
||||
)
|
||||
permission_def = models.ForeignKey(
|
||||
"fonrey_permission.PermissionDef",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="role_assignments",
|
||||
)
|
||||
value = models.JSONField()
|
||||
updated_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="role_permissions_updated",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "role_permissions"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["role", "permission_def"],
|
||||
name="uq_role_permissions",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["role"], name="idx_role_permissions_role"),
|
||||
models.Index(fields=["permission_def"], name="idx_role_permissions_def"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.role.name} → {self.permission_def.code}"
|
||||
200
apps/permission/models/staff_perm.py
Normal file
200
apps/permission/models/staff_perm.py
Normal file
@@ -0,0 +1,200 @@
|
||||
from django.db import models
|
||||
|
||||
from core.enums import (
|
||||
PermissionChangeAction,
|
||||
PermissionChangeTargetType,
|
||||
PermissionDataScopeType,
|
||||
PermissionOverrideMode,
|
||||
)
|
||||
from core.models.base import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
|
||||
|
||||
class StaffRole(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="staff_roles",
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
"fonrey_permission.Role",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="staff_links",
|
||||
)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
assigned_at = models.DateTimeField(auto_now_add=True)
|
||||
assigned_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="staff_role_assignments_made",
|
||||
)
|
||||
valid_from = models.DateField(null=True, blank=True)
|
||||
valid_until = models.DateField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_roles"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff", "role"],
|
||||
name="uq_staff_roles",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["staff"],
|
||||
condition=models.Q(is_primary=True),
|
||||
name="uq_staff_roles_primary",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["role"], name="idx_staff_roles_role"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
marker = " [primary]" if self.is_primary else ""
|
||||
return f"{self.staff_id} → {self.role_id}{marker}"
|
||||
|
||||
|
||||
class StaffPermissionOverride(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="permission_overrides",
|
||||
)
|
||||
permission_def = models.ForeignKey(
|
||||
"fonrey_permission.PermissionDef",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="staff_overrides",
|
||||
)
|
||||
value = models.JSONField()
|
||||
override_mode = models.CharField(
|
||||
max_length=10,
|
||||
choices=PermissionOverrideMode.choices,
|
||||
default=PermissionOverrideMode.REPLACE,
|
||||
)
|
||||
reason = models.TextField(blank=True, default="")
|
||||
modified_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="staff_overrides_modified",
|
||||
)
|
||||
modified_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_permission_overrides"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["staff", "permission_def"],
|
||||
name="uq_staff_overrides",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["staff"], name="idx_staff_overrides_staff"),
|
||||
]
|
||||
|
||||
|
||||
class StaffDataScope(UUIDPrimaryKeyModel):
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="data_scopes",
|
||||
)
|
||||
scope_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=PermissionDataScopeType.choices,
|
||||
)
|
||||
org_unit = models.ForeignKey(
|
||||
"org.OrgUnit",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="data_scope_grants",
|
||||
)
|
||||
is_readable = models.BooleanField(default=True)
|
||||
is_writable = models.BooleanField(default=False)
|
||||
granted_by = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="data_scopes_granted",
|
||||
)
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
reason = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "staff_data_scopes"
|
||||
indexes = [
|
||||
models.Index(fields=["staff"], name="idx_data_scopes_staff"),
|
||||
models.Index(fields=["org_unit"], name="idx_data_scopes_org"),
|
||||
models.Index(
|
||||
fields=["expires_at"],
|
||||
name="idx_data_scopes_expires",
|
||||
condition=models.Q(expires_at__isnull=False),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PermissionChangeLog(UUIDPrimaryKeyModel):
|
||||
target_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=PermissionChangeTargetType.choices,
|
||||
)
|
||||
target_id = models.UUIDField()
|
||||
staff = models.ForeignKey(
|
||||
"org.Staff",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="permission_change_logs_affecting",
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
"fonrey_permission.Role",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="change_logs",
|
||||
)
|
||||
permission_code = models.CharField(max_length=150, blank=True, default="")
|
||||
action = models.CharField(max_length=20, choices=PermissionChangeAction.choices)
|
||||
old_value = models.JSONField(null=True, blank=True)
|
||||
new_value = models.JSONField(null=True, blank=True)
|
||||
operator = models.ForeignKey(
|
||||
"org.Staff",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="permission_changes_operated",
|
||||
)
|
||||
operator_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True, default="")
|
||||
reason = models.TextField(blank=True, default="")
|
||||
operated_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "permission_change_logs"
|
||||
ordering = ["-operated_at"]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["staff", "-operated_at"],
|
||||
name="idx_perm_log_staff",
|
||||
condition=models.Q(staff__isnull=False),
|
||||
),
|
||||
models.Index(
|
||||
fields=["role", "-operated_at"],
|
||||
name="idx_perm_log_role",
|
||||
condition=models.Q(role__isnull=False),
|
||||
),
|
||||
models.Index(
|
||||
fields=["target_type", "target_id", "-operated_at"],
|
||||
name="idx_perm_log_target",
|
||||
),
|
||||
models.Index(
|
||||
fields=["operator", "-operated_at"],
|
||||
name="idx_perm_log_operator",
|
||||
),
|
||||
models.Index(fields=["-operated_at"], name="idx_perm_log_time"),
|
||||
]
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
raise NotImplementedError("PermissionChangeLog is append-only and cannot be deleted.")
|
||||
Reference in New Issue
Block a user