feat(phases): implement webhooks (6.1), email (7.1), user preferences (7.2)
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- Phase 6.1: WebhookConfig model, CRUD router (/api/v1/webhooks, admin-only),
  dispatch_webhook() with HMAC signing; integrated into test validation,
  campaign completion, and MITRE sync job
- Phase 7.1: SMTP email service with send_test_validated_email,
  send_campaign_completed_email, send_new_mitre_techniques_email;
  notify_role_with_email() added to notification_service
- Phase 7.2: notification_preferences and jira_account_id on User model;
  PATCH /users/me/preferences endpoint; Alembic migrations b031phase6 and b032phase7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-19 13:40:45 +02:00
parent d6df7fdc09
commit c1e06d4c0a
16 changed files with 590 additions and 2 deletions

View File

@@ -0,0 +1,32 @@
"""Phase 6.1: webhook_configs table.
Revision ID: b031phase6
Revises: b030phase5
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b031phase6"
down_revision: Union[str, None] = "b030phase5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"webhook_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("url", sa.Text, nullable=False),
sa.Column("secret", sa.String(256), nullable=True),
sa.Column("events", postgresql.JSONB, nullable=False, server_default="[]"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("last_triggered_at", sa.DateTime, nullable=True),
sa.Column("failure_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("webhook_configs")

View File

@@ -0,0 +1,41 @@
"""Phase 7.2: user notification_preferences and jira_account_id columns.
Revision ID: b032phase7
Revises: b031phase6
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b032phase7"
down_revision: Union[str, None] = "b031phase6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_DEFAULT_PREFS = '{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}'
def _column_names(table: str) -> set[str]:
bind = op.get_bind()
insp = sa.inspect(bind)
return {c["name"] for c in insp.get_columns(table)}
def upgrade() -> None:
user_cols = _column_names("users")
if "notification_preferences" not in user_cols:
op.add_column(
"users",
sa.Column("notification_preferences", postgresql.JSONB, nullable=True, server_default=_DEFAULT_PREFS),
)
if "jira_account_id" not in user_cols:
op.add_column(
"users",
sa.Column("jira_account_id", sa.String(100), nullable=True),
)
def downgrade() -> None:
user_cols = _column_names("users")
if "jira_account_id" in user_cols:
op.drop_column("users", "jira_account_id")
if "notification_preferences" in user_cols:
op.drop_column("users", "notification_preferences")

View File

@@ -70,6 +70,16 @@ class Settings(BaseSettings):
COMPANY_NAME: str = "Organization"
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
# ── Email / SMTP ──────────────────────────────────────────────────
SMTP_ENABLED: bool = False
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USERNAME: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM_EMAIL: str = "aegis@company.com"
SMTP_USE_TLS: bool = True
PLATFORM_URL: str = "http://localhost:5173" # base URL for links in emails
# ── Scoring weights (must sum to 100) ────────────────────────────
SCORING_WEIGHT_TESTS: int = 40
SCORING_WEIGHT_DETECTION_RULES: int = 25

View File

@@ -42,11 +42,13 @@ scheduler = BackgroundScheduler()
def _run_mitre_sync() -> None:
"""Execute a MITRE sync inside its own DB session."""
from app.services.webhook_service import dispatch_webhook
logger.info("Scheduled MITRE sync job starting...")
db = SessionLocal()
try:
summary = sync_mitre(db)
logger.info("Scheduled MITRE sync job finished — %s", summary)
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)})
except Exception:
logger.exception("Scheduled MITRE sync job failed")
finally:

View File

@@ -37,6 +37,7 @@ from app.routers import professional_reports as professional_reports_router
from app.routers import analytics as analytics_router
from app.routers import advanced_metrics as advanced_metrics_router
from app.routers import osint as osint_router
from app.routers import webhooks as webhooks_router
from app.domain.errors import DomainError
from app.middleware.error_handler import domain_exception_handler
from app.middleware.request_context import RequestContextMiddleware
@@ -123,6 +124,7 @@ app.include_router(professional_reports_router.router, prefix="/api/v1")
app.include_router(analytics_router.router, prefix="/api/v1")
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
app.include_router(osint_router.router, prefix="/api/v1")
app.include_router(webhooks_router.router, prefix="/api/v1")
@app.get("/health", include_in_schema=False)

View File

@@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
@@ -28,3 +28,5 @@ class User(Base):
must_change_password = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_login = Column(DateTime, nullable=True)
notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}')
jira_account_id = Column(String(100), nullable=True)

