Files
TimeTracker/app/routes/api_v1_projects.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

161 lines
5.7 KiB
Python

"""
API v1 - Projects sub-blueprint.
Routes under /api/v1/projects.
"""
from flask import Blueprint, g, jsonify, request
from marshmallow import ValidationError
from app.utils.api_auth import require_api_token
from app.utils.api_responses import (
error_response,
forbidden_response,
handle_validation_error,
not_found_response,
validation_error_response,
)
api_v1_projects_bp = Blueprint("api_v1_projects", __name__, url_prefix="/api/v1")
@api_v1_projects_bp.route("/projects", methods=["GET"])
@require_api_token("read:projects")
def list_projects():
"""List all projects."""
from app.services import ProjectService
from app.utils.scope_filter import get_allowed_client_ids
status = request.args.get("status", "active")
client_id = request.args.get("client_id", type=int)
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 50, type=int), 100)
scope_client_ids = get_allowed_client_ids(g.api_user)
project_service = ProjectService()
result = project_service.list_projects(
status=status,
client_id=client_id,
page=page,
per_page=per_page,
scope_client_ids=scope_client_ids,
)
pag = result["pagination"]
pagination_dict = {
"page": pag.page,
"per_page": pag.per_page,
"total": pag.total,
"pages": pag.pages,
"has_next": pag.has_next,
"has_prev": pag.has_prev,
"next_page": pag.page + 1 if pag.has_next else None,
"prev_page": pag.page - 1 if pag.has_prev else None,
}
return jsonify({"projects": [p.to_dict() for p in result["projects"]], "pagination": pagination_dict})
@api_v1_projects_bp.route("/projects/<int:project_id>", methods=["GET"])
@require_api_token("read:projects")
def get_project(project_id):
"""Get a specific project."""
from app.services import ProjectService
from app.utils.scope_filter import user_can_access_project
project_service = ProjectService()
result = project_service.get_project_with_details(project_id=project_id, include_time_entries=False)
if not result:
return not_found_response("Project", project_id)
if not user_can_access_project(g.api_user, project_id):
return forbidden_response("You do not have access to this project")
return jsonify({"project": result.to_dict()})
@api_v1_projects_bp.route("/projects", methods=["POST"])
@require_api_token("write:projects")
def create_project():
"""Create a new project."""
from app.schemas import ProjectCreateSchema
from app.services import ProjectService
data = request.get_json() or {}
if not data.get("name"):
return validation_error_response(
errors={"name": ["Project name is required"]},
message="Project name is required",
)
try:
loaded = ProjectCreateSchema(partial=True).load(data)
except ValidationError as err:
return handle_validation_error(err)
project_service = ProjectService()
result = project_service.create_project(
name=loaded["name"],
client_id=loaded.get("client_id"),
created_by=g.api_user.id,
description=loaded.get("description"),
billable=loaded.get("billable", True),
hourly_rate=loaded.get("hourly_rate"),
code=loaded.get("code"),
budget_amount=loaded.get("budget_amount"),
budget_threshold_percent=loaded.get("budget_threshold_percent"),
billing_ref=loaded.get("billing_ref"),
)
if not result.get("success"):
return error_response(result.get("message", "Could not create project"), status_code=400)
return jsonify({"message": "Project created successfully", "project": result["project"].to_dict()}), 201
@api_v1_projects_bp.route("/projects/<int:project_id>", methods=["PUT", "PATCH"])
@require_api_token("write:projects")
def update_project(project_id):
"""Update a project."""
from app.services import ProjectService
data = request.get_json() or {}
project_service = ProjectService()
update_kwargs = {}
if "name" in data:
update_kwargs["name"] = data["name"]
if "description" in data:
update_kwargs["description"] = data["description"]
if "client_id" in data:
update_kwargs["client_id"] = data["client_id"]
if "hourly_rate" in data:
update_kwargs["hourly_rate"] = data["hourly_rate"]
if "estimated_hours" in data:
update_kwargs["estimated_hours"] = data["estimated_hours"]
if "status" in data:
update_kwargs["status"] = data["status"]
if "code" in data:
update_kwargs["code"] = data["code"]
if "budget_amount" in data:
update_kwargs["budget_amount"] = data["budget_amount"]
if "billing_ref" in data:
update_kwargs["billing_ref"] = data["billing_ref"]
result = project_service.update_project(project_id=project_id, user_id=g.api_user.id, **update_kwargs)
if not result.get("success"):
return error_response(result.get("message", "Could not update project"), status_code=400)
return jsonify({"message": "Project updated successfully", "project": result["project"].to_dict()})
@api_v1_projects_bp.route("/projects/<int:project_id>", methods=["DELETE"])
@require_api_token("write:projects")
def delete_project(project_id):
"""Delete/archive a project."""
from app.services import ProjectService
project_service = ProjectService()
result = project_service.archive_project(project_id=project_id, user_id=g.api_user.id, reason="Archived via API")
if not result.get("success"):
return error_response(result.get("message", "Could not archive project"), status_code=404)
return jsonify({"message": "Project archived successfully"})