Files
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

367 lines
15 KiB
Python

from typing import IO, Any, cast
from django import forms
from django.contrib import admin, messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.core.validators import FileExtensionValidator
from django.db import IntegrityError, transaction
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import SafeString
import structlog
from insights.admin.inline_registry import register_admin_inline
from insights.cloud_utils import is_cloud, is_dev_mode
from insights.models.organization import Organization
from insights.storage import object_storage
from . import logic
from .models import LegalDocument
from .storage import signed_pdf_storage_key
logger = structlog.get_logger(__name__)
# 25 MiB. PandaDoc-signed PDFs are ~100 KiB; counter-signed scans uploaded by sales
# can be larger but rarely come close to this. Anything bigger is almost certainly
# the wrong file.
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
class LegalDocumentAdminForm(forms.ModelForm):
"""
Admin upload form for documents signed outside the PandaDoc flow (sales /
legal-ops dropping a counter-signed PDF). The PDF lives in object storage
under the same key the public download endpoint already serves from, so the
customer can download it via /v1/.../legal_documents/{id}/download just
like a PandaDoc-originated row.
"""
signed_pdf = forms.FileField(
required=True,
validators=[FileExtensionValidator(allowed_extensions=["pdf"])],
help_text="Counter-signed PDF (max 25 MiB). The row will be created with status='signed'.",
)
class Meta:
model = LegalDocument
fields = (
"organization",
"document_type",
"company_name",
"company_address",
"representative_email",
)
def clean_signed_pdf(self) -> UploadedFile:
pdf: UploadedFile = self.cleaned_data["signed_pdf"]
if pdf.size and pdf.size > _MAX_UPLOAD_BYTES:
raise ValidationError(f"PDF is too large ({pdf.size} bytes). Limit is {_MAX_UPLOAD_BYTES} bytes.")
# Browsers populate content_type from the multipart upload; we only reject
# obvious mismatches. The .pdf extension validator above is the primary check.
if pdf.content_type and pdf.content_type != "application/pdf":
raise ValidationError(f"File must be a PDF (got content-type {pdf.content_type!r}).")
return pdf
@admin.register(LegalDocument)
class LegalDocumentAdmin(admin.ModelAdmin):
# FK to insights.Organization — without this the add view renders a <select>
# of every org on Cloud, which times out. Autocomplete searches lazily via
# OrganizationAdmin.search_fields.
autocomplete_fields = ("organization",)
list_display = (
"id",
"document_type",
"company_name",
"organization_link",
"status",
"pandadoc_link",
"created_at",
)
list_display_links = ("id",)
list_filter = ("document_type", "status", "created_at")
search_fields = (
"id",
"company_name",
"representative_email",
"organization__name",
)
ordering = ("-created_at",)
show_full_result_count = False
list_select_related = ("organization", "created_by")
actions = ("resend_signing_email",)
# Change view stays read-only — customer-submitted content can't be quietly
# rewritten. The add view uses LegalDocumentAdminForm fields directly.
readonly_fields = (
"id",
"organization",
"document_type",
"company_name",
"company_address",
"representative_email",
"status",
"pandadoc_link",
"download_link",
"created_by",
"created_at",
"updated_at",
)
add_fieldsets = (
(
"Upload signed document",
{
"description": (
"Use this form when a document was signed outside the PandaDoc flow "
"(e.g., counter-signed offline, DocuSign, or an MSA negotiated by sales). "
"The row is saved as 'signed' and the PDF goes to object storage at the "
"same key the customer-facing download endpoint reads from."
),
"fields": (
"organization",
"document_type",
"company_name",
"company_address",
"representative_email",
"signed_pdf",
),
},
),
)
fieldsets = (
(
"Document",
{
"fields": (
"id",
"organization",
"document_type",
"status",
"pandadoc_link",
"download_link",
)
},
),
(
"Customer details",
{
"fields": (
"company_name",
"company_address",
"representative_email",
)
},
),
(
"Audit",
{"fields": ("created_by", "created_at", "updated_at")},
),
)
def get_fieldsets(self, request: HttpRequest, obj: LegalDocument | None = None) -> Any:
if obj is None:
return self.add_fieldsets
return self.fieldsets
def get_form(
self, request: HttpRequest, obj: LegalDocument | None = None, change: bool = False, **kwargs: Any
) -> Any:
# Add view uses LegalDocumentAdminForm (with the required signed_pdf
# FileField). The change view falls back to Django's default ModelForm
# for the model — `signed_pdf` is a non-model class attribute, so it
# would otherwise be inherited by modelform_factory's subclass and
# block "Save" with a "This field is required" error even though the
# change view renders no upload widget.
if obj is None:
kwargs["form"] = LegalDocumentAdminForm
return super().get_form(request, obj, change, **kwargs)
def get_readonly_fields(
self, request: HttpRequest, obj: LegalDocument | None = None
) -> tuple[str, ...] | list[str]:
# On the change view every customer-submitted field is read-only so it
# can't be quietly rewritten. On the add view (obj is None) the upload
# form needs the fields to be editable so return an empty tuple.
if obj is None:
return ()
return self.readonly_fields
def has_add_permission(self, request: HttpRequest) -> bool:
return bool(request.user and request.user.is_staff)
def has_delete_permission(self, request: HttpRequest, obj: LegalDocument | None = None) -> bool:
# Needed so admins can clear an existing row (the unique-per-org-per-type
# constraint blocks re-uploads otherwise). Best-effort S3 cleanup runs
# alongside the row delete.
return bool(request.user and request.user.is_staff)
def save_model(self, request: HttpRequest, obj: LegalDocument, form: Any, change: bool) -> None:
if change:
super().save_model(request, obj, form, change)
return
# Add path: row + S3 upload happen together so we never leave a row
# pointing at a missing PDF (and never leave a PDF without a row).
obj.status = LegalDocument.Status.SIGNED
obj.created_by = request.user if request.user.is_authenticated else None
try:
with transaction.atomic():
obj.save()
pdf: UploadedFile = form.cleaned_data["signed_pdf"]
try:
object_storage.write_stream(
signed_pdf_storage_key(obj),
cast(IO[bytes], pdf),
extras={"ContentType": "application/pdf"},
)
except Exception as exc:
logger.exception(
"legal_document_admin_upload_failed",
document_id=str(obj.id),
error=str(exc),
)
raise ValidationError(f"Failed to upload PDF to object storage: {exc}") from exc
except IntegrityError as exc:
# Surface the unique-per-org-per-type constraint as a form error
# instead of a 500.
raise ValidationError(
f"This organization already has a {obj.document_type}. Delete the existing row first."
) from exc
@admin.action(description="Re-send PandaDoc signing email")
def resend_signing_email(self, request: HttpRequest, queryset: Any) -> None:
# Recovery path for envelopes stranded because the `document.draft`
# webhook that normally triggers the send was missed: the envelope was
# created but the signing email never went out. Re-dispatching the send
# (idempotent on PandaDoc's side) gets the signer their link without
# forcing a delete + regenerate. Signed rows and rows without an
# envelope have nothing to send.
sent = skipped = failed = 0
for document in queryset:
if document.status == LegalDocument.Status.SIGNED or not document.pandadoc_document_id:
skipped += 1
elif logic.send_pandadoc_envelope(document):
sent += 1
else:
failed += 1
if sent:
messages.success(request, f"Re-sent the signing email for {sent} document(s).")
if skipped:
messages.warning(request, f"Skipped {skipped} document(s): already signed or no PandaDoc envelope.")
if failed:
messages.error(
request,
f"Couldn't re-send {failed} document(s). PandaDoc may still be processing the template, or the "
"envelope is no longer sendable. Check the logs and try again shortly.",
)
def delete_model(self, request: HttpRequest, obj: LegalDocument) -> None:
# Shared helper voids the PandaDoc envelope, removes the S3 object,
# and deletes the row (firing the activity-log entry via
# ModelActivityMixin). The same helper backs the public DELETE
# endpoint — admin keeps the privilege of deleting signed rows by
# calling the helper directly rather than going through the facade.
logic.delete_document(obj)
def delete_queryset(self, request: HttpRequest, queryset: Any) -> None:
# Per-row delete (rather than queryset.delete()) so each row fires its
# own activity-log delete and so the shared helper can run its
# PandaDoc + S3 cleanup against each envelope individually.
for obj in queryset:
logic.delete_document(obj)
def changelist_view(self, request: HttpRequest, extra_context: dict[str, Any] | None = None) -> HttpResponse:
if not (is_cloud() or is_dev_mode()):
messages.warning(
request,
"Legal documents are only generated on Insights Cloud. On self-hosted "
"deployments, listed rows (if any) are read-only historical records and "
"the PandaDoc integration is disabled.",
)
return super().changelist_view(request, extra_context=extra_context)
def change_view(
self, request: HttpRequest, object_id: str, form_url: str = "", extra_context: dict[str, Any] | None = None
) -> HttpResponse:
if not (is_cloud() or is_dev_mode()):
messages.warning(
request,
"Legal documents are only generated on Insights Cloud. On self-hosted "
"deployments, listed rows (if any) are read-only historical records and "
"the PandaDoc integration is disabled.",
)
return super().change_view(request, object_id, form_url, extra_context=extra_context)
@admin.display(description="Organization", ordering="organization__name")
def organization_link(self, document: LegalDocument) -> SafeString:
url = reverse("admin:insights_organization_change", args=[document.organization_id])
return format_html('<a href="{}">{}</a>', url, document.organization.name)
@admin.display(description="PandaDoc", ordering="pandadoc_document_id")
def pandadoc_link(self, document: LegalDocument) -> str | SafeString:
if not document.pandadoc_document_id:
return "—"
return format_html(
'<a href="https://app.pandadoc.com/a/#/documents/{id}" target="_blank" rel="noopener">{id}</a>',
id=document.pandadoc_document_id,
)
@admin.display(description="Signed PDF")
def download_link(self, document: LegalDocument) -> str | SafeString:
# The PDF only exists once the row is signed (PandaDoc-signed rows get
# the file via the completion webhook; admin-uploaded rows write it
# synchronously on save). Hide the link until there's something to fetch.
if document.status != LegalDocument.Status.SIGNED:
return "—"
url = f"/v1/organizations/{document.organization_id}/legal_documents/{document.id}/download"
return format_html('<a href="{}" target="_blank" rel="noopener">Download PDF</a>', url)
class LegalDocumentInline(admin.TabularInline):
"""List of an organization's legal documents on the Organization admin."""
model = LegalDocument
extra = 0
show_change_link = True
template = "admin/legal_documents/edit_inline/tabular.html"
fields = (
"document_type",
"company_name",
"status",
"pandadoc_link",
"download_link",
"created_at",
)
readonly_fields = fields
can_delete = False
@admin.display(description="PandaDoc")
def pandadoc_link(self, document: LegalDocument) -> str | SafeString:
if not document.pandadoc_document_id:
return "—"
return format_html(
'<a href="https://app.pandadoc.com/a/#/documents/{id}" target="_blank" rel="noopener">{id}</a>',
id=document.pandadoc_document_id,
)
@admin.display(description="Signed PDF")
def download_link(self, document: LegalDocument) -> str | SafeString:
if document.status != LegalDocument.Status.SIGNED:
return "—"
url = f"/v1/organizations/{document.organization_id}/legal_documents/{document.id}/download"
return format_html('<a href="{}" target="_blank" rel="noopener">Download PDF</a>', url)
# Surface this inline on core's Organization admin page without core importing the product.
# OrganizationAdmin pulls it in via get_inlines() — see insights.admin.inline_registry.
register_admin_inline(Organization, LegalDocumentInline)