forked from erp-dev/erp
feat: added frontend_pages models(basic_info) and api(v2), support employees query pages that they can visit by themself token
This commit is contained in:
@@ -142,12 +142,33 @@ class EmployeeAdmin(AdminBase):
|
||||
|
||||
@admin.register(models.EmployeeType)
|
||||
class EmployeeTypeAdmin(AdminBase):
|
||||
list_display = ('id', 'title', 'description')
|
||||
list_display = ('id', 'title', 'description', 'visible_pages_count')
|
||||
search_fields = ('title', 'description')
|
||||
filter_horizontal = ('visible_pages',) # 便于多对多字段选择
|
||||
|
||||
def get_readonly_fields(self, request, obj=...):
|
||||
return []
|
||||
|
||||
@admin.display(description='可见页面数')
|
||||
def visible_pages_count(self, obj):
|
||||
return obj.visible_pages.count()
|
||||
|
||||
|
||||
@admin.register(models.FrontendPage)
|
||||
class FrontendPageAdmin(AdminBase):
|
||||
"""前端页面管理"""
|
||||
list_display = ('key', 'label', 'page_type', 'parent', 'sort_order', 'is_active')
|
||||
list_filter = ('page_type', 'is_active', 'parent')
|
||||
search_fields = ('key', 'label')
|
||||
list_editable = ('sort_order', 'is_active')
|
||||
ordering = ('sort_order', 'id')
|
||||
|
||||
def get_readonly_fields(self, request, obj=...):
|
||||
# key 字段创建后不可修改
|
||||
if obj:
|
||||
return ['key']
|
||||
return []
|
||||
|
||||
|
||||
@admin.register(models.DeviceInfo)
|
||||
class DeviceInfoAdmin(AdminBase):
|
||||
|
||||
0
basic_info/management/__init__.py
Normal file
0
basic_info/management/__init__.py
Normal file
0
basic_info/management/commands/__init__.py
Normal file
0
basic_info/management/commands/__init__.py
Normal file
164
basic_info/management/commands/sync_frontend_pages.py
Normal file
164
basic_info/management/commands/sync_frontend_pages.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
同步前端页面数据
|
||||
|
||||
从 docs/menu_keys.json 文件同步前端页面到数据库。
|
||||
|
||||
用法:
|
||||
python manage.py sync_frontend_pages
|
||||
python manage.py sync_frontend_pages --dry-run # 仅预览,不实际写入
|
||||
python manage.py sync_frontend_pages --clear # 清空后重新同步
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from basic_info.models import FrontendPage, FrontendPageTypeEnum
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '从 docs/menu_keys.json 同步前端页面到数据库'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='仅预览变更,不实际写入数据库',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear',
|
||||
action='store_true',
|
||||
help='清空现有数据后重新同步(谨慎使用)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--file',
|
||||
type=str,
|
||||
default='docs/menu_keys.json',
|
||||
help='JSON 文件路径(相对于项目根目录)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
clear = options['clear']
|
||||
file_path = options['file']
|
||||
|
||||
# 读取 JSON 文件
|
||||
# __file__ = .../basic_info/management/commands/sync_frontend_pages.py
|
||||
# parents[3] = 项目根目录 (flower/)
|
||||
json_path = Path(__file__).resolve().parents[3] / file_path
|
||||
if not json_path.exists():
|
||||
self.stderr.write(self.style.ERROR(f'文件不存在: {json_path}'))
|
||||
return
|
||||
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
menus = data.get('menus', [])
|
||||
if not menus:
|
||||
self.stderr.write(self.style.ERROR('JSON 文件中没有 menus 数据'))
|
||||
return
|
||||
|
||||
self.stdout.write(f'读取到 {len(menus)} 个主菜单')
|
||||
|
||||
# 统计
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# 清空现有数据
|
||||
if clear:
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('[DRY-RUN] 将清空所有前端页面数据'))
|
||||
else:
|
||||
deleted_count, _ = FrontendPage.objects.all().delete()
|
||||
self.stdout.write(self.style.WARNING(f'已清空 {deleted_count} 条记录'))
|
||||
|
||||
# 第一轮:创建/更新主菜单
|
||||
main_menu_map = {} # key -> FrontendPage instance
|
||||
for sort_order, menu in enumerate(menus):
|
||||
key = menu['key']
|
||||
label = menu['label']
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(f'[DRY-RUN] 主菜单: {key} ({label})')
|
||||
created_count += 1
|
||||
else:
|
||||
page, created = FrontendPage.objects.update_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
'label': label,
|
||||
'page_type': FrontendPageTypeEnum.MAIN,
|
||||
'parent': None,
|
||||
'sort_order': sort_order * 100, # 主菜单间隔100
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
main_menu_map[key] = page
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f'创建主菜单: {key} ({label})'))
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(f'更新主菜单: {key} ({label})')
|
||||
|
||||
# 第二轮:创建/更新子菜单
|
||||
for menu in menus:
|
||||
parent_key = menu['key']
|
||||
children = menu.get('children', [])
|
||||
|
||||
parent_page = main_menu_map.get(parent_key) if not dry_run else None
|
||||
|
||||
for sub_order, child in enumerate(children):
|
||||
child_key = child['key']
|
||||
child_label = child['label']
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(f'[DRY-RUN] 子菜单: {child_key} ({child_label}) <- {parent_key}')
|
||||
created_count += 1
|
||||
else:
|
||||
# 计算排序:父级排序 + 子级偏移
|
||||
parent_sort = parent_page.sort_order if parent_page else 0
|
||||
child_sort = parent_sort + sub_order + 1
|
||||
|
||||
page, created = FrontendPage.objects.update_or_create(
|
||||
key=child_key,
|
||||
defaults={
|
||||
'label': child_label,
|
||||
'page_type': FrontendPageTypeEnum.SUB,
|
||||
'parent': parent_page,
|
||||
'sort_order': child_sort,
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(self.style.SUCCESS(f' 创建子菜单: {child_key} ({child_label})'))
|
||||
else:
|
||||
updated_count += 1
|
||||
self.stdout.write(f' 更新子菜单: {child_key} ({child_label})')
|
||||
|
||||
if dry_run:
|
||||
# 回滚事务
|
||||
raise DryRunException()
|
||||
|
||||
except DryRunException:
|
||||
pass
|
||||
|
||||
# 输出统计
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS('=' * 40))
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f'[DRY-RUN] 预计创建: {created_count} 条'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'创建: {created_count} 条'))
|
||||
self.stdout.write(f'更新: {updated_count} 条')
|
||||
self.stdout.write(f'总计: {FrontendPage.objects.count()} 条')
|
||||
|
||||
|
||||
class DryRunException(Exception):
|
||||
"""用于 dry-run 模式回滚事务"""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2026-01-07 05:20
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0023_alter_product_segment_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FrontendPage',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('key', models.CharField(db_index=True, help_text='前端路由 key,如 plate-order-kanban', max_length=100, unique=True, verbose_name='页面唯一标识')),
|
||||
('label', models.CharField(max_length=100, verbose_name='页面名称')),
|
||||
('page_type', models.CharField(choices=[('main', '主菜单'), ('sub', '子菜单')], default='sub', max_length=10, verbose_name='页面类型')),
|
||||
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='basic_info.frontendpage', verbose_name='父级菜单')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '前端页面',
|
||||
'verbose_name_plural': '前端页面',
|
||||
'ordering': ['sort_order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='employeetype',
|
||||
name='visible_pages',
|
||||
field=models.ManyToManyField(blank=True, help_text='授予主菜单将自动包含其所有子菜单', related_name='employee_types', to='basic_info.frontendpage', verbose_name='可见页面'),
|
||||
),
|
||||
]
|
||||
@@ -70,6 +70,54 @@ class DeviceTypeEnum(models.TextChoices):
|
||||
# ==================== 模型定义 ====================
|
||||
|
||||
|
||||
class FrontendPageTypeEnum(models.TextChoices):
|
||||
"""前端页面类型枚举"""
|
||||
MAIN = 'main', '主菜单'
|
||||
SUB = 'sub', '子菜单'
|
||||
|
||||
|
||||
class FrontendPage(ModelBase):
|
||||
"""
|
||||
前端页面/菜单项
|
||||
|
||||
用于管理前端可见页面,与员工岗位关联实现权限控制。
|
||||
数据来源:docs/menu_keys.json,通过 sync_frontend_pages 命令同步。
|
||||
"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
key = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
verbose_name='页面唯一标识',
|
||||
help_text='前端路由 key,如 plate-order-kanban'
|
||||
)
|
||||
label = models.CharField(max_length=100, verbose_name='页面名称')
|
||||
page_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=FrontendPageTypeEnum.choices,
|
||||
default=FrontendPageTypeEnum.SUB,
|
||||
verbose_name='页面类型'
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='children',
|
||||
verbose_name='父级菜单'
|
||||
)
|
||||
sort_order = models.IntegerField(default=0, verbose_name='排序')
|
||||
is_active = models.BooleanField(default=True, verbose_name='是否启用')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.label} ({self.key})'
|
||||
|
||||
class Meta:
|
||||
verbose_name = '前端页面'
|
||||
verbose_name_plural = '前端页面'
|
||||
ordering = ['sort_order', 'id']
|
||||
|
||||
|
||||
class EmployeeType(ModelBase):
|
||||
"""员工职位类型"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
@@ -77,6 +125,13 @@ class EmployeeType(ModelBase):
|
||||
title = models.CharField(max_length=50, verbose_name='职位名称')
|
||||
description = models.TextField(blank=True, null=True, verbose_name='职位描述')
|
||||
reverse = models.CharField(max_length=100, blank=True, null=True, verbose_name='预留字段')
|
||||
visible_pages = models.ManyToManyField(
|
||||
'FrontendPage',
|
||||
blank=True,
|
||||
related_name='employee_types',
|
||||
verbose_name='可见页面',
|
||||
help_text='授予主菜单将自动包含其所有子菜单'
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .models import (
|
||||
Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum,
|
||||
WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum
|
||||
WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum,
|
||||
FrontendPage, FrontendPageTypeEnum
|
||||
)
|
||||
|
||||
|
||||
@@ -164,3 +169,211 @@ class EmployeeTypeTestCase(TestCase):
|
||||
self.assertEqual(emp1.job_type, '打纸工')
|
||||
self.assertEqual(emp2.job_type, '打纸工')
|
||||
self.assertEqual(emp_type.employees.count(), 2)
|
||||
|
||||
|
||||
class FrontendPageTestCase(TestCase):
|
||||
"""测试前端页面模型"""
|
||||
|
||||
def test_create_main_menu(self):
|
||||
"""测试创建主菜单"""
|
||||
page = FrontendPage.objects.create(
|
||||
key='plate-opening',
|
||||
label='开版管理',
|
||||
page_type=FrontendPageTypeEnum.MAIN,
|
||||
sort_order=0
|
||||
)
|
||||
self.assertEqual(page.key, 'plate-opening')
|
||||
self.assertEqual(page.label, '开版管理')
|
||||
self.assertEqual(page.page_type, FrontendPageTypeEnum.MAIN)
|
||||
self.assertIsNone(page.parent)
|
||||
self.assertTrue(page.is_active)
|
||||
self.assertEqual(str(page), '开版管理 (plate-opening)')
|
||||
|
||||
def test_create_sub_menu_with_parent(self):
|
||||
"""测试创建子菜单并关联父级"""
|
||||
parent = FrontendPage.objects.create(
|
||||
key='plate-opening',
|
||||
label='开版管理',
|
||||
page_type=FrontendPageTypeEnum.MAIN
|
||||
)
|
||||
child = FrontendPage.objects.create(
|
||||
key='plate-order',
|
||||
label='开版订单',
|
||||
page_type=FrontendPageTypeEnum.SUB,
|
||||
parent=parent
|
||||
)
|
||||
self.assertEqual(child.parent, parent)
|
||||
self.assertEqual(parent.children.count(), 1)
|
||||
self.assertEqual(parent.children.first(), child)
|
||||
|
||||
def test_key_unique_constraint(self):
|
||||
"""测试 key 字段唯一性约束"""
|
||||
FrontendPage.objects.create(key='test-page', label='测试页面')
|
||||
with self.assertRaises(Exception):
|
||||
FrontendPage.objects.create(key='test-page', label='重复页面')
|
||||
|
||||
def test_cascade_delete(self):
|
||||
"""测试删除父级时子级也被删除"""
|
||||
parent = FrontendPage.objects.create(
|
||||
key='parent',
|
||||
label='父级',
|
||||
page_type=FrontendPageTypeEnum.MAIN
|
||||
)
|
||||
FrontendPage.objects.create(
|
||||
key='child1',
|
||||
label='子级1',
|
||||
parent=parent
|
||||
)
|
||||
FrontendPage.objects.create(
|
||||
key='child2',
|
||||
label='子级2',
|
||||
parent=parent
|
||||
)
|
||||
self.assertEqual(FrontendPage.objects.count(), 3)
|
||||
parent.delete()
|
||||
self.assertEqual(FrontendPage.objects.count(), 0)
|
||||
|
||||
|
||||
class EmployeeTypeVisiblePagesTestCase(TestCase):
|
||||
"""测试员工职位类型的可见页面功能"""
|
||||
|
||||
def setUp(self):
|
||||
"""设置测试数据"""
|
||||
self.merchant = Merchant.objects.create(
|
||||
name='测试商户',
|
||||
type=MerchantTypeEnum.STORE
|
||||
)
|
||||
# 创建页面
|
||||
self.main_menu = FrontendPage.objects.create(
|
||||
key='production',
|
||||
label='生产管理',
|
||||
page_type=FrontendPageTypeEnum.MAIN
|
||||
)
|
||||
self.sub_menu1 = FrontendPage.objects.create(
|
||||
key='production-kanban',
|
||||
label='生产看板',
|
||||
page_type=FrontendPageTypeEnum.SUB,
|
||||
parent=self.main_menu
|
||||
)
|
||||
self.sub_menu2 = FrontendPage.objects.create(
|
||||
key='printing-order-list',
|
||||
label='生产订单',
|
||||
page_type=FrontendPageTypeEnum.SUB,
|
||||
parent=self.main_menu
|
||||
)
|
||||
# 创建岗位
|
||||
self.emp_type = EmployeeType.objects.create(
|
||||
merchant=self.merchant,
|
||||
title='生产主管'
|
||||
)
|
||||
|
||||
def test_assign_visible_pages(self):
|
||||
"""测试为岗位分配可见页面"""
|
||||
self.emp_type.visible_pages.add(self.main_menu)
|
||||
self.assertEqual(self.emp_type.visible_pages.count(), 1)
|
||||
self.assertIn(self.main_menu, self.emp_type.visible_pages.all())
|
||||
|
||||
def test_get_visible_page_keys_with_main_menu(self):
|
||||
"""测试授予主菜单时获取所有可见页面(含子菜单)"""
|
||||
self.emp_type.visible_pages.add(self.main_menu)
|
||||
|
||||
# 模拟查询逻辑
|
||||
visible_keys = set()
|
||||
for page in self.emp_type.visible_pages.filter(is_active=True):
|
||||
visible_keys.add(page.key)
|
||||
if page.page_type == FrontendPageTypeEnum.MAIN:
|
||||
children_keys = page.children.filter(is_active=True).values_list('key', flat=True)
|
||||
visible_keys.update(children_keys)
|
||||
|
||||
self.assertIn('production', visible_keys)
|
||||
self.assertIn('production-kanban', visible_keys)
|
||||
self.assertIn('printing-order-list', visible_keys)
|
||||
self.assertEqual(len(visible_keys), 3)
|
||||
|
||||
def test_get_visible_page_keys_with_sub_menu_only(self):
|
||||
"""测试仅授予子菜单时只能看到该子菜单"""
|
||||
self.emp_type.visible_pages.add(self.sub_menu1)
|
||||
|
||||
visible_keys = set()
|
||||
for page in self.emp_type.visible_pages.filter(is_active=True):
|
||||
visible_keys.add(page.key)
|
||||
if page.page_type == FrontendPageTypeEnum.MAIN:
|
||||
children_keys = page.children.filter(is_active=True).values_list('key', flat=True)
|
||||
visible_keys.update(children_keys)
|
||||
|
||||
self.assertIn('production-kanban', visible_keys)
|
||||
self.assertNotIn('production', visible_keys)
|
||||
self.assertNotIn('printing-order-list', visible_keys)
|
||||
self.assertEqual(len(visible_keys), 1)
|
||||
|
||||
def test_employee_visible_pages_through_position(self):
|
||||
"""测试通过员工岗位查询可见页面"""
|
||||
self.emp_type.visible_pages.add(self.main_menu)
|
||||
|
||||
employee = Employee.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试员工',
|
||||
position=self.emp_type
|
||||
)
|
||||
|
||||
# 通过员工 -> 岗位 -> 可见页面
|
||||
visible_pages = employee.position.visible_pages.all()
|
||||
self.assertEqual(visible_pages.count(), 1)
|
||||
self.assertIn(self.main_menu, visible_pages)
|
||||
|
||||
|
||||
class SyncFrontendPagesCommandTestCase(TestCase):
|
||||
"""测试 sync_frontend_pages 管理命令"""
|
||||
|
||||
def test_sync_command_dry_run(self):
|
||||
"""测试 dry-run 模式不写入数据"""
|
||||
out = StringIO()
|
||||
call_command('sync_frontend_pages', '--dry-run', stdout=out)
|
||||
|
||||
# 验证没有实际写入
|
||||
self.assertEqual(FrontendPage.objects.count(), 0)
|
||||
self.assertIn('DRY-RUN', out.getvalue())
|
||||
|
||||
def test_sync_command_creates_pages(self):
|
||||
"""测试命令创建页面数据"""
|
||||
out = StringIO()
|
||||
call_command('sync_frontend_pages', stdout=out)
|
||||
|
||||
# 验证创建了页面
|
||||
self.assertGreater(FrontendPage.objects.count(), 0)
|
||||
|
||||
# 验证主菜单
|
||||
main_menus = FrontendPage.objects.filter(page_type=FrontendPageTypeEnum.MAIN)
|
||||
self.assertEqual(main_menus.count(), 9) # 9 个主菜单
|
||||
|
||||
# 验证子菜单
|
||||
sub_menus = FrontendPage.objects.filter(page_type=FrontendPageTypeEnum.SUB)
|
||||
self.assertEqual(sub_menus.count(), 43) # 43 个子菜单
|
||||
|
||||
# 验证父子关系
|
||||
plate_opening = FrontendPage.objects.get(key='plate-opening')
|
||||
self.assertEqual(plate_opening.children.count(), 5)
|
||||
|
||||
def test_sync_command_idempotent(self):
|
||||
"""测试命令幂等性(多次运行结果一致)"""
|
||||
call_command('sync_frontend_pages', stdout=StringIO())
|
||||
first_count = FrontendPage.objects.count()
|
||||
|
||||
call_command('sync_frontend_pages', stdout=StringIO())
|
||||
second_count = FrontendPage.objects.count()
|
||||
|
||||
self.assertEqual(first_count, second_count)
|
||||
|
||||
def test_sync_command_clear_option(self):
|
||||
"""测试 --clear 选项"""
|
||||
# 先创建数据
|
||||
call_command('sync_frontend_pages', stdout=StringIO())
|
||||
self.assertGreater(FrontendPage.objects.count(), 0)
|
||||
|
||||
# 使用 --clear 重新同步
|
||||
out = StringIO()
|
||||
call_command('sync_frontend_pages', '--clear', stdout=out)
|
||||
|
||||
self.assertIn('已清空', out.getvalue())
|
||||
# 数据应该重新创建
|
||||
self.assertEqual(FrontendPage.objects.count(), 52) # 9 主菜单 + 43 子菜单
|
||||
|
||||
Reference in New Issue
Block a user