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.
241 lines
15 KiB
Python
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()
|