688 lines
23 KiB
Python
688 lines
23 KiB
Python
"""
|
||
Settings API endpoints.
|
||
|
||
Provides endpoints to read and write application-level settings persisted
|
||
in the ``settings`` database table. Settings are organised into categories:
|
||
|
||
- ``general`` – App name, base URL, reports-per-page, etc.
|
||
- ``dmarc`` – Default DMARC policy, percentage, etc.
|
||
- ``dns`` – Default DNS resolver, Cloudflare DoH toggle.
|
||
- ``cloudflare`` – Cloudflare API token and Zone ID.
|
||
- ``notifications`` – Future alerting/notification settings.
|
||
"""
|
||
|
||
import logging
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
|
||
from app.core.database import get_db
|
||
from app.core.security import require_admin_auth
|
||
from app.models.setting import Setting
|
||
from app.services.alert_history import (
|
||
list_alert_config_audit,
|
||
list_alert_history,
|
||
record_alert_config_change,
|
||
record_alert_evaluation,
|
||
)
|
||
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
||
from app.services.notifications import send_notification
|
||
from app.services.summary_notifications import build_summary, send_summary_notification
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Defaults – used to seed missing keys on first read
|
||
# ---------------------------------------------------------------------------
|
||
|
||
SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
||
# ── General ─────────────────────────────────────────────────────────────
|
||
{
|
||
"key": "general.app_name",
|
||
"value": "DMARQ",
|
||
"description": "Application display name shown in the UI",
|
||
"value_type": "string",
|
||
"category": "general",
|
||
},
|
||
{
|
||
"key": "general.base_url",
|
||
"value": "",
|
||
"description": "Public base URL (e.g. https://dmarc.example.com)",
|
||
"value_type": "string",
|
||
"category": "general",
|
||
},
|
||
{
|
||
"key": "general.reports_per_page",
|
||
"value": "25",
|
||
"description": "Number of reports shown per page in the reports list",
|
||
"value_type": "integer",
|
||
"category": "general",
|
||
},
|
||
{
|
||
"key": "general.session_lifetime_minutes",
|
||
"value": "1440",
|
||
"description": "How long a login session stays valid (minutes)",
|
||
"value_type": "integer",
|
||
"category": "general",
|
||
},
|
||
# ── DMARC ────────────────────────────────────────────────────────────────
|
||
{
|
||
"key": "dmarc.default_policy",
|
||
"value": "none",
|
||
"description": "Default DMARC policy applied when adding a new domain",
|
||
"value_type": "string",
|
||
"category": "dmarc",
|
||
},
|
||
{
|
||
"key": "dmarc.default_percentage",
|
||
"value": "100",
|
||
"description": "Default DMARC percentage (pct) tag for new domains",
|
||
"value_type": "integer",
|
||
"category": "dmarc",
|
||
},
|
||
{
|
||
"key": "dmarc.default_adkim",
|
||
"value": "r",
|
||
"description": "Default DKIM alignment mode: r (relaxed) or s (strict)",
|
||
"value_type": "string",
|
||
"category": "dmarc",
|
||
},
|
||
{
|
||
"key": "dmarc.default_aspf",
|
||
"value": "r",
|
||
"description": "Default SPF alignment mode: r (relaxed) or s (strict)",
|
||
"value_type": "string",
|
||
"category": "dmarc",
|
||
},
|
||
# ── DNS ──────────────────────────────────────────────────────────────────
|
||
{
|
||
"key": "dns.resolver",
|
||
"value": "system",
|
||
"description": "DNS resolver to use: system or cloudflare",
|
||
"value_type": "string",
|
||
"category": "dns",
|
||
},
|
||
# ── Cloudflare ───────────────────────────────────────────────────────────
|
||
{
|
||
"key": "cloudflare.api_token",
|
||
"value": "",
|
||
"description": "Cloudflare API token for DNS record management",
|
||
"value_type": "string",
|
||
"category": "cloudflare",
|
||
},
|
||
{
|
||
"key": "cloudflare.zone_id",
|
||
"value": "",
|
||
"description": "Cloudflare Zone ID for DNS record management",
|
||
"value_type": "string",
|
||
"category": "cloudflare",
|
||
},
|
||
# ── Notifications ─────────────────────────────────────────────────────────
|
||
{
|
||
"key": "notifications.apprise_enabled",
|
||
"value": "false",
|
||
"description": "Send notifications through configured Apprise target URLs",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.apprise_urls",
|
||
"value": "",
|
||
"description": "Newline-separated Apprise notification target URLs",
|
||
"value_type": "string",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.min_send_interval_minutes",
|
||
"value": "15",
|
||
"description": "Minimum minutes between outbound notification deliveries",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.redact_pii_enabled",
|
||
"value": "true",
|
||
"description": "Redact email addresses from outbound notification titles and bodies",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.last_sent_at",
|
||
"value": "",
|
||
"description": "Internal timestamp for outbound notification rate limiting",
|
||
"value_type": "string",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_new_sources_enabled",
|
||
"value": "true",
|
||
"description": "Alert when a new sending source appears in recent DMARC reports",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_compliance_drop_enabled",
|
||
"value": "true",
|
||
"description": "Alert when DMARC compliance drops by the configured percentage points",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_compliance_drop_points",
|
||
"value": "10",
|
||
"description": "Minimum compliance-rate drop, in percentage points, before alerting",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_failure_threshold_enabled",
|
||
"value": "true",
|
||
"description": "Alert when DMARC failures exceed the configured daily threshold",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_failure_threshold_count",
|
||
"value": "100",
|
||
"description": "Minimum failed message count in the last day before alerting",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_missing_reports_enabled",
|
||
"value": "true",
|
||
"description": "Alert when a monitored domain has not received recent DMARC reports",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.alert_missing_reports_days",
|
||
"value": "2",
|
||
"description": "Number of days without DMARC reports before alerting",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_daily_enabled",
|
||
"value": "false",
|
||
"description": "Send one daily DMARC activity summary notification",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_weekly_enabled",
|
||
"value": "false",
|
||
"description": "Send one weekly DMARC activity summary notification",
|
||
"value_type": "boolean",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_send_hour_utc",
|
||
"value": "8",
|
||
"description": "UTC hour when scheduled summary notifications can be sent",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_weekday_utc",
|
||
"value": "0",
|
||
"description": "UTC weekday for weekly summaries, where 0 is Monday",
|
||
"value_type": "integer",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_daily_last_sent_date",
|
||
"value": "",
|
||
"description": "Internal date marker for the last sent daily summary",
|
||
"value_type": "string",
|
||
"category": "notifications",
|
||
},
|
||
{
|
||
"key": "notifications.summary_weekly_last_sent_week",
|
||
"value": "",
|
||
"description": "Internal ISO week marker for the last sent weekly summary",
|
||
"value_type": "string",
|
||
"category": "notifications",
|
||
},
|
||
]
|
||
|
||
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||
_SECRET_KEYS = {
|
||
"cloudflare.api_token",
|
||
"notifications.apprise_urls",
|
||
"notifications.smtp_password",
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _seed_defaults(db: Session) -> None:
|
||
"""Insert any missing default settings rows (idempotent)."""
|
||
for defaults in SETTING_DEFAULTS:
|
||
key = defaults["key"]
|
||
if db.query(Setting).filter(Setting.key == key).first() is None:
|
||
db.add(
|
||
Setting(
|
||
key=key,
|
||
value=defaults["value"],
|
||
description=defaults["description"],
|
||
value_type=defaults["value_type"],
|
||
category=defaults["category"],
|
||
)
|
||
)
|
||
_migrate_plaintext_secret_settings(db)
|
||
db.commit()
|
||
|
||
|
||
def _get_setting(key: str, db: Session) -> Optional[Setting]:
|
||
return db.query(Setting).filter(Setting.key == key).first()
|
||
|
||
|
||
def _migrate_plaintext_secret_settings(db: Session) -> None:
|
||
"""Encrypt legacy plaintext secret settings opportunistically."""
|
||
rows = db.query(Setting).filter(Setting.key.in_(_SECRET_KEYS)).all()
|
||
for row in rows:
|
||
if row.value and not is_encrypted_secret(row.value):
|
||
row.value = encrypt_secret(row.value)
|
||
|
||
|
||
def _stored_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||
if key in _SECRET_KEYS:
|
||
return encrypt_secret(value)
|
||
return value
|
||
|
||
|
||
def _plain_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||
if key not in _SECRET_KEYS:
|
||
return value
|
||
return decrypt_secret(value)
|
||
|
||
|
||
def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||
if key in _SECRET_KEYS:
|
||
return "[redacted]" if value else ""
|
||
return value
|
||
|
||
|
||
def _should_audit_setting(key: str) -> bool:
|
||
return key.startswith("notifications.")
|
||
|
||
|
||
def _audit_setting_change(
|
||
db: Session,
|
||
*,
|
||
key: str,
|
||
old_plain: Optional[str],
|
||
new_plain: Optional[str],
|
||
auth_context: Optional[Dict[str, Any]],
|
||
) -> None:
|
||
if not _should_audit_setting(key) or old_plain == new_plain:
|
||
return
|
||
record_alert_config_change(
|
||
db,
|
||
key=key,
|
||
old_value=_audit_value_for_setting(key, old_plain),
|
||
new_value=_audit_value_for_setting(key, new_plain),
|
||
auth_context=auth_context,
|
||
)
|
||
|
||
|
||
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
|
||
value = row.value
|
||
if redact_secrets and row.key in _SECRET_KEYS and value:
|
||
value = "**redacted**"
|
||
return {
|
||
"key": row.key,
|
||
"value": value,
|
||
"description": row.description,
|
||
"value_type": row.value_type,
|
||
"category": row.category,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pydantic schemas
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class SettingUpdate(BaseModel):
|
||
"""Payload for updating a single setting."""
|
||
|
||
value: Optional[str] = None
|
||
|
||
|
||
class BulkSettingsUpdate(BaseModel):
|
||
"""Payload for updating multiple settings at once."""
|
||
|
||
settings: Dict[str, Optional[str]]
|
||
|
||
|
||
class SettingResponse(BaseModel):
|
||
"""Response for a single setting."""
|
||
|
||
key: str
|
||
value: Optional[str]
|
||
description: Optional[str]
|
||
value_type: str
|
||
category: str
|
||
updated_at: Optional[str]
|
||
|
||
|
||
class NotificationTestResponse(BaseModel):
|
||
"""Sanitized response from a test notification send."""
|
||
|
||
success: bool
|
||
message: str
|
||
configured_targets: int = 0
|
||
invalid_targets: int = 0
|
||
skipped: bool = False
|
||
rate_limited: bool = False
|
||
error: Optional[str] = None
|
||
|
||
|
||
class AlertRulesResponse(BaseModel):
|
||
"""Current alert-rule evaluation response."""
|
||
|
||
alerts: List[Dict[str, Any]]
|
||
|
||
|
||
class AlertNotificationResponse(BaseModel):
|
||
"""Alert-rule evaluation plus notification delivery status."""
|
||
|
||
alerts: List[Dict[str, Any]]
|
||
notification: Dict[str, Any]
|
||
|
||
|
||
class AlertHistoryResponse(BaseModel):
|
||
"""Persisted alert history response."""
|
||
|
||
history: List[Dict[str, Any]]
|
||
|
||
|
||
class AlertConfigurationAuditResponse(BaseModel):
|
||
"""Persisted alert configuration audit response."""
|
||
|
||
audit: List[Dict[str, Any]]
|
||
|
||
|
||
class SummaryResponse(BaseModel):
|
||
"""Current DMARC summary notification preview."""
|
||
|
||
summary: Dict[str, Any]
|
||
|
||
|
||
class SummaryNotificationResponse(BaseModel):
|
||
"""DMARC summary plus notification delivery status."""
|
||
|
||
summary: Dict[str, Any]
|
||
notification: Dict[str, Any]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@router.get("", response_model=List[SettingResponse])
|
||
async def list_settings(
|
||
category: Optional[str] = None,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> List[SettingResponse]:
|
||
"""
|
||
Return all persisted settings, optionally filtered by category.
|
||
|
||
Missing rows are seeded from defaults before returning.
|
||
"""
|
||
_seed_defaults(db)
|
||
query = db.query(Setting)
|
||
if category:
|
||
query = query.filter(Setting.category == category)
|
||
rows = query.order_by(Setting.category, Setting.key).all()
|
||
return [_row_to_dict(row) for row in rows]
|
||
|
||
|
||
@router.post("/notifications/test", response_model=NotificationTestResponse)
|
||
async def test_notification_settings(
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> NotificationTestResponse:
|
||
"""Send a test notification using the configured Apprise targets."""
|
||
_seed_defaults(db)
|
||
result = send_notification(
|
||
db,
|
||
title="DMARQ test notification",
|
||
body="This confirms that DMARQ can reach the configured notification target.",
|
||
force=True,
|
||
)
|
||
if not result.success:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=result.to_dict(),
|
||
)
|
||
return result.to_dict()
|
||
|
||
|
||
@router.get("/notifications/alerts", response_model=AlertRulesResponse)
|
||
async def evaluate_notification_alerts(
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> AlertRulesResponse:
|
||
"""Evaluate enabled notification alert rules against current DMARC data."""
|
||
_seed_defaults(db)
|
||
alerts = evaluate_alert_rules(db)
|
||
record_alert_evaluation(db, alerts)
|
||
return {"alerts": alerts}
|
||
|
||
|
||
@router.post("/notifications/alerts/send", response_model=AlertNotificationResponse)
|
||
async def send_notification_alerts(
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> AlertNotificationResponse:
|
||
"""Evaluate current alert rules and send a notification summary when needed."""
|
||
_seed_defaults(db)
|
||
result = send_current_alerts(db)
|
||
notification = result["notification"]
|
||
if result["alerts"] and not notification.get("success"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=result,
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/notifications/alerts/history", response_model=AlertHistoryResponse)
|
||
async def get_notification_alert_history(
|
||
active: Optional[bool] = None,
|
||
limit: int = 50,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> AlertHistoryResponse:
|
||
"""Return persisted alert history rows."""
|
||
return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))}
|
||
|
||
|
||
@router.get("/notifications/config-audit", response_model=AlertConfigurationAuditResponse)
|
||
async def get_notification_config_audit(
|
||
limit: int = 50,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> AlertConfigurationAuditResponse:
|
||
"""Return recent notification and alert-rule configuration changes."""
|
||
return {"audit": list_alert_config_audit(db, limit=max(1, min(limit, 200)))}
|
||
|
||
|
||
@router.get("/notifications/summary", response_model=SummaryResponse)
|
||
async def preview_notification_summary(
|
||
period: str = "daily",
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> SummaryResponse:
|
||
"""Preview a daily or weekly DMARC summary notification."""
|
||
_seed_defaults(db)
|
||
try:
|
||
summary = build_summary(db, period)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(exc),
|
||
) from exc
|
||
return {"summary": summary}
|
||
|
||
|
||
@router.post("/notifications/summary/send", response_model=SummaryNotificationResponse)
|
||
async def send_notification_summary(
|
||
period: str = "daily",
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> SummaryNotificationResponse:
|
||
"""Send a daily or weekly DMARC summary notification immediately."""
|
||
_seed_defaults(db)
|
||
try:
|
||
result = send_summary_notification(db, period)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(exc),
|
||
) from exc
|
||
notification = result["notification"]
|
||
if not notification.get("success"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=result,
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/{key:path}", response_model=SettingResponse)
|
||
async def get_setting(
|
||
key: str,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> SettingResponse:
|
||
"""Return a single setting by key."""
|
||
_seed_defaults(db)
|
||
row = _get_setting(key, db)
|
||
if row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=f"Setting '{key}' not found",
|
||
)
|
||
return _row_to_dict(row)
|
||
|
||
|
||
@router.put("/{key:path}", response_model=SettingResponse)
|
||
async def update_setting(
|
||
key: str,
|
||
payload: SettingUpdate,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> SettingResponse:
|
||
"""Update or create a single setting."""
|
||
row = _get_setting(key, db)
|
||
new_value = payload.value
|
||
if row is None:
|
||
# Find matching default metadata
|
||
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||
new_plain = _plain_value_for_setting(key, new_value)
|
||
row = Setting(
|
||
key=key,
|
||
value=_stored_value_for_setting(key, new_value),
|
||
description=default_meta["description"] if default_meta else None,
|
||
value_type=default_meta["value_type"] if default_meta else "string",
|
||
category=default_meta["category"] if default_meta else "general",
|
||
)
|
||
db.add(row)
|
||
_audit_setting_change(
|
||
db,
|
||
key=key,
|
||
old_plain=None,
|
||
new_plain=new_plain,
|
||
auth_context=_auth,
|
||
)
|
||
else:
|
||
# For secret keys, only update if not the redacted placeholder
|
||
if key in _SECRET_KEYS and payload.value == "**redacted**":
|
||
db.refresh(row)
|
||
return _row_to_dict(row)
|
||
old_plain = _plain_value_for_setting(key, row.value)
|
||
new_plain = _plain_value_for_setting(key, new_value)
|
||
row.value = _stored_value_for_setting(key, new_value)
|
||
_audit_setting_change(
|
||
db,
|
||
key=key,
|
||
old_plain=old_plain,
|
||
new_plain=new_plain,
|
||
auth_context=_auth,
|
||
)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return _row_to_dict(row)
|
||
|
||
|
||
@router.post("/bulk", response_model=List[SettingResponse])
|
||
async def bulk_update_settings(
|
||
payload: BulkSettingsUpdate,
|
||
db: Session = Depends(get_db),
|
||
_auth: dict = Depends(require_admin_auth),
|
||
) -> List[SettingResponse]:
|
||
"""
|
||
Update multiple settings in a single request.
|
||
|
||
Accepts ``{"settings": {"key1": "value1", "key2": "value2", ...}}``.
|
||
"""
|
||
results = []
|
||
for key, value in payload.settings.items():
|
||
row = _get_setting(key, db)
|
||
if row is None:
|
||
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||
new_plain = _plain_value_for_setting(key, value)
|
||
row = Setting(
|
||
key=key,
|
||
value=_stored_value_for_setting(key, value),
|
||
description=default_meta["description"] if default_meta else None,
|
||
value_type=default_meta["value_type"] if default_meta else "string",
|
||
category=default_meta["category"] if default_meta else "general",
|
||
)
|
||
db.add(row)
|
||
_audit_setting_change(
|
||
db,
|
||
key=key,
|
||
old_plain=None,
|
||
new_plain=new_plain,
|
||
auth_context=_auth,
|
||
)
|
||
else:
|
||
# Skip secret placeholder updates
|
||
if key in _SECRET_KEYS and value == "**redacted**":
|
||
results.append(_row_to_dict(row))
|
||
continue
|
||
old_plain = _plain_value_for_setting(key, row.value)
|
||
new_plain = _plain_value_for_setting(key, value)
|
||
row.value = _stored_value_for_setting(key, value)
|
||
_audit_setting_change(
|
||
db,
|
||
key=key,
|
||
old_plain=old_plain,
|
||
new_plain=new_plain,
|
||
auth_context=_auth,
|
||
)
|
||
results.append(_row_to_dict(row))
|
||
db.commit()
|
||
# Re-read rows to get updated_at timestamps
|
||
refreshed = []
|
||
for item in results:
|
||
row = _get_setting(item["key"], db)
|
||
if row:
|
||
refreshed.append(_row_to_dict(row))
|
||
return refreshed
|