feat: add stage 0 foundation

This commit is contained in:
hailin
2026-06-30 15:55:46 +08:00
parent 6d373da09f
commit 604f456e03
68 changed files with 4884 additions and 0 deletions

1
backend/apps/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
from ninja import Router
router = Router(tags=["accounts"])

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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."))

View File

@@ -0,0 +1 @@
# Use Django's built-in User, Group, and Permission models for MVP RBAC.

View 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",
),
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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)

View File

@@ -0,0 +1 @@

26
backend/apps/audit/api.py Normal file
View 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]

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.audit"

View 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'),
],
},
),
]

View 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}"

View File

@@ -0,0 +1 @@

View 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"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@
from ninja import Router
router = Router(tags=["system"])
@router.get("/health")
def health(request):
return {"status": "ok"}

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SystemConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.system"

View File

@@ -0,0 +1 @@

View 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"}

View File

@@ -0,0 +1 @@

7
backend/config/asgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
application = get_asgi_application()

9
backend/config/celery.py Normal file
View File

@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
app = Celery("ai_ticket_platform")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,80 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parents[3]
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"corsheaders",
"apps.accounts",
"apps.audit",
"apps.system",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB", "ai_ticket_platform"),
"USER": os.getenv("POSTGRES_USER", "ai_ticket"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", "ai_ticket"),
"HOST": os.getenv("POSTGRES_HOST", "localhost"),
"PORT": int(os.getenv("POSTGRES_PORT", "5432")),
}
}
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CORS_ALLOWED_ORIGINS = os.getenv(
"CORS_ALLOWED_ORIGINS",
"http://localhost:5173",
).split(",")
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")

View File

@@ -0,0 +1,3 @@
from .base import * # noqa: F403
DEBUG = True

View File

@@ -0,0 +1,12 @@
from .base import * # noqa: F403
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]

15
backend/config/urls.py Normal file
View File

@@ -0,0 +1,15 @@
from django.contrib import admin
from django.urls import path
from ninja import NinjaAPI
from apps.audit.api import router as audit_router
from apps.system.api import router as system_router
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
api.add_router("/system", system_router)
api.add_router("/audit", audit_router)
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]

7
backend/config/wsgi.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
application = get_wsgi_application()

14
backend/manage.py Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

52
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,52 @@
[project]
name = "ai-ticket-platform-backend"
version = "0.1.0"
description = "Backend for AI-native technical support ticket platform"
requires-python = ">=3.12"
dependencies = [
"Django>=5.0,<5.3",
"django-ninja>=1.3,<2.0",
"psycopg[binary]>=3.2,<4.0",
"celery>=5.4,<6.0",
"redis>=5.0,<6.0",
"django-cors-headers>=4.4,<5.0",
"pydantic>=2.8,<3.0",
"python-dotenv>=1.0,<2.0",
]
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["apps*", "config*"]
[project.optional-dependencies]
dev = [
"pytest>=8.2,<9.0",
"pytest-django>=4.8,<5.0",
"ruff>=0.6,<1.0",
"mypy>=1.11,<2.0",
"django-stubs[compatible-mypy]>=5.0,<6.0",
]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings.test"
python_files = ["test_*.py", "*_test.py"]
testpaths = ["apps", "tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.12"
plugins = ["mypy_django_plugin.main"]
ignore_missing_imports = true
strict_optional = true
[tool.django-stubs]
django_settings_module = "config.settings.test"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@