Merge pull request #108 from christianlouis/codex/domain-report-export
feat: add domain report csv export
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -443,6 +446,85 @@ async def get_domain_reports(
|
|||||||
return DomainReportsResponse(reports=report_entries, compliance_timeline=timeline)
|
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]:
|
def _build_compliance_timeline(store: ReportStore, domain: str) -> List[TimelinePoint]:
|
||||||
"""
|
"""
|
||||||
Build a compliance timeline from actual report data stored in ReportStore.
|
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
|
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]:
|
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.
|
"""Return a copy-paste SPF mechanism (e.g. ``ip4:1.2.3.4``) for a failing IP.
|
||||||
|
|
||||||
|
|||||||
@@ -409,7 +409,29 @@
|
|||||||
<!-- Recent Reports -->
|
<!-- Recent Reports -->
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% 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() %}
|
{% call card_description() %}
|
||||||
Latest DMARC reports received for this domain
|
Latest DMARC reports received for this domain
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
@@ -498,7 +520,9 @@ function domainDetailsApp(domainId) {
|
|||||||
complianceChart: null,
|
complianceChart: null,
|
||||||
filters: {
|
filters: {
|
||||||
dateRange: '30',
|
dateRange: '30',
|
||||||
sourceFilter: ''
|
sourceFilter: '',
|
||||||
|
exportStartDate: '',
|
||||||
|
exportEndDate: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
init() {
|
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() {
|
get dkimLiveText() {
|
||||||
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
|
if (!this.dns.dkim) return 'No DKIM record found for configured selectors';
|
||||||
if (this.dns.dkimSelectors && this.dns.dkimSelectors.length > 0) {
|
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.
|
the use of begin_timestamp/end_timestamp integers for date fields.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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
|
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
|
# GET /api/v1/domains/{domain_id}/sources
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ Objective: turn parsed DMARC data into administrator-friendly reports.
|
|||||||
|
|
||||||
Priority tasks:
|
Priority tasks:
|
||||||
- Add "what changed" summaries for newly observed senders and sudden compliance drops.
|
- 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.
|
- Add actionable recommendations for common SPF, DKIM, and DMARC failure patterns.
|
||||||
|
|
||||||
Delivered:
|
Delivered:
|
||||||
- Dashboard time-series charts show daily mail volume, compliance rate, and failure rate.
|
- 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.
|
- 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.
|
- 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:
|
Quality bar:
|
||||||
- A domain owner can understand who sends mail as their domain, which sources fail, and what to fix next.
|
- 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.
|
- Dashboard trend charts for volume, compliance rate, and failure rate.
|
||||||
- Top sender/source reports with pass/fail breakdowns.
|
- Top sender/source reports with pass/fail breakdowns.
|
||||||
- Per-domain report timeline and daily rollups.
|
- Per-domain report timeline and daily rollups.
|
||||||
|
- Exportable reports for a selected domain and date range.
|
||||||
|
|
||||||
Planned:
|
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.
|
- Clear recommendations for common cases: unknown source, SPF-only pass, DKIM-only pass, full fail, and policy not enforced.
|
||||||
|
|
||||||
Exit criteria:
|
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)
|
- [ ] Add alert status summary (for later integration)
|
||||||
|
|
||||||
### Historical Data
|
### Historical Data
|
||||||
- [ ] Implement date range filtering
|
- [x] Implement date range filtering
|
||||||
- [ ] Create historical trend analysis
|
- [ ] Create historical trend analysis
|
||||||
- [ ] Add data aggregation for different time periods
|
- [ ] Add data aggregation for different time periods
|
||||||
- [ ] Implement data comparison features
|
- [ ] 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 per-domain daily rollups
|
||||||
- [x] Add sender/source pass/fail totals
|
- [x] Add sender/source pass/fail totals
|
||||||
- [ ] Add newly observed source detection
|
- [ ] Add newly observed source detection
|
||||||
- [ ] Add exportable domain reports
|
- [x] Add exportable domain reports
|
||||||
- [ ] Add actionable recommendations for common DMARC failure patterns
|
- [ ] Add actionable recommendations for common DMARC failure patterns
|
||||||
|
|
||||||
## Future Milestones
|
## Future Milestones
|
||||||
|
|||||||
Reference in New Issue
Block a user