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.
Files changed (54) hide show
  1. endo/__init__.py +0 -0
  2. endo/api/__init__.py +0 -0
  3. endo/api/deps.py +5 -0
  4. endo/api/v1/__init__.py +0 -0
  5. endo/api/v1/analysis.py +82 -0
  6. endo/api/v1/attachments.py +206 -0
  7. endo/api/v1/exports.py +133 -0
  8. endo/api/v1/projects.py +96 -0
  9. endo/api/v1/requirements.py +253 -0
  10. endo/api/v1/tests.py +175 -0
  11. endo/core/__init__.py +0 -0
  12. endo/core/config.py +50 -0
  13. endo/core/database.py +25 -0
  14. endo/core/plugin_registry.py +81 -0
  15. endo/main.py +92 -0
  16. endo/models/__init__.py +0 -0
  17. endo/models/attachment.py +47 -0
  18. endo/models/base.py +30 -0
  19. endo/models/project.py +24 -0
  20. endo/models/requirement.py +105 -0
  21. endo/models/test.py +79 -0
  22. endo/plugins/__init__.py +0 -0
  23. endo/plugins/analysis/__init__.py +0 -0
  24. endo/plugins/analysis/plugin.py +34 -0
  25. endo/plugins/attachment/__init__.py +0 -0
  26. endo/plugins/attachment/plugin.py +24 -0
  27. endo/plugins/base.py +60 -0
  28. endo/plugins/export/__init__.py +0 -0
  29. endo/plugins/export/plugin.py +25 -0
  30. endo/plugins/project/__init__.py +0 -0
  31. endo/plugins/project/plugin.py +30 -0
  32. endo/plugins/requirement/__init__.py +0 -0
  33. endo/plugins/requirement/plugin.py +38 -0
  34. endo/plugins/test/__init__.py +0 -0
  35. endo/plugins/test/plugin.py +34 -0
  36. endo/runner.py +1275 -0
  37. endo/schemas/__init__.py +0 -0
  38. endo/schemas/analysis.py +53 -0
  39. endo/schemas/attachment.py +43 -0
  40. endo/schemas/project.py +30 -0
  41. endo/schemas/requirement.py +133 -0
  42. endo/schemas/test.py +99 -0
  43. endo/services/__init__.py +0 -0
  44. endo/services/document_parser.py +353 -0
  45. endo/services/export_service.py +562 -0
  46. endo/static/assets/index-B9wUVca-.js +477 -0
  47. endo/static/favicon.svg +1 -0
  48. endo/static/icons.svg +24 -0
  49. endo/static/index.html +13 -0
  50. endo-0.3.0.dist-info/METADATA +169 -0
  51. endo-0.3.0.dist-info/RECORD +54 -0
  52. endo-0.3.0.dist-info/WHEEL +4 -0
  53. endo-0.3.0.dist-info/entry_points.txt +2 -0
  54. 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
@@ -0,0 +1,5 @@
1
+ """API 依赖注入"""
2
+
3
+ from endo.core.database import get_db
4
+
5
+ __all__ = ["get_db"]
File without changes
@@ -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
+ )
@@ -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()