Files
zeekayandHanzo Dev b497511c04 no hogs: stamphog and review_hog are stamp and review
Both are Django apps, and both pin `label` in apps.py — "stamp" and "review".
django_migrations keys a row on (app_label, name), and app_label comes from that
pin and not from the import path, so moving the directory does not orphan a
single migration. All six stay where they are, untouched.

  products/stamphog -> products/stamp     name = products.stamp.backend
  products/review_hog -> products/review  name = products.review.backend
  StamphogScene/ -> StampScene/, and stamphogSceneLogic.ts with it

189 files follow. An earlier attempt at this move failed twice for reasons worth
recording: `git mv A B` where B exists puts A INSIDE B, and a stray
products/stamp/node_modules from an abandoned run was enough to make B exist —
the app ended up nested two deep. And `git ls-files` does not list a file moved
with plain mv and not yet staged, so a pass over "every tracked file" silently
skips it; that is how StampScene.tsx kept importing ./stampSceneLogic after the
file beside it had been renamed back.

bin/check-imports-are-committed: all first-party imports across 15,843 files
resolve.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:18:18 -07:00

650 lines
34 KiB
Python

"""
DRF views for stamp.
Validate JSON via serializers, call facade methods,
return serialized responses. No business logic here.
"""
from functools import cached_property
from typing import Any
from urllib.parse import quote
from uuid import UUID
from django.conf import settings
from django.core import signing
from django.db import IntegrityError, router, transaction
from django.db.models import QuerySet
from django.utils import timezone
import structlog
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import BaseSerializer
from rest_framework.views import APIView
from insights.api.routing import TeamAndOrgViewSetMixin
from insights.auth import OAuthAccessTokenAuthentication, PersonalAPIKeyAuthentication
from insights.models.scoping.manager import resolve_effective_team_id
from products.stamp.backend.facade.enums import TERMINAL_STATUSES, ReviewRunStatus
from ..logic.github_client import (
StampGitHubError,
exchange_oauth_code_for_user_token,
list_user_accessible_repositories,
list_user_installations,
user_can_access_installation,
)
from ..models import DigestChannel, DigestRun, PullRequest, ReviewRun, StampRepoConfig
from .serializers import (
DigestChannelSerializer,
DigestRunSerializer,
PullRequestSerializer,
ReviewRunSerializer,
StampInstallInfoSerializer,
StampRepoConfigSerializer,
StampSyncInstallationRequestSerializer,
StampSyncInstallationResponseSerializer,
)
logger = structlog.get_logger(__name__)
# The install-flow state token binds a GitHub App install callback to the team + user that started it.
# GitHub round-trips ?state=... through the install redirect; sync_installation only accepts a fresh,
# validly-signed token for the current team, so a stolen installation_id + code can't be replayed
# against another team's session.
_INSTALL_STATE_SALT = "stamp-install-state"
_INSTALL_STATE_MAX_AGE_SECONDS = 60 * 60
def _adopt_preexisting_config(team_id: int, repository: str, installation_id: str) -> StampRepoConfig | None:
"""Bind a manually-created (installation-less) config to a now-verified installation.
Reached when the installation sync hits the unique (team, repository) constraint: a row for this
repo already exists on the team, created through the plain API/MCP path with a blank installation_id
— or bound to a PREVIOUS installation after an uninstall/reinstall cycle (each reinstall mints a new
installation id, and the app can only be installed once per repo, so the old binding is dead).
Stamp the verified installation onto it so it starts resolving webhooks again, rather than reporting
it skipped and leaving it unbound forever. Safe to rebind: this helper is team-scoped and only
reached from the sync flow, which already proved the caller owns the NEW installation.
A never-bound placeholder binds DISABLED: its enabled flags were set by whoever created the row,
who never proved GitHub access to the repo — otherwise a member could pre-arm ``enabled=True``
for a private repo and have reviews start (under the syncing teammate's identity) the moment
someone else completes the install. Reinstall rows keep their settings: they were configured
while verifiably bound to a real installation.
"""
# Writer pin: the writer-side unique constraint is what routed us here, so the row exists on the
# writer — a lagged reader missing it would mark the repo skipped and leave it unbound forever.
existing = (
StampRepoConfig.objects.for_team(team_id)
.using(router.db_for_write(StampRepoConfig))
.filter(provider="github", repository=repository)
.first()
)
if existing is None:
return None
if existing.installation_id != installation_id:
update_fields = ["installation_id", "updated_at"]
if not existing.installation_id:
existing.enabled = False
existing.digest_enabled = False
update_fields += ["enabled", "digest_enabled"]
existing.installation_id = installation_id
try:
existing.save(update_fields=update_fields)
except IntegrityError:
return None
return existing
class StampCanonicalTeamAccessPermission(BasePermission):
"""Authorize against the canonical (data-owning) team, not just the URL environment team.
stamp rows canonicalize to the parent (project-root) team on save, so a request made against a
child environment reads and writes the PARENT's data. The default team gate only checks membership
of the URL team, so a user with access to the child but not the parent (or the reverse) would be
authorized against one team while touching another's rows. Re-anchor the membership check to the
canonical team so authorization and data access target the same team. Root teams (no parent) are
unaffected — the default checks already cover them.
"""
message = "You don't have access to the project that owns this data."
def has_permission(self, request: Request, view: APIView) -> bool:
if not request.user.is_authenticated:
return True # IsAuthenticated handles the unauthenticated case first
assert isinstance(view, _StampTeamScopedViewSet) # only ever attached to the shared base
team = view.team
# parent_team_id is null (or equals self) for a root team; then canonical == URL team and the
# default membership gate already authorized the right team.
if team.parent_team_id is None or team.parent_team_id == team.id or team.parent_team is None:
return True
# A team-scoped token must cover the CANONICAL team too: the default scope check accepted the
# URL (child) team, but the rows read and written belong to the parent — a PAK/OAuth token
# scoped only to the child must not reach them through the child's URL.
authenticator = request.successful_authenticator
scoped_teams = None
if isinstance(authenticator, OAuthAccessTokenAuthentication):
scoped_teams = authenticator.access_token.scoped_teams
elif isinstance(authenticator, PersonalAPIKeyAuthentication):
scoped_teams = authenticator.personal_api_key.scoped_teams
if scoped_teams and team.parent_team_id not in scoped_teams:
return False
# Same helper the default gate uses (effective_membership_level), just re-pointed at the parent.
# It already accounts for a private parent team, so None means genuinely no access -> 403.
level = view.user_permissions.team(team.parent_team).effective_membership_level
return level is not None
class _StampTeamScopedViewSet(TeamAndOrgViewSetMixin):
"""Shared base that exposes the canonical (parent) team id for queryset scoping.
ProductTeamModel.save() rewrites new rows to the canonical team id (parent when the team is a
child environment, else itself). A request made with a child environment's project id must read
under that same canonical id — scoping by the raw request team_id would miss rows the parent
stored. resolve_effective_team_id is the framework helper the model uses; self.team is already
loaded by the permission checks, so this resolves cheaply and is cached for the request.
StampCanonicalTeamAccessPermission keeps authorization pointed at that same canonical team, so a
caller can never be authorized against the child environment while reading/writing the parent's rows.
"""
# Appended onto the default team/scope permission stack by get_permissions.
permission_classes = [StampCanonicalTeamAccessPermission]
@cached_property
def canonical_team_id(self) -> int:
return resolve_effective_team_id(self.team_id)
def get_serializer_context(self) -> dict[str, Any]:
# The mixin sets context["team_id"] to the RAW url team, but serializers validate team-scoped
# lookups (e.g. DigestChannel.slack_integration_id) against it. stamp rows canonicalize to the
# parent team on save, so those lookups must target the canonical team the row is stored under —
# a child-environment request would otherwise validate against the wrong team's integrations.
context = super().get_serializer_context()
context["team_id"] = self.canonical_team_id
return context
def _should_skip_parents_filter(self) -> bool:
# safely_get_queryset already scopes every read by canonical_team_id, which resolves a child
# environment's id to its parent. The default parent-lookup filter would re-add the RAW url
# team_id, ANDing it with the canonical filter and hiding rows stored under the parent. Skip it
# and let canonical_team_id be the single source of truth for team scoping.
return True
class StampRepoConfigViewSet(_StampTeamScopedViewSet, viewsets.ModelViewSet):
"""Per-repo stamp settings — enable/disable review, GitHub App installation, policy overrides."""
scope_object = "stamp"
serializer_class = StampRepoConfigSerializer
# Unscoped base: the fail-closed manager raises at class-body eval if scoped here.
# safely_get_queryset re-applies the team filter per request.
queryset = StampRepoConfig.objects.unscoped()
def safely_get_queryset(self, queryset: QuerySet[StampRepoConfig]) -> QuerySet[StampRepoConfig]:
return queryset.filter(team_id=self.canonical_team_id).order_by("repository")
def perform_create(self, serializer: BaseSerializer[StampRepoConfig]) -> None:
# installation_id is read-only on this serializer, so a manual create can never claim an
# installation the caller hasn't proven ownership of — only the verified sync_installation flow
# sets it. A manually created config therefore carries an empty installation and won't resolve
# webhooks until synced. The cross-team guard below still binds a (provider, installation,
# repository) triple to the first team that claims it, closing the check-then-act race at the DB.
provider = serializer.validated_data.get("provider", "github")
installation_id = serializer.validated_data.get("installation_id", "")
repository = serializer.validated_data.get("repository", "")
already_claimed_error = ValidationError(
{"repository": "This repository is already configured under this GitHub installation by another team."}
)
# A blank installation proves no ownership, so a manual placeholder never claims a repo across
# teams (the DB constraint is likewise restricted to non-empty installation_id). Only a real,
# synced installation can already be owned elsewhere.
already_claimed = bool(installation_id) and (
StampRepoConfig.objects.unscoped()
.filter(provider=provider, installation_id=installation_id, repository=repository)
.exclude(team_id=self.canonical_team_id)
.exists()
)
if already_claimed:
raise already_claimed_error
# unique_stamp_installation_repo backs the check above at the DB level, so a race that slips
# past the read still fails closed here — surface it as the same 400, not a 500.
try:
serializer.save(team_id=self.team_id)
except IntegrityError:
raise already_claimed_error
def perform_destroy(self, instance: StampRepoConfig) -> None:
# Soft-disable rather than hard-delete (same tombstone pattern as digest channels). A hard
# delete cascades away the PRs and review runs — including posted_review_id — so a push to a
# previously approved PR could no longer resolve the config or dismiss the stale approval,
# leaving it satisfying required reviews forever. A disabled row keeps webhooks resolvable,
# and the disabled-repo skip path retracts standing approvals on the next head change.
instance.enabled = False
instance.digest_enabled = False
instance.save(update_fields=["enabled", "digest_enabled", "updated_at"])
self._supersede_active_runs(instance)
def perform_update(self, serializer: BaseSerializer[StampRepoConfig]) -> None:
was_enabled = serializer.instance is not None and serializer.instance.enabled
serializer.save()
if was_enabled and serializer.instance is not None and not serializer.instance.enabled:
self._supersede_active_runs(serializer.instance)
def _supersede_active_runs(self, instance: StampRepoConfig) -> None:
# Disabling (or tombstone-deleting) a repo must also stop reviews already in flight: their
# workflows never re-check enabled, so a queued/reviewing run could still post an approval
# after an admin removed stamp from the repo. Every workflow step bails on SUPERSEDED.
superseded = (
ReviewRun.objects.for_team(self.canonical_team_id)
.filter(pull_request__repo_config=instance)
.exclude(status__in=TERMINAL_STATUSES)
.update(status=ReviewRunStatus.SUPERSEDED, updated_at=timezone.now())
)
if superseded:
logger.info("stamp_repo_disable_superseded_runs", repository=instance.repository, superseded=superseded)
@extend_schema(responses={200: StampInstallInfoSerializer})
@action(detail=False, methods=["GET"], url_path="install_info", required_scopes=["stamp:read"])
def install_info(self, request: Request, **kwargs) -> Response:
# Deep link into GitHub's install page for the "Connect a repository" button. The state token
# binds the eventual callback to THIS team and user: GitHub round-trips ?state=... back to the
# Setup URL, and sync_installation rejects any callback whose state isn't a fresh token for the
# current team. Without it, an attacker could send a logged-in member a callback carrying the
# attacker's own installation and bind it to the victim's team.
slug = settings.STAMPFN_GITHUB_APP_SLUG
client_id = settings.STAMPFN_GITHUB_APP_CLIENT_ID
install_url = ""
authorize_url = ""
# One state token backs both URLs — either callback (install Setup URL or authorize Callback URL)
# round-trips it, and sync_installation binds the result to the team+user encoded here.
state = signing.dumps({"team_id": self.team_id, "user_id": request.user.pk}, salt=_INSTALL_STATE_SALT)
if slug:
install_url = f"https://github.com/apps/{slug}/installations/new?state={quote(state)}"
if client_id:
# Authorize-first: an already-installed user gets a silent instant redirect back with an OAuth
# code but no installation_id, so the connect button never dead-ends on GitHub's "update
# installation" screen. Discovery then finds the installations from the code, server-side.
authorize_url = (
f"https://github.com/login/oauth/authorize?client_id={quote(client_id)}&state={quote(state)}"
)
data = StampInstallInfoSerializer(
{"app_slug": slug, "install_url": install_url, "authorize_url": authorize_url}
).data
return Response(data)
def _sync_installation_repositories(
self, installation_id: str, user_token: str
) -> tuple[list[StampRepoConfig], list[str]]:
"""Bind every user-accessible repo in one installation to the current team. Returns (synced, skipped).
Enumerate with the USER token, not the app installation token: bind only the repos this user can
actually reach in the installation, so proving access to one repo can't attach repos they can't
see. The app-token list would return every repo the installer selected regardless of this user.
Raises :class:`StampGitHubError` on an enumeration failure so the caller fails closed. Shared by
the explicit-id and discovery paths so both bind identically.
"""
repositories = list_user_accessible_repositories(installation_id, user_token)
synced: list[StampRepoConfig] = []
skipped: list[str] = []
# Bind the per-row savepoint to the model's routed DB (stamp_db_writer when the product DB is
# configured, else default) — a bare atomic() opens on the default connection, so the get_or_create
# would run outside any transaction on the product DB.
write_db = router.db_for_write(StampRepoConfig)
for full_name in repositories:
# Per-row savepoint: an IntegrityError only rolls back that row, leaving the rest of the
# batch (and the outer autocommit context) intact.
try:
with transaction.atomic(using=write_db):
config, _ = StampRepoConfig.objects.for_team(self.team_id).get_or_create(
provider="github",
installation_id=installation_id,
repository=full_name,
# for_team() scopes the read but not row creation, so team_id is explicit here.
# Bind disabled: an installation can surface hundreds of repos, so connect them
# but don't start reviewing until a human toggles each on. enabled only seeds new
# rows; an existing row's toggle is never flipped.
defaults={"team_id": self.team_id, "enabled": False},
)
except IntegrityError:
# The unique (team, repository) constraint tripped: a same-team row for this repo already
# exists under a different installation_id — the manually-created config (blank
# installation) finally being bound. Adopt it instead of skipping; only a real conflict
# (already bound to another installation) stays skipped.
adopted = _adopt_preexisting_config(self.team_id, full_name, installation_id)
if adopted is None:
skipped.append(full_name)
else:
synced.append(adopted)
continue
synced.append(config)
return synced, skipped
@extend_schema(
request=StampSyncInstallationRequestSerializer,
responses={200: StampSyncInstallationResponseSerializer},
)
@action(detail=False, methods=["POST"], url_path="sync_installation", required_scopes=["stamp:write"])
def sync_installation(self, request: Request, **kwargs) -> Response:
# Custom action names fall outside the default read/write action classification, so without
# explicit required_scopes this write would be reachable with no scope check at all.
#
# Post-authorize binding. GitHub redirects the browser back with a user-to-server OAuth code and
# our state token; installation_id is present only on the fresh-install redirect, absent on the
# authorize-first redirect. We exchange the code for the user's token (the ownership proof) and:
# - explicit installation_id → verify the user can reach exactly it, then sync it.
# - no installation_id → discover the user's installations of THIS App from the token. Exactly
# one → sync it (the overwhelmingly common case). Several → bind NOTHING and return them as
# choices: reachability is not intent, and binding foreign orgs' installations here would
# misroute their webhooks (oldest-wins resolution). Zero isn't an error — the App isn't
# installed anywhere the user can see, signalled via app_not_installed.
# Either way ownership is proven by the code, never the caller-supplied (forgeable) id. A repo
# already owned by another team is skipped, not fatal, so one shared repo can't block the batch.
request_serializer = StampSyncInstallationRequestSerializer(data=request.data)
request_serializer.is_valid(raise_exception=True)
installation_id = request_serializer.validated_data["installation_id"]
code = request_serializer.validated_data["code"]
state = request_serializer.validated_data["state"]
# First gate: the state token must be a fresh, validly-signed token minted for THIS team by
# install_info. This binds the callback to the team that started the flow, so a stolen
# installation_id + code can't be replayed against another logged-in member's session.
try:
state_payload = signing.loads(state, salt=_INSTALL_STATE_SALT, max_age=_INSTALL_STATE_MAX_AGE_SECONDS)
except signing.BadSignature:
raise ValidationError(
{"state": "Invalid or expired install session. Restart the installation from Insights."}
)
if state_payload.get("team_id") != self.team_id:
logger.warning(
"stamp sync_installation: state team mismatch",
installation_id=installation_id,
team_id=self.team_id,
)
raise PermissionDenied("This installation link was started for a different project.")
# The token also binds the callback to the member who started the flow. Without this, one
# project member could hand another the callback and complete an install under the second
# member's session (both pass the team check). Same 403 path as the team mismatch.
if state_payload.get("user_id") != request.user.pk:
logger.warning(
"stamp sync_installation: state user mismatch",
installation_id=installation_id,
team_id=self.team_id,
)
raise PermissionDenied("This installation link was started by a different user.")
# Fail closed: no proven ownership, no binding. A missing OAuth token (bad/expired code or unset
# Stamp OAuth creds) is a 400; a valid user who simply can't reach an installation is a 403.
user_token = exchange_oauth_code_for_user_token(code)
if user_token is None:
raise ValidationError({"code": "Could not verify GitHub authorization. Reinstall the app and try again."})
if installation_id:
# Explicit-id path (fresh-install redirect): the id is caller-supplied, so verify the user can
# actually reach exactly it before binding — otherwise a caller who learns another org's
# installation_id could bind its repos and hijack its webhooks.
try:
owns_installation = user_can_access_installation(installation_id, user_token)
except StampGitHubError:
logger.warning(
"stamp sync_installation: installation ownership check failed",
installation_id=installation_id,
team_id=self.team_id,
)
raise ValidationError({"installation_id": "Failed to verify installation access. Try again."})
if not owns_installation:
logger.warning(
"stamp sync_installation: caller does not own installation",
installation_id=installation_id,
team_id=self.team_id,
)
raise PermissionDenied("You do not have access to this GitHub App installation.")
installation_ids = [installation_id]
else:
# Discovery path (authorize-first redirect): GitHub returns only installations of THIS App the
# user can reach, so the list is itself the ownership proof — no per-id verification needed.
try:
discovered = list_user_installations(user_token)
except StampGitHubError:
logger.warning(
"stamp sync_installation: discovering user installations failed", team_id=self.team_id
)
raise ValidationError({"code": "Failed to discover GitHub App installations. Try again."})
if not discovered:
# The App isn't installed anywhere the user can see. Not an error: the frontend routes them
# to the GitHub install page (install_url) off app_not_installed.
data = StampSyncInstallationResponseSerializer(
{"synced": [], "skipped": [], "app_not_installed": True, "installations": []}
).data
return Response(data)
if len(discovered) > 1:
# Reachability is not intent: a user in several orgs that all carry the App must pick which
# installation this team binds — silently binding them all would attach foreign orgs' repos
# here, and via the oldest-wins webhook resolution even blackhole another team's future
# connect. Bind nothing; the frontend re-runs the flow with an explicit installation_id
# (which the explicit path above verifies).
data = StampSyncInstallationResponseSerializer(
{"synced": [], "skipped": [], "app_not_installed": False, "installations": discovered}
).data
return Response(data)
installation_ids = [discovered[0]["id"]]
synced: list[StampRepoConfig] = []
skipped: list[str] = []
for one_installation_id in installation_ids:
try:
installation_synced, installation_skipped = self._sync_installation_repositories(
one_installation_id, user_token
)
except StampGitHubError:
logger.warning(
"stamp sync_installation: listing user-accessible repositories failed",
installation_id=one_installation_id,
team_id=self.team_id,
)
raise ValidationError({"installation_id": "Failed to list accessible repositories. Try again."})
synced.extend(installation_synced)
skipped.extend(installation_skipped)
# Every synced row records the caller as its connecting user — the identity the review
# sandbox's short-lived gateway token is minted under. Re-syncs re-stamp on purpose: the
# latest human to prove installation ownership is the right principal (the original
# installer may be long gone). .update() bypasses auto_now, so updated_at is set by hand.
restamp_ids = [config.id for config in synced if config.connected_by_user_id != request.user.pk]
if restamp_ids:
StampRepoConfig.objects.for_team(self.team_id).filter(id__in=restamp_ids).update(
connected_by_user_id=request.user.pk, updated_at=timezone.now()
)
data = StampSyncInstallationResponseSerializer(
{"synced": synced, "skipped": skipped, "app_not_installed": False, "installations": []}
).data
return Response(data)
class ReviewRunViewSet(_StampTeamScopedViewSet, viewsets.ReadOnlyModelViewSet):
"""Read-only history of stamp review runs, filterable by repository, PR number, and status."""
scope_object = "stamp"
serializer_class = ReviewRunSerializer
# Unscoped base: the fail-closed manager raises at class-body eval if scoped here.
# safely_get_queryset re-applies the team filter per request.
queryset = ReviewRun.objects.unscoped()
def safely_get_queryset(self, queryset: QuerySet[ReviewRun]) -> QuerySet[ReviewRun]:
queryset = (
queryset.filter(team_id=self.canonical_team_id)
.select_related("pull_request__repo_config")
.order_by("-created_at")
)
repository = self.request.query_params.get("repository")
if repository:
queryset = queryset.filter(pull_request__repo_config__repository=repository)
pr_number = self.request.query_params.get("pr_number")
if pr_number:
try:
queryset = queryset.filter(pull_request__pr_number=int(pr_number))
except ValueError:
return queryset.none()
status_filter = self.request.query_params.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
return queryset
@extend_schema(
parameters=[
OpenApiParameter(
"repository",
OpenApiTypes.STR,
OpenApiParameter.QUERY,
required=False,
description="Filter by repository full name, e.g. 'Insights/insights'.",
),
OpenApiParameter(
"pr_number",
OpenApiTypes.INT,
OpenApiParameter.QUERY,
required=False,
description="Filter by pull request number.",
),
OpenApiParameter(
"status",
OpenApiTypes.STR,
OpenApiParameter.QUERY,
required=False,
description="Filter by review run status.",
),
],
responses={200: ReviewRunSerializer(many=True)},
)
def list(self, request: Request, **kwargs) -> Response:
return super().list(request, **kwargs)
class PullRequestViewSet(_StampTeamScopedViewSet, viewsets.ReadOnlyModelViewSet):
"""Read-only pull requests stamp knows about, filterable by PR number and merge state."""
scope_object = "stamp"
serializer_class = PullRequestSerializer
# Unscoped base: the fail-closed manager raises at class-body eval if scoped here.
# safely_get_queryset re-applies the team filter per request.
queryset = PullRequest.objects.unscoped()
def safely_get_queryset(self, queryset: QuerySet[PullRequest]) -> QuerySet[PullRequest]:
queryset = queryset.filter(team_id=self.canonical_team_id).select_related("repo_config").order_by("-created_at")
pr_number = self.request.query_params.get("pr_number")
if pr_number:
try:
queryset = queryset.filter(pr_number=int(pr_number))
except ValueError:
return queryset.none()
merged = self.request.query_params.get("merged")
if merged is not None:
queryset = queryset.filter(merged_at__isnull=merged.lower() not in ("true", "1"))
return queryset
@extend_schema(
parameters=[
OpenApiParameter(
"pr_number",
OpenApiTypes.INT,
OpenApiParameter.QUERY,
required=False,
description="Filter by pull request number.",
),
OpenApiParameter(
"merged",
OpenApiTypes.BOOL,
OpenApiParameter.QUERY,
required=False,
description="Filter by merge state: true for merged pull requests, false for unmerged.",
),
],
responses={200: PullRequestSerializer(many=True)},
)
def list(self, request: Request, **kwargs) -> Response:
return super().list(request, **kwargs)
class DigestChannelViewSet(_StampTeamScopedViewSet, viewsets.ModelViewSet):
"""Per-audience Slack destinations for the daily merged-PR digest."""
scope_object = "stamp"
serializer_class = DigestChannelSerializer
# Unscoped base: the fail-closed manager raises at class-body eval if scoped here.
# safely_get_queryset re-applies the team filter per request.
queryset = DigestChannel.objects.unscoped()
def safely_get_queryset(self, queryset: QuerySet[DigestChannel]) -> QuerySet[DigestChannel]:
return queryset.filter(team_id=self.canonical_team_id).order_by("audience_key")
def perform_create(self, serializer: BaseSerializer[DigestChannel]) -> None:
# team_id is injected here (not a serializer field), so DRF can't pre-validate the
# unique (team, audience_key) constraint — without this catch a duplicate audience
# surfaces as a 500 instead of a plain validation error.
try:
serializer.save(team_id=self.team_id)
except IntegrityError:
raise ValidationError({"audience_key": "A digest channel for this audience already exists."})
def perform_destroy(self, instance: DigestChannel) -> None:
# Soft-disable rather than removing the row. The (team_id, audience_key) row is the tombstone
# that stops auto_provision_channel from recreating and re-posting a digest someone opted out of
# (see logic/channel_resolution.py — auto-provisioning skips any existing row, disabled included).
# A hard delete would let the next daily beat resurrect the channel and re-send the digest.
instance.enabled = False
instance.save(update_fields=["enabled", "updated_at"])
class DigestRunViewSet(_StampTeamScopedViewSet, viewsets.ReadOnlyModelViewSet):
"""Read-only history of posted (or attempted) digests, filterable by digest channel."""
scope_object = "stamp"
serializer_class = DigestRunSerializer
# Unscoped base: the fail-closed manager raises at class-body eval if scoped here.
# safely_get_queryset re-applies the team filter per request.
queryset = DigestRun.objects.unscoped()
def safely_get_queryset(self, queryset: QuerySet[DigestRun]) -> QuerySet[DigestRun]:
queryset = queryset.filter(team_id=self.canonical_team_id).order_by("-created_at")
digest_channel = self.request.query_params.get("digest_channel")
if digest_channel:
try:
channel_id = UUID(digest_channel)
except ValueError:
return queryset.none()
queryset = queryset.filter(digest_channel_id=channel_id)
return queryset
@extend_schema(
parameters=[
OpenApiParameter(
"digest_channel",
OpenApiTypes.UUID,
OpenApiParameter.QUERY,
required=False,
description="Filter by digest channel ID.",
),
],
responses={200: DigestRunSerializer(many=True)},
)
def list(self, request: Request, **kwargs) -> Response:
return super().list(request, **kwargs)