From e8b5cee208bef7c0595ba1918311156608dc7e32 Mon Sep 17 00:00:00 2001 From: yoshifuminakamura Date: Wed, 12 Aug 2026 17:00:30 +0900 Subject: [PATCH] Add execution profile request workflow Signed-off-by: yoshifuminakamura --- .../portal-execution-profiles-handoff.md | 32 + result_server/app.py | 6 +- result_server/app_dev.py | 3 +- result_server/routes/admin.py | 423 ++++++++++++ result_server/templates/_navigation.html | 6 + .../admin_execution_profile_requests.html | 439 ++++++++++++ result_server/test_support.py | 7 +- .../tests/test_execution_profiles.py | 288 ++++++++ result_server/utils/execution_profiles.py | 622 +++++++++++++++--- 9 files changed, 1724 insertions(+), 102 deletions(-) create mode 100644 result_server/templates/admin_execution_profile_requests.html diff --git a/docs/guides/portal-execution-profiles-handoff.md b/docs/guides/portal-execution-profiles-handoff.md index e44a33d..31a945d 100644 --- a/docs/guides/portal-execution-profiles-handoff.md +++ b/docs/guides/portal-execution-profiles-handoff.md @@ -99,6 +99,38 @@ payload assembled from `results/environment_snapshot_build.json`, `results/environment_snapshot_run.json`, or `results/environment_snapshot_build_run.json`. +## Profile Request Workflow + +Execution profile requests are tracked separately from the approved profile +registry. The applicant-facing page is `/execution-profile-requests/`; it is +available to authenticated users and records the requester from the active +session. The admin review page is `/admin/execution-profile-requests`. + +The applicant form intentionally asks only for fields the application side can +reasonably own: application code, system, optional exp/case scope, optional +activity, optional desired schedule, optional desired repo/ref watch target, and +an operational note. Allocation project ID, validity, and trigger conversion are +reviewer responsibilities. + +Requests have a `request_type`: + +- `new_profile`: creates an approved execution profile after review. +- `change_profile`: updates an existing linked profile after review. +- `pause_profile`: disables the linked profile and its trigger definitions. +- `retire_profile`: marks the linked profile retired and disables its trigger + definitions. + +Pause and retirement are state transitions, not deletes. The profile and request +history remain in the site-local DB so operators can audit what happened and +why. Deleting a profile remains an admin operation outside the applicant request +workflow. + +The applicant page shows each request's linked profile, current profile status, +enabled state, allocation project ID, and enabled/total trigger counts when a +profile has been created. Follow-up requests are created from that linked +profile, so the applicant view and the approved registry stay connected after +the initial approval. + ## GitLab Pipeline Trigger Configuration Dry-run payload rendering requires: diff --git a/result_server/app.py b/result_server/app.py index a88aa98..02e5b2a 100644 --- a/result_server/app.py +++ b/result_server/app.py @@ -133,7 +133,7 @@ def _configure_execution_profiles(app, base_dir): def _register_portal_blueprints(app, prefix): """Register all portal blueprints using the given URL prefix.""" - from routes.admin import admin_bp + from routes.admin import admin_bp, profile_requests_bp from routes.auth import auth_bp from routes.security_metadata import register_security_metadata_routes @@ -142,6 +142,10 @@ def _register_portal_blueprints(app, prefix): app.register_blueprint(results_bp, url_prefix=f"{prefix}/results") app.register_blueprint(estimated_bp, url_prefix=f"{prefix}/estimated") app.register_blueprint(auth_bp, url_prefix=f"{prefix}/auth") + app.register_blueprint( + profile_requests_bp, + url_prefix=f"{prefix}/execution-profile-requests", + ) app.register_blueprint(admin_bp, url_prefix=f"{prefix}/admin") diff --git a/result_server/app_dev.py b/result_server/app_dev.py index 7691f0b..11ee935 100644 --- a/result_server/app_dev.py +++ b/result_server/app_dev.py @@ -246,10 +246,11 @@ def payload_too_large(_error): app.register_blueprint(auth_bp, url_prefix="/auth") - from routes.admin import admin_bp + from routes.admin import admin_bp, profile_requests_bp init_csrf(app, exempt_blueprints=(api_bp,)) + app.register_blueprint(profile_requests_bp) app.register_blueprint(admin_bp, url_prefix="/admin") @app.route("/systemlist") diff --git a/result_server/routes/admin.py b/result_server/routes/admin.py index ac95470..eb4ed9e 100644 --- a/result_server/routes/admin.py +++ b/result_server/routes/admin.py @@ -6,6 +6,7 @@ import json import logging import os +import re import sqlite3 from flask import ( @@ -41,6 +42,11 @@ from utils.user_store import get_user_store admin_bp = Blueprint("admin", __name__, url_prefix="/admin") +profile_requests_bp = Blueprint( + "profile_requests", + __name__, + url_prefix="/execution-profile-requests", +) def _add_no_store_headers(response): @@ -131,6 +137,64 @@ def _parse_execution_profile_form(): return raw_profile, errors +def _profile_request_slug(*parts): + text = "-".join(part for part in (str(item).strip() for item in parts) if part) + text = re.sub(r"[^A-Za-z0-9_.:-]+", "-", text).strip("-") + return text[:128] or "execution-profile-request" + + +def _parse_execution_profile_request_form(): + """Return applicant-owned request data as a draft profile payload.""" + errors = [] + code = _split_form_list(request.form.get("code", "")) + system = _split_form_list(request.form.get("system", "")) + if not code: + errors.append("code is required") + if not system: + errors.append("system is required") + + activity = request.form.get("activity", "").strip() + profile_id = request.form.get("profile_id", "").strip() or _profile_request_slug( + activity, + code[0] if code else "", + system[0] if system else "", + ) + actor = session.get("user_email", "") + metadata = { + "note": request.form.get("note", "").strip(), + "desired_schedule": request.form.get("desired_schedule", "").strip(), + "desired_watch_target": request.form.get("desired_watch_target", "").strip(), + } + raw_profile = { + "id": profile_id, + "display_name": profile_id, + "enabled": True, + "status": "draft", + "owner": "", + "purpose": metadata["note"], + "activity": activity, + "code": code, + "system": system, + "exp": _split_form_list(request.form.get("exp", "")), + "allocation_project_id": "", + "scheduler_extra_args": "", + "visibility": "", + "valid_from": "", + "valid_until": "", + "created_by": actor, + "metadata_json": metadata, + } + return raw_profile, errors + + +def _profile_request_note_metadata(note: str) -> dict: + return { + "note": note.strip(), + "desired_schedule": "", + "desired_watch_target": "", + } + + def _parse_trigger_definition_form(): """Return a raw trigger definition object from the submitted admin form.""" actor = session.get("user_email", "") @@ -155,6 +219,22 @@ def _parse_bool_form(name): return request.form.get(name) == "on" +def _profile_request_status_filter(): + selected = request.args.get("status", "open").strip() + options = { + "open": ("submitted", "changes_requested"), + "submitted": ("submitted",), + "changes_requested": ("changes_requested",), + "approved": ("approved",), + "rejected": ("rejected",), + "cancelled": ("cancelled",), + "all": None, + } + if selected not in options: + selected = "open" + return selected, options[selected] + + def _profile_scope_csv(profile, key): if not profile: return "" @@ -251,6 +331,103 @@ def _find_trigger_definition(triggers, trigger_id): ) +def _build_profile_request_links(store, requests): + """Attach approved/source profile and trigger state for request views.""" + profiles_by_id = {profile["id"]: profile for profile in store.list_profiles()} + triggers_by_profile: dict[str, list[dict]] = {} + for trigger in store.list_trigger_definitions(): + triggers_by_profile.setdefault(trigger["profile_id"], []).append(trigger) + + links = {} + for item in requests: + linked_profile_id = item.get("created_profile_id") or item.get("source_profile_id") or "" + profile = profiles_by_id.get(linked_profile_id) + triggers = triggers_by_profile.get(linked_profile_id, []) + links[item["id"]] = { + "profile_id": linked_profile_id, + "profile": profile, + "triggers": triggers, + "trigger_count": len(triggers), + "enabled_trigger_count": sum(1 for trigger in triggers if trigger.get("enabled")), + } + return links + + +def _requester_can_follow_profile(store, requester_email, source_profile_id): + if _session_is_admin(): + return True + for item in store.list_profile_requests(requester_email=requester_email, limit=500): + if item.get("created_profile_id") == source_profile_id: + return True + if item.get("source_profile_id") == source_profile_id: + return True + return False + + +def _split_requested_schedule(value): + value = str(value or "").strip() + if not value: + return "", "Asia/Tokyo" + if " / " not in value: + return value, "Asia/Tokyo" + cron_expr, timezone = value.rsplit(" / ", 1) + return cron_expr.strip(), timezone.strip() or "Asia/Tokyo" + + +def _create_review_requested_triggers(store, profile_request, profile_id, actor): + """Create approved trigger definitions requested by the applicant.""" + requested_profile = profile_request.get("requested_profile") or {} + metadata = requested_profile.get("metadata_json") or {} + created = [] + errors = [] + gitlab_target = request.form.get("gitlab_target", "").strip() + target_ref = request.form.get("target_ref", "").strip() or _default_trigger_ref() + + if request.form.get("create_scheduled_trigger") == "on": + cron_expr, timezone = _split_requested_schedule(metadata.get("desired_schedule", "")) + raw_trigger = { + "id": _profile_request_slug(profile_id, "scheduled"), + "name": _profile_request_slug(profile_id, "scheduled"), + "trigger_type": "scheduled", + "profile_id": profile_id, + "enabled": True, + "gitlab_target": gitlab_target, + "target_ref": target_ref, + "cron_expr": cron_expr, + "timezone": timezone, + "created_by": actor, + } + trigger, trigger_errors = normalize_trigger_definition(raw_trigger) + if trigger_errors or trigger is None: + errors.extend(trigger_errors) + else: + store.upsert_trigger_definition(trigger, actor=actor) + created.append(trigger["id"]) + + if request.form.get("create_watch_trigger") == "on": + raw_trigger = { + "id": _profile_request_slug(profile_id, "watch"), + "name": _profile_request_slug(profile_id, "watch"), + "trigger_type": "watch_event", + "profile_id": profile_id, + "enabled": True, + "gitlab_target": gitlab_target, + "target_ref": target_ref, + "watch_kind": "repo_ref", + "watch_targets": _split_form_list(metadata.get("desired_watch_target", "")), + "match_mode": "any", + "created_by": actor, + } + trigger, trigger_errors = normalize_trigger_definition(raw_trigger) + if trigger_errors or trigger is None: + errors.extend(trigger_errors) + else: + store.upsert_trigger_definition(trigger, actor=actor) + created.append(trigger["id"]) + + return created, errors + + def _build_execution_pipeline_plan(store): """Resolve the submitted target and build a GitLab pipeline plan.""" target_ref = request.form.get("target_ref", "").strip() or _default_trigger_ref() @@ -345,6 +522,22 @@ def decorated(*args, **kwargs): return decorated +def authenticated_required(f): + """Allow access only to authenticated portal users.""" + + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("authenticated"): + return _add_no_store_headers(make_response(redirect(url_for("auth.login")))) + return _add_no_store_headers(make_response(f(*args, **kwargs))) + + return decorated + + +def _session_is_admin(): + return "admin" in session.get("user_affiliations", []) + + @admin_bp.route("/users", methods=["GET"]) @admin_required def users(): @@ -392,6 +585,236 @@ def execution_profiles(): ) +@admin_bp.route("/execution-profile-requests", methods=["GET"]) +@admin_required +def execution_profile_requests(): + """Render execution-profile request review queue for admins.""" + db_path = current_app.config.get("EXECUTION_PROFILE_DB_PATH") + store = ExecutionProfileStore(db_path) + selected_status, statuses = _profile_request_status_filter() + requests = store.list_profile_requests(statuses=statuses) + profile_links = _build_profile_request_links(store, requests) + return render_template( + "admin_execution_profile_requests.html", + profile_requests=requests, + profile_links=profile_links, + selected_status=selected_status, + status_options=[ + ("open", "Open"), + ("submitted", "Submitted"), + ("changes_requested", "Changes Requested"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ("cancelled", "Cancelled"), + ("all", "All"), + ], + today=datetime.now(UTC).date().isoformat(), + default_target_ref=_default_trigger_ref(), + gitlab_targets=configured_gitlab_targets()[0], + review_mode=True, + create_endpoint="admin.create_execution_profile_request", + ) + + +@profile_requests_bp.route("/", methods=["GET"]) +@authenticated_required +def profile_requests(): + """Render the authenticated user's execution-profile requests.""" + db_path = current_app.config.get("EXECUTION_PROFILE_DB_PATH") + store = ExecutionProfileStore(db_path) + requester_email = session.get("user_email", "") + requests = store.list_profile_requests(requester_email=requester_email) + profile_links = _build_profile_request_links(store, requests) + return render_template( + "admin_execution_profile_requests.html", + profile_requests=requests, + profile_links=profile_links, + selected_status="mine", + status_options=[], + today=datetime.now(UTC).date().isoformat(), + default_target_ref=_default_trigger_ref(), + gitlab_targets=[], + review_mode=False, + create_endpoint="profile_requests.submit_execution_profile_request", + current_requester_email=requester_email, + ) + + +def _create_execution_profile_request_response(redirect_endpoint): + """Create a submitted execution-profile request from the review queue.""" + raw_profile, errors = _parse_execution_profile_request_form() + if not errors: + _profile, errors = normalize_profile(raw_profile) + if errors: + audit_event( + "admin_execution_profile_request_rejected", + actor=session.get("user_email"), + target=raw_profile.get("id", "")[:128], + result="failure", + level=logging.WARNING, + details={"errors": errors}, + ) + flash("Execution profile request was not created: " + "; ".join(errors)) + return redirect(url_for(redirect_endpoint)) + + requester_email = session.get("user_email", "") + try: + store = ExecutionProfileStore(current_app.config.get("EXECUTION_PROFILE_DB_PATH")) + request_id = store.create_profile_request( + requested_profile=raw_profile, + requester_email=requester_email, + requester_affiliation="", + status="submitted", + actor=session.get("user_email", ""), + ) + except (ValueError, sqlite3.Error) as exc: + audit_event( + "admin_execution_profile_request_create_failed", + actor=session.get("user_email"), + target=raw_profile.get("id", "")[:128], + result="failure", + level=logging.ERROR, + details={"error": str(exc)}, + ) + flash(f"Execution profile request was not created: {exc}") + return redirect(url_for(redirect_endpoint)) + + audit_event( + "admin_execution_profile_request_created", + actor=session.get("user_email"), + target=raw_profile.get("id", "")[:128], + result="success", + details={"request_id": request_id, "requester_email": requester_email}, + ) + flash(f"Execution profile request #{request_id} submitted.") + return redirect(url_for(redirect_endpoint)) + + +@profile_requests_bp.route("/", methods=["POST"]) +@authenticated_required +@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="profile_request_write") +def submit_execution_profile_request(): + """Create a submitted execution-profile request for the logged-in user.""" + return _create_execution_profile_request_response("profile_requests.profile_requests") + + +@profile_requests_bp.route("/follow-up", methods=["POST"]) +@authenticated_required +@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="profile_request_write") +def submit_execution_profile_followup_request(): + """Create a change, pause, or retirement request for an approved profile.""" + requester_email = session.get("user_email", "") + source_profile_id = request.form.get("source_profile_id", "").strip() + request_type = request.form.get("request_type", "").strip() + note = request.form.get("note", "").strip() + if request_type not in {"change_profile", "pause_profile", "retire_profile"}: + flash("Execution profile follow-up request was not created: invalid request type") + return redirect(url_for("profile_requests.profile_requests")) + store = ExecutionProfileStore(current_app.config.get("EXECUTION_PROFILE_DB_PATH")) + profiles = {profile["id"]: profile for profile in store.list_profiles()} + source_profile = profiles.get(source_profile_id) + if not source_profile: + flash(f"Execution profile follow-up request was not created: profile not found: {source_profile_id}") + return redirect(url_for("profile_requests.profile_requests")) + if not _requester_can_follow_profile(store, requester_email, source_profile_id): + abort(403) + + requested_profile = dict(source_profile) + requested_profile["status"] = "draft" + requested_profile["metadata_json"] = { + **(source_profile.get("metadata_json") or {}), + **_profile_request_note_metadata(note), + } + try: + request_id = store.create_profile_request( + requested_profile=requested_profile, + requester_email=requester_email, + requester_affiliation="", + request_type=request_type, + status="submitted", + source_profile_id=source_profile_id, + actor=requester_email, + ) + except (ValueError, sqlite3.Error) as exc: + flash(f"Execution profile follow-up request was not created: {exc}") + return redirect(url_for("profile_requests.profile_requests")) + + audit_event( + "execution_profile_followup_request_created", + actor=requester_email, + target=source_profile_id, + result="success", + details={"request_id": request_id, "request_type": request_type}, + ) + flash(f"Execution profile follow-up request #{request_id} submitted.") + return redirect(url_for("profile_requests.profile_requests")) + + +@admin_bp.route("/execution-profile-requests/create", methods=["POST"]) +@admin_required +@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write") +def create_execution_profile_request(): + """Create a submitted execution-profile request from the review queue.""" + return _create_execution_profile_request_response("admin.execution_profile_requests") + + +@admin_bp.route("/execution-profile-requests//review", methods=["POST"]) +@admin_required +@rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write") +def review_execution_profile_request(request_id): + """Review an execution-profile request.""" + action = request.form.get("action", "").strip() + comment = request.form.get("review_comment", "").strip() + actor = session.get("user_email", "") + profile_overrides = {} + if action == "approve": + profile_overrides = { + "id": request.form.get("approved_profile_id", "").strip(), + "allocation_project_id": request.form.get("allocation_project_id", "").strip(), + "valid_from": request.form.get("valid_from", "").strip(), + "valid_until": request.form.get("valid_until", "").strip(), + } + store = ExecutionProfileStore(current_app.config.get("EXECUTION_PROFILE_DB_PATH")) + ok, errors = store.review_profile_request( + request_id, + action=action, + actor=actor, + comment=comment, + profile_overrides=profile_overrides, + ) + created_triggers = [] + trigger_errors = [] + if ok and action == "approve": + reviewed_request = store.get_profile_request(request_id) + profile_id = (reviewed_request or {}).get("created_profile_id", "") + created_triggers, trigger_errors = _create_review_requested_triggers( + store, + reviewed_request or {}, + profile_id, + actor, + ) + audit_event( + "admin_execution_profile_request_reviewed", + actor=actor, + target=str(request_id), + result="success" if ok and not trigger_errors else "failure", + details={ + "action": action, + "errors": errors, + "trigger_errors": trigger_errors, + "created_triggers": created_triggers, + }, + ) + if ok: + trigger_note = f" Created triggers: {', '.join(created_triggers)}." if created_triggers else "" + if trigger_errors: + trigger_note += " Requested triggers were not fully created: " + "; ".join(trigger_errors) + flash(f"Execution profile request #{request_id} {action.replace('_', ' ')}.{trigger_note}") + else: + flash("Execution profile request was not reviewed: " + "; ".join(errors)) + return redirect(url_for("admin.execution_profile_requests")) + + @admin_bp.route("/execution-profiles/triggers/upsert", methods=["POST"]) @admin_required @rate_limited(max_per_minute=20, key_fn=_admin_rate_key, scope="admin_write") diff --git a/result_server/templates/_navigation.html b/result_server/templates/_navigation.html index 930f51f..ebc6763 100644 --- a/result_server/templates/_navigation.html +++ b/result_server/templates/_navigation.html @@ -123,7 +123,13 @@ {% if 'admin' in session.get('user_affiliations', []) %} Admin Execution Profiles + Profile Request Review + {% set portal_prefix = '/dev2' if request.path.startswith('/dev2/') else ('/dev' if request.path.startswith('/dev/') else '') %} + Profile Requests
+ {% else %} + {% set portal_prefix = '/dev2' if request.path.startswith('/dev2/') else ('/dev' if request.path.startswith('/dev/') else '') %} + Profile Requests {% endif %} Logout diff --git a/result_server/templates/admin_execution_profile_requests.html b/result_server/templates/admin_execution_profile_requests.html new file mode 100644 index 0000000..964fb4d --- /dev/null +++ b/result_server/templates/admin_execution_profile_requests.html @@ -0,0 +1,439 @@ +{% extends "_results_base.html" %} + +{% block title %}Execution Profile Requests{% endblock %} +{% block page_subtitle %}Review requested execution profiles before they become approved site-local governance records.{% endblock %} + +{% block content %} + + +{% with messages = get_flashed_messages() %} +{% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} +{% endwith %} + +{% if not review_mode %} +
+

