"""Public web Human Input v2 form transport. The versioned public routes are separate from legacy v1 forms and Web App passport-authenticated forms. Each runtime path must reject tokens owned by the other version when the service implementation is added. """ from __future__ import annotations from typing import NoReturn from flask import request from flask_restx import Resource from controllers.common.human_input_v2_contracts import ( FormAccessRequestResponse, FormDefinitionResponse, FormSubmitResponse, FormUploadTokenResponse, HumanInputV2FormSubmitRequest, ) from controllers.common.human_input_v2_errors import ( HumanInputAccessDeliveryError, HumanInputAccessRateLimitError, HumanInputExpiredChallengeError, HumanInputInvalidOtpError, HumanInputStaleChallengeError, HumanInputV2InvalidInputError, HumanInputV2UnavailableError, HumanInputV2WorkflowNotActiveError, PublicHumanInputV2ExpiredError, PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, email_otp_http_error, public_form_http_error, ) from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console.wraps import model_validate from controllers.web import web_ns from controllers.web.human_input_file_upload import InvalidUploadTokenForbiddenError from core.db.session_factory import session_factory from factories.human_input_v2_form_presentation import build_form_presentation from libs.datetime_utils import to_utc_timestamp from libs.external_api import error_responses from libs.helper import dump_response, extract_remote_ip from services.human_input_file_upload_service import InvalidUploadTokenError from services.human_input_v2.email_otp_composition import build_email_otp_service from services.human_input_v2.email_otp_service import EmailOtpError from services.human_input_v2.file_upload_service import build_file_upload_service from services.human_input_v2.submission_service import ( HumanInputFormError, HumanInputSubmissionService, ) register_schema_models(web_ns, HumanInputV2FormSubmitRequest) register_response_schema_models( web_ns, FormAccessRequestResponse, FormDefinitionResponse, FormSubmitResponse, FormUploadTokenResponse, ) def _raise_public_form_error(error: HumanInputFormError) -> NoReturn: raise public_form_http_error(error) from error @web_ns.route("/form/human-input/v2/public/") class HumanInputV2FormApi(Resource): """Read or submit a Human Input v2 form without sharing v1 submission logic.""" @error_responses(PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError) @web_ns.doc( description="Read a public v2 form definition by delivery token without an OTP or login session.", security=[], responses={404: "Form not found", 412: "Form already submitted or expired"}, ) @web_ns.response(200, "Form definition", web_ns.models[FormDefinitionResponse.__name__]) def get(self, form_token: str): service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) try: form = service.get_public_form(form_token=form_token, ip_address=extract_remote_ip(request) or None) except HumanInputFormError as error: _raise_public_form_error(error) presentation = build_form_presentation(form.resolved_form) return dump_response( FormDefinitionResponse, { "form_content": presentation.form_content, "inputs": presentation.inputs, "resolved_default_values": presentation.resolved_default_values, "user_actions": presentation.user_actions, "expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)), }, ) @error_responses( PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError, HumanInputV2WorkflowNotActiveError, HumanInputV2InvalidInputError, HumanInputExpiredChallengeError, HumanInputInvalidOtpError, HumanInputStaleChallengeError, HumanInputV2UnavailableError, ) @web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__]) @web_ns.doc( description="Submit a form with inputs, an action, and email verification proof.", security=[], responses={ 400: "Invalid form or email proof", 404: "Form not found", 409: "Workflow not active", 412: "Already submitted or expired", 422: "Invalid request body", 503: "Email verification unavailable", }, ) @web_ns.response(200, "Form submitted", web_ns.models[FormSubmitResponse.__name__]) @model_validate(HumanInputV2FormSubmitRequest) def post(self, payload: HumanInputV2FormSubmitRequest, form_token: str): service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker()) try: service.submit_public_form( email_otp_service=build_email_otp_service(), form_token=form_token, action=payload.action, inputs=payload.inputs, challenge_token=payload.challenge_token, otp_code=payload.otp_code, ip_address=extract_remote_ip(request) or None, ) except EmailOtpError as error: raise email_otp_http_error(error) from error except HumanInputFormError as error: _raise_public_form_error(error) return FormSubmitResponse().model_dump(mode="json") @web_ns.route("/form/human-input/v2/public//upload-token") class HumanInputV2FormUploadTokenApi(Resource): """Issue an upload token for an active Human Input v2 form.""" @error_responses( PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError, InvalidUploadTokenForbiddenError, ) @web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__]) @web_ns.doc(security=[], responses={404: "Form not found", 412: "Already submitted or expired"}) def post(self, form_token: str): try: grant = build_file_upload_service().issue_public_upload_token(form_token) except HumanInputFormError as error: _raise_public_form_error(error) except InvalidUploadTokenError as error: raise InvalidUploadTokenForbiddenError() from error return FormUploadTokenResponse( upload_token=grant.upload_token, expires_at=to_utc_timestamp(grant.expires_at) ).model_dump(mode="json") @web_ns.route("/form/human-input/v2/public//access-request") class FormAccessRequestApi(Resource): @error_responses( PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError, HumanInputAccessRateLimitError, HumanInputAccessDeliveryError, HumanInputV2UnavailableError, ) @web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__]) def post(self, form_token: str): try: challenge = build_email_otp_service().request_access( form_token, ip_address=extract_remote_ip(request) or None ) except EmailOtpError as error: raise email_otp_http_error(error) from error return FormAccessRequestResponse( challenge_token=challenge.challenge_token, expires_in_seconds=challenge.expires_in_seconds, resend_after_seconds=challenge.resend_after_seconds, ).model_dump(mode="json")