Files
zkxw/backend/tests/test_project_management.py
T
zlwu 79ca01ac0d feat(project): show presign counts as a statistics column
Group presign counts by project and team, include totals and unassigned rows, and reuse count drill-down. Remove the standalone footer while retaining presignTotal compatibility and the four formal business types.

Related to issue #13. Independent source review completed; no tests or builds run under user self-test arrangement.
2026-11-04 14:53:07 +08:00

241 lines
15 KiB
Python

"""Project attribution isolation and history contract, using an in-memory database."""
import importlib.util
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from fastapi import HTTPException
class ProjectManagementTest(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing')
from app.services import project_management as svc
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
from app.models.base import Base
self.svc, self.Project, self.Team = svc, Project, ProjectTeam
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = {'user_id': 'u', 'tenant_no': 't', 'role': 'super_admin'}
self.db.add_all([ProjectTeam(id='g', tenant_no='t', team_name='Team', leader_user_id='u'),
Project(id='p', tenant_no='t', project_no='P1', project_name='Project', team_id='g', leader_user_id='u', biz_types='cell_storage,presign', status='active')])
self.db.commit()
def tearDown(self):
if hasattr(self, 'db'):
self.db.close()
self.engine.dispose()
def test_assignment_and_tenant_boundary(self):
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage'), 'p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 'other', self.user, 'p', 'cell_storage')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, 'p', 'basic_experiment')
def test_legacy_edit_keeps_disabled_project(self):
self.db.get(self.Project, 'p').status = 'disabled'
self.db.commit()
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage', current_project_id='p'), 'p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', current_project_id='p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', required=True)
def test_statistics_excludes_foreign_and_deleted(self):
from app.models.models import BizCollectionProtocol
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'),
BizCollectionProtocol(id='b', protocol_no='B', tenant_no='other', project_id='p'),
BizCollectionProtocol(id='c', protocol_no='C', tenant_no='t', project_id='p', del_flag=1),
BizCollectionProtocol(id='d', protocol_no='D', tenant_no='t')])
self.db.commit()
result = self.svc.statistics(self.db, 't', self.user)
self.assertEqual(result['totals']['cell_storage'], 2)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2)
self.assertEqual(result['unassignedTotals']['cell_storage'], 1)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage', projectId='p')['total'], 1)
def test_assignment_preview_and_atomic_snapshot(self):
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([BizPresignOrder(id='s', tenant_no='t', project_id=None, converted_protocol_id='a'),
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
self.db.commit()
result = self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
self.assertEqual(len(result['records']), 2)
with self.assertRaises(HTTPException):
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', [])
self.db.rollback()
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', result['records'])
self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p')
self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1)
def test_presign_statistics_column_groups_totals_and_drill_down(self):
from datetime import datetime
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([
self.Team(id='g2', tenant_no='t', team_name='Presign only', leader_user_id='u'),
self.Project(id='p2', tenant_no='t', project_no='P2', project_name='Presign only',
team_id='g2', leader_user_id='u', biz_types='cell_storage', status='active'),
BizCollectionProtocol(id='protocol', tenant_no='t', protocol_no='CP', project_id='p',
create_time=datetime(2026, 11, 4)),
])
for row_id, project_id, status, tenant, deleted, day in (
('active', 'p', 'active', 't', 0, 4),
('converted', 'p', 'converted', 't', 0, 4),
('draft', 'p2', 'draft', 't', 0, 4),
('void', None, 'void', 't', 0, 4),
('foreign', 'p', 'active', 'foreign', 0, 4),
('deleted', 'p', 'active', 't', 1, 4),
('old', 'p', 'active', 't', 0, 1),
):
self.db.add(BizPresignOrder(
id=row_id, tenant_no=tenant, protocol_no=row_id, project_id=project_id,
order_status=status, del_flag=deleted, create_by='u',
create_time=datetime(2026, 11, day),
converted_protocol_id='protocol' if status == 'converted' else None,
))
self.db.commit()
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
for group_by, expected in (('project', {'p': 2, 'p2': 1}), ('team', {'g': 2, 'g2': 1})):
with self.subTest(group_by=group_by):
result = self.svc.statistics(self.db, 't', self.user, groupBy=group_by, **filters)
self.assertEqual(result['totals']['presign'], 4)
self.assertEqual(result['presignTotal'], 4)
self.assertEqual(result['unassignedTotals']['presign'], 1)
self.assertEqual(result['totals']['cell_storage'], 1)
self.assertEqual({r['id']: r['presign'] for r in result['items']}, expected)
for row in result['items']:
drill = {('teamId' if group_by == 'team' else 'projectId'): row['id']}
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', **drill, **filters)['total'], row['presign'])
for selection in (dict(teamId='g'), dict(projectId='p')):
result = self.svc.statistics(self.db, 't', self.user, **selection, **filters)
self.assertEqual(result['totals']['presign'], 2)
self.assertEqual(result['unassignedTotals']['presign'], 0)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', unassigned=True, **filters)['total'], 1)
denied = self.svc.statistics(self.db, 't', dict(user_id='outsider', role='admin'), **filters)
self.assertEqual(denied['totals']['presign'], 0)
self.assertEqual(denied['items'], [])
self.assertNotIn('presign', self.svc.COUNT_TYPES)
def test_team_child_ids_survive_edits(self):
from app.models.models import SysUser
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
body = dict(teamName='Team', leaderUserId='u', members=[{'userId': 'u'}])
first = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
second = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
self.assertEqual(first['members'][0]['id'], second['members'][0]['id'])
with self.assertRaises(HTTPException):
self.svc.delete_entity(self.db, 't', self.user, self.Team, 'g')
def test_non_member_admin_cannot_choose_project(self):
# An ordinary admin with no enabled-role permissions is not a substitute recorder.
with self.assertRaises(HTTPException) as error:
self.svc.validate_assignment(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'p', 'cell_storage')
self.assertEqual(error.exception.status_code, 403)
def test_non_member_data_scope_does_not_expand(self):
from app.models.models import BizCollectionProtocol
self.db.add(BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'))
self.db.commit()
user = {'user_id': 'u', 'role': 'admin'}
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 0)
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 0)
def test_empty_date_input_is_optional(self):
from app.models.models import SysUser
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
self.assertTrue(result['projectNo'].startswith('XM'))
self.assertIsNone(result['startDate'])
def test_conversion_table_reverse_graph_and_audit(self):
from app.models.models import BizCollectionProtocol
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
self.db.get(self.Project, 'p').biz_types = 'cell_storage,research_donation'
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t'),
BizResearchDonation(id='d', donation_no='D', tenant_no='t'),
BizProtocolDonationConvert(id='c', protocol_id='a', research_donation_id='d', biz_no='A', convert_type='research_to_storage', tenant_no='t')])
self.db.commit()
preview = self.svc.preview_assignment(self.db, 't', self.user, 'research_donation', 'd', 'p')
self.assertEqual({r['bizType'] for r in preview['records']}, {'cell_storage', 'research_donation'})
self.svc.assign(self.db, 't', self.user, 'research_donation', 'd', 'p', 'reason', preview['records'])
self.assertEqual(self.db.get(BizCollectionProtocol, 'a').project_id, 'p')
self.assertEqual(self.db.get(BizResearchDonation, 'd').project_id, 'p')
def test_preview_rejects_inaccessible_link_without_writes(self):
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([BizPresignOrder(id='s', tenant_no='other', converted_protocol_id='a'),
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
self.db.commit()
with self.assertRaises(HTTPException):
self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
def test_date_end_inclusive_and_business_types_separate(self):
from datetime import datetime
from app.models.models import BizPilotApplication
self.db.add_all([BizPilotApplication(id='a', tenant_no='t', project_id='p', biz_type='basic_experiment', channel_service_order_id='', create_time=datetime(2026, 1, 2, 23, 59)),
BizPilotApplication(id='b', tenant_no='t', project_id='p', biz_type='pilot_service', channel_service_order_id='', create_time=datetime(2026, 1, 3)),
BizPilotApplication(id='c', tenant_no='t', project_id='p', biz_type='clinical_application', channel_service_order_id='', create_time=datetime(2026, 1, 2))])
self.db.commit()
result = self.svc.statistics(self.db, 't', self.user, startDate='2026-01-02', endDate='2026-01-02')
self.assertEqual(result['totals']['basic_experiment'], 1)
self.assertEqual(result['totals']['pilot_service'], 0)
self.assertEqual(sum(result['totals'].values()), 1)
def test_routes_exist_and_protect_management(self):
from app.api.project_management import router
paths = {r.path for r in router.routes}
self.assertIn('/project-management/options', paths)
self.assertIn('/web/admin/project-management/assignments/preview', paths)
with self.assertRaises(HTTPException) as error:
self.svc.check_permission(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'project:team:list')
self.assertEqual(error.exception.status_code, 403)
def test_enabled_role_union_authorizes_substitute_and_keeps_data_scope(self):
from app.models.models import SysRole, SysUserRole, SysRoleMenu, SysMenu, BizCollectionProtocol
self.db.add_all([SysRole(id='role1', role_name='reader', tenant_no='t', data_scope='4'),
SysRole(id='role2', role_name='assignment', tenant_no='t', data_scope='4'),
SysUserRole(id='ur1', user_id='outsider', role_id='role1', tenant_no='t'),
SysUserRole(id='ur2', user_id='outsider', role_id='role2', tenant_no='t'),
SysMenu(id='menu1', tenant_no='t', menu_name='全部项目', perms='project:project:all'),
SysRoleMenu(id='rm1', tenant_no='t', role_id='role2', menu_id='menu1'),
BizCollectionProtocol(id='mine', protocol_no='Mine', tenant_no='t', create_by='outsider', project_id='p'),
BizCollectionProtocol(id='foreign', protocol_no='Other', tenant_no='t', create_by='u', project_id='p')])
self.db.commit()
user = dict(user_id='outsider', role='admin')
self.assertEqual(self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage'), 'p')
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 1)
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 1)
self.db.get(SysRole, 'role2').status = '0'
self.db.commit()
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage')
def test_mysql_migration_has_repeat_guards_and_menu_parent_structure(self):
from pathlib import Path
root = Path(__file__).resolve().parents[2]
migration = (root / 'docs/sql/migrations/2026-11-02-project_management.sql').read_text()
self.assertEqual(migration.count('CREATE TABLE IF NOT EXISTS'), 6)
self.assertEqual(migration.count('information_schema.columns'), 9)
self.assertEqual(migration.count('information_schema.statistics'), 4)
self.assertNotIn(' BEFORE ', migration)
self.assertNotIn('JSON_TABLE', migration)
self.assertIn('INTO @project_charset, @project_collation', migration)
self.assertEqual(migration.count('ADD COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
self.assertEqual(migration.count('MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
menus = (root / 'docs/sql/migrations/2026-11-02-project_management_menu_seed.sql').read_text()
self.assertEqual(menus.count('AND NOT EXISTS'), 6)
self.assertEqual(menus.count("parent.perms='menu:market'"), 4)
self.assertIn("parent.perms='project:team:list'", menus)
self.assertIn("parent.perms='project:project:list'", menus)
self.assertNotIn('INSERT INTO sys_role_menu', menus)
if __name__ == '__main__':
unittest.main()