Create Request

+

+ {% if not review_mode %} + Applicant request view. Requests submitted here are recorded under + {{ current_requester_email or 'the current logged-in user' }}. + {% endif %} + Applicants describe the app, system, activity, and optional schedule or + watch target. Approvers assign allocation, validity, and operational + trigger details before creating the active profile. +

+
+ {% if csrf_token is defined %}{% endif %} + + + + + + + +
+ + {% if review_mode or 'admin' in session.get('user_affiliations', []) %} + Execution Profiles + {% endif %} +
+
+
+{% else %} + +{% endif %} + +
+

{% if review_mode %}Review Queue{% else %}My Requests{% endif %}

+ {% if review_mode %} +
+ {% for value, label in status_options %} + {{ label }} + {% endfor %} +
+ {% endif %} +
+ + + + + + + {% if review_mode %} + + {% else %} + + {% endif %} + + + + {% for item in profile_requests %} + {% set profile = item.requested_profile %} + {% set metadata = profile.metadata_json if profile.metadata_json is mapping else {} %} + {% set link = profile_links.get(item.id, {}) if profile_links is mapping else {} %} + {% set linked_profile = link.profile if link.profile is mapping else none %} + {% set request_type_label = { + 'new_profile': 'New profile request', + 'change_profile': 'Change request', + 'pause_profile': 'Pause request', + 'retire_profile': 'Retirement request' + }.get(item.request_type, item.request_type | replace('_', ' ')) %} + + + + + {% if review_mode %} + + {% else %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
RequestApplicant ScopeDesired ExecutionReviewLinked Profile
+ #{{ item.id }} / {{ item.status }} + {{ request_type_label }} + {{ item.requester_email or '-' }} + {{ item.created_at }} + {% if item.reviewer_email %} + reviewed by {{ item.reviewer_email }} + {% endif %} + + {{ item.profile_id }} + + {{ profile.code | join(', ') or '*' }} + on {{ profile.system | join(', ') or '*' }} + {% if profile.exp %}/ {{ profile.exp | join(', ') }}{% endif %} + + + {% if not metadata.desired_schedule and not metadata.desired_watch_target %} + Manual trigger handled by approver + {% endif %} + {% if metadata.desired_schedule %} + schedule {{ metadata.desired_schedule }} + {% endif %} + {% if metadata.desired_watch_target %} + watch {{ metadata.desired_watch_target }} + {% endif %} + {{ profile.activity or '-' }} + {% if metadata.note %} + {{ metadata.note }} + {% endif %} + + {% if item.status == 'submitted' %} +
+ {% if csrf_token is defined %}{% endif %} + {% if item.request_type in ['pause_profile', 'retire_profile'] %} +
+ {{ request_type_label }} +
+ {% if item.request_type == 'pause_profile' %} + Approval disables the source profile and pauses its trigger definitions. It does not delete the profile. + {% else %} + Approval marks the source profile retired and disables its trigger definitions. It does not delete stored history. + {% endif %} +
+ source profile {{ item.source_profile_id or '-' }} +
+ {% else %} + + + + + + + {% endif %} + {% if metadata.desired_schedule and item.request_type not in ['pause_profile', 'retire_profile'] %} + + {% endif %} + {% if metadata.desired_watch_target and item.request_type not in ['pause_profile', 'retire_profile'] %} + + {% endif %} + +
+ + + + +
+
+ {% elif item.status == 'changes_requested' %} + {{ item.review_comment or '-' }} +
+ {% if csrf_token is defined %}{% endif %} +
+ +
+
+ {% else %} + {{ item.review_comment or '-' }} + {% endif %} +
+ {% if linked_profile %} + {{ linked_profile.id }} + + {{ linked_profile.status }} + / {% if linked_profile.enabled %}enabled{% else %}disabled{% endif %} + + {{ linked_profile.allocation_project_id or '-' }} + + {{ link.enabled_trigger_count or 0 }} / {{ link.trigger_count or 0 }} triggers enabled + +
+
+ New follow-up +
+ Pause disables the profile and its triggers temporarily. Retirement disables them and marks the profile retired; neither action deletes history. +
+
+ {% if csrf_token is defined %}{% endif %} + + + + +
+
+
+ {% elif link.profile_id %} + {{ link.profile_id }} + profile not found or deleted + {% else %} + - + {% endif %} +
No execution profile requests match this filter.
+
+
+{% endblock %} diff --git a/result_server/test_support.py b/result_server/test_support.py index c88f85b..3212a27 100644 --- a/result_server/test_support.py +++ b/result_server/test_support.py @@ -85,6 +85,10 @@ def users(): def execution_profiles(): return "" + @admin_bp.route("/execution-profile-requests") + def execution_profile_requests(): + return "" + @admin_bp.route("/users/add", methods=["POST"]) def add_user(): return "" @@ -227,8 +231,9 @@ def build_portal_route_app( app.register_blueprint(auth_bp, url_prefix="/auth") if include_admin: - from routes.admin import admin_bp + from routes.admin import admin_bp, profile_requests_bp + app.register_blueprint(profile_requests_bp) app.register_blueprint(admin_bp, url_prefix="/admin") @app.route("/systemlist") diff --git a/result_server/tests/test_execution_profiles.py b/result_server/tests/test_execution_profiles.py index 57a453e..9fd5f28 100644 --- a/result_server/tests/test_execution_profiles.py +++ b/result_server/tests/test_execution_profiles.py @@ -51,6 +51,13 @@ def _login_admin(client): sess["user_affiliations"] = ["admin"] +def _login_user(client, email="applicant@test.com"): + with client.session_transaction() as sess: + sess["authenticated"] = True + sess["user_email"] = email + sess["user_affiliations"] = ["app"] + + def _admin_app(db_path): received = tempfile.mkdtemp() estimated = tempfile.mkdtemp() @@ -138,6 +145,73 @@ def test_execution_profile_store_updates_profile_and_scopes(tmp_path): assert result.profiles[0]["scheduler_extra_args"] == "--account=updated" +def test_execution_profile_request_approval_creates_profile(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + store = ExecutionProfileStore(str(db_path)) + request_id = store.create_profile_request( + requested_profile=_profile(id="qws-fugaku-request", status="draft"), + requester_email="applicant@test.com", + requester_affiliation="project-a", + status="submitted", + actor="applicant@test.com", + ) + + request_row = store.get_profile_request(request_id) + assert request_row is not None + assert request_row["profile_id"] == "qws-fugaku-request" + assert request_row["status"] == "submitted" + assert request_row["requested_profile"]["status"] == "draft" + + ok, errors = store.review_profile_request( + request_id, + action="approve", + actor="approver@test.com", + comment="approved for test", + ) + + assert ok is True + assert errors == [] + reviewed = store.get_profile_request(request_id) + assert reviewed["status"] == "approved" + assert reviewed["reviewer_email"] == "approver@test.com" + assert reviewed["created_profile_id"] == "qws-fugaku-request" + profile = load_execution_profiles(str(db_path)).profiles[0] + assert profile["id"] == "qws-fugaku-request" + assert profile["status"] == "approved" + assert profile["approved_by"] == "approver@test.com" + assert profile["approved_at"].endswith("Z") + events = store.list_profile_request_events(request_id) + assert [event["event_type"] for event in events] == [ + "profile_request_submitted", + "profile_request_approved", + ] + + +def test_execution_profile_request_reject_does_not_create_profile(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + store = ExecutionProfileStore(str(db_path)) + request_id = store.create_profile_request( + requested_profile=_profile(id="qws-rejected", status="draft"), + requester_email="applicant@test.com", + status="submitted", + actor="applicant@test.com", + ) + + ok, errors = store.review_profile_request( + request_id, + action="reject", + actor="approver@test.com", + comment="not enough information", + ) + + assert ok is True + assert errors == [] + reviewed = store.get_profile_request(request_id) + assert reviewed["status"] == "rejected" + assert reviewed["review_comment"] == "not enough information" + assert load_execution_profiles(str(db_path)).profiles == [] + + def test_execution_profile_summary_counts_status_and_expiration(tmp_path): db_path = tmp_path / "cx_portal.sqlite3" store = ExecutionProfileStore(str(db_path)) @@ -1108,6 +1182,220 @@ def test_admin_execution_profiles_requires_admin(tmp_path): _cleanup(temp_dirs) +def test_admin_execution_profile_requests_create_and_approve(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + app, temp_dirs = _admin_app(db_path) + try: + with app.test_client() as client: + _login_admin(client) + resp = client.post( + "/admin/execution-profile-requests/create", + data={ + "code": "qws", + "system": "Fugaku", + "desired_schedule": "0 14 * * * / Asia/Tokyo", + "desired_watch_target": "https://github.com/RIKEN-LQCD/qws.git@master", + "note": "routine qws validation", + "activity": "FugakuNEXT", + "exp": "CASE0", + }, + follow_redirects=True, + ) + + html = resp.data.decode() + assert resp.status_code == 200 + assert "Execution profile request #1 submitted." in html + assert "Create Request" not in html + assert "Open Applicant Request View" in html + assert "FugakuNEXT-qws-Fugaku" in html + assert "admin@test.com" in html + assert "watch https://github.com/RIKEN-LQCD/qws.git@master" in html + + resp = client.post( + "/admin/execution-profile-requests/1/review", + data={ + "action": "approve", + "approved_profile_id": "qws-fugaku-request", + "allocation_project_id": "rkp00010", + "valid_from": "2026-09-01", + "valid_until": "2027-03-31", + "review_comment": "approved for operation", + "target_ref": "develop", + "create_scheduled_trigger": "on", + "create_watch_trigger": "on", + }, + follow_redirects=True, + ) + mine_resp = client.get("/execution-profile-requests/") + + assert resp.status_code == 200 + mine_html = mine_resp.data.decode() + assert mine_resp.status_code == 200 + assert "Linked Profile" in mine_html + assert "qws-fugaku-request" in mine_html + assert "approved" in mine_html + assert "2 / 2 triggers enabled" in mine_html + assert "New follow-up" in mine_html + assert "Pause request" in mine_html + result = load_execution_profiles(str(db_path)) + assert len(result.profiles) == 1 + assert result.profiles[0]["id"] == "qws-fugaku-request" + assert result.profiles[0]["status"] == "approved" + assert result.profiles[0]["approved_by"] == "admin@test.com" + assert result.profiles[0]["allocation_project_id"] == "rkp00010" + assert result.profiles[0]["valid_from"] == "2026-09-01" + assert result.profiles[0]["valid_until"] == "2027-03-31" + assert result.profiles[0]["metadata_json"]["note"] == "routine qws validation" + assert ( + result.profiles[0]["metadata_json"]["desired_watch_target"] + == "https://github.com/RIKEN-LQCD/qws.git@master" + ) + request_row = ExecutionProfileStore(str(db_path)).get_profile_request(1) + assert request_row["status"] == "approved" + assert request_row["requester_email"] == "admin@test.com" + assert request_row["requester_affiliation"] == "" + assert request_row["review_comment"] == "approved for operation" + triggers = ExecutionProfileStore(str(db_path)).list_trigger_definitions() + assert [trigger["id"] for trigger in triggers] == [ + "qws-fugaku-request-scheduled", + "qws-fugaku-request-watch", + ] + assert triggers[0]["profile_id"] == "qws-fugaku-request" + assert triggers[0]["trigger_type"] == "scheduled" + assert triggers[0]["cron_expr"] == "0 14 * * *" + assert triggers[0]["timezone"] == "Asia/Tokyo" + assert triggers[0]["target_ref"] == "develop" + assert triggers[1]["trigger_type"] == "watch_event" + assert triggers[1]["watch_kind"] == "repo_ref" + assert triggers[1]["watch_targets"] == ["https://github.com/RIKEN-LQCD/qws.git@master"] + + with app.test_client() as client: + _login_admin(client) + followup_resp = client.post( + "/execution-profile-requests/follow-up", + data={ + "source_profile_id": "qws-fugaku-request", + "request_type": "pause_profile", + "note": "pause during maintenance", + }, + follow_redirects=True, + ) + pause_review_page = client.get("/admin/execution-profile-requests") + pause_review_resp = client.post( + "/admin/execution-profile-requests/2/review", + data={"action": "approve", "review_comment": "paused"}, + follow_redirects=True, + ) + + assert followup_resp.status_code == 200 + assert b"Execution profile follow-up request #2 submitted." in followup_resp.data + assert b"Pause request" in followup_resp.data + assert b"Approval disables the source profile" in pause_review_page.data + assert b"Allocation Project ID" not in pause_review_page.data + assert pause_review_resp.status_code == 200 + paused_result = load_execution_profiles(str(db_path)) + assert paused_result.profiles[0]["id"] == "qws-fugaku-request" + assert paused_result.profiles[0]["status"] == "paused" + assert paused_result.profiles[0]["enabled"] is False + paused_triggers = ExecutionProfileStore(str(db_path)).list_trigger_definitions() + assert all(not trigger["enabled"] for trigger in paused_triggers) + finally: + _cleanup(temp_dirs) + + +def test_authenticated_user_can_submit_own_execution_profile_request(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + app, temp_dirs = _admin_app(db_path) + try: + with app.test_client() as client: + unauthenticated = client.get("/execution-profile-requests/") + assert unauthenticated.status_code == 302 + assert "/auth/login" in unauthenticated.headers["Location"] + + _login_user(client) + resp = client.post( + "/execution-profile-requests/", + data={ + "code": "qws", + "system": "Fugaku", + "desired_schedule": "0 14 * * * / Asia/Tokyo", + "note": "please review", + }, + follow_redirects=True, + ) + html = resp.data.decode() + assert resp.status_code == 200 + assert "Execution profile request #1 submitted." in html + assert "My Requests" in html + assert "applicant@test.com" in html + assert "please review" in html + + admin_page = client.get("/admin/execution-profile-requests") + assert admin_page.status_code == 403 + + request_row = ExecutionProfileStore(str(db_path)).get_profile_request(1) + assert request_row["requester_email"] == "applicant@test.com" + assert request_row["requester_affiliation"] == "" + assert request_row["status"] == "submitted" + finally: + _cleanup(temp_dirs) + + +def test_admin_can_open_applicant_execution_profile_requests_view(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + app, temp_dirs = _admin_app(db_path) + try: + with app.test_client() as client: + _login_admin(client) + resp = client.get("/execution-profile-requests/") + submit_resp = client.post( + "/execution-profile-requests/", + data={"code": "qws", "system": "Fugaku"}, + follow_redirects=True, + ) + + html = resp.data.decode() + assert resp.status_code == 200 + assert "Applicant request view" in html + assert "Create Request" in html + assert "Open Applicant Request View" not in html + assert "admin@test.com" in html + assert "My Requests" in html + assert submit_resp.status_code == 200 + request_row = ExecutionProfileStore(str(db_path)).get_profile_request(1) + assert request_row["requester_email"] == "admin@test.com" + finally: + _cleanup(temp_dirs) + + +def test_execution_profile_requests_page_shows_only_current_user_requests(tmp_path): + db_path = tmp_path / "cx_portal.sqlite3" + store = ExecutionProfileStore(str(db_path)) + store.create_profile_request( + requested_profile=_profile(id="mine", status="draft"), + requester_email="applicant@test.com", + actor="applicant@test.com", + ) + store.create_profile_request( + requested_profile=_profile(id="theirs", status="draft"), + requester_email="other@test.com", + actor="other@test.com", + ) + app, temp_dirs = _admin_app(db_path) + try: + with app.test_client() as client: + _login_user(client) + resp = client.get("/execution-profile-requests/") + + html = resp.data.decode() + assert resp.status_code == 200 + assert "mine" in html + assert "theirs" not in html + assert "Review Queue" not in html + finally: + _cleanup(temp_dirs) + + def test_admin_execution_profiles_renders_profile_summary(tmp_path): db_path = tmp_path / "cx_portal.sqlite3" ExecutionProfileStore(str(db_path)).upsert_profile( diff --git a/result_server/utils/execution_profiles.py b/result_server/utils/execution_profiles.py index 9a32cc2..f6cbe24 100644 --- a/result_server/utils/execution_profiles.py +++ b/result_server/utils/execution_profiles.py @@ -15,7 +15,21 @@ PROFILE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$") TRIGGER_TYPES = {"manual_button", "scheduled", "watch_event"} MATCH_MODES = {"any", "all"} -SCHEMA_VERSION = 9 +SCHEMA_VERSION = 11 +PROFILE_REQUEST_TYPES = { + "new_profile", + "change_profile", + "pause_profile", + "retire_profile", +} +PROFILE_REQUEST_STATUSES = { + "draft", + "submitted", + "changes_requested", + "approved", + "rejected", + "cancelled", +} @dataclass(frozen=True) @@ -328,6 +342,12 @@ def migrate(self) -> None: current = 8 if current < 9: self._apply_v9(conn) + current = 9 + if current < 10: + self._apply_v10(conn) + current = 10 + if current < 11: + self._apply_v11(conn) def _apply_v1(self, conn: sqlite3.Connection) -> None: now = _utc_now_iso() @@ -599,113 +619,209 @@ def _apply_v9(self, conn: sqlite3.Connection) -> None: (9, now), ) + def _apply_v10(self, conn: sqlite3.Connection) -> None: + now = _utc_now_iso() + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS execution_profile_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT NOT NULL DEFAULT '', + requester_email TEXT NOT NULL DEFAULT '', + requester_affiliation TEXT NOT NULL DEFAULT '', + requested_profile_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL CHECK( + status IN ( + 'draft', 'submitted', 'changes_requested', + 'approved', 'rejected', 'cancelled' + ) + ), + reviewer_email TEXT NOT NULL DEFAULT '', + review_comment TEXT NOT NULL DEFAULT '', + source_profile_id TEXT NOT NULL DEFAULT '', + created_profile_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + submitted_at TEXT NOT NULL DEFAULT '', + reviewed_at TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS execution_profile_request_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES execution_profile_requests(id) + ON DELETE CASCADE, + actor TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_execution_profile_requests_status + ON execution_profile_requests(status, updated_at); + CREATE INDEX IF NOT EXISTS idx_execution_profile_requests_profile + ON execution_profile_requests(profile_id, status); + CREATE INDEX IF NOT EXISTS idx_execution_profile_request_events_request + ON execution_profile_request_events(request_id, created_at); + """ + ) + conn.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (10, now), + ) + + def _apply_v11(self, conn: sqlite3.Connection) -> None: + now = _utc_now_iso() + columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(execution_profile_requests)").fetchall() + } + if "request_type" not in columns: + conn.execute( + """ + ALTER TABLE execution_profile_requests + ADD COLUMN request_type TEXT NOT NULL DEFAULT 'new_profile' + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_execution_profile_requests_type + ON execution_profile_requests(request_type, status, updated_at) + """ + ) + conn.execute( + "INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)", + (11, now), + ) + def upsert_profile(self, profile: dict[str, Any], *, actor: str = "") -> None: self.migrate() now = _utc_now_iso() with self.connect() as conn: - existing = conn.execute( - """ - SELECT id, status, approved_by, approved_at, created_at - FROM execution_profiles - WHERE id = ? - """, - (profile["id"],), - ).fetchone() - created_at = existing["created_at"] if existing else now - approved_by = "" - approved_at = "" - if profile["status"] == "approved": - if ( - existing - and existing["status"] == "approved" - and existing["approved_by"] - and existing["approved_at"] - ): - approved_by = existing["approved_by"] - approved_at = existing["approved_at"] - else: - approved_by = actor - approved_at = now - elif existing: + self._upsert_profile_in_conn(conn, profile, actor=actor, now=now) + + def _upsert_profile_in_conn( + self, + conn: sqlite3.Connection, + profile: dict[str, Any], + *, + actor: str, + now: str, + ) -> None: + existing = conn.execute( + """ + SELECT id, status, approved_by, approved_at, created_at + FROM execution_profiles + WHERE id = ? + """, + (profile["id"],), + ).fetchone() + created_at = existing["created_at"] if existing else now + approved_by = "" + approved_at = "" + if profile["status"] == "approved": + if ( + existing + and existing["status"] == "approved" + and existing["approved_by"] + and existing["approved_at"] + ): approved_by = existing["approved_by"] approved_at = existing["approved_at"] - conn.execute( - """ - INSERT INTO execution_profiles ( - id, display_name, enabled, status, activity, owner, purpose, - visibility, allocation_project_id, scheduler_extra_args, - valid_from, valid_until, created_by, approved_by, approved_at, - metadata_json, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - display_name=excluded.display_name, - enabled=excluded.enabled, - status=excluded.status, - activity=excluded.activity, - owner=excluded.owner, - purpose=excluded.purpose, - visibility=excluded.visibility, - allocation_project_id=excluded.allocation_project_id, - scheduler_extra_args=excluded.scheduler_extra_args, - valid_from=excluded.valid_from, - valid_until=excluded.valid_until, - created_by=excluded.created_by, - approved_by=excluded.approved_by, - approved_at=excluded.approved_at, - metadata_json=excluded.metadata_json, - updated_at=excluded.updated_at - """, - ( - profile["id"], - profile["display_name"], - 1 if profile["enabled"] else 0, - profile["status"], - profile["activity"], - profile["owner"], - profile["purpose"], - profile["visibility"], - profile["allocation_project_id"], - profile["scheduler_extra_args"], - profile["valid_from"], - profile["valid_until"], - profile["created_by"], - approved_by, - approved_at, - _json_dump(profile["metadata_json"]), - created_at, - now, - ), - ) - conn.execute( - "DELETE FROM execution_profile_scopes WHERE profile_id = ?", - (profile["id"],), - ) - for scope_type in ("code", "system", "exp"): - conn.executemany( - """ - INSERT INTO execution_profile_scopes(profile_id, scope_type, value) - VALUES (?, ?, ?) - """, - [ - (profile["id"], scope_type, value) - for value in profile.get(scope_type, []) - ], - ) - conn.execute( + else: + approved_by = actor + approved_at = now + elif existing: + approved_by = existing["approved_by"] + approved_at = existing["approved_at"] + conn.execute( + """ + INSERT INTO execution_profiles ( + id, display_name, enabled, status, activity, owner, purpose, + visibility, allocation_project_id, scheduler_extra_args, + valid_from, valid_until, created_by, approved_by, approved_at, + metadata_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + display_name=excluded.display_name, + enabled=excluded.enabled, + status=excluded.status, + activity=excluded.activity, + owner=excluded.owner, + purpose=excluded.purpose, + visibility=excluded.visibility, + allocation_project_id=excluded.allocation_project_id, + scheduler_extra_args=excluded.scheduler_extra_args, + valid_from=excluded.valid_from, + valid_until=excluded.valid_until, + created_by=excluded.created_by, + approved_by=excluded.approved_by, + approved_at=excluded.approved_at, + metadata_json=excluded.metadata_json, + updated_at=excluded.updated_at + """, + ( + profile["id"], + profile["display_name"], + 1 if profile["enabled"] else 0, + profile["status"], + profile["activity"], + profile["owner"], + profile["purpose"], + profile["visibility"], + profile["allocation_project_id"], + profile["scheduler_extra_args"], + profile["valid_from"], + profile["valid_until"], + profile["created_by"], + approved_by, + approved_at, + _json_dump(profile["metadata_json"]), + created_at, + now, + ), + ) + conn.execute( + "DELETE FROM execution_profile_scopes WHERE profile_id = ?", + (profile["id"],), + ) + for scope_type in ("code", "system", "exp"): + conn.executemany( """ - INSERT INTO execution_profile_events( - profile_id, actor, event_type, payload_json, created_at - ) VALUES (?, ?, ?, ?, ?) + INSERT INTO execution_profile_scopes(profile_id, scope_type, value) + VALUES (?, ?, ?) """, - ( - profile["id"], - actor, - "profile_upserted" if existing else "profile_created", - _json_dump({"source": "admin_or_seed"}), - now, - ), + [ + (profile["id"], scope_type, value) + for value in profile.get(scope_type, []) + ], ) + self._add_profile_event_in_conn( + conn, + profile_id=profile["id"], + actor=actor, + event_type="profile_upserted" if existing else "profile_created", + payload={"source": "admin_or_seed"}, + created_at=now, + ) + + def _add_profile_event_in_conn( + self, + conn: sqlite3.Connection, + *, + profile_id: str, + actor: str, + event_type: str, + payload: dict[str, Any], + created_at: str, + ) -> None: + conn.execute( + """ + INSERT INTO execution_profile_events( + profile_id, actor, event_type, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?) + """, + (profile_id, actor, event_type, _json_dump(payload), created_at), + ) def upsert_trigger_definition(self, trigger: dict[str, Any], *, actor: str = "") -> None: self.migrate() @@ -1159,6 +1275,314 @@ def has_trigger_run( row = conn.execute(query, tuple(params)).fetchone() return row is not None + def create_profile_request( + self, + *, + requested_profile: dict[str, Any], + requester_email: str, + requester_affiliation: str = "", + request_type: str = "new_profile", + status: str = "submitted", + source_profile_id: str = "", + actor: str = "", + ) -> int: + """Create an execution-profile request draft or submitted request.""" + self.migrate() + normalized, errors = normalize_profile(requested_profile) + if errors or normalized is None: + raise ValueError("; ".join(errors)) + if status not in PROFILE_REQUEST_STATUSES: + raise ValueError(f"invalid profile request status: {status}") + if request_type not in PROFILE_REQUEST_TYPES: + raise ValueError(f"invalid profile request type: {request_type}") + + now = _utc_now_iso() + submitted_at = now if status == "submitted" else "" + request_actor = actor or requester_email + with self.connect() as conn: + cur = conn.execute( + """ + INSERT INTO execution_profile_requests ( + profile_id, requester_email, requester_affiliation, + requested_profile_json, status, request_type, source_profile_id, + created_at, updated_at, submitted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + normalized["id"], + requester_email, + requester_affiliation, + _json_dump(normalized), + status, + request_type, + source_profile_id, + now, + now, + submitted_at, + ), + ) + request_id = int(cur.lastrowid) + self._add_profile_request_event( + conn, + request_id=request_id, + actor=request_actor, + event_type="profile_request_created" + if status == "draft" + else "profile_request_submitted", + payload={ + "profile_id": normalized["id"], + "request_type": request_type, + "status": status, + }, + created_at=now, + ) + return request_id + + def list_profile_requests( + self, + *, + statuses: list[str] | tuple[str, ...] | None = None, + requester_email: str = "", + limit: int = 100, + ) -> list[dict[str, Any]]: + """List execution-profile requests ordered by most recent update.""" + self.migrate() + params: list[Any] = [] + query = "SELECT * FROM execution_profile_requests" + clauses = [] + if statuses: + placeholders = ", ".join("?" for _status in statuses) + clauses.append(f"status IN ({placeholders})") + params.extend(statuses) + if requester_email: + clauses.append("requester_email = ?") + params.append(requester_email) + if clauses: + query += " WHERE " + " AND ".join(clauses) + query += " ORDER BY updated_at DESC, id DESC LIMIT ?" + params.append(limit) + with self.connect() as conn: + rows = conn.execute(query, tuple(params)).fetchall() + return [self._profile_request_from_row(row) for row in rows] + + def get_profile_request(self, request_id: int) -> dict[str, Any] | None: + """Return one execution-profile request.""" + self.migrate() + with self.connect() as conn: + row = conn.execute( + "SELECT * FROM execution_profile_requests WHERE id = ?", + (request_id,), + ).fetchone() + return self._profile_request_from_row(row) if row else None + + def list_profile_request_events(self, request_id: int) -> list[dict[str, Any]]: + """Return request review events in chronological order.""" + self.migrate() + with self.connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM execution_profile_request_events + WHERE request_id = ? + ORDER BY id + """, + (request_id,), + ).fetchall() + return [self._profile_request_event_from_row(row) for row in rows] + + def review_profile_request( + self, + request_id: int, + *, + action: str, + actor: str, + comment: str = "", + profile_overrides: dict[str, Any] | None = None, + ) -> tuple[bool, list[str]]: + """Approve, reject, request changes, or cancel a profile request.""" + if action not in {"approve", "reject", "request_changes", "cancel"}: + return False, [f"unsupported review action: {action}"] + self.migrate() + now = _utc_now_iso() + with self.connect() as conn: + row = conn.execute( + "SELECT * FROM execution_profile_requests WHERE id = ?", + (request_id,), + ).fetchone() + if not row: + return False, [f"profile request {request_id} was not found"] + profile_request = self._profile_request_from_row(row) + status = profile_request["status"] + allowed = { + "approve": {"submitted"}, + "reject": {"submitted"}, + "request_changes": {"submitted"}, + "cancel": {"draft", "submitted", "changes_requested"}, + } + if status not in allowed[action]: + return False, [f"cannot {action} profile request in status {status}"] + + created_profile_id = profile_request["created_profile_id"] + new_status = { + "approve": "approved", + "reject": "rejected", + "request_changes": "changes_requested", + "cancel": "cancelled", + }[action] + errors: list[str] = [] + if action == "approve": + request_type = profile_request["request_type"] + if request_type in {"new_profile", "change_profile"}: + payload = dict(profile_request["requested_profile"]) + for key, value in (profile_overrides or {}).items(): + if value not in (None, ""): + payload[key] = value + if request_type == "change_profile" and profile_request["source_profile_id"]: + payload["id"] = profile_request["source_profile_id"] + payload["status"] = "approved" + payload["approved_by"] = "" + payload["approved_at"] = "" + allocation_project_id = str(payload.get("allocation_project_id") or "").strip() + systems = _as_text_list(payload.get("system")) + if allocation_project_id and len(systems) != 1: + system_label = ", ".join(systems) if systems else "none" + return False, [ + "allocation_project_id requires exactly one system; " + f"got {len(systems)} ({system_label})" + ] + profile, errors = normalize_profile(payload) + if errors or profile is None: + return False, errors + self._upsert_profile_in_conn(conn, profile, actor=actor, now=now) + created_profile_id = profile["id"] + self._add_profile_event_in_conn( + conn, + profile_id=profile["id"], + actor=actor, + event_type="profile_request_approved", + payload={"request_id": request_id, "request_type": request_type}, + created_at=now, + ) + elif request_type in {"pause_profile", "retire_profile"}: + source_profile_id = profile_request["source_profile_id"] + if not source_profile_id: + return False, [f"{request_type} requires source_profile_id"] + row = conn.execute( + "SELECT * FROM execution_profiles WHERE id = ?", + (source_profile_id,), + ).fetchone() + if not row: + return False, [f"source profile was not found: {source_profile_id}"] + new_profile_status = "paused" if request_type == "pause_profile" else "retired" + conn.execute( + """ + UPDATE execution_profiles + SET enabled = 0, status = ?, updated_at = ? + WHERE id = ? + """, + (new_profile_status, now, source_profile_id), + ) + conn.execute( + """ + UPDATE trigger_definitions + SET enabled = 0, updated_at = ? + WHERE profile_id = ? + """, + (now, source_profile_id), + ) + created_profile_id = source_profile_id + self._add_profile_event_in_conn( + conn, + profile_id=source_profile_id, + actor=actor, + event_type=f"profile_request_{new_profile_status}", + payload={"request_id": request_id, "request_type": request_type}, + created_at=now, + ) + + conn.execute( + """ + UPDATE execution_profile_requests + SET status = ?, reviewer_email = ?, review_comment = ?, + created_profile_id = ?, updated_at = ?, reviewed_at = ? + WHERE id = ? + """, + ( + new_status, + actor, + comment, + created_profile_id, + now, + now, + request_id, + ), + ) + self._add_profile_request_event( + conn, + request_id=request_id, + actor=actor, + event_type=f"profile_request_{new_status}", + payload={"comment": comment, "created_profile_id": created_profile_id}, + created_at=now, + ) + return True, [] + + def _profile_request_from_row(self, row: sqlite3.Row) -> dict[str, Any]: + try: + requested_profile = json.loads(row["requested_profile_json"] or "{}") + except json.JSONDecodeError: + requested_profile = {"_invalid_requested_profile_json": row["requested_profile_json"]} + return { + "id": row["id"], + "profile_id": row["profile_id"], + "requester_email": row["requester_email"], + "requester_affiliation": row["requester_affiliation"], + "requested_profile": requested_profile if isinstance(requested_profile, dict) else {}, + "status": row["status"], + "request_type": row["request_type"] if "request_type" in row.keys() else "new_profile", + "reviewer_email": row["reviewer_email"], + "review_comment": row["review_comment"], + "source_profile_id": row["source_profile_id"], + "created_profile_id": row["created_profile_id"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "submitted_at": row["submitted_at"], + "reviewed_at": row["reviewed_at"], + } + + def _profile_request_event_from_row(self, row: sqlite3.Row) -> dict[str, Any]: + try: + payload = json.loads(row["payload_json"] or "{}") + except json.JSONDecodeError: + payload = {"_invalid_payload_json": row["payload_json"]} + return { + "id": row["id"], + "request_id": row["request_id"], + "actor": row["actor"], + "event_type": row["event_type"], + "payload": payload if isinstance(payload, dict) else {}, + "created_at": row["created_at"], + } + + def _add_profile_request_event( + self, + conn: sqlite3.Connection, + *, + request_id: int, + actor: str, + event_type: str, + payload: dict[str, Any], + created_at: str, + ) -> None: + conn.execute( + """ + INSERT INTO execution_profile_request_events( + request_id, actor, event_type, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?) + """, + (request_id, actor, event_type, _json_dump(payload), created_at), + ) + def list_profiles(self) -> list[dict[str, Any]]: self.migrate() with self.connect() as conn: