Files
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

143 lines
5.5 KiB
Python

"""Project endpoints (Workspace -> Project -> Environment -> Applications).
Projects are workspace-scoped. The active workspace is resolved the same way the
rest of the app resolves it: the X-Workspace-Id header (or ?workspace_id=), via
WorkspaceService.resolve_workspace_id (membership-checked, lenient). With no
active context we fall back to the workspaces the caller can access, so the list
always reflects the user's own projects without needing a header.
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.models.project import Project
from app.services.project_service import ProjectService
from app.services.workspace_service import WorkspaceService
from app.middleware.rbac import get_current_user
from app.exceptions import permission_denied
from app.api._access import visible_workspace_ids
from app.api._request import json_object
projects_bp = Blueprint('projects', __name__)
def _active_workspace_id(user):
"""Resolve the active workspace for stamping a new project, defaulting to the
user's Default workspace when no valid context is supplied."""
ws_id = WorkspaceService.resolve_workspace_id(
user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id'))
if ws_id is None:
ws_id = WorkspaceService.ensure_default_workspace().id
return ws_id
def _can_view_project(user, project):
if user.is_admin:
return True
return project.workspace_id in set(visible_workspace_ids(user))
@projects_bp.route('', methods=['GET'])
@projects_bp.route('/', methods=['GET'])
@jwt_required()
def list_projects():
"""List projects in the accessible workspace(s), each with resource counts."""
user = get_current_user()
ws_ids = visible_workspace_ids(user)
projects = []
for ws_id in ws_ids:
projects.extend(ProjectService.list_projects(ws_id))
return jsonify({'projects': [p.to_dict(include_counts=True) for p in projects]}), 200
@projects_bp.route('', methods=['POST'])
@projects_bp.route('/', methods=['POST'])
@jwt_required()
def create_project():
"""Create a project (auto-creates a default environment)."""
user = get_current_user()
data = json_object()
name = (data.get('name') or '').strip()
if not name:
return jsonify({'error': 'Project name is required'}), 400
ws_id = _active_workspace_id(user)
if not WorkspaceService.can_write_in_workspace(user, ws_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
try:
project = ProjectService.create_project(
workspace_id=ws_id,
name=name,
description=data.get('description'),
metadata=data.get('metadata'),
default_environment=data.get('default_environment') or 'production',
)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
result = project.to_dict(include_counts=True)
result['environments'] = [e.to_dict(include_counts=True)
for e in ProjectService.list_environments(project.id)]
return jsonify({'message': 'Project created', 'project': result}), 201
@projects_bp.route('/<int:project_id>', methods=['GET'])
@jwt_required()
def get_project(project_id):
"""Get a project with its environments and resource counts."""
user = get_current_user()
project = ProjectService.get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
if not _can_view_project(user, project):
raise permission_denied()
result = project.to_dict(include_counts=True)
result['environments'] = [e.to_dict(include_counts=True)
for e in ProjectService.list_environments(project_id)]
return jsonify({'project': result}), 200
@projects_bp.route('/<int:project_id>', methods=['PUT'])
@jwt_required()
def update_project(project_id):
user = get_current_user()
project = ProjectService.get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
if not _can_view_project(user, project):
raise permission_denied()
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
data = json_object()
updated = ProjectService.update_project(
project_id,
name=data.get('name'),
description=data.get('description'),
metadata=data.get('metadata'),
)
return jsonify({'message': 'Project updated', 'project': updated.to_dict(include_counts=True)}), 200
@projects_bp.route('/<int:project_id>', methods=['DELETE'])
@jwt_required()
def delete_project(project_id):
"""Delete a project. Refuses (409) if it still has applications assigned."""
user = get_current_user()
project = ProjectService.get_project(project_id)
if not project:
return jsonify({'error': 'Project not found'}), 404
if not _can_view_project(user, project):
raise permission_denied()
if not WorkspaceService.can_write_in_workspace(user, project.workspace_id):
return jsonify({'error': 'You have read-only access to this workspace'}), 403
result = ProjectService.delete_project(project_id)
if result == 'has_apps':
return jsonify({
'error': 'Project still has applications assigned. Reassign or remove them first.',
'app_count': ProjectService.count_project_apps(project_id),
}), 409
return jsonify({'message': 'Project deleted'}), 200