ui: добавить экспорт в CSV для сканирований и пакетов

- api/scans.py: добавить /api/v1/scans/export endpoint с фильтрами
- api/packages.py: добавить /api/v1/packages/export endpoint с фильтрами
- scans_list.html: добавить кнопку Export CSV в filter bar
- packages_list.html: добавить кнопку Export CSV в filter bar
- CSV включает все поля с правильным форматированием
This commit is contained in:
Marker689
2026-05-10 03:16:58 +03:00
parent d5df1d75b8
commit 97b89d3fdf
4 changed files with 104 additions and 2 deletions

View File

@@ -1,6 +1,9 @@
"""REST API for packages (distinct packages across scans).""" """REST API for packages (distinct packages across scans)."""
from fastapi import APIRouter, Depends, Query import csv
import io
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -87,6 +90,54 @@ async def list_packages(
} }
@router.get("/export")
async def export_packages_csv(
flagged: bool | None = Query(None),
search: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
subq = select(
Scan.package_name,
Scan.package_version,
Scan.ecosystem,
Scan.repository,
func.max(Scan.started_at).label("last_scanned_at"),
func.max(Scan.flagged).label("is_flagged"),
func.sum(Scan.total_findings).label("total_findings"),
).group_by(Scan.package_name, Scan.package_version)
if flagged is not None:
subq = subq.having(func.max(Scan.flagged) == flagged)
if search:
pattern = f"%{search}%"
subq = subq.where(
Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
)
subq = subq.order_by(func.max(Scan.started_at).desc())
rows = (await session.execute(subq)).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"name", "version", "ecosystem", "repository",
"last_scanned_at", "flagged", "total_findings"
])
for r in rows:
writer.writerow([
r.package_name, r.package_version, r.ecosystem, r.repository,
r.last_scanned_at.isoformat() if r.last_scanned_at else "",
bool(r.is_flagged),
r.total_findings,
])
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=packages_export.csv"},
)
@router.get("/{name}/{version}") @router.get("/{name}/{version}")
async def get_package( async def get_package(
name: str, name: str,

View File

@@ -1,6 +1,9 @@
"""REST API for scans.""" """REST API for scans."""
from fastapi import APIRouter, Depends, Query import csv
import io
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import func, select, text from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -78,6 +81,52 @@ async def list_scans(
} }
@router.get("/export")
async def export_scans_csv(
flagged: bool | None = Query(None),
search: str | None = Query(None),
status: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
q = select(Scan)
if flagged is not None:
q = q.where(Scan.flagged == flagged)
if status:
q = q.where(Scan.status == status)
if search:
pattern = f"%{search}%"
q = q.where(
Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
)
q = q.order_by(Scan.started_at.desc())
scans = (await session.execute(q)).scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "package_name", "package_version", "ecosystem", "repository",
"status", "total_findings", "flagged", "started_at", "finished_at",
"error_message", "sha256"
])
for s in scans:
writer.writerow([
s.id, s.package_name, s.package_version, s.ecosystem, s.repository,
s.status, s.total_findings, s.flagged,
s.started_at.isoformat() if s.started_at else "",
s.finished_at.isoformat() if s.finished_at else "",
s.error_message or "",
s.sha256 or "",
])
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=scans_export.csv"},
)
@router.get("/stats") @router.get("/stats")
async def scan_stats(session: AsyncSession = Depends(get_session)): async def scan_stats(session: AsyncSession = Depends(get_session)):
total_scans = await session.scalar(select(func.count(Scan.id))) total_scans = await session.scalar(select(func.count(Scan.id)))

View File

@@ -15,6 +15,7 @@
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline"> <a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a> </a>
<a href="/api/v1/packages/export?flagged={{ flagged_filter }}&search={{ search }}" role="button" class="outline">Export CSV</a>
</div> </div>
<div id="packages-table-container"> <div id="packages-table-container">

View File

@@ -22,6 +22,7 @@
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline"> <a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a> </a>
<a href="/api/v1/scans/export?flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}" role="button" class="outline">Export CSV</a>
</div> </div>
<div id="scans-table-container"> <div id="scans-table-container">