import random # Used for mock data generation - TODO: Replace with actual historical data from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Path, Query, status from pydantic import BaseModel from app.services.report_store import ReportStore 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 spfRecord: Optional[str] = None 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 end_date: int total_emails: int pass_rate: float policy: str class SourceEntry(BaseModel): """Summary of a sending source""" ip: str count: int spf: str dkim: str 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(): """ Get summary statistics for all domains, formatted for the dashboard. """ 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), } ) # 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, ) @router.get("/domains", response_model=List[DomainResponse]) async def read_domains(): """ Retrieve domains with their statistics. For Milestone 1, this simply returns domains from the in-memory store. """ 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, {}) domain_response = 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), ) result.append(domain_response) return result @router.get("/domains/{domain_name}", response_model=DomainResponse) async def read_domain(domain_name: str): """ Get statistics for a specific domain. """ 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), ) # 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")): """ Get detailed statistics for a specific domain """ 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, ) @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, 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 mock_dmarc_record = ( "v=DMARC1; p=none; rua=mailto:dmarc@example.com;" " ruf=mailto:forensic@example.com; pct=100" ) return DNSRecordResponse( dmarc=True, dmarcRecord=mock_dmarc_record, spf=True, spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all", dkim=True, 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"), ): """ 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"), ) ) # 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") # TODO: Replace with actual historical data in future milestone # pylint: disable=fixme # For now, generate mock data with variation for demonstration purposes compliance_rate = random.uniform(80, 100) # nosec B311 - Mock data only 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"), ): """ 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) @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")): """ Delete a domain and all associated data. This performs a full cleanup of all reports and records related to this 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", ) # 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), ): """ 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) page: Page number (1-based) limit: Number of domains per page (max 100) """ 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), } ) # 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]