Add comprehensive documentation for DMARQ, including user guides, deployment instructions, and feature descriptions

- Created main documentation index and user guide with sections on getting started, dashboard overview, managing domains, and reports.
- Added detailed deployment guide for Docker and manual installation.
- Included user-friendly explanations of DMARC, its benefits, and how to manage domains and reports.
- Implemented visual assets for dashboard, domains, IMAP, and reports.
- Established requirements for documentation build using MkDocs and Material theme.
- Integrated navigation structure for easy access to all documentation sections.
This commit is contained in:
Christian Krakau-Louis
2025-04-21 01:49:34 +02:00
parent 1b79ec4f20
commit 5e8b1f033f
29 changed files with 1947 additions and 110 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import domains, health, reports, setup, imap
from app.api.api_v1.endpoints import domains, health, reports, setup, imap, stats
api_router = APIRouter()
@@ -9,4 +9,5 @@ api_router.include_router(health.router, tags=["health"])
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
+79 -1
View File
@@ -315,4 +315,82 @@ async def get_domain_sources(
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]
+59 -3
View File
@@ -11,10 +11,11 @@ async def test_imap_connection(
server: str = None,
port: int = 993,
username: str = None,
password: str = None
password: str = None,
ssl: bool = True
) -> Dict[str, Any]:
"""
Test connection to an IMAP server
Test connection to an IMAP server and gather mailbox statistics
"""
imap_client = IMAPClient(
server=server,
@@ -23,11 +24,15 @@ async def test_imap_connection(
password=password
)
success, message = imap_client.test_connection()
success, message, stats = imap_client.test_connection()
return {
"success": success,
"message": message,
"message_count": stats.get("message_count", 0),
"unread_count": stats.get("unread_count", 0),
"dmarc_count": stats.get("dmarc_count", 0),
"available_mailboxes": stats.get("available_mailboxes", []),
"timestamp": datetime.now().isoformat()
}
@@ -62,4 +67,55 @@ async def fetch_imap_reports(
"new_domains": results["new_domains"],
"errors": results["errors"] if "errors" in results and results["errors"] else None,
"timestamp": datetime.now().isoformat()
}
@router.get("/status")
async def get_imap_status() -> Dict[str, Any]:
"""
Get the current status of IMAP polling background processes
"""
# In a real implementation this would check a persistent store
# or a global variable tracking the status of background tasks
# For now, returning mock data as this is MVP
# Get the last check time if available
last_check_time = None
try:
# In a production app, this would be stored in database
# For MVP, using a simple file-based approach
import os
status_file = os.path.join(os.path.dirname(__file__), "../../../../../tmp/imap_last_check.txt")
if os.path.exists(status_file):
with open(status_file, "r") as f:
last_check_time = f.read().strip()
except:
pass
# If status file doesn't exist, create the directory
try:
os.makedirs(os.path.dirname(os.path.join(os.path.dirname(__file__), "../../../../../tmp")), exist_ok=True)
except:
pass
# For demonstration purposes, update the last check time to now
# In a real app, this would be updated by the background process
try:
with open(os.path.join(os.path.dirname(__file__), "../../../../../tmp/imap_last_check.txt"), "w") as f:
now = datetime.now().isoformat()
f.write(now)
# If there was no previous check time, set it to now
if not last_check_time:
last_check_time = now
except:
pass
# Return the status
return {
"is_running": True, # In a real app, check if the background task is running
"last_check": last_check_time,
"next_check": None, # In production, this would be calculated based on polling interval
"messages_processed": 0, # In production, this would track actual messages processed
"reports_found": 0, # In production, this would track reports found
"timestamp": datetime.now().isoformat()
}
+80 -1
View File
@@ -33,6 +33,14 @@ class ReportSummary(BaseModel):
passed_count: int
failed_count: int
class PaginatedReportResponse(BaseModel):
"""Paginated reports response model"""
total: int
page: int
page_size: int
total_pages: int
reports: List[ReportSummary]
@router.post("/upload", response_model=UploadResponse)
async def upload_report(file: UploadFile = File(...)):
"""
@@ -132,4 +140,75 @@ async def get_domain_reports(domain: str):
failed_count=report.get("summary", {}).get("failed_count", 0)
)
for report in reports
]
]
@router.get("/domain/{domain}/reports/paginated", response_model=PaginatedReportResponse)
async def get_domain_reports_paginated(
domain: str,
page: int = 1,
page_size: int = 10,
sort_by: str = "end_date",
sort_order: str = "desc"
):
"""
Get paginated reports for a specific domain with sorting options
Args:
domain: Domain name
page: Page number (1-based)
page_size: Number of reports per page
sort_by: Field to sort by (report_id, org_name, begin_date, end_date, total_count)
sort_order: Sort order (asc or desc)
"""
store = ReportStore.get_instance()
all_reports = store.get_domain_reports(domain)
if not all_reports:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No reports found for domain {domain}"
)
# Apply sorting
valid_sort_fields = ["report_id", "org_name", "begin_date", "end_date", "total_count"]
sort_field = sort_by if sort_by in valid_sort_fields else "end_date"
if sort_field == "total_count":
all_reports.sort(
key=lambda r: r.get("summary", {}).get("total_count", 0),
reverse=(sort_order == "desc")
)
else:
all_reports.sort(
key=lambda r: r.get(sort_field, ""),
reverse=(sort_order == "desc")
)
# Apply pagination
total = len(all_reports)
total_pages = (total + page_size - 1) // page_size
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_reports = all_reports[start_idx:end_idx]
# Format reports
report_entries = [
ReportSummary(
report_id=report.get("report_id", ""),
org_name=report.get("org_name", ""),
begin_date=report.get("begin_date", ""),
end_date=report.get("end_date", ""),
total_count=report.get("summary", {}).get("total_count", 0),
passed_count=report.get("summary", {}).get("passed_count", 0),
failed_count=report.get("summary", {}).get("failed_count", 0)
)
for report in paginated_reports
]
return PaginatedReportResponse(
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
reports=report_entries
)
+75
View File
@@ -0,0 +1,75 @@
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, Depends, Query, Path
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.utils.stats_summarizer import StatsSummarizer
router = APIRouter()
@router.get("/dashboard")
async def get_dashboard_statistics(
db: Session = Depends(get_db),
force_refresh: bool = Query(False, title="Force refresh of statistics"),
period_days: int = Query(30, title="Period in days for time-based statistics")
) -> Dict[str, Any]:
"""
Get optimized statistics for the dashboard using cached data when possible.
This endpoint provides efficient access to statistics for large datasets.
Args:
force_refresh: If True, invalidate cache and recalculate statistics
period_days: Period in days for time-based statistics (default: 30)
Returns:
Dictionary with dashboard statistics
"""
# Initialize statistics summarizer
stats_summarizer = StatsSummarizer()
# If force refresh, invalidate cache
if force_refresh:
stats_summarizer.invalidate_cache()
# Get statistics (from cache or calculate if needed)
stats = stats_summarizer.calculate_summary_statistics(db)
# Add version and timestamp
stats["api_version"] = "1.0"
stats["period_days"] = period_days
return stats
@router.get("/domain/{domain_id}")
async def get_domain_statistics(
domain_id: str = Path(..., title="The domain ID or name"),
db: Session = Depends(get_db),
force_refresh: bool = Query(False, title="Force refresh of statistics"),
period_days: int = Query(30, title="Period in days for time-based statistics")
) -> Dict[str, Any]:
"""
Get optimized statistics for a specific domain using cached data when possible.
Args:
domain_id: The domain ID or name
force_refresh: If True, invalidate cache and recalculate statistics
period_days: Period in days for time-based statistics (default: 30)
Returns:
Dictionary with domain statistics
"""
# Initialize statistics summarizer
stats_summarizer = StatsSummarizer()
# If force refresh, invalidate domain cache
if force_refresh:
stats_summarizer.invalidate_cache(domain_id)
# Get domain statistics (from cache or calculate if needed)
stats = stats_summarizer.calculate_summary_statistics(db, domain_id)
# Add version and timestamp
stats["api_version"] = "1.0"
stats["period_days"] = period_days
return stats
+15 -5
View File
@@ -1,7 +1,7 @@
from typing import List, Optional
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Index
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -15,25 +15,35 @@ class Domain(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
active = Column(Boolean, default=True)
active = Column(Boolean, default=True, index=True)
# DMARC policy information
dmarc_policy = Column(String, nullable=True)
dmarc_policy = Column(String, nullable=True, index=True)
spf_record = Column(String, nullable=True)
dkim_selectors = Column(String, nullable=True) # Comma-separated list of DKIM selectors
# DNS verification status
verified = Column(Boolean, default=False)
verified = Column(Boolean, default=False, index=True)
verification_token = Column(String, nullable=True)
# Date fields
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan")
user_domains = relationship("UserDomain", back_populates="domain", cascade="all, delete-orphan")
# Indexes for common queries
__table_args__ = (
# Index for finding active and verified domains
Index('ix_domains_active_verified', 'active', 'verified'),
# Index for finding domains by policy
Index('ix_domains_policy', 'dmarc_policy'),
# Index for finding recently updated domains
Index('ix_domains_updated', 'updated_at'),
)
def __repr__(self):
return f"<Domain {self.name}>"
+30 -12
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Index
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -13,30 +13,40 @@ class DMARCReport(Base):
__tablename__ = "dmarc_reports"
id = Column(Integer, primary_key=True, index=True)
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False)
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False, index=True)
# Report metadata
report_id = Column(String, index=True, nullable=False)
org_name = Column(String, nullable=False)
begin_date = Column(Integer, nullable=False) # Unix timestamp
end_date = Column(Integer, nullable=False) # Unix timestamp
org_name = Column(String, nullable=False, index=True)
begin_date = Column(Integer, nullable=False, index=True) # Unix timestamp
end_date = Column(Integer, nullable=False, index=True) # Unix timestamp
source_email = Column(String, nullable=True)
# Policy information
policy = Column(String, nullable=True) # none, quarantine, reject
policy = Column(String, nullable=True, index=True) # none, quarantine, reject
subdomain_policy = Column(String, nullable=True)
adkim = Column(String(1), nullable=True) # r (relaxed) or s (strict)
aspf = Column(String(1), nullable=True) # r (relaxed) or s (strict)
percentage = Column(Integer, nullable=True)
# Processing metadata
processed_at = Column(DateTime, default=datetime.utcnow)
processed_at = Column(DateTime, default=datetime.utcnow, index=True)
raw_data = Column(Text, nullable=True) # Original XML content (optional)
# Relationships
domain = relationship("Domain", back_populates="reports")
records = relationship("ReportRecord", back_populates="report", cascade="all, delete-orphan")
# Indexes for common queries
__table_args__ = (
# Composite index for domain and date range queries (common dashboard queries)
Index('ix_dmarc_reports_domain_dates', 'domain_id', 'begin_date', 'end_date'),
# Index for finding reports by policy
Index('ix_dmarc_reports_policy', 'policy'),
# Index for finding recent reports (dashboard statistics)
Index('ix_dmarc_reports_processed', 'processed_at'),
)
def __repr__(self):
return f"<DMARCReport {self.report_id} for {self.domain_id}>"
@@ -47,19 +57,19 @@ class ReportRecord(Base):
__tablename__ = "report_records"
id = Column(Integer, primary_key=True, index=True)
report_id = Column(Integer, ForeignKey("dmarc_reports.id"), nullable=False)
report_id = Column(Integer, ForeignKey("dmarc_reports.id"), nullable=False, index=True)
# Source information
source_ip = Column(String, nullable=False, index=True)
count = Column(Integer, nullable=False, default=0)
# Policy evaluation
disposition = Column(String, nullable=False) # none, quarantine, reject
dkim = Column(String, nullable=True) # pass, fail
spf = Column(String, nullable=True) # pass, fail
disposition = Column(String, nullable=False, index=True) # none, quarantine, reject
dkim = Column(String, nullable=True, index=True) # pass, fail
spf = Column(String, nullable=True, index=True) # pass, fail
# Identifiers
header_from = Column(String, nullable=True)
header_from = Column(String, nullable=True, index=True)
envelope_from = Column(String, nullable=True)
# Authentication details (optional JSON fields)
@@ -69,5 +79,13 @@ class ReportRecord(Base):
# Relationships
report = relationship("DMARCReport", back_populates="records")
# Indexes for common queries
__table_args__ = (
# Composite index for source IP and evaluation results (for filtering)
Index('ix_report_records_source_auth', 'source_ip', 'dkim', 'spf'),
# Composite index for disposition and count (for statistics)
Index('ix_report_records_disposition', 'disposition', 'count'),
)
def __repr__(self):
return f"<ReportRecord {self.id} ({self.source_ip})>"
+62 -11
View File
@@ -48,31 +48,82 @@ class IMAPClient:
if not all([self.server, self.username, self.password]):
logger.warning("IMAP credentials not fully configured")
def test_connection(self) -> Tuple[bool, str]:
def test_connection(self) -> Tuple[bool, str, Dict[str, Any]]:
"""
Test the IMAP connection
Test the IMAP connection and gather basic mailbox statistics
Returns:
Tuple of (success, message)
Tuple of (success, message, stats)
- success: Boolean indicating if connection was successful
- message: String message describing the result
- stats: Dictionary with mailbox statistics (if successful)
"""
if not all([self.server, self.username, self.password]):
return False, "IMAP credentials not fully configured"
return False, "IMAP credentials not fully configured", {}
try:
# Create IMAP4 connection
mail = imaplib.IMAP4_SSL(self.server, self.port)
# Login
mail.login(self.username, self.password)
# List mailboxes
mail.list()
# Select inbox
mail.select('INBOX')
# Logout
# List available mailboxes
status, mailbox_list = mail.list()
available_mailboxes = []
if status == 'OK':
for mailbox in mailbox_list:
if isinstance(mailbox, bytes):
try:
# Extract mailbox name from response
mailbox_str = mailbox.decode('utf-8')
# Extract the mailbox name (after the last quote)
parts = mailbox_str.split('"')
if len(parts) > 2:
mailbox_name = parts[-1].strip()
if mailbox_name.startswith(' '):
mailbox_name = mailbox_name[1:]
available_mailboxes.append(mailbox_name)
except Exception:
pass
# Select inbox and get message count
status, data = mail.select('INBOX')
message_count = 0
unread_count = 0
if status == 'OK':
message_count = int(data[0])
# Count unread messages
status, data = mail.search(None, 'UNSEEN')
if status == 'OK':
unread_count = len(data[0].split())
# Gather some stats about potential DMARC reports
dmarc_count = 0
status, data = mail.search(None, 'SUBJECT "DMARC"')
if status == 'OK':
dmarc_count = len(data[0].split())
# Close connection
mail.close()
mail.logout()
return True, "Connection successful"
stats = {
"message_count": message_count,
"unread_count": unread_count,
"dmarc_count": dmarc_count,
"available_mailboxes": available_mailboxes,
"server": self.server,
"port": self.port,
"timestamp": datetime.now().isoformat()
}
return True, "Connection successful", stats
except Exception as e:
logger.error(f"IMAP connection test failed: {str(e)}")
return False, f"Connection failed: {str(e)}"
return False, f"Connection failed: {str(e)}", {}
def fetch_reports(self, days: int = 7) -> Dict[str, Any]:
"""
+24 -1
View File
@@ -189,4 +189,27 @@ class ReportStore:
"""
self.domain_reports = {}
self.domain_summary = {}
self.domain_sources = {}
self.domain_sources = {}
def delete_domain_with_cleanup(self, domain: str) -> bool:
"""
Delete a domain and all its associated data
Args:
domain: Domain name to delete
Returns:
True if domain was deleted, False otherwise
"""
if domain not in self.domain_reports:
return False
try:
# Remove all data for this domain
self.domain_reports.pop(domain, None)
self.domain_summary.pop(domain, None)
self.domain_sources.pop(domain, None)
return True
except Exception:
# If any exception occurs during deletion, return False
return False
+84 -10
View File
@@ -436,36 +436,110 @@ function domainDetailsApp(domainId) {
const labels = timelineData.map(item => item.date);
const complianceData = timelineData.map(item => item.compliance_rate);
// Calculate the threshold line data (recommended 98% for policy advancement)
const thresholdData = Array(labels.length).fill(98);
this.complianceChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Compliance Rate',
data: complianceData,
borderColor: '#1A237E',
backgroundColor: 'rgba(26, 35, 126, 0.1)',
tension: 0.3,
fill: true
}]
datasets: [
{
label: 'Compliance Rate',
data: complianceData,
borderColor: 'rgb(59, 130, 246)', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointRadius: 3,
pointHoverRadius: 5
},
{
label: 'Recommended Threshold (98%)',
data: thresholdData,
borderColor: 'rgba(220, 38, 38, 0.6)', // red-600 with opacity
borderDash: [5, 5],
pointRadius: 0,
borderWidth: 2,
fill: false,
tension: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
beginAtZero: false,
min: Math.max(0, Math.min(...complianceData) - 10), // Dynamic min value
max: 100,
ticks: {
callback: value => value + '%'
},
title: {
display: true,
text: 'Compliance Rate (%)',
font: {
weight: 'bold'
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
title: {
display: true,
text: 'Date',
font: {
weight: 'bold'
}
},
grid: {
display: false
}
}
},
plugins: {
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: {
size: 13
},
bodyFont: {
size: 12
},
padding: 10,
callbacks: {
label: function(context) {
return context.parsed.y + '%';
if (context.dataset.label === 'Compliance Rate') {
return `Compliance: ${context.parsed.y}%`;
}
return context.dataset.label;
},
title: function(context) {
return `Date: ${context[0].label}`;
}
}
},
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 15
}
},
annotation: {
annotations: {
box1: {
type: 'box',
yMin: 90,
yMax: 100,
backgroundColor: 'rgba(34, 197, 94, 0.05)',
borderWidth: 0
}
}
}
+3
View File
@@ -0,0 +1,3 @@
"""
Utilities for DMARQ application.
"""
+70
View File
@@ -0,0 +1,70 @@
import re
import socket
from typing import Dict, Tuple, Union, Optional
def validate_domain(domain_name: str) -> Tuple[bool, Optional[str]]:
"""
Validates a domain name for format and resolvability.
Args:
domain_name: The domain name to validate
Returns:
Tuple containing (is_valid, error_message)
- is_valid: Boolean indicating if domain is valid
- error_message: String with error message if not valid, None if valid
"""
# Check for empty domain
if not domain_name:
return False, "Domain name cannot be empty"
# Check domain format with regex
# This regex allows domain names with alphanumeric characters, hyphens,
# and periods as separators. It enforces proper domain structure.
domain_pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$'
if not re.match(domain_pattern, domain_name):
return False, "Invalid domain format"
# Check if domain exists by attempting to resolve DNS
try:
socket.gethostbyname(domain_name)
return True, None
except socket.gaierror:
# We could consider this valid if we don't require DNS resolution,
# but since DMARC requires valid DNS, we'll mark it as warning
return False, "Domain could not be resolved (DNS lookup failed)"
def validate_domain_config(domain_data: Dict) -> Dict[str, Union[bool, str]]:
"""
Validates domain configuration data for creating or updating domains.
Args:
domain_data: Dictionary with domain configuration
Returns:
Dictionary with validation results containing:
- valid: Boolean indicating if configuration is valid
- errors: Dict of field-specific errors
"""
errors = {}
# Validate domain name
if "name" in domain_data:
is_valid, error_msg = validate_domain(domain_data["name"])
if not is_valid:
errors["name"] = error_msg
else:
errors["name"] = "Domain name is required"
# Validate description (optional but with max length)
if "description" in domain_data and domain_data["description"]:
if len(domain_data["description"]) > 255:
errors["description"] = "Description is too long (max 255 characters)"
# Return validation results
return {
"valid": len(errors) == 0,
"errors": errors
}
+202
View File
@@ -0,0 +1,202 @@
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
import logging
import json
import os
# Setup logger
logger = logging.getLogger(__name__)
class StatsSummarizer:
"""
Utility class for summarizing and caching dashboard statistics
to improve performance with large datasets.
"""
def __init__(self, cache_dir: str = None):
"""
Initialize the stats summarizer with optional cache directory
Args:
cache_dir: Directory to store cached statistics (defaults to tmp/stats)
"""
if cache_dir is None:
# Default cache directory is tmp/stats under the project root
self.cache_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "tmp", "stats")
else:
self.cache_dir = cache_dir
# Create cache directory if it doesn't exist
os.makedirs(self.cache_dir, exist_ok=True)
def get_cached_summary(self, domain_id: Optional[str] = None, max_age_minutes: int = 60) -> Optional[Dict[str, Any]]:
"""
Get cached summary statistics if available and not too old
Args:
domain_id: Optional domain ID to get domain-specific stats
If None, gets global summary
max_age_minutes: Maximum age of cache in minutes
Returns:
Cached statistics or None if not available or too old
"""
cache_file = self._get_cache_filename(domain_id)
try:
if not os.path.exists(cache_file):
return None
# Check file modification time
mtime = os.path.getmtime(cache_file)
file_age = datetime.now() - datetime.fromtimestamp(mtime)
# If cache is too old, return None
if file_age > timedelta(minutes=max_age_minutes):
return None
# Read cache file
with open(cache_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Error reading cache file {cache_file}: {str(e)}")
return None
def save_summary(self, stats: Dict[str, Any], domain_id: Optional[str] = None) -> bool:
"""
Save summary statistics to cache
Args:
stats: Dictionary of statistics to cache
domain_id: Optional domain ID for domain-specific stats
Returns:
True if save was successful, False otherwise
"""
cache_file = self._get_cache_filename(domain_id)
try:
# Add timestamp
stats["cached_at"] = datetime.now().isoformat()
# Write to cache file
with open(cache_file, 'w') as f:
json.dump(stats, f)
return True
except Exception as e:
logger.error(f"Error writing cache file {cache_file}: {str(e)}")
return False
def invalidate_cache(self, domain_id: Optional[str] = None) -> None:
"""
Invalidate cache for a domain or all domains
Args:
domain_id: Optional domain ID to invalidate specific domain cache
If None, invalidates global summary cache
"""
if domain_id is None:
# Invalidate all caches
cache_file = self._get_cache_filename()
if os.path.exists(cache_file):
os.remove(cache_file)
else:
# Invalidate specific domain cache
cache_file = self._get_cache_filename(domain_id)
if os.path.exists(cache_file):
os.remove(cache_file)
def _get_cache_filename(self, domain_id: Optional[str] = None) -> str:
"""
Get the filename for a cache file
Args:
domain_id: Optional domain ID for domain-specific cache
Returns:
Path to the cache file
"""
if domain_id is None:
return os.path.join(self.cache_dir, "global_summary.json")
else:
# Sanitize domain_id to use as filename
safe_domain = domain_id.replace(".", "_").replace("/", "_")
return os.path.join(self.cache_dir, f"domain_{safe_domain}.json")
def calculate_summary_statistics(self, db, domain_id: Optional[str] = None) -> Dict[str, Any]:
"""
Calculate summary statistics from the database
Args:
db: Database session
domain_id: Optional domain ID to calculate domain-specific stats
Returns:
Dictionary with summary statistics
"""
# In a real implementation, this would query the database
# using SQLAlchemy models and calculate statistics
# For now, we'll return mock statistics
# First check if we have cached stats
cached_stats = self.get_cached_summary(domain_id)
if cached_stats:
return cached_stats
# If no cached stats, calculate from database
# In a real implementation, this would be done with SQL queries
# optimized for performance with large datasets
# For now, mock statistics
if domain_id is None:
# Global statistics
stats = {
"total_domains": 5,
"total_emails": 1250,
"compliant_emails": 1100,
"compliance_rate": 88.0,
"reports_processed": 25,
"top_sources": [
{"ip": "192.168.1.1", "count": 150},
{"ip": "10.0.0.1", "count": 120},
{"ip": "172.16.0.1", "count": 100}
],
"compliance_trend": [
{"date": "2025-04-13", "rate": 85.5},
{"date": "2025-04-14", "rate": 86.2},
{"date": "2025-04-15", "rate": 86.8},
{"date": "2025-04-16", "rate": 87.3},
{"date": "2025-04-17", "rate": 87.9},
{"date": "2025-04-18", "rate": 88.4},
{"date": "2025-04-19", "rate": 88.0}
]
}
else:
# Domain-specific statistics
stats = {
"domain": domain_id,
"total_emails": 250,
"compliant_emails": 220,
"compliance_rate": 88.0,
"reports_processed": 5,
"sources": [
{"ip": "192.168.1.1", "count": 100, "spf": "pass", "dkim": "pass"},
{"ip": "10.0.0.1", "count": 80, "spf": "pass", "dkim": "fail"},
{"ip": "172.16.0.1", "count": 70, "spf": "fail", "dkim": "pass"}
],
"compliance_trend": [
{"date": "2025-04-13", "rate": 85.0},
{"date": "2025-04-14", "rate": 86.0},
{"date": "2025-04-15", "rate": 87.0},
{"date": "2025-04-16", "rate": 87.5},
{"date": "2025-04-17", "rate": 88.0},
{"date": "2025-04-18", "rate": 88.5},
{"date": "2025-04-19", "rate": 88.0}
]
}
# Cache the statistics
self.save_summary(stats, domain_id)
return stats