endo 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- endo/__init__.py +0 -0
- endo/api/__init__.py +0 -0
- endo/api/deps.py +5 -0
- endo/api/v1/__init__.py +0 -0
- endo/api/v1/analysis.py +82 -0
- endo/api/v1/attachments.py +206 -0
- endo/api/v1/exports.py +133 -0
- endo/api/v1/projects.py +96 -0
- endo/api/v1/requirements.py +253 -0
- endo/api/v1/tests.py +175 -0
- endo/core/__init__.py +0 -0
- endo/core/config.py +50 -0
- endo/core/database.py +25 -0
- endo/core/plugin_registry.py +81 -0
- endo/main.py +92 -0
- endo/models/__init__.py +0 -0
- endo/models/attachment.py +47 -0
- endo/models/base.py +30 -0
- endo/models/project.py +24 -0
- endo/models/requirement.py +105 -0
- endo/models/test.py +79 -0
- endo/plugins/__init__.py +0 -0
- endo/plugins/analysis/__init__.py +0 -0
- endo/plugins/analysis/plugin.py +34 -0
- endo/plugins/attachment/__init__.py +0 -0
- endo/plugins/attachment/plugin.py +24 -0
- endo/plugins/base.py +60 -0
- endo/plugins/export/__init__.py +0 -0
- endo/plugins/export/plugin.py +25 -0
- endo/plugins/project/__init__.py +0 -0
- endo/plugins/project/plugin.py +30 -0
- endo/plugins/requirement/__init__.py +0 -0
- endo/plugins/requirement/plugin.py +38 -0
- endo/plugins/test/__init__.py +0 -0
- endo/plugins/test/plugin.py +34 -0
- endo/runner.py +1275 -0
- endo/schemas/__init__.py +0 -0
- endo/schemas/analysis.py +53 -0
- endo/schemas/attachment.py +43 -0
- endo/schemas/project.py +30 -0
- endo/schemas/requirement.py +133 -0
- endo/schemas/test.py +99 -0
- endo/services/__init__.py +0 -0
- endo/services/document_parser.py +353 -0
- endo/services/export_service.py +562 -0
- endo/static/assets/index-B9wUVca-.js +477 -0
- endo/static/favicon.svg +1 -0
- endo/static/icons.svg +24 -0
- endo/static/index.html +13 -0
- endo-0.3.0.dist-info/METADATA +169 -0
- endo-0.3.0.dist-info/RECORD +54 -0
- endo-0.3.0.dist-info/WHEEL +4 -0
- endo-0.3.0.dist-info/entry_points.txt +2 -0
- endo-0.3.0.dist-info/licenses/LICENSE +21 -0
endo/__init__.py
ADDED
|
File without changes
|
endo/api/__init__.py
ADDED
|
File without changes
|
endo/api/deps.py
ADDED
endo/api/v1/__init__.py
ADDED
|
File without changes
|
endo/api/v1/analysis.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""文档解析 API 路由"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from endo.api.deps import get_db
|
|
9
|
+
from endo.models.requirement import Requirement
|
|
10
|
+
from endo.schemas.analysis import (
|
|
11
|
+
ImportResult,
|
|
12
|
+
ImportToRequirementRequest,
|
|
13
|
+
ParseResultResponse,
|
|
14
|
+
)
|
|
15
|
+
from endo.services.document_parser import parse_document
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/parse", response_model=ParseResultResponse)
|
|
21
|
+
async def parse_file(file: UploadFile = File(...)):
|
|
22
|
+
"""上传文件并解析出结构化技术要求"""
|
|
23
|
+
if not file.filename:
|
|
24
|
+
raise HTTPException(status_code=400, detail="文件名不能为空")
|
|
25
|
+
|
|
26
|
+
content = await file.read()
|
|
27
|
+
if not content:
|
|
28
|
+
raise HTTPException(status_code=400, detail="文件内容为空")
|
|
29
|
+
|
|
30
|
+
# 限制文件大小 20MB
|
|
31
|
+
if len(content) > 20 * 1024 * 1024:
|
|
32
|
+
raise HTTPException(status_code=413, detail="文件过大,限制 20MB")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
result = parse_document(
|
|
36
|
+
io.BytesIO(content),
|
|
37
|
+
file.filename,
|
|
38
|
+
file.content_type or "",
|
|
39
|
+
)
|
|
40
|
+
except ValueError as e:
|
|
41
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise HTTPException(status_code=500, detail=f"解析失败: {e}") from e
|
|
44
|
+
|
|
45
|
+
return result.to_dict()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post("/import", response_model=ImportResult, status_code=201)
|
|
49
|
+
def import_to_requirements(
|
|
50
|
+
data: ImportToRequirementRequest,
|
|
51
|
+
db: Session = Depends(get_db),
|
|
52
|
+
):
|
|
53
|
+
"""将解析结果导入为需求条目"""
|
|
54
|
+
if not data.items:
|
|
55
|
+
raise HTTPException(status_code=400, detail="没有可导入的条目")
|
|
56
|
+
|
|
57
|
+
created: list[dict] = []
|
|
58
|
+
for item in data.items:
|
|
59
|
+
params = [p.model_dump() for p in item.parameters]
|
|
60
|
+
# 参数摘要追加到描述
|
|
61
|
+
param_summary = ""
|
|
62
|
+
if params:
|
|
63
|
+
param_summary = "\n\n关键参数:\n" + "\n".join(
|
|
64
|
+
f"- {p['name']} {p['operator']} {p['value']} {p['unit']}".strip() for p in params
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
req = Requirement(
|
|
68
|
+
project_id=data.project_id,
|
|
69
|
+
req_code=item.raw_code or f"REQ-{item.index:03d}",
|
|
70
|
+
title=item.title or f"导入条目 {item.index}",
|
|
71
|
+
description=item.content + param_summary,
|
|
72
|
+
category=item.category,
|
|
73
|
+
priority=data.default_priority,
|
|
74
|
+
source=data.default_source or "文档解析导入",
|
|
75
|
+
created_by=data.created_by,
|
|
76
|
+
structural_constraints={"parameters": params} if params else None,
|
|
77
|
+
)
|
|
78
|
+
db.add(req)
|
|
79
|
+
created.append({"req_code": req.req_code, "title": req.title})
|
|
80
|
+
|
|
81
|
+
db.commit()
|
|
82
|
+
return ImportResult(imported=len(created), items=created)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""附件管理 API 路由"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import (
|
|
7
|
+
APIRouter,
|
|
8
|
+
Depends,
|
|
9
|
+
File,
|
|
10
|
+
Form,
|
|
11
|
+
HTTPException,
|
|
12
|
+
Query,
|
|
13
|
+
UploadFile,
|
|
14
|
+
)
|
|
15
|
+
from fastapi.responses import FileResponse
|
|
16
|
+
from sqlalchemy import func, select
|
|
17
|
+
from sqlalchemy.orm import Session
|
|
18
|
+
|
|
19
|
+
from endo.api.deps import get_db
|
|
20
|
+
from endo.core.config import settings
|
|
21
|
+
from endo.models.attachment import Attachment
|
|
22
|
+
from endo.schemas.attachment import (
|
|
23
|
+
AttachmentListResponse,
|
|
24
|
+
AttachmentResponse,
|
|
25
|
+
AttachmentUpdate,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
router = APIRouter()
|
|
29
|
+
|
|
30
|
+
# 允许的关联实体类型
|
|
31
|
+
ALLOWED_ENTITY_TYPES = {"project", "requirement", "test_case", "test_execution"}
|
|
32
|
+
|
|
33
|
+
# 最大文件大小 100MB
|
|
34
|
+
MAX_FILE_SIZE = 100 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_upload_dir() -> Path:
|
|
38
|
+
"""确保上传目录存在"""
|
|
39
|
+
upload_dir = settings.UPLOAD_DIR
|
|
40
|
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return upload_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_file_size(size: int) -> str:
|
|
45
|
+
"""格式化文件大小"""
|
|
46
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
47
|
+
if size < 1024:
|
|
48
|
+
return f"{size:.1f}{unit}"
|
|
49
|
+
size /= 1024
|
|
50
|
+
return f"{size:.1f}TB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post("/upload", response_model=AttachmentResponse, status_code=201)
|
|
54
|
+
async def upload_attachment(
|
|
55
|
+
entity_type: str = Form(..., description="关联实体类型"),
|
|
56
|
+
entity_id: str = Form(..., description="关联实体ID"),
|
|
57
|
+
title: str | None = Form(None, description="附件标题"),
|
|
58
|
+
description: str | None = Form(None, description="附件描述"),
|
|
59
|
+
category: str = Form("general", description="分类"),
|
|
60
|
+
uploaded_by: str | None = Form(None, description="上传人"),
|
|
61
|
+
file: UploadFile = File(..., description="文件"),
|
|
62
|
+
db: Session = Depends(get_db),
|
|
63
|
+
):
|
|
64
|
+
"""上传附件文件"""
|
|
65
|
+
if entity_type not in ALLOWED_ENTITY_TYPES:
|
|
66
|
+
allowed = ", ".join(sorted(ALLOWED_ENTITY_TYPES))
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=400,
|
|
69
|
+
detail=f"不支持的实体类型: {entity_type}(支持: {allowed})",
|
|
70
|
+
)
|
|
71
|
+
if not file.filename:
|
|
72
|
+
raise HTTPException(status_code=400, detail="文件名不能为空")
|
|
73
|
+
|
|
74
|
+
content = await file.read()
|
|
75
|
+
if not content:
|
|
76
|
+
raise HTTPException(status_code=400, detail="文件内容为空")
|
|
77
|
+
if len(content) > MAX_FILE_SIZE:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=413, detail=f"文件过大,限制 {_format_file_size(MAX_FILE_SIZE)}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# 生成存储文件名:UUID + 原扩展名
|
|
83
|
+
ext = Path(file.filename).suffix.lower()
|
|
84
|
+
stored_filename = f"{uuid.uuid4().hex}{ext}"
|
|
85
|
+
# 按实体类型分子目录,避免单目录文件过多
|
|
86
|
+
sub_dir = _get_upload_dir() / entity_type
|
|
87
|
+
sub_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
file_path = sub_dir / stored_filename
|
|
89
|
+
relative_path = f"{entity_type}/{stored_filename}"
|
|
90
|
+
|
|
91
|
+
# 写入磁盘
|
|
92
|
+
with file_path.open("wb") as f:
|
|
93
|
+
f.write(content)
|
|
94
|
+
|
|
95
|
+
# 保存元数据
|
|
96
|
+
attachment = Attachment(
|
|
97
|
+
entity_type=entity_type,
|
|
98
|
+
entity_id=entity_id,
|
|
99
|
+
filename=file.filename,
|
|
100
|
+
stored_filename=stored_filename,
|
|
101
|
+
file_path=relative_path,
|
|
102
|
+
file_size=len(content),
|
|
103
|
+
content_type=file.content_type,
|
|
104
|
+
title=title,
|
|
105
|
+
description=description,
|
|
106
|
+
category=category,
|
|
107
|
+
uploaded_by=uploaded_by,
|
|
108
|
+
)
|
|
109
|
+
db.add(attachment)
|
|
110
|
+
db.commit()
|
|
111
|
+
db.refresh(attachment)
|
|
112
|
+
return attachment
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.get("/", response_model=AttachmentListResponse)
|
|
116
|
+
def list_attachments(
|
|
117
|
+
entity_type: str | None = Query(None, description="按实体类型筛选"),
|
|
118
|
+
entity_id: str | None = Query(None, description="按实体ID筛选"),
|
|
119
|
+
category: str | None = Query(None, description="按分类筛选"),
|
|
120
|
+
page: int = Query(1, ge=1),
|
|
121
|
+
page_size: int = Query(50, ge=1, le=200),
|
|
122
|
+
db: Session = Depends(get_db),
|
|
123
|
+
):
|
|
124
|
+
"""列出附件"""
|
|
125
|
+
stmt = select(Attachment)
|
|
126
|
+
if entity_type:
|
|
127
|
+
stmt = stmt.where(Attachment.entity_type == entity_type)
|
|
128
|
+
if entity_id:
|
|
129
|
+
stmt = stmt.where(Attachment.entity_id == entity_id)
|
|
130
|
+
if category:
|
|
131
|
+
stmt = stmt.where(Attachment.category == category)
|
|
132
|
+
|
|
133
|
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
|
134
|
+
total = db.scalar(count_stmt) or 0
|
|
135
|
+
|
|
136
|
+
stmt = stmt.order_by(Attachment.created_at.desc())
|
|
137
|
+
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
|
138
|
+
items = db.scalars(stmt).all()
|
|
139
|
+
|
|
140
|
+
return AttachmentListResponse(
|
|
141
|
+
items=[AttachmentResponse.model_validate(i) for i in items],
|
|
142
|
+
total=total,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.get("/{attachment_id}", response_model=AttachmentResponse)
|
|
147
|
+
def get_attachment(attachment_id: str, db: Session = Depends(get_db)):
|
|
148
|
+
"""获取附件元数据"""
|
|
149
|
+
attachment = db.get(Attachment, attachment_id)
|
|
150
|
+
if not attachment:
|
|
151
|
+
raise HTTPException(status_code=404, detail="附件不存在")
|
|
152
|
+
return attachment
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.patch("/{attachment_id}", response_model=AttachmentResponse)
|
|
156
|
+
def update_attachment(
|
|
157
|
+
attachment_id: str,
|
|
158
|
+
data: AttachmentUpdate,
|
|
159
|
+
db: Session = Depends(get_db),
|
|
160
|
+
):
|
|
161
|
+
"""更新附件元数据(标题/描述/分类)"""
|
|
162
|
+
attachment = db.get(Attachment, attachment_id)
|
|
163
|
+
if not attachment:
|
|
164
|
+
raise HTTPException(status_code=404, detail="附件不存在")
|
|
165
|
+
|
|
166
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
167
|
+
for key, value in update_data.items():
|
|
168
|
+
setattr(attachment, key, value)
|
|
169
|
+
|
|
170
|
+
db.commit()
|
|
171
|
+
db.refresh(attachment)
|
|
172
|
+
return attachment
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.delete("/{attachment_id}", status_code=204)
|
|
176
|
+
def delete_attachment(attachment_id: str, db: Session = Depends(get_db)):
|
|
177
|
+
"""删除附件(同时删除磁盘文件)"""
|
|
178
|
+
attachment = db.get(Attachment, attachment_id)
|
|
179
|
+
if not attachment:
|
|
180
|
+
raise HTTPException(status_code=404, detail="附件不存在")
|
|
181
|
+
|
|
182
|
+
# 删除磁盘文件
|
|
183
|
+
file_path = settings.UPLOAD_DIR / attachment.file_path
|
|
184
|
+
if file_path.exists():
|
|
185
|
+
file_path.unlink()
|
|
186
|
+
|
|
187
|
+
db.delete(attachment)
|
|
188
|
+
db.commit()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.get("/{attachment_id}/download")
|
|
192
|
+
def download_attachment(attachment_id: str, db: Session = Depends(get_db)):
|
|
193
|
+
"""下载附件文件"""
|
|
194
|
+
attachment = db.get(Attachment, attachment_id)
|
|
195
|
+
if not attachment:
|
|
196
|
+
raise HTTPException(status_code=404, detail="附件不存在")
|
|
197
|
+
|
|
198
|
+
file_path = settings.UPLOAD_DIR / attachment.file_path
|
|
199
|
+
if not file_path.exists():
|
|
200
|
+
raise HTTPException(status_code=404, detail="文件不存在或已被删除")
|
|
201
|
+
|
|
202
|
+
return FileResponse(
|
|
203
|
+
path=str(file_path),
|
|
204
|
+
filename=attachment.filename,
|
|
205
|
+
media_type=attachment.content_type or "application/octet-stream",
|
|
206
|
+
)
|
endo/api/v1/exports.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""导出 API 路由 - 需求规格书/追溯矩阵/测试报告"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
|
|
11
|
+
from endo.api.deps import get_db
|
|
12
|
+
from endo.models.project import Project
|
|
13
|
+
from endo.models.requirement import (
|
|
14
|
+
Requirement,
|
|
15
|
+
RequirementTraceability,
|
|
16
|
+
)
|
|
17
|
+
from endo.models.test import TestCase, TestExecution
|
|
18
|
+
from endo.services.export_service import (
|
|
19
|
+
export_requirement_spec_pdf,
|
|
20
|
+
export_test_report_pdf,
|
|
21
|
+
export_traceability_excel,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_project_info(db: Session, project_id: str | None) -> tuple[str, str]:
|
|
28
|
+
"""获取项目名称和编号"""
|
|
29
|
+
if not project_id:
|
|
30
|
+
return "", ""
|
|
31
|
+
project = db.get(Project, project_id)
|
|
32
|
+
if not project:
|
|
33
|
+
return "", ""
|
|
34
|
+
return project.name, project.code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_filename(prefix: str, project_code: str, ext: str) -> str:
|
|
38
|
+
"""构造导出文件名"""
|
|
39
|
+
code = project_code or "all"
|
|
40
|
+
date_str = datetime.now().strftime("%Y%m%d")
|
|
41
|
+
return f"{prefix}_{code}_{date_str}.{ext}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/requirements/spec")
|
|
45
|
+
def export_requirements(
|
|
46
|
+
project_id: str | None = Query(None, description="按项目筛选"),
|
|
47
|
+
db: Session = Depends(get_db),
|
|
48
|
+
):
|
|
49
|
+
"""导出需求规格书 PDF"""
|
|
50
|
+
stmt = select(Requirement).order_by(Requirement.req_code)
|
|
51
|
+
if project_id:
|
|
52
|
+
stmt = stmt.where(Requirement.project_id == project_id)
|
|
53
|
+
|
|
54
|
+
requirements = db.scalars(stmt).all()
|
|
55
|
+
if not requirements:
|
|
56
|
+
raise HTTPException(status_code=404, detail="没有可导出的需求")
|
|
57
|
+
|
|
58
|
+
project_name, project_code = _get_project_info(db, project_id)
|
|
59
|
+
pdf_bytes = export_requirement_spec_pdf(requirements, project_name, project_code)
|
|
60
|
+
|
|
61
|
+
filename = _build_filename("需求规格书", project_code, "pdf")
|
|
62
|
+
return StreamingResponse(
|
|
63
|
+
io.BytesIO(pdf_bytes),
|
|
64
|
+
media_type="application/pdf",
|
|
65
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/traceability/matrix")
|
|
70
|
+
def export_traceability(
|
|
71
|
+
project_id: str | None = Query(None, description="按项目筛选"),
|
|
72
|
+
db: Session = Depends(get_db),
|
|
73
|
+
):
|
|
74
|
+
"""导出追溯矩阵 Excel"""
|
|
75
|
+
# 获取需求
|
|
76
|
+
req_stmt = select(Requirement)
|
|
77
|
+
if project_id:
|
|
78
|
+
req_stmt = req_stmt.where(Requirement.project_id == project_id)
|
|
79
|
+
requirements = db.scalars(req_stmt).all()
|
|
80
|
+
|
|
81
|
+
# 获取追溯关系
|
|
82
|
+
trace_stmt = select(RequirementTraceability)
|
|
83
|
+
if project_id:
|
|
84
|
+
req_ids = {r.id for r in requirements}
|
|
85
|
+
if not req_ids:
|
|
86
|
+
trace_stmt = trace_stmt.where(False)
|
|
87
|
+
else:
|
|
88
|
+
trace_stmt = trace_stmt.where(RequirementTraceability.source_req_id.in_(req_ids))
|
|
89
|
+
|
|
90
|
+
traceability = db.scalars(trace_stmt).all()
|
|
91
|
+
|
|
92
|
+
if not requirements and not traceability:
|
|
93
|
+
raise HTTPException(status_code=404, detail="没有可导出的追溯数据")
|
|
94
|
+
|
|
95
|
+
excel_bytes = export_traceability_excel(traceability, requirements)
|
|
96
|
+
|
|
97
|
+
_, project_code = _get_project_info(db, project_id)
|
|
98
|
+
filename = _build_filename("追溯矩阵", project_code, "xlsx")
|
|
99
|
+
return StreamingResponse(
|
|
100
|
+
io.BytesIO(excel_bytes),
|
|
101
|
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
102
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/tests/report")
|
|
107
|
+
def export_test_report(
|
|
108
|
+
project_id: str | None = Query(None, description="按项目筛选"),
|
|
109
|
+
db: Session = Depends(get_db),
|
|
110
|
+
):
|
|
111
|
+
"""导出测试报告 PDF"""
|
|
112
|
+
case_stmt = select(TestCase).order_by(TestCase.case_code)
|
|
113
|
+
if project_id:
|
|
114
|
+
case_stmt = case_stmt.where(TestCase.project_id == project_id)
|
|
115
|
+
|
|
116
|
+
test_cases = db.scalars(case_stmt).all()
|
|
117
|
+
if not test_cases:
|
|
118
|
+
raise HTTPException(status_code=404, detail="没有可导出的测试用例")
|
|
119
|
+
|
|
120
|
+
# 获取这些用例的执行记录
|
|
121
|
+
case_ids = {c.id for c in test_cases}
|
|
122
|
+
exec_stmt = select(TestExecution).where(TestExecution.test_case_id.in_(case_ids))
|
|
123
|
+
executions = db.scalars(exec_stmt).all()
|
|
124
|
+
|
|
125
|
+
project_name, project_code = _get_project_info(db, project_id)
|
|
126
|
+
pdf_bytes = export_test_report_pdf(test_cases, executions, project_name, project_code)
|
|
127
|
+
|
|
128
|
+
filename = _build_filename("测试报告", project_code, "pdf")
|
|
129
|
+
return StreamingResponse(
|
|
130
|
+
io.BytesIO(pdf_bytes),
|
|
131
|
+
media_type="application/pdf",
|
|
132
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
133
|
+
)
|
endo/api/v1/projects.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""项目 API 路由"""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from sqlalchemy import func, select
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from endo.api.deps import get_db
|
|
8
|
+
from endo.models.project import Project
|
|
9
|
+
from endo.models.requirement import Requirement
|
|
10
|
+
from endo.schemas.project import ProjectCreate, ProjectResponse, ProjectUpdate
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _with_requirement_count(stmt, db: Session):
|
|
16
|
+
"""为查询结果附加 requirement_count"""
|
|
17
|
+
count_sub = (
|
|
18
|
+
select(Requirement.project_id, func.count().label("cnt"))
|
|
19
|
+
.group_by(Requirement.project_id)
|
|
20
|
+
.subquery()
|
|
21
|
+
)
|
|
22
|
+
stmt = stmt.outerjoin(count_sub, Project.id == count_sub.c.project_id)
|
|
23
|
+
stmt = stmt.add_columns(func.coalesce(count_sub.c.cnt, 0).label("requirement_count"))
|
|
24
|
+
return stmt
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------- 项目统计(必须在 /{project_id} 之前定义)----------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/statistics/summary")
|
|
31
|
+
def get_project_statistics(db: Session = Depends(get_db)):
|
|
32
|
+
"""获取项目统计摘要"""
|
|
33
|
+
total = db.scalar(select(func.count()).select_from(Project)) or 0
|
|
34
|
+
by_status_rows = db.execute(select(Project.status, func.count()).group_by(Project.status)).all()
|
|
35
|
+
by_status = {row[0]: row[1] for row in by_status_rows}
|
|
36
|
+
return {"total": total, "by_status": by_status}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------- 项目 CRUD ----------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/", response_model=ProjectResponse, status_code=201)
|
|
43
|
+
def create_project(data: ProjectCreate, db: Session = Depends(get_db)):
|
|
44
|
+
project = Project(**data.model_dump())
|
|
45
|
+
db.add(project)
|
|
46
|
+
db.commit()
|
|
47
|
+
db.refresh(project)
|
|
48
|
+
return ProjectResponse.model_validate(project)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/", response_model=list[ProjectResponse])
|
|
52
|
+
def list_projects(db: Session = Depends(get_db)):
|
|
53
|
+
stmt = select(Project).order_by(Project.created_at.desc())
|
|
54
|
+
stmt = _with_requirement_count(stmt, db)
|
|
55
|
+
rows = db.execute(stmt).all()
|
|
56
|
+
results = []
|
|
57
|
+
for row in rows:
|
|
58
|
+
proj_data = ProjectResponse.model_validate(row[0])
|
|
59
|
+
proj_data.requirement_count = row[1]
|
|
60
|
+
results.append(proj_data)
|
|
61
|
+
return results
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/{project_id}", response_model=ProjectResponse)
|
|
65
|
+
def get_project(project_id: str, db: Session = Depends(get_db)):
|
|
66
|
+
project = db.get(Project, project_id)
|
|
67
|
+
if not project:
|
|
68
|
+
raise HTTPException(status_code=404, detail="项目不存在")
|
|
69
|
+
count = db.scalar(select(func.count()).where(Requirement.project_id == project_id)) or 0
|
|
70
|
+
resp = ProjectResponse.model_validate(project)
|
|
71
|
+
resp.requirement_count = count
|
|
72
|
+
return resp
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.put("/{project_id}", response_model=ProjectResponse)
|
|
76
|
+
def update_project(project_id: str, data: ProjectUpdate, db: Session = Depends(get_db)):
|
|
77
|
+
project = db.get(Project, project_id)
|
|
78
|
+
if not project:
|
|
79
|
+
raise HTTPException(status_code=404, detail="项目不存在")
|
|
80
|
+
for key, value in data.model_dump(exclude_unset=True).items():
|
|
81
|
+
setattr(project, key, value)
|
|
82
|
+
db.commit()
|
|
83
|
+
db.refresh(project)
|
|
84
|
+
count = db.scalar(select(func.count()).where(Requirement.project_id == project_id)) or 0
|
|
85
|
+
resp = ProjectResponse.model_validate(project)
|
|
86
|
+
resp.requirement_count = count
|
|
87
|
+
return resp
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.delete("/{project_id}", status_code=204)
|
|
91
|
+
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
|
92
|
+
project = db.get(Project, project_id)
|
|
93
|
+
if not project:
|
|
94
|
+
raise HTTPException(status_code=404, detail="项目不存在")
|
|
95
|
+
db.delete(project)
|
|
96
|
+
db.commit()
|