Files
ServerKit/backend/app/api/previews.py
T
Juan DenisandClaude Opus 4.8 478b24a3cb feat: Phase 3 team & scale — projects/environments, shared resources, PR previews, proxy stacks
- Project / Environment hierarchy (C2): Workspace -> Project -> Environment
  org model. New Project/Environment models + CRUD APIs, nullable
  applications.project_id/environment_id (opt-in, no destructive backfill),
  ProjectService.ensure_default, a Projects list + ProjectDetail (env tabs),
  a project/environment selector in the create wizard, and sidebar nav.

- Polymorphic shared resources facade (C9): ResourceTag + SharedVariableGroup
  /SharedVariable/attachment models keyed by (resource_type, resource_id),
  reusing the env-var Fernet encryption; SharedResourceService resolves merged
  variables (most-recent attachment wins) with secrets masked. /api/v1/shared
  endpoints + reusable TagsPanel/EnvironmentVariablesPanel + a Shared Variables
  management page. Existing per-resource tables untouched (facade first).

- PR preview environments (C3): ApplicationPreview + settings models, a
  PreviewService with pure domain-templating + reconcile (open PRs vs active
  previews) cores and best-effort provisioning (incl. guarded WordPress
  file+DB clone), a pull_request webhook endpoint, preview.* jobs, and a
  Previews tab on the app detail page.

- Per-server managed proxy stack (C6): opt-in Dockerized Traefik/Caddy as a
  Compose stack (host Nginx stays default and superior for PHP/WordPress).
  ProxyStack model + ProxyStackService with pure compose/config generators and
  versioned config backups, /api/v1/servers/<id>/proxy endpoints, and a Proxy
  tab on the server detail page.

Migrations 031-034 add the new tables/columns; the full chain applies cleanly
from an empty DB. Full backend suite: 871 passed, 14 skipped; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:33:44 -04:00

111 lines
3.8 KiB
Python

"""PR Preview Environments API.
Mounted by the host at ``/api/v1/apps`` so every route here is nested under an
application id, e.g. ``GET /api/v1/apps/<app_id>/previews``.
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.middleware.rbac import developer_required
from app.models import Application
from app.models.application_preview import ApplicationPreview
from app.services.preview_service import PreviewService
previews_bp = Blueprint('previews', __name__)
def _get_app_or_404(app_id):
app = Application.query.get(app_id)
if not app:
return None, (jsonify({'error': 'Application not found'}), 404)
return app, None
@previews_bp.route('/<int:app_id>/previews', methods=['GET'])
@jwt_required()
def list_previews(app_id):
"""List the non-destroyed previews for an application (newest first)."""
app, err = _get_app_or_404(app_id)
if err:
return err
previews = ApplicationPreview.query.filter(
ApplicationPreview.application_id == app_id,
ApplicationPreview.status != ApplicationPreview.STATUS_DESTROYED,
).order_by(ApplicationPreview.pr_number.desc()).all()
return jsonify({'previews': [p.to_dict() for p in previews]}), 200
@previews_bp.route('/<int:app_id>/previews/settings', methods=['GET'])
@jwt_required()
def get_preview_settings(app_id):
"""Return the per-app preview settings (defaults when never configured)."""
app, err = _get_app_or_404(app_id)
if err:
return err
settings = PreviewService.get_settings(app_id)
return jsonify(settings.to_dict()), 200
@previews_bp.route('/<int:app_id>/previews/settings', methods=['PUT'])
@developer_required
def update_preview_settings(app_id):
"""Enable/disable previews and set the domain template + TTL for an app."""
app, err = _get_app_or_404(app_id)
if err:
return err
data = request.get_json(silent=True) or {}
try:
result = PreviewService.enable_previews(app_id, data)
except Exception as exc: # pragma: no cover - defensive
return jsonify({'error': str(exc)}), 400
return jsonify(result), 200
@previews_bp.route('/<int:app_id>/previews/sync', methods=['POST'])
@developer_required
def sync_previews(app_id):
"""Reconcile this app's previews against its open PRs (best-effort)."""
app, err = _get_app_or_404(app_id)
if err:
return err
result = PreviewService.sync_previews(app_id)
status = 400 if result.get('error') else 200
return jsonify(result), status
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>/redeploy', methods=['POST'])
@developer_required
def redeploy_preview(app_id, preview_id):
"""Re-provision a preview for the same PR (idempotent update-in-place)."""
app, err = _get_app_or_404(app_id)
if err:
return err
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
return jsonify({'error': 'Preview not found'}), 404
result = PreviewService.create_preview(app, {
'pr_number': preview.pr_number,
'branch': preview.branch,
'pr_title': preview.pr_title,
'commit_sha': preview.commit_sha,
})
status = 400 if result.get('error') else 200
return jsonify(result), status
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>', methods=['DELETE'])
@developer_required
def destroy_preview(app_id, preview_id):
"""Tear down and mark a preview destroyed."""
app, err = _get_app_or_404(app_id)
if err:
return err
preview = ApplicationPreview.query.filter_by(
id=preview_id, application_id=app_id).first()
if not preview:
return jsonify({'error': 'Preview not found'}), 404
result = PreviewService.destroy_preview(preview_id)
status = 400 if result.get('error') else 200
return jsonify(result), status