feat: add domain report csv export
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import ipaddress
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -443,6 +446,85 @@ async def get_domain_reports(
|
||||
return DomainReportsResponse(reports=report_entries, compliance_timeline=timeline)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/reports/export")
|
||||
async def export_domain_reports(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
start_date: Optional[date] = Query(None, title="Start date for exported reports"),
|
||||
end_date: Optional[date] = Query(None, title="End date for exported reports"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Export DMARC report summaries for a specific domain as CSV.
|
||||
"""
|
||||
if start_date and end_date and start_date > end_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="start_date must be on or before end_date",
|
||||
)
|
||||
|
||||
store = ReportStore.get_instance()
|
||||
hydrate_report_store_from_db(db, store)
|
||||
|
||||
if domain_id not in store.get_domains():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
reports = [
|
||||
report
|
||||
for report in store.get_domain_reports(domain_id, limit=10000)
|
||||
if _report_in_export_range(report, start_date, end_date)
|
||||
]
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"domain",
|
||||
"report_id",
|
||||
"org_name",
|
||||
"begin_date",
|
||||
"end_date",
|
||||
"total_emails",
|
||||
"passed",
|
||||
"failed",
|
||||
"pass_rate",
|
||||
"policy",
|
||||
]
|
||||
)
|
||||
|
||||
for report in reports:
|
||||
summary = report.get("summary", {})
|
||||
total = int(summary.get("total_count", report.get("total_count", 0)) or 0)
|
||||
passed = int(summary.get("passed_count", report.get("passed_count", 0)) or 0)
|
||||
failed = int(summary.get("failed_count", report.get("failed_count", 0)) or 0)
|
||||
policy = report.get("policy", "none")
|
||||
if isinstance(policy, dict):
|
||||
policy = policy.get("p", "none")
|
||||
writer.writerow(
|
||||
[
|
||||
domain_id,
|
||||
report.get("report_id", "unknown"),
|
||||
report.get("org_name", "Unknown Organization"),
|
||||
_format_report_date(report.get("begin_timestamp") or report.get("begin_date")),
|
||||
_format_report_date(report.get("end_timestamp") or report.get("end_date")),
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
report.get("pass_rate", 0.0),
|
||||
policy,
|
||||
]
|
||||
)
|
||||
|
||||
filename = f"{domain_id.replace('/', '_')}-dmarc-reports.csv"
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _build_compliance_timeline(store: ReportStore, domain: str) -> List[TimelinePoint]:
|
||||
"""
|
||||
Build a compliance timeline from actual report data stored in ReportStore.
|
||||
@@ -501,6 +583,35 @@ def _build_compliance_timeline(store: ReportStore, domain: str) -> List[Timeline
|
||||
return timeline
|
||||
|
||||
|
||||
def _report_in_export_range(
|
||||
report: Dict[str, Any], start_date: Optional[date], end_date: Optional[date]
|
||||
) -> bool:
|
||||
report_date = _report_date(report.get("begin_timestamp") or report.get("begin_date"))
|
||||
if report_date is None:
|
||||
return False
|
||||
if start_date and report_date < start_date:
|
||||
return False
|
||||
if end_date and report_date > end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _report_date(value: Any) -> Optional[date]:
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc).date()
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value).date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _format_report_date(value: Any) -> str:
|
||||
report_date = _report_date(value)
|
||||
return report_date.isoformat() if report_date else ""
|
||||
|
||||
|
||||
def _spf_fix_hint(ip: str, spf_result: str, failed_count: int = 0) -> Optional[str]:
|
||||
"""Return a copy-paste SPF mechanism (e.g. ``ip4:1.2.3.4``) for a failing IP.
|
||||
|
||||
|
||||
@@ -409,7 +409,29 @@
|
||||
<!-- Recent Reports -->
|
||||
{% call card() %}
|
||||
{% call card_header() %}
|
||||
{% call card_title() %}Recent Reports{% endcall %}
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
{% call card_title() %}Recent Reports{% endcall %}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
x-model="filters.exportStartDate"
|
||||
type="date"
|
||||
class="input input-sm input-bordered"
|
||||
aria-label="Export start date"
|
||||
>
|
||||
<input
|
||||
x-model="filters.exportEndDate"
|
||||
type="date"
|
||||
class="input input-sm input-bordered"
|
||||
aria-label="Export end date"
|
||||
>
|
||||
<a :href="exportReportsUrl" class="btn btn-sm btn-outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% call card_description() %}
|
||||
Latest DMARC reports received for this domain
|
||||
{% endcall %}
|
||||
@@ -498,7 +520,9 @@ function domainDetailsApp(domainId) {
|
||||
complianceChart: null,
|
||||
filters: {
|
||||
dateRange: '30',
|
||||
sourceFilter: ''
|
||||
sourceFilter: '',
|
||||
exportStartDate: '',
|
||||
exportEndDate: ''
|
||||
},
|
||||
|
||||
init() {
|
||||
@@ -522,6 +546,15 @@ function domainDetailsApp(domainId) {
|
||||
});
|
||||
},
|
||||
|
||||
get exportReportsUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filters.exportStartDate) params.set('start_date', this.filters.exportStartDate);
|
||||
if (this.filters.exportEndDate) params.set('end_date', this.filters.exportEndDate);
|
||||
const query = params.toString();
|
||||
const baseUrl = `/api/v1/domains/${encodeURIComponent(this.domainId)}/reports/export`;
|
||||
return query ? `${baseUrl}?${query}` : baseUrl;
|
||||
},
|
||||
|
||||
get dkimLiveText() {
|
||||
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
|
||||
if (this.dns.dkimSelectors && this.dns.dkimSelectors.length > 0) {
|
||||
|
||||
@@ -8,6 +8,9 @@ the Pydantic response models, including the policy-dict extraction and
|
||||
the use of begin_timestamp/end_timestamp integers for date fields.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -113,6 +116,68 @@ def test_get_domain_reports_unknown_domain_returns_404(client: TestClient):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/domains/{domain_id}/reports/export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_export_domain_reports_returns_csv(seeded_client: TestClient):
|
||||
"""CSV export includes report summary rows for the requested domain."""
|
||||
response = seeded_client.get(f"/api/v1/domains/{DOMAIN}/reports/export")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
assert "attachment" in response.headers["content-disposition"]
|
||||
|
||||
rows = list(csv.DictReader(StringIO(response.text)))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["domain"] == DOMAIN
|
||||
assert rows[0]["report_id"] == "rpt-dict-policy"
|
||||
assert rows[0]["begin_date"] == "2020-08-15"
|
||||
assert rows[0]["total_emails"] == "10"
|
||||
assert rows[0]["passed"] == "10"
|
||||
assert rows[0]["failed"] == "0"
|
||||
assert rows[0]["policy"] == "reject"
|
||||
|
||||
|
||||
def test_export_domain_reports_filters_by_date_range(client: TestClient):
|
||||
"""CSV export only includes reports inside the requested date range."""
|
||||
ReportStore.get_instance().add_report(REPORT_DICT_POLICY)
|
||||
ReportStore.get_instance().add_report(
|
||||
{
|
||||
**REPORT_STR_POLICY,
|
||||
"report_id": "rpt-next-day",
|
||||
"begin_date": "2020-08-16T00:00:00",
|
||||
"end_date": "2020-08-16T23:59:59",
|
||||
"begin_timestamp": 1597536000,
|
||||
"end_timestamp": 1597622399,
|
||||
}
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-16&end_date=2020-08-16"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
rows = list(csv.DictReader(StringIO(response.text)))
|
||||
assert [row["report_id"] for row in rows] == ["rpt-next-day"]
|
||||
|
||||
|
||||
def test_export_domain_reports_rejects_invalid_date_order(seeded_client: TestClient):
|
||||
"""CSV export validates that the start date is not after the end date."""
|
||||
response = seeded_client.get(
|
||||
f"/api/v1/domains/{DOMAIN}/reports/export?start_date=2020-08-17&end_date=2020-08-16"
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_export_domain_reports_unknown_domain_returns_404(client: TestClient):
|
||||
"""Returns 404 when exporting a domain with no reports."""
|
||||
response = client.get("/api/v1/domains/no-such-domain.example.com/reports/export")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/domains/{domain_id}/sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -42,13 +42,13 @@ Objective: turn parsed DMARC data into administrator-friendly reports.
|
||||
|
||||
Priority tasks:
|
||||
- Add "what changed" summaries for newly observed senders and sudden compliance drops.
|
||||
- Add exportable reports for a domain and date range.
|
||||
- Add actionable recommendations for common SPF, DKIM, and DMARC failure patterns.
|
||||
|
||||
Delivered:
|
||||
- Dashboard time-series charts show daily mail volume, compliance rate, and failure rate.
|
||||
- Top sending sources show DMARC, SPF, and DKIM pass/fail breakdowns on the dashboard.
|
||||
- Per-domain timelines include daily volume, pass, fail, compliance-rate, and failure-rate rollups.
|
||||
- Domain reports can be exported to CSV for a selected date range.
|
||||
|
||||
Quality bar:
|
||||
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
|
||||
|
||||
+1
-1
@@ -81,9 +81,9 @@ Delivered:
|
||||
- Dashboard trend charts for volume, compliance rate, and failure rate.
|
||||
- Top sender/source reports with pass/fail breakdowns.
|
||||
- Per-domain report timeline and daily rollups.
|
||||
- Exportable reports for a selected domain and date range.
|
||||
|
||||
Planned:
|
||||
- Exportable reports for a selected domain and date range.
|
||||
- Clear recommendations for common cases: unknown source, SPF-only pass, DKIM-only pass, full fail, and policy not enforced.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
+2
-2
@@ -142,7 +142,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
|
||||
- [ ] Add alert status summary (for later integration)
|
||||
|
||||
### Historical Data
|
||||
- [ ] Implement date range filtering
|
||||
- [x] Implement date range filtering
|
||||
- [ ] Create historical trend analysis
|
||||
- [ ] Add data aggregation for different time periods
|
||||
- [ ] Implement data comparison features
|
||||
@@ -151,7 +151,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
|
||||
- [x] Add per-domain daily rollups
|
||||
- [x] Add sender/source pass/fail totals
|
||||
- [ ] Add newly observed source detection
|
||||
- [ ] Add exportable domain reports
|
||||
- [x] Add exportable domain reports
|
||||
- [ ] Add actionable recommendations for common DMARC failure patterns
|
||||
|
||||
## Future Milestones
|
||||
|
||||
Reference in New Issue
Block a user