- 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.
90 lines
3.3 KiB
Python
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})"
|