feat: add stage 0 foundation
This commit is contained in:
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/accounts/__init__.py
Normal file
1
backend/apps/accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
backend/apps/accounts/api.py
Normal file
3
backend/apps/accounts/api.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ninja import Router
|
||||
|
||||
router = Router(tags=["accounts"])
|
||||
6
backend/apps/accounts/apps.py
Normal file
6
backend/apps/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
1
backend/apps/accounts/management/__init__.py
Normal file
1
backend/apps/accounts/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/accounts/management/commands/__init__.py
Normal file
1
backend/apps/accounts/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed MVP permissions and default user groups."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
content_type, _ = ContentType.objects.get_or_create(
|
||||
app_label="system",
|
||||
model="platformpermission",
|
||||
)
|
||||
|
||||
permissions_by_codename: dict[str, Permission] = {}
|
||||
for spec in PERMISSIONS:
|
||||
permission, _ = Permission.objects.get_or_create(
|
||||
content_type=content_type,
|
||||
codename=spec.codename,
|
||||
defaults={"name": spec.name},
|
||||
)
|
||||
if permission.name != spec.name:
|
||||
permission.name = spec.name
|
||||
permission.save(update_fields=["name"])
|
||||
permissions_by_codename[spec.codename] = permission
|
||||
|
||||
for group_name, codenames in DEFAULT_GROUPS.items():
|
||||
group, _ = Group.objects.get_or_create(name=group_name)
|
||||
group.permissions.set(permissions_by_codename[codename] for codename in codenames)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded permissions and groups."))
|
||||
1
backend/apps/accounts/models.py
Normal file
1
backend/apps/accounts/models.py
Normal file
@@ -0,0 +1 @@
|
||||
# Use Django's built-in User, Group, and Permission models for MVP RBAC.
|
||||
61
backend/apps/accounts/permissions.py
Normal file
61
backend/apps/accounts/permissions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionSpec:
|
||||
codename: str
|
||||
name: str
|
||||
|
||||
|
||||
PERMISSIONS: tuple[PermissionSpec, ...] = (
|
||||
PermissionSpec("ticket.view", "View tickets"),
|
||||
PermissionSpec("ticket.create", "Create tickets"),
|
||||
PermissionSpec("ticket.edit", "Edit tickets"),
|
||||
PermissionSpec("ticket.export", "Export tickets"),
|
||||
PermissionSpec("ticket.assign", "Assign tickets"),
|
||||
PermissionSpec("ticket.close", "Close tickets"),
|
||||
PermissionSpec("knowledge.view", "View knowledge"),
|
||||
PermissionSpec("knowledge.manage", "Manage knowledge"),
|
||||
PermissionSpec("solution.view", "View solutions"),
|
||||
PermissionSpec("solution.manage", "Manage solutions"),
|
||||
PermissionSpec("agent.use", "Use Agents"),
|
||||
PermissionSpec("agent.manage", "Manage Agents"),
|
||||
PermissionSpec("model.manage", "Manage model configs"),
|
||||
PermissionSpec("tool.manage", "Manage MCP tools"),
|
||||
PermissionSpec("audit.view", "View audit logs"),
|
||||
PermissionSpec("system.manage", "Manage system settings"),
|
||||
)
|
||||
|
||||
DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
|
||||
"System Admin": tuple(permission.codename for permission in PERMISSIONS),
|
||||
"Knowledge Admin": (
|
||||
"ticket.view",
|
||||
"knowledge.view",
|
||||
"knowledge.manage",
|
||||
"solution.view",
|
||||
"solution.manage",
|
||||
"agent.use",
|
||||
),
|
||||
"Support Engineer": (
|
||||
"ticket.view",
|
||||
"ticket.create",
|
||||
"ticket.edit",
|
||||
"ticket.assign",
|
||||
"ticket.close",
|
||||
"knowledge.view",
|
||||
"solution.view",
|
||||
"agent.use",
|
||||
),
|
||||
"Requester": (
|
||||
"ticket.view",
|
||||
"ticket.create",
|
||||
"knowledge.view",
|
||||
"solution.view",
|
||||
),
|
||||
"Auditor": (
|
||||
"ticket.view",
|
||||
"knowledge.view",
|
||||
"solution.view",
|
||||
"audit.view",
|
||||
),
|
||||
}
|
||||
1
backend/apps/accounts/schemas.py
Normal file
1
backend/apps/accounts/schemas.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/accounts/tests/__init__.py
Normal file
1
backend/apps/accounts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/apps/accounts/tests/test_permissions_seed.py
Normal file
17
backend/apps/accounts/tests/test_permissions_seed.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
||||
|
||||
|
||||
def test_seed_permissions_creates_permissions_and_groups(db):
|
||||
call_command("seed_permissions")
|
||||
|
||||
assert Permission.objects.filter(
|
||||
codename__in=[permission.codename for permission in PERMISSIONS]
|
||||
).count() == len(PERMISSIONS)
|
||||
|
||||
for group_name, codenames in DEFAULT_GROUPS.items():
|
||||
group = Group.objects.get(name=group_name)
|
||||
actual = set(group.permissions.values_list("codename", flat=True))
|
||||
assert actual == set(codenames)
|
||||
1
backend/apps/audit/__init__.py
Normal file
1
backend/apps/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
26
backend/apps/audit/api.py
Normal file
26
backend/apps/audit/api.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ninja import Router, Schema
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
|
||||
router = Router(tags=["audit"])
|
||||
|
||||
|
||||
class OperationAuditLogOut(Schema):
|
||||
id: int
|
||||
module: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
request_snapshot: dict[str, Any]
|
||||
result: str
|
||||
ip_address: str | None
|
||||
user_agent: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.get("/operation-logs", response=list[OperationAuditLogOut])
|
||||
def list_operation_logs(request):
|
||||
return OperationAuditLog.objects.all()[:100]
|
||||
6
backend/apps/audit/apps.py
Normal file
6
backend/apps/audit/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.audit"
|
||||
61
backend/apps/audit/migrations/0001_initial.py
Normal file
61
backend/apps/audit/migrations/0001_initial.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-30 07:47
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OperationAuditLog',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
('module', models.CharField(max_length=80)),
|
||||
('action', models.CharField(max_length=80)),
|
||||
('target_type', models.CharField(max_length=80)),
|
||||
('target_id', models.CharField(blank=True, max_length=120)),
|
||||
('request_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('result', models.CharField(max_length=20)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='operation_audit_logs',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [
|
||||
models.Index(fields=['module', 'action'], name='audit_opera_module_8f87af_idx'),
|
||||
models.Index(
|
||||
fields=['target_type', 'target_id'],
|
||||
name='audit_opera_target__560d76_idx',
|
||||
),
|
||||
models.Index(fields=['created_at'], name='audit_opera_created_077b40_idx'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/audit/migrations/__init__.py
Normal file
0
backend/apps/audit/migrations/__init__.py
Normal file
32
backend/apps/audit/models.py
Normal file
32
backend/apps/audit/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class OperationAuditLog(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="operation_audit_logs",
|
||||
)
|
||||
module = models.CharField(max_length=80)
|
||||
action = models.CharField(max_length=80)
|
||||
target_type = models.CharField(max_length=80)
|
||||
target_id = models.CharField(max_length=120, blank=True)
|
||||
request_snapshot = models.JSONField(default=dict, blank=True)
|
||||
result = models.CharField(max_length=20)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["module", "action"]),
|
||||
models.Index(fields=["target_type", "target_id"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.module}.{self.action}:{self.result}"
|
||||
1
backend/apps/audit/tests/__init__.py
Normal file
1
backend/apps/audit/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
20
backend/apps/audit/tests/test_audit_api.py
Normal file
20
backend/apps/audit/tests/test_audit_api.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from apps.audit.models import OperationAuditLog
|
||||
|
||||
|
||||
def test_list_operation_logs_returns_recent_logs(client, db):
|
||||
OperationAuditLog.objects.create(
|
||||
module="system",
|
||||
action="health_check",
|
||||
target_type="system",
|
||||
target_id="health",
|
||||
result="success",
|
||||
request_snapshot={"path": "/api/system/health"},
|
||||
)
|
||||
|
||||
response = client.get("/api/audit/operation-logs")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["module"] == "system"
|
||||
assert payload[0]["action"] == "health_check"
|
||||
1
backend/apps/system/__init__.py
Normal file
1
backend/apps/system/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
backend/apps/system/api.py
Normal file
8
backend/apps/system/api.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from ninja import Router
|
||||
|
||||
router = Router(tags=["system"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(request):
|
||||
return {"status": "ok"}
|
||||
6
backend/apps/system/apps.py
Normal file
6
backend/apps/system/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SystemConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.system"
|
||||
1
backend/apps/system/tests/__init__.py
Normal file
1
backend/apps/system/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
backend/apps/system/tests/test_health_api.py
Normal file
5
backend/apps/system/tests/test_health_api.py
Normal file
@@ -0,0 +1,5 @@
|
||||
def test_health_endpoint_returns_ok(client):
|
||||
response = client.get("/api/system/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
Reference in New Issue
Block a user