bottleneckcrm/backend/webhooks/models.py
Ashwin e26ab380fc Add unit tests for ticket board provider, status settable, web forms, CSV import, deal aging badge, and tickets selection
- Implement comprehensive tests for the ticket board provider to validate kanban and pipeline modes, including ticket movement and error handling.
- Create tests for ticket status settable to ensure proper status selection logic.
- Enhance web form model tests to cover ticket-specific fields and submission behavior.
- Introduce CSV import sheet tests to verify file handling and error reporting.
- Add deal aging badge tests to confirm correct display of aging statuses.
- Implement tickets selection tests to ensure UI responsiveness and functionality at various screen sizes.
2026-09-26 16:53:22 +05:30

90 lines
3.3 KiB
Python

"""Outbound webhooks: where an org's events are sent, and what happened to each send."""
import secrets
from django.db import models
from common.base import BaseOrgModel
JSON = "json"
SLACK = "slack"
FORMATS = ((JSON, "Signed JSON"), (SLACK, "Slack incoming webhook"))
PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"
DELIVERY_STATUSES = ((PENDING, "Pending"), (SUCCEEDED, "Succeeded"), (FAILED, "Failed"))
def generate_secret():
"""A signing secret. Only ever minted here, never taken from a request."""
return f"whsec_{secrets.token_urlsafe(32)}"
class WebhookEndpoint(BaseOrgModel):
"""A URL the org's events are POSTed to.
`secret` is stored as written because every delivery has to sign with it.
It leaves the server in exactly two responses: the create, and a rotate.
"""
url = models.URLField(max_length=500)
description = models.CharField(max_length=255, blank=True, default="")
# Event names from `webhooks.events.EVENT_NAMES`; the serializer refuses
# anything else.
events = models.JSONField(default=list)
format = models.CharField(max_length=10, choices=FORMATS, default=JSON)
is_active = models.BooleanField(default=True)
# Why the server turned the endpoint off (an HTTP 410), shown beside the
# switch. Cleared when an admin turns it back on.
disabled_reason = models.CharField(max_length=255, blank=True, default="")
secret = models.CharField(max_length=100, default=generate_secret, editable=False)
class Meta:
db_table = "webhook_endpoint"
ordering = ("-created_at",)
# Restated: a subclass Meta replaces BaseOrgModel's, index included.
indexes = [models.Index(fields=["org", "-created_at"])]
def __str__(self):
return self.url
@property
def secret_hint(self):
return f"whsec_...{self.secret[-4:]}"
class WebhookDelivery(BaseOrgModel):
"""One event sent to one endpoint, across all of its attempts.
A redelivery is a new row carrying the same `event_id` and payload, so the
log keeps what happened the first time.
"""
endpoint = models.ForeignKey(
WebhookEndpoint, on_delete=models.CASCADE, related_name="deliveries"
)
event = models.CharField(max_length=64)
event_id = models.UUIDField()
payload = models.JSONField()
status = models.CharField(max_length=10, choices=DELIVERY_STATUSES, default=PENDING)
attempts = models.PositiveSmallIntegerField(default=0)
response_status = models.PositiveSmallIntegerField(null=True, blank=True)
# A short, fixed description of the failure. Never an exception's own
# text, which can carry the URL and whatever secret its query string holds.
error = models.CharField(max_length=255, blank=True, default="")
# When the next attempt is due; null once the delivery has settled.
next_attempt_at = models.DateTimeField(null=True, blank=True)
last_attempt_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "webhook_delivery"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["org", "-created_at"]),
models.Index(fields=["endpoint", "-created_at"]),
models.Index(fields=["status", "next_attempt_at"]),
]
def __str__(self):
return f"{self.event} -> {self.endpoint_id} ({self.status})"