feat: initialize deployment governance
This commit is contained in:
@@ -2,6 +2,10 @@ DJANGO_SECRET_KEY=dev-only-change-me
|
|||||||
DJANGO_DEBUG=true
|
DJANGO_DEBUG=true
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
DJANGO_SUPERUSER_USERNAME=admin
|
||||||
|
DJANGO_SUPERUSER_PASSWORD=admin12345
|
||||||
|
DJANGO_SUPERUSER_EMAIL=admin@example.com
|
||||||
|
|
||||||
POSTGRES_DB=ai_ticket_platform
|
POSTGRES_DB=ai_ticket_platform
|
||||||
POSTGRES_USER=ai_ticket
|
POSTGRES_USER=ai_ticket
|
||||||
@@ -14,3 +18,5 @@ CELERY_RESULT_BACKEND=redis://redis:6379/1
|
|||||||
|
|
||||||
MINIO_ROOT_USER=minio
|
MINIO_ROOT_USER=minio
|
||||||
MINIO_ROOT_PASSWORD=minio123
|
MINIO_ROOT_PASSWORD=minio123
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000/api
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from apps.accounts.menus import DEFAULT_MENUS
|
|||||||
from apps.accounts.models import MenuItem, UserGroup
|
from apps.accounts.models import MenuItem, UserGroup
|
||||||
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
||||||
|
|
||||||
|
LEGACY_ROLE_RENAMES = {
|
||||||
|
"System Admin": "系统管理员",
|
||||||
|
"Knowledge Admin": "知识库管理员",
|
||||||
|
"Support Engineer": "技术支持",
|
||||||
|
"Requester": "普通建单人",
|
||||||
|
"Auditor": "只读审计员",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def seed_permissions() -> dict[str, Permission]:
|
def seed_permissions() -> dict[str, Permission]:
|
||||||
content_type, _ = ContentType.objects.get_or_create(
|
content_type, _ = ContentType.objects.get_or_create(
|
||||||
@@ -29,11 +37,26 @@ def seed_permissions() -> dict[str, Permission]:
|
|||||||
|
|
||||||
|
|
||||||
def seed_roles(permissions_by_codename: dict[str, Permission]) -> None:
|
def seed_roles(permissions_by_codename: dict[str, Permission]) -> None:
|
||||||
|
migrate_legacy_roles()
|
||||||
for group_name, codenames in DEFAULT_GROUPS.items():
|
for group_name, codenames in DEFAULT_GROUPS.items():
|
||||||
group, _ = Group.objects.get_or_create(name=group_name)
|
group, _ = Group.objects.get_or_create(name=group_name)
|
||||||
group.permissions.set(permissions_by_codename[codename] for codename in codenames)
|
group.permissions.set(permissions_by_codename[codename] for codename in codenames)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_legacy_roles() -> None:
|
||||||
|
for legacy_name, target_name in LEGACY_ROLE_RENAMES.items():
|
||||||
|
legacy_group = Group.objects.filter(name=legacy_name).first()
|
||||||
|
if legacy_group is None:
|
||||||
|
continue
|
||||||
|
target_group = Group.objects.filter(name=target_name).first()
|
||||||
|
if target_group is None:
|
||||||
|
legacy_group.name = target_name
|
||||||
|
legacy_group.save(update_fields=["name"])
|
||||||
|
continue
|
||||||
|
target_group.user_set.add(*legacy_group.user_set.all())
|
||||||
|
legacy_group.delete()
|
||||||
|
|
||||||
|
|
||||||
def seed_user_groups() -> None:
|
def seed_user_groups() -> None:
|
||||||
UserGroup.objects.update_or_create(
|
UserGroup.objects.update_or_create(
|
||||||
code="default",
|
code="default",
|
||||||
|
|||||||
@@ -19,17 +19,74 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
|||||||
MenuSpec("workbench.overview", "运营总览", "#/", "home", "workbench", 10),
|
MenuSpec("workbench.overview", "运营总览", "#/", "home", "workbench", 10),
|
||||||
MenuSpec("business", "业务处理", icon="ticket", sort_order=20),
|
MenuSpec("business", "业务处理", icon="ticket", sort_order=20),
|
||||||
MenuSpec("business.tickets", "工单中心", "#/tickets", "ticket", "business", 10, "ticket.view"),
|
MenuSpec("business.tickets", "工单中心", "#/tickets", "ticket", "business", 10, "ticket.view"),
|
||||||
MenuSpec("business.ticket_create", "创建工单", "#/tickets/new", "briefcase", "business", 20, "ticket.create"),
|
MenuSpec(
|
||||||
MenuSpec("business.solutions", "解决方案库", "#/solutions", "library", "business", 30, "solution.view"),
|
"business.ticket_create",
|
||||||
|
"创建工单",
|
||||||
|
"#/tickets/new",
|
||||||
|
"briefcase",
|
||||||
|
"business",
|
||||||
|
20,
|
||||||
|
"ticket.create",
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"business.solutions",
|
||||||
|
"解决方案库",
|
||||||
|
"#/solutions",
|
||||||
|
"library",
|
||||||
|
"business",
|
||||||
|
30,
|
||||||
|
"solution.view",
|
||||||
|
),
|
||||||
MenuSpec("platform", "平台能力", icon="bot", sort_order=30),
|
MenuSpec("platform", "平台能力", icon="bot", sort_order=30),
|
||||||
MenuSpec("platform.knowledge", "知识库", "#/knowledge", "database", "platform", 10, "knowledge.view", is_enabled=False),
|
MenuSpec(
|
||||||
MenuSpec("platform.agents", "Agent 配置", "#/agents", "bot", "platform", 20, "agent.manage", is_enabled=False),
|
"platform.knowledge",
|
||||||
MenuSpec("platform.models", "模型与工具", "#/models", "settings", "platform", 30, "model.manage", is_enabled=False),
|
"知识库",
|
||||||
|
"#/knowledge",
|
||||||
|
"database",
|
||||||
|
"platform",
|
||||||
|
10,
|
||||||
|
"knowledge.view",
|
||||||
|
is_enabled=False,
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"platform.agents",
|
||||||
|
"Agent 配置",
|
||||||
|
"#/agents",
|
||||||
|
"bot",
|
||||||
|
"platform",
|
||||||
|
20,
|
||||||
|
"agent.manage",
|
||||||
|
is_enabled=False,
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"platform.models",
|
||||||
|
"模型与工具",
|
||||||
|
"#/models",
|
||||||
|
"settings",
|
||||||
|
"platform",
|
||||||
|
30,
|
||||||
|
"model.manage",
|
||||||
|
is_enabled=False,
|
||||||
|
),
|
||||||
MenuSpec("system", "系统治理", icon="shield", sort_order=40),
|
MenuSpec("system", "系统治理", icon="shield", sort_order=40),
|
||||||
MenuSpec("system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"),
|
MenuSpec(
|
||||||
MenuSpec("system.user_groups", "用户组管理", "#/system/user-groups", "user_groups", "system", 20, "system.user_group.view"),
|
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
|
||||||
MenuSpec("system.roles", "角色权限", "#/system/roles", "roles", "system", 30, "system.role.view"),
|
),
|
||||||
MenuSpec("system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"),
|
MenuSpec(
|
||||||
|
"system.user_groups",
|
||||||
|
"用户组管理",
|
||||||
|
"#/system/user-groups",
|
||||||
|
"user_groups",
|
||||||
|
"system",
|
||||||
|
20,
|
||||||
|
"system.user_group.view",
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"system.roles", "角色权限", "#/system/roles", "roles", "system", 30, "system.role.view"
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
|
||||||
|
),
|
||||||
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"),
|
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"),
|
||||||
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60),
|
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -15,39 +14,63 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='MenuItem',
|
name="MenuItem",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('code', models.CharField(max_length=120, unique=True)),
|
"id",
|
||||||
('title', models.CharField(max_length=120)),
|
models.BigAutoField(
|
||||||
('path', models.CharField(blank=True, max_length=200)),
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
('icon', models.CharField(blank=True, max_length=80)),
|
),
|
||||||
('sort_order', models.PositiveIntegerField(default=0)),
|
),
|
||||||
('permission_code', models.CharField(blank=True, max_length=120)),
|
("code", models.CharField(max_length=120, unique=True)),
|
||||||
('is_visible', models.BooleanField(default=True)),
|
("title", models.CharField(max_length=120)),
|
||||||
('is_enabled', models.BooleanField(default=True)),
|
("path", models.CharField(blank=True, max_length=200)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
("icon", models.CharField(blank=True, max_length=80)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
("sort_order", models.PositiveIntegerField(default=0)),
|
||||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='accounts.menuitem')),
|
("permission_code", models.CharField(blank=True, max_length=120)),
|
||||||
|
("is_visible", models.BooleanField(default=True)),
|
||||||
|
("is_enabled", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"parent",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="children",
|
||||||
|
to="accounts.menuitem",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['sort_order', 'id'],
|
"ordering": ["sort_order", "id"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='UserGroup',
|
name="UserGroup",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=120, unique=True)),
|
"id",
|
||||||
('code', models.CharField(max_length=80, unique=True)),
|
models.BigAutoField(
|
||||||
('description', models.TextField(blank=True)),
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
('is_active', models.BooleanField(default=True)),
|
),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
("name", models.CharField(max_length=120, unique=True)),
|
||||||
('users', models.ManyToManyField(blank=True, related_name='business_groups', to=settings.AUTH_USER_MODEL)),
|
("code", models.CharField(max_length=80, unique=True)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"users",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True, related_name="business_groups", to=settings.AUTH_USER_MODEL
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'ordering': ['name'],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ class UserGroup(models.Model):
|
|||||||
name = models.CharField(max_length=120, unique=True)
|
name = models.CharField(max_length=120, unique=True)
|
||||||
code = models.CharField(max_length=80, unique=True)
|
code = models.CharField(max_length=80, unique=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name="business_groups")
|
users = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL, blank=True, related_name="business_groups"
|
||||||
|
)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -21,3 +21,19 @@ def test_bootstrap_governance_seeds_admin_roles_groups_permissions_and_menus(db,
|
|||||||
assert UserGroup.objects.filter(code="default").exists()
|
assert UserGroup.objects.filter(code="default").exists()
|
||||||
assert MenuItem.objects.filter(code="system.users", title="用户管理").exists()
|
assert MenuItem.objects.filter(code="system.users", title="用户管理").exists()
|
||||||
assert MenuItem.objects.filter(code="system.roles", permission_code="system.role.view").exists()
|
assert MenuItem.objects.filter(code="system.roles", permission_code="system.role.view").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_governance_migrates_legacy_english_roles(db, settings):
|
||||||
|
legacy_group = Group.objects.create(name="Support Engineer")
|
||||||
|
user = get_user_model().objects.create_user(username="support", password="password")
|
||||||
|
user.groups.add(legacy_group)
|
||||||
|
|
||||||
|
settings.DJANGO_SUPERUSER_USERNAME = "admin"
|
||||||
|
settings.DJANGO_SUPERUSER_PASSWORD = "admin12345"
|
||||||
|
settings.DJANGO_SUPERUSER_EMAIL = "admin@example.com"
|
||||||
|
|
||||||
|
call_command("bootstrap_governance")
|
||||||
|
|
||||||
|
assert not Group.objects.filter(name="Support Engineer").exists()
|
||||||
|
support_role = Group.objects.get(name="技术支持")
|
||||||
|
assert support_role.user_set.filter(username="support").exists()
|
||||||
|
|||||||
@@ -83,5 +83,9 @@ CSRF_TRUSTED_ORIGINS = os.getenv(
|
|||||||
"http://localhost:5173,http://127.0.0.1:5173",
|
"http://localhost:5173,http://127.0.0.1:5173",
|
||||||
).split(",")
|
).split(",")
|
||||||
|
|
||||||
|
DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin")
|
||||||
|
DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345")
|
||||||
|
DJANGO_SUPERUSER_EMAIL = os.getenv("DJANGO_SUPERUSER_EMAIL", "admin@example.com")
|
||||||
|
|
||||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ cd infra
|
|||||||
docker compose --env-file ../.env up --build
|
docker compose --env-file ../.env up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
首次启动时,backend 容器会自动等待 PostgreSQL、执行数据库迁移,并初始化企业后台治理数据。
|
||||||
|
|
||||||
Backend:
|
Backend:
|
||||||
|
|
||||||
- API: http://localhost:8000/api/docs
|
- API: http://localhost:8000/api/docs
|
||||||
@@ -23,6 +25,12 @@ Frontend:
|
|||||||
|
|
||||||
- App: http://localhost:5173
|
- App: http://localhost:5173
|
||||||
|
|
||||||
|
默认管理员:
|
||||||
|
|
||||||
|
- 账号: `admin`
|
||||||
|
- 密码: `admin12345`
|
||||||
|
- 可通过 `.env` 中的 `DJANGO_SUPERUSER_USERNAME`、`DJANGO_SUPERUSER_PASSWORD`、`DJANGO_SUPERUSER_EMAIL` 修改。
|
||||||
|
|
||||||
## Backend Tests
|
## Backend Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAuth } from './AuthProvider'
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const [username, setUsername] = React.useState('admin')
|
const [username, setUsername] = React.useState('admin')
|
||||||
const [password, setPassword] = React.useState('admin123')
|
const [password, setPassword] = React.useState('admin12345')
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
|
||||||
|
|||||||
26
infra/backend-entrypoint.sh
Normal file
26
infra/backend-entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
host = os.environ.get("POSTGRES_HOST", "postgres")
|
||||||
|
port = int(os.environ.get("POSTGRES_PORT", "5432"))
|
||||||
|
deadline = time.time() + int(os.environ.get("POSTGRES_WAIT_TIMEOUT", "60"))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=2):
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.time() > deadline:
|
||||||
|
raise SystemExit(f"Timed out waiting for PostgreSQL at {host}:{port}")
|
||||||
|
time.sleep(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py bootstrap_governance
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -9,5 +9,7 @@ COPY backend/pyproject.toml /app/pyproject.toml
|
|||||||
RUN pip install --no-cache-dir -e ".[dev]"
|
RUN pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
COPY backend /app
|
COPY backend /app
|
||||||
|
COPY infra/backend-entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: infra/backend.Dockerfile
|
dockerfile: infra/backend.Dockerfile
|
||||||
|
entrypoint: ["/entrypoint.sh"]
|
||||||
|
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
env_file:
|
env_file:
|
||||||
|
- path: ../.env.example
|
||||||
|
required: true
|
||||||
- path: ../.env
|
- path: ../.env
|
||||||
required: false
|
required: false
|
||||||
ports:
|
ports:
|
||||||
@@ -46,10 +50,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: infra/backend.Dockerfile
|
dockerfile: infra/backend.Dockerfile
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
env_file:
|
env_file:
|
||||||
|
- path: ../.env.example
|
||||||
|
required: true
|
||||||
- path: ../.env
|
- path: ../.env
|
||||||
required: false
|
required: false
|
||||||
command: celery -A config.celery worker -l info
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- redis
|
- redis
|
||||||
@@ -60,6 +66,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: infra/frontend.Dockerfile
|
dockerfile: infra/frontend.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- path: ../.env.example
|
||||||
|
required: true
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user