View File

@@ -0,0 +1,19 @@
"""WebhookConfig model — outbound HTTP notification endpoints."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class WebhookConfig(Base):
__tablename__ = "webhook_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
url = Column(Text, nullable=False)
secret = Column(String(256), nullable=True) # HMAC signature key
events = Column(JSONB, nullable=False, server_default="[]") # list of event types
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
last_triggered_at = Column(DateTime, nullable=True)
failure_count = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -33,6 +33,7 @@ from app.services.campaign_crud_service import (
from app.domain.unit_of_work import UnitOfWork
from app.services.audit_service import log_action
from app.services.notification_service import notify_role
from app.services.webhook_service import dispatch_webhook
logger = logging.getLogger(__name__)
@@ -284,6 +285,7 @@ def complete_campaign(
)
uow.commit()
db.refresh(campaign)
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
return serialize_campaign(db, campaign)

View File

@@ -45,6 +45,7 @@ from app.schemas.test_template import TestTemplateInstantiate
from app.domain.unit_of_work import UnitOfWork
from app.services.audit_service import log_action
from app.services.status_service import recalculate_technique_status
from app.services.webhook_service import dispatch_webhook
from app.services.test_crud_service import (
create_test as crud_create_test,
create_test_from_template as crud_create_from_template,
@@ -462,6 +463,10 @@ def validate_red(
recalculate_technique_status(db, test.technique)
uow.commit()
db.refresh(test)
if test.state == TestState.validated:
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
elif test.state == TestState.rejected:
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
return test
@@ -489,6 +494,10 @@ def validate_blue(
recalculate_technique_status(db, test.technique)
uow.commit()
db.refresh(test)
if test.state == TestState.validated:
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
elif test.state == TestState.rejected:
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
return test

View File

@@ -9,7 +9,8 @@ from app.database import get_db
from app.dependencies.auth import require_role
from app.domain.unit_of_work import UnitOfWork
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut
from app.dependencies.auth import get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate
from app.services.audit_service import log_action
from app.services.user_service import (
create_user,
@@ -21,6 +22,26 @@ from app.services.user_service import (
router = APIRouter(prefix="/users", tags=["users"])
# ---------------------------------------------------------------------------
# PATCH /users/me/preferences — update current user preferences
# ---------------------------------------------------------------------------
@router.patch("/me/preferences", response_model=UserOut)
def update_my_preferences(
payload: UserPreferencesUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user's notification preferences and Jira account ID."""
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
db.commit()
db.refresh(current_user)
return current_user
# ---------------------------------------------------------------------------
# GET /users — list all users
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,149 @@
"""Webhook configuration CRUD router — admin only.
Endpoints
---------
GET /webhooks — list all webhook configs
POST /webhooks — create a new webhook config
GET /webhooks/{id} — get a single webhook config
PATCH /webhooks/{id} — update a webhook config
DELETE /webhooks/{id} — hard-delete a webhook config
POST /webhooks/{id}/test — send a test ping
"""
import uuid
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_role
from app.domain.unit_of_work import UnitOfWork
from app.models.user import User
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
from app.services.webhook_service import (
create_webhook,
delete_webhook,
dispatch_webhook,
get_webhook_or_raise,
list_webhooks,
update_webhook,
)
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
def _mask_secret(wh) -> WebhookConfigOut:
"""Return a WebhookConfigOut with the secret masked."""
out = WebhookConfigOut.model_validate(wh)
if wh.secret:
out.secret = "***"
else:
out.secret = None
return out
# ---------------------------------------------------------------------------
# GET /webhooks
# ---------------------------------------------------------------------------
@router.get("", response_model=list[WebhookConfigOut])
def list_webhooks_route(
offset: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return all webhook configurations. **Requires admin role.**"""
webhooks = list_webhooks(db, offset=offset, limit=limit)
return [_mask_secret(wh) for wh in webhooks]
# ---------------------------------------------------------------------------
# POST /webhooks
# ---------------------------------------------------------------------------
@router.post("", response_model=WebhookConfigOut, status_code=status.HTTP_201_CREATED)
def create_webhook_route(
payload: WebhookConfigCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Create a new webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
wh = create_webhook(db, created_by=current_user.id, payload=payload)
uow.commit()
db.refresh(wh)
return _mask_secret(wh)
# ---------------------------------------------------------------------------
# GET /webhooks/{id}
# ---------------------------------------------------------------------------
@router.get("/{webhook_id}", response_model=WebhookConfigOut)
def get_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return a single webhook configuration. **Requires admin role.**"""
wh = get_webhook_or_raise(db, webhook_id)
return _mask_secret(wh)
# ---------------------------------------------------------------------------
# PATCH /webhooks/{id}
# ---------------------------------------------------------------------------
@router.patch("/{webhook_id}", response_model=WebhookConfigOut)
def update_webhook_route(
webhook_id: uuid.UUID,
payload: WebhookConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update one or more fields of a webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
wh = update_webhook(db, webhook_id, payload)
uow.commit()
db.refresh(wh)
return _mask_secret(wh)
# ---------------------------------------------------------------------------
# DELETE /webhooks/{id}
# ---------------------------------------------------------------------------
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Hard-delete a webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
delete_webhook(db, webhook_id)
uow.commit()
# ---------------------------------------------------------------------------
# POST /webhooks/{id}/test
# ---------------------------------------------------------------------------
@router.post("/{webhook_id}/test", status_code=status.HTTP_202_ACCEPTED)
def test_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Send a test ping to the webhook endpoint. **Requires admin role.**"""
# Verify the webhook exists before dispatching
get_webhook_or_raise(db, webhook_id)
dispatch_webhook("webhook.test", {"webhook_id": str(webhook_id), "message": "Test ping from Aegis"})
return {"detail": "Test ping dispatched"}

View File

@@ -121,6 +121,13 @@ class PasswordChange(BaseModel):
return _validate_password_strength(v)
class UserPreferencesUpdate(BaseModel):
"""Payload for updating current user's notification preferences and Jira account."""
notification_preferences: dict | None = None
jira_account_id: str | None = None
class UserOut(BaseModel):
"""Complete representation returned by the API."""
@@ -132,5 +139,7 @@ class UserOut(BaseModel):
must_change_password: bool = True
created_at: datetime | None = None
last_login: datetime | None = None
notification_preferences: dict | None = None
jira_account_id: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,32 @@
"""Pydantic schemas for Webhook endpoints."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, HttpUrl, ConfigDict
class WebhookConfigCreate(BaseModel):
name: str
url: str
secret: str | None = None
events: list[str] = []
is_active: bool = True
class WebhookConfigUpdate(BaseModel):
name: str | None = None
url: str | None = None
secret: str | None = None
events: list[str] | None = None
is_active: bool | None = None
class WebhookConfigOut(BaseModel):
id: uuid.UUID
name: str
url: str
secret: str | None = None # masked on read
events: list[str]
is_active: bool
created_by: uuid.UUID | None = None
last_triggered_at: datetime | None = None
failure_count: int
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,79 @@
"""Email notification service using SMTP.
Sending is silently skipped when SMTP_ENABLED=False (default).
All errors are caught and logged — email failures never crash the caller.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Send an HTML email. Returns True on success, False on skip/error."""
if not settings.SMTP_ENABLED:
logger.debug("SMTP disabled — skipping email to %s: %s", to, subject)
return False
if not to:
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Aegis] {subject}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server:
if settings.SMTP_USE_TLS:
server.starttls()
if settings.SMTP_USERNAME:
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
server.send_message(msg)
logger.info("Email sent to %s: %s", to, subject)
return True
except Exception:
logger.exception("Failed to send email to %s: %s", to, subject)
return False
def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool:
"""Notify that a test was validated."""
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x2705; Test Validated</h2>
<p>Test <strong>{test_name}</strong> for technique <code>{technique_id}</code> has been validated.</p>
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Test</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"Test Validated: {test_name}", html)
def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool:
"""Notify that a campaign was completed."""
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x1F3AF; Campaign Completed</h2>
<p>Campaign <strong>{campaign_name}</strong> has been completed.</p>
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Campaign</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"Campaign Completed: {campaign_name}", html)
def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool:
"""Notify of new MITRE techniques after sync."""
if created == 0:
return False
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x1F504; MITRE ATT&CK Updated</h2>
<p><strong>{created}</strong> new techniques added, <strong>{updated}</strong> updated.</p>
<p><a href="{settings.PLATFORM_URL}/techniques" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Techniques</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html)

View File

@@ -158,6 +158,40 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int:
# ---------------------------------------------------------------------------
def notify_role_with_email(
db: Session,
*,
role: str,
type: str,
title: str,
message: str,
entity_type: str,
entity_id: uuid.UUID,
email_fn=None, # callable(user_email) -> bool, optional
) -> None:
"""Send in-app notifications + optional email to all active users with a role."""
users = (
db.query(User)
.filter(User.role == role, User.is_active == True) # noqa: E712
.all()
)
for user in users:
create_notification(
db,
user_id=user.id,
type=type,
title=title,
message=message,
entity_type=entity_type,
entity_id=entity_id,
)
if email_fn and user.email:
try:
email_fn(user.email)
except Exception:
pass # email failures never crash notification flow
def notify_test_state_change(db: Session, test, new_state: str) -> None:
"""Dispatch notifications based on a test's new state.

View File

@@ -0,0 +1,145 @@
"""Webhook dispatch service — outbound HTTP notifications.
Supported event types:
- test.validated — fired when a test reaches "validated" state
- test.rejected — fired when a test reaches "rejected" state
- campaign.completed — fired when a campaign is completed
- campaign.started — fired when a campaign is activated
- mitre.synced — fired after MITRE ATT&CK sync completes
- technique.status_changed — fired when a technique's status changes
- webhook.test — manual test ping from the admin UI
"""
import hashlib
import hmac
import logging
import uuid
from datetime import datetime
import requests
from app.database import SessionLocal
from app.models.webhook_config import WebhookConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def dispatch_webhook(event_type: str, payload: dict) -> None:
"""Send an outbound webhook to all active subscribers for *event_type*.
Opens its own DB session so this can be called outside request context
(e.g. from background jobs). All exceptions are caught; webhook failures
never crash the caller.
"""
db = SessionLocal()
try:
webhooks = (
db.query(WebhookConfig)
.filter(WebhookConfig.is_active == True) # noqa: E712
.all()
)
for wh in webhooks:
# Filter by subscribed events — empty list means "all events"
subscribed: list = wh.events or []
if subscribed and event_type not in subscribed:
continue
_send_webhook(db, wh, event_type, payload)
except Exception:
logger.exception("dispatch_webhook: unexpected error for event_type=%s", event_type)
finally:
db.close()
def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None:
"""Send a single webhook POST and update its metadata."""
body = {
"event": event_type,
"data": payload,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
headers = {"Content-Type": "application/json"}
if wh.secret:
import json
raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
sig = hmac.new(wh.secret.encode(), raw, hashlib.sha256).hexdigest()
headers["X-Aegis-Signature"] = f"sha256={sig}"
try:
resp = requests.post(wh.url, json=body, headers=headers, timeout=10)
resp.raise_for_status()
wh.last_triggered_at = datetime.utcnow()
wh.failure_count = 0
db.commit()
logger.info("Webhook '%s' (%s) dispatched OK for event=%s", wh.name, wh.url, event_type)
except Exception as exc:
wh.failure_count = (wh.failure_count or 0) + 1
wh.last_triggered_at = datetime.utcnow()
try:
db.commit()
except Exception:
db.rollback()
logger.warning(
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
wh.name, wh.url, event_type, exc, wh.failure_count,
)
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
def list_webhooks(db, *, offset: int = 0, limit: int = 50) -> list[WebhookConfig]:
"""Return paginated webhook configs."""
return (
db.query(WebhookConfig)
.order_by(WebhookConfig.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
def get_webhook_or_raise(db, webhook_id: uuid.UUID) -> WebhookConfig:
"""Fetch a webhook by ID or raise 404."""
from app.domain.errors import EntityNotFoundError
wh = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
if wh is None:
raise EntityNotFoundError("WebhookConfig", str(webhook_id))
return wh
def create_webhook(db, created_by: uuid.UUID, payload) -> WebhookConfig:
"""Create and persist a new WebhookConfig."""
wh = WebhookConfig(
name=payload.name,
url=payload.url,
secret=payload.secret,
events=payload.events,
is_active=payload.is_active,
created_by=created_by,
)
db.add(wh)
db.flush()
return wh
def update_webhook(db, webhook_id: uuid.UUID, payload) -> WebhookConfig:
"""Apply a partial update to an existing WebhookConfig."""
wh = get_webhook_or_raise(db, webhook_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(wh, field, value)
db.flush()
return wh
def delete_webhook(db, webhook_id: uuid.UUID) -> None:
"""Hard-delete a WebhookConfig."""
wh = get_webhook_or_raise(db, webhook_id)
db.delete(wh)
db.flush()