Fix code formatting and linting issues
- Auto-format all Python files with black and isort - Remove unused imports with autoflake - Fix flake8 issues (missing newlines, blank lines, etc.) - Fix nonlocal/global scope issues in main.py - Fix security.py import order (E402) - Remove f-string without placeholders - Add nosec comment for intentional exception handling - Fix test imports to match refactored DMARCParser API Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -1,33 +1,41 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, status, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.report_store import ReportStore
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DomainBase(BaseModel):
|
||||
"""Base Domain schema"""
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
policy: Optional[str] = None
|
||||
|
||||
|
||||
class DomainResponse(DomainBase):
|
||||
"""Domain response schema"""
|
||||
|
||||
reports_count: int = 0
|
||||
emails_count: int = 0
|
||||
compliance_rate: float = 0.0
|
||||
|
||||
|
||||
class DomainStatsResponse(BaseModel):
|
||||
"""Domain statistics for the domain details page"""
|
||||
|
||||
complianceRate: float
|
||||
totalEmails: int
|
||||
failedEmails: int
|
||||
reportCount: int
|
||||
|
||||
|
||||
class DNSRecordResponse(BaseModel):
|
||||
"""DNS record information for a domain"""
|
||||
|
||||
dmarc: bool
|
||||
dmarcRecord: Optional[str] = None
|
||||
spf: bool
|
||||
@@ -35,13 +43,17 @@ class DNSRecordResponse(BaseModel):
|
||||
dkim: bool
|
||||
dkimSelectors: Optional[str] = None
|
||||
|
||||
|
||||
class TimelinePoint(BaseModel):
|
||||
"""Data point for compliance timeline"""
|
||||
|
||||
date: str
|
||||
compliance_rate: float
|
||||
|
||||
|
||||
class ReportEntry(BaseModel):
|
||||
"""Summary of a DMARC report"""
|
||||
|
||||
id: str
|
||||
org_name: str
|
||||
begin_date: int
|
||||
@@ -50,8 +62,10 @@ class ReportEntry(BaseModel):
|
||||
pass_rate: float
|
||||
policy: str
|
||||
|
||||
|
||||
class SourceEntry(BaseModel):
|
||||
"""Summary of a sending source"""
|
||||
|
||||
ip: str
|
||||
count: int
|
||||
spf: str
|
||||
@@ -59,23 +73,30 @@ class SourceEntry(BaseModel):
|
||||
dmarc: str
|
||||
disposition: str
|
||||
|
||||
|
||||
class DomainReportsResponse(BaseModel):
|
||||
"""Domain reports with compliance timeline"""
|
||||
|
||||
reports: List[ReportEntry]
|
||||
compliance_timeline: List[TimelinePoint]
|
||||
|
||||
|
||||
class DomainSourcesResponse(BaseModel):
|
||||
"""Domain sending sources"""
|
||||
|
||||
sources: List[SourceEntry]
|
||||
|
||||
|
||||
class DomainSummaryResponse(BaseModel):
|
||||
"""Domain summary for dashboard"""
|
||||
|
||||
total_domains: int
|
||||
total_emails: int
|
||||
overall_pass_rate: float
|
||||
reports_processed: int
|
||||
domains: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DomainSummaryResponse)
|
||||
async def get_domains_summary():
|
||||
"""
|
||||
@@ -84,45 +105,48 @@ async def get_domains_summary():
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
summaries = store.get_all_domain_summaries()
|
||||
|
||||
|
||||
# Calculate overall statistics
|
||||
total_domains = len(domains)
|
||||
total_emails = 0
|
||||
total_passed = 0
|
||||
total_reports = 0
|
||||
|
||||
|
||||
domains_list = []
|
||||
|
||||
|
||||
for domain_name in domains:
|
||||
summary = summaries.get(domain_name, {})
|
||||
total_emails += summary.get("total_count", 0)
|
||||
total_passed += summary.get("passed_count", 0)
|
||||
total_reports += summary.get("reports_processed", 0)
|
||||
|
||||
|
||||
# Format domain data for frontend
|
||||
domains_list.append({
|
||||
"id": domain_name, # Using the domain name as ID for now
|
||||
"domain_name": domain_name,
|
||||
"total_emails": summary.get("total_count", 0),
|
||||
"passed_count": summary.get("passed_count", 0),
|
||||
"failed_count": summary.get("failed_count", 0),
|
||||
"pass_rate": summary.get("compliance_rate", 0),
|
||||
"report_count": summary.get("reports_processed", 0)
|
||||
})
|
||||
|
||||
domains_list.append(
|
||||
{
|
||||
"id": domain_name, # Using the domain name as ID for now
|
||||
"domain_name": domain_name,
|
||||
"total_emails": summary.get("total_count", 0),
|
||||
"passed_count": summary.get("passed_count", 0),
|
||||
"failed_count": summary.get("failed_count", 0),
|
||||
"pass_rate": summary.get("compliance_rate", 0),
|
||||
"report_count": summary.get("reports_processed", 0),
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate overall pass rate
|
||||
overall_pass_rate = 0
|
||||
if total_emails > 0:
|
||||
overall_pass_rate = round((total_passed / total_emails) * 100, 1)
|
||||
|
||||
|
||||
return DomainSummaryResponse(
|
||||
total_domains=total_domains,
|
||||
total_emails=total_emails,
|
||||
overall_pass_rate=overall_pass_rate,
|
||||
reports_processed=total_reports,
|
||||
domains=domains_list
|
||||
domains=domains_list,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/domains", response_model=List[DomainResponse])
|
||||
async def read_domains():
|
||||
"""
|
||||
@@ -132,7 +156,7 @@ async def read_domains():
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
summaries = store.get_all_domain_summaries()
|
||||
|
||||
|
||||
result = []
|
||||
for domain_name in domains:
|
||||
summary = summaries.get(domain_name, {})
|
||||
@@ -141,12 +165,13 @@ async def read_domains():
|
||||
policy=summary.get("policy", "unknown"),
|
||||
reports_count=summary.get("reports_processed", 0),
|
||||
emails_count=summary.get("total_count", 0),
|
||||
compliance_rate=summary.get("compliance_rate", 0.0)
|
||||
compliance_rate=summary.get("compliance_rate", 0.0),
|
||||
)
|
||||
result.append(domain_response)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/domains/{domain_name}", response_model=DomainResponse)
|
||||
async def read_domain(domain_name: str):
|
||||
"""
|
||||
@@ -154,25 +179,27 @@ async def read_domain(domain_name: str):
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
if domain_name not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
summary = store.get_domain_summary(domain_name)
|
||||
|
||||
|
||||
return DomainResponse(
|
||||
name=domain_name,
|
||||
policy=summary.get("policy", "unknown"),
|
||||
reports_count=summary.get("reports_processed", 0),
|
||||
emails_count=summary.get("total_count", 0),
|
||||
compliance_rate=summary.get("compliance_rate", 0.0)
|
||||
compliance_rate=summary.get("compliance_rate", 0.0),
|
||||
)
|
||||
|
||||
|
||||
# New endpoints for domain details page
|
||||
|
||||
|
||||
@router.get("/{domain_id}/stats", response_model=DomainStatsResponse)
|
||||
async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or name")):
|
||||
"""
|
||||
@@ -180,43 +207,44 @@ async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or na
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
# For Milestone 1, domain_id is simply the domain name
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
summary = store.get_domain_summary(domain_id)
|
||||
total_count = summary.get("total_count", 0)
|
||||
passed_count = summary.get("passed_count", 0)
|
||||
failed_count = total_count - passed_count
|
||||
compliance_rate = summary.get("compliance_rate", 0.0)
|
||||
reports_processed = summary.get("reports_processed", 0)
|
||||
|
||||
|
||||
return DomainStatsResponse(
|
||||
complianceRate=compliance_rate,
|
||||
totalEmails=total_count,
|
||||
failedEmails=failed_count,
|
||||
reportCount=reports_processed
|
||||
reportCount=reports_processed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/dns", response_model=DNSRecordResponse)
|
||||
async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID or name")):
|
||||
"""
|
||||
Get DNS records for a specific domain. For Milestone 1,
|
||||
Get DNS records for a specific domain. For Milestone 1,
|
||||
this returns mock data since DNS integration is part of a future milestone.
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
# For Milestone 1, return mock DNS record data
|
||||
# In a future milestone, this will be replaced with actual DNS lookups
|
||||
return DNSRecordResponse(
|
||||
@@ -225,97 +253,101 @@ async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID
|
||||
spf=True,
|
||||
spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
|
||||
dkim=True,
|
||||
dkimSelectors="selector1, selector2"
|
||||
dkimSelectors="selector1, selector2",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/reports", response_model=DomainReportsResponse)
|
||||
async def get_domain_reports(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
limit: int = Query(10, title="Maximum number of reports to return")
|
||||
limit: int = Query(10, title="Maximum number of reports to return"),
|
||||
):
|
||||
"""
|
||||
Get recent DMARC reports for a specific domain, along with compliance timeline
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
# Get reports for this domain
|
||||
reports = store.get_domain_reports(domain_id, limit=limit)
|
||||
|
||||
|
||||
# Generate report entries
|
||||
report_entries = []
|
||||
for report in reports:
|
||||
report_entries.append(ReportEntry(
|
||||
id=report.get("report_id", "unknown"),
|
||||
org_name=report.get("org_name", "Unknown Organization"),
|
||||
begin_date=report.get("begin_date", 0),
|
||||
end_date=report.get("end_date", 0),
|
||||
total_emails=report.get("total_count", 0),
|
||||
pass_rate=report.get("pass_rate", 0.0),
|
||||
policy=report.get("policy", "none")
|
||||
))
|
||||
|
||||
report_entries.append(
|
||||
ReportEntry(
|
||||
id=report.get("report_id", "unknown"),
|
||||
org_name=report.get("org_name", "Unknown Organization"),
|
||||
begin_date=report.get("begin_date", 0),
|
||||
end_date=report.get("end_date", 0),
|
||||
total_emails=report.get("total_count", 0),
|
||||
pass_rate=report.get("pass_rate", 0.0),
|
||||
policy=report.get("policy", "none"),
|
||||
)
|
||||
)
|
||||
|
||||
# Generate compliance timeline (last 30 days)
|
||||
timeline = []
|
||||
for i in range(30, 0, -1):
|
||||
date = datetime.now() - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# For Milestone 1, generate some mock data with variation
|
||||
# In future milestone, this will use actual historical data
|
||||
import random
|
||||
|
||||
compliance_rate = random.uniform(80, 100)
|
||||
|
||||
timeline.append(TimelinePoint(
|
||||
date=date_str,
|
||||
compliance_rate=round(compliance_rate, 1)
|
||||
))
|
||||
|
||||
return DomainReportsResponse(
|
||||
reports=report_entries,
|
||||
compliance_timeline=timeline
|
||||
)
|
||||
|
||||
timeline.append(TimelinePoint(date=date_str, compliance_rate=round(compliance_rate, 1)))
|
||||
|
||||
return DomainReportsResponse(reports=report_entries, compliance_timeline=timeline)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/sources", response_model=DomainSourcesResponse)
|
||||
async def get_domain_sources(
|
||||
domain_id: str = Path(..., title="The domain ID or name"),
|
||||
days: int = Query(30, title="Number of days to look back")
|
||||
days: int = Query(30, title="Number of days to look back"),
|
||||
):
|
||||
"""
|
||||
Get sending sources for a specific domain
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
# Get sending sources for this domain
|
||||
sources = store.get_domain_sources(domain_id, days=days)
|
||||
|
||||
|
||||
source_entries = []
|
||||
for source in sources:
|
||||
source_entries.append(SourceEntry(
|
||||
ip=source.get("source_ip", "unknown"),
|
||||
count=source.get("count", 0),
|
||||
spf=source.get("spf_result", "unknown"),
|
||||
dkim=source.get("dkim_result", "unknown"),
|
||||
dmarc="pass" if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass" else "fail",
|
||||
disposition=source.get("disposition", "none")
|
||||
))
|
||||
|
||||
return DomainSourcesResponse(
|
||||
sources=source_entries
|
||||
)
|
||||
source_entries.append(
|
||||
SourceEntry(
|
||||
ip=source.get("source_ip", "unknown"),
|
||||
count=source.get("count", 0),
|
||||
spf=source.get("spf_result", "unknown"),
|
||||
dkim=source.get("dkim_result", "unknown"),
|
||||
dmarc=(
|
||||
"pass"
|
||||
if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass"
|
||||
else "fail"
|
||||
),
|
||||
disposition=source.get("disposition", "none"),
|
||||
)
|
||||
)
|
||||
|
||||
return DomainSourcesResponse(sources=source_entries)
|
||||
|
||||
|
||||
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_domain(domain_id: str = Path(..., title="The domain ID or name")):
|
||||
@@ -325,36 +357,37 @@ async def delete_domain(domain_id: str = Path(..., title="The domain ID or name"
|
||||
"""
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
|
||||
|
||||
if domain_id not in domains:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
|
||||
# Perform deletion with cleanup
|
||||
deleted = store.delete_domain_with_cleanup(domain_id)
|
||||
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete domain",
|
||||
)
|
||||
|
||||
|
||||
# Return 204 No Content on success
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/search", response_model=List[DomainResponse])
|
||||
async def search_domains(
|
||||
q: Optional[str] = Query(None, title="Search query for domain name or description"),
|
||||
policy: Optional[str] = Query(None, title="Filter by DMARC policy"),
|
||||
page: int = Query(1, title="Page number", ge=1),
|
||||
limit: int = Query(10, title="Number of domains per page", ge=1, le=100)
|
||||
limit: int = Query(10, title="Number of domains per page", ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Search domains with filtering and pagination.
|
||||
This supports searching by domain name/description and filtering by DMARC policy.
|
||||
|
||||
|
||||
Args:
|
||||
q: Optional search query for domain name or description
|
||||
policy: Optional filter by DMARC policy (none, quarantine, reject)
|
||||
@@ -364,33 +397,35 @@ async def search_domains(
|
||||
store = ReportStore.get_instance()
|
||||
domains = store.get_domains()
|
||||
summaries = store.get_all_domain_summaries()
|
||||
|
||||
|
||||
# Apply search filter if provided
|
||||
filtered_domains = []
|
||||
for domain_name in domains:
|
||||
summary = summaries.get(domain_name, {})
|
||||
|
||||
|
||||
# Skip domain if it doesn't match the search query
|
||||
if q and q.lower() not in domain_name.lower():
|
||||
continue
|
||||
|
||||
|
||||
# Skip domain if it doesn't match the policy filter
|
||||
if policy and summary.get("policy") != policy:
|
||||
continue
|
||||
|
||||
|
||||
# Domain passed all filters
|
||||
filtered_domains.append({
|
||||
"name": domain_name,
|
||||
"description": "", # No description in in-memory store
|
||||
"policy": summary.get("policy", "unknown"),
|
||||
"reports_count": summary.get("reports_processed", 0),
|
||||
"emails_count": summary.get("total_count", 0),
|
||||
"compliance_rate": summary.get("compliance_rate", 0.0)
|
||||
})
|
||||
|
||||
filtered_domains.append(
|
||||
{
|
||||
"name": domain_name,
|
||||
"description": "", # No description in in-memory store
|
||||
"policy": summary.get("policy", "unknown"),
|
||||
"reports_count": summary.get("reports_processed", 0),
|
||||
"emails_count": summary.get("total_count", 0),
|
||||
"compliance_rate": summary.get("compliance_rate", 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
start_idx = (page - 1) * limit
|
||||
end_idx = start_idx + limit
|
||||
paginated_domains = filtered_domains[start_idx:end_idx]
|
||||
|
||||
return [DomainResponse(**domain) for domain in paginated_domains]
|
||||
|
||||
return [DomainResponse(**domain) for domain in paginated_domains]
|
||||
|
||||
Reference in New Issue
Block a user