diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 7a8306e100..6ffd5b40ea 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -9,13 +9,20 @@ v3.0-32 | July XX, 2026 """""""""""""""""""""""" - Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined. +- **Field canonicalization** for background job tracking: + * The ``job`` field is now the canonical way to identify background jobs returned by `/sensors//schedules/trigger`, `/assets//schedules/trigger`, and `/sensors//forecasts/trigger` endpoints. If applicable, the triggered response now also returns a ``results-url`` pointing to the sensor-specific results endpoint, alongside the generic ``job-url``. + * Legacy ``schedule`` field (in scheduling endpoints) and ``forecast`` field (in forecasting endpoints) are deprecated but remain in responses for backward compatibility. Responses containing these fields include a dated ``Deprecation`` header, a ``Link`` header pointing to migration guidance, and a ``FlexMeasures-Deprecated-Response-Fields`` header naming the deprecated fields. +- ``GET /api/v3_0/jobs/`` now returns ``202 Accepted`` while a job is queued or running, ``422 Unprocessable Entity`` for failed jobs, and ``200 OK`` for finished jobs. See :ref:`api_background_jobs` for the response format and polling flow. +- ``GET /api/v3_0/jobs/`` now returns kebab-case metadata fields such as ``func-name`` and ``enqueued-at``. Legacy snake_case metadata fields such as ``func_name`` and ``enqueued_at`` remain available and are signalled with a dated ``Deprecation`` header, a ``Link`` header, and a ``FlexMeasures-Deprecated-Response-Fields`` header. - Added a ``group`` field to the storage flex-model, accepted by the `/assets/(id)/schedules/trigger <../api/v3_0.html#post--api-v3_0-assets-id-schedules-trigger>`_ (POST) endpoint, referencing a power sensor representing a group of devices (e.g. a shared inverter or feeder). The group's ``power-capacity`` is enforced as a hard constraint on the group's aggregate power, while its ``consumption-capacity``/``production-capacity`` are enforced as soft constraints with default breach prices; the group's scheduled aggregate power is saved to the group sensor. - The ``group`` field also accepts a ``{"asset": }`` reference (besides ``{"sensor": }``), pointing at an asset whose own (DB-stored) flex-model defines the group's constraints. Such a group defines no power sensor of its own; its aggregate schedule is instead saved via its ``consumption``/``production`` output sensor references, following the same conventions as any other asset-only flex-model entry. This lets the entire flex-model for a device tree (including groups) live in the DB, with ``flex-model`` omitted or empty on the trigger request. v3.0-31 | 2026-06-01 """""""""""""""""""" + - Support filtering assets and sensors by ID prefix in the ``filter`` query parameter of ``GET /assets``, ``GET /assets//sensors`` and ``GET /sensors``. -- Support filtering assets and sensors by ID prefix in the ``filter`` query parameter of ``GET /assets``, ``GET /assets//sensors`` and ``GET /sensors``. +v3.0-31 | 2026-06-01 +"""""""""""""""""""" - Added a unified job status endpoint ``GET /api/v3_0/jobs/`` that returns the current execution status and a human-readable result message for any background job (scheduling, forecasting, etc.) identified by its UUID. - Switched from ``force_new_job_creation`` to ``force-new-job-creation`` (maintaining backwards compatibility) and added the field to `/assets/(id)/schedules/trigger` (POST) endpoint, too. - Support both snake_case and kebab-case fields in `/sensors//data` (GET), while only documenting the kebab-case ones. diff --git a/documentation/api/introduction.rst b/documentation/api/introduction.rst index caa047d716..657666227e 100644 --- a/documentation/api/introduction.rst +++ b/documentation/api/introduction.rst @@ -155,6 +155,93 @@ Here is a client-side code example in Python for handling 303 redirects (this me print(f"Failed to fetch fallback schedule: {response.status_code} {response.text}") return response +.. _api_background_jobs: + +Background job monitoring +-------------------------- + +Several API endpoints queue background jobs for asynchronous processing (scheduling, forecasting, data ingestion) and return a ``202 Accepted`` response. +These responses include a ``job`` field (the canonical identifier) that clients can use to monitor job progress and retrieve results. +They also include both ``job-url`` for generic status monitoring and (if applicable) ``results-url`` for the sensor-specific results endpoint. + +**Example 202 Accepted response from a scheduling endpoint:** + +.. code-block:: json + + { + "status": "ACCEPTED", + "job": "364bfd06-c1fa-430b-8d25-8f5a547651fb", + "job-url": "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "results-url": "/api/v3_0/sensors/3/schedules/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "message": "Request has been accepted for processing." + } + +**Monitoring job status:** + +Clients should use the ``job.id`` to query the unified job status endpoint: + +.. code-block:: bash + + GET /api/v3_0/jobs/ + +This returns the current execution status and a human-readable result message. For example: + +.. code-block:: python + + import requests + import time + + def wait_for_job(job_id, job_url, timeout=300, poll_interval=5): + """Wait for a background job to complete and return the result. + + Parameters + ---------- + job_id : str + The UUID of the background job, we use it for logging here.. + job_url : str + The URL to query for job status (e.g., "/api/v3_0/jobs/"). + timeout : int + Maximum seconds to wait for job completion. + poll_interval : int + Seconds between status checks. + """ + start_time = time.time() + while time.time() - start_time < timeout: + response = requests.get(job_url) + if response.status_code not in (200, 202, 422): + raise RuntimeError( + f"Failed to query job status: {response.status_code} {response.text}" + ) + + job_data = response.json() + status = job_data.get("status") + + if response.status_code == 202: + print(f"Job {job_id} is still {status.lower()}...") + time.sleep(poll_interval) + elif status == "FINISHED": + return job_data.get("result") + else: # Failed, error, etc. + raise RuntimeError(f"Job failed with status {status}: {job_data.get('message')}") + + raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds") + +.. note:: + + For **schedules**, after the job completes successfully, use the job ID (same value as the legacy ``schedule`` field) to retrieve the actual schedule or follow the returned ``results-url``: + + .. code-block:: bash + + GET /api/v3_0/sensors//schedules/ + + For **forecasts**, after the job completes successfully, use the job ID to retrieve the forecast or follow the returned ``results-url``: + + .. code-block:: bash + + GET /api/v3_0/sensors//forecasts/ + + Both of these endpoints will also return `202 Accepted` if the job is still being computed, so clients can continue to poll them directly if they prefer. + .. _api_deprecation: Deprecation and sunset @@ -166,12 +253,62 @@ For more information on our multi-stage deprecation approach and available optio .. _api_deprecation_clients: -Clients -^^^^^^^ +Deprecated response fields +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In addition to deprecating entire endpoints, we sometimes deprecate individual fields in API responses while maintaining backward compatibility by including both the legacy and canonical fields. +When this happens, responses include a ``Deprecation`` response header with the deprecation date, a ``Link`` header pointing to migration guidance, and a ``FlexMeasures-Deprecated-Response-Fields`` header identifying the deprecated fields in that response. + +For example, when scheduling endpoints switched from ``schedule`` to ``job`` as the canonical field identifier for background jobs, a response that still contains the legacy ``schedule`` field also carries deprecation headers: + +.. code-block:: http + + HTTP/1.1 202 Accepted + Deprecation: Wed, 01 Jul 2026 00:00:00 GMT + Link: ; rel="deprecation"; type="text/html" + FlexMeasures-Deprecated-Response-Fields: schedule + Content-Type: application/json + + { + "status": "ACCEPTED", + "job": "364bfd06-c1fa-430b-8d25-8f5a547651fb", + "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb", + "job-url": "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "results-url": "/api/v3_0/sensors/3/schedules/364bfd06-c1fa-430b-8d25-8f5a547651fb" + } + +In this example, clients should treat ``job`` as the canonical field and ``schedule`` as a backward-compatible alias. +The ``FlexMeasures-Deprecated-Response-Fields`` header tells clients which response fields are deprecated, the ``Deprecation`` header tells clients when they were deprecated, and the ``Link`` header points to migration guidance for this endpoint. Professional API users should monitor API responses for the ``"Deprecation"`` and ``"Sunset"`` response headers [see `draft-ietf-httpapi-deprecation-header-02 `_ and `RFC 8594 `_, respectively], so system administrators can be warned when using API endpoints that are flagged for deprecation and/or are likely to become unresponsive in the future. +The ``Deprecation`` header may describe either the endpoint itself or specific response fields. +Clients can tell the difference by checking for ``FlexMeasures-Deprecated-Response-Fields``: if it is present, only the named response fields are deprecated; if it is absent, the deprecation applies to the endpoint or API version as a whole. + +For deprecated response fields, clients should: + +- Monitor the ``Deprecation`` response header to detect deprecated API behavior. +- Use the ``FlexMeasures-Deprecated-Response-Fields`` header to identify which response fields are deprecated. +- Follow the ``Link`` response header to find migration guidance. +- Migrate to use the canonical field names documented in the API schema. +- Plan upgrades based on the deprecation guidance to avoid breakage when deprecated fields are eventually removed in a future API version. + +Client code should therefore inspect both headers and body fields, for example: + +.. code-block:: python + + response = requests.post(trigger_url, json=payload, headers=headers) + response.raise_for_status() + + deprecated_fields = response.headers.get("FlexMeasures-Deprecated-Response-Fields") + if deprecated_fields: + print(f"Deprecated response fields detected: {deprecated_fields}") + print(f"See {response.headers.get('Link')}") + + data = response.json() + job_id = data["job"] -The deprecation header field shows an `IMF-fixdate `_ indicating when the API endpoint was deprecated. +For endpoint deprecations, the deprecation header field shows an `IMF-fixdate `_ indicating when the API endpoint was deprecated. +For deprecated response fields, the deprecation header field also shows an IMF-fixdate, while the presence of the ``FlexMeasures-Deprecated-Response-Fields`` header narrows the deprecation to the named response fields. The sunset header field shows an `IMF-fixdate `_ indicating when the API endpoint is likely to become unresponsive. More information about a deprecation, sunset, and possibly recommended replacements, can be found under the ``"Link"`` response header. Relevant relations are: diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 5c2902dc2b..17ab052548 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -8,6 +8,8 @@ FlexMeasures Changelog v1.0.0 | July XX, 2026 ============================ +.. warning:: As of this release we standardize asynchronous job responses to use the ``job`` field and return HTTP ``202 Accepted`` while a background job is queued or running. See :ref:`api_background_jobs` for the response format and polling flow. Legacy response fields such as ``schedule`` and ``forecast`` will be deprecated; clients should migrate to ``job`` (see the Infrastructure / Support section below for migration details). + .. warning:: Upgrading to this version requires running ``flexmeasures db upgrade`` (you can create a backup first with ``flexmeasures db-ops dump``). New features @@ -36,6 +38,7 @@ Infrastructure / Support * ``flexmeasures db upgrade`` now runs ``VACUUM ANALYZE`` after upgrading by default, so Postgres has fresh planner statistics right after a migration; opt out with ``--no-vacuum`` [see `PR #2333 `_] * Upgraded dependencies [see `PR #1485 `_, `PR #2215 `_ and `PR #2243 `_] * Prepare the ``device_scheduler`` to deal with commitments per device group [see `PR #1934 `_] +* Standardize job-trigger API responses to return ``202 Accepted`` and a canonical ``job`` field; legacy response fields such as ``schedule`` and ``forecast`` are preserved for backward-compatibility but marked deprecated with migration guidance in :ref:`api_background_jobs` [see `PR #2224 `_]. * Speed up scheduling on longer horizons using a recursive stock model, making the solve time linear with the horizon instead of quadratic (about 10x faster solves at the 2-day default horizon, 23x at 3 days) [see `PR #2282 `_] * Support storing encrypted connection secrets on organisations and assets, including utility functions, encryption key configuration, CLI commands to set and delete secrets, and UI tables that show stored secret names and optional expiration times without exposing their values [see `PR #2236 `_] * Documentation section on the modelling choice for recording measurements, forecasts and schedules under one or multiple sensors [see `PR #2217 `_] diff --git a/documentation/tut/toy-example-expanded.rst b/documentation/tut/toy-example-expanded.rst index 08ec37556f..9fe8c37ae2 100644 --- a/documentation/tut/toy-example-expanded.rst +++ b/documentation/tut/toy-example-expanded.rst @@ -119,6 +119,10 @@ This will have an effect on the available headroom for the battery, given the `` } } + .. note:: + The API returns a **202 Accepted** response containing a ``job`` field. + Use this UUID to monitor the job status via :ref:`api_background_jobs`. + .. tab:: FlexMeasures Client Using the `FlexMeasures Client `_: diff --git a/documentation/tut/toy-example-from-scratch.rst b/documentation/tut/toy-example-from-scratch.rst index 9e70320018..2f77140060 100644 --- a/documentation/tut/toy-example-from-scratch.rst +++ b/documentation/tut/toy-example-from-scratch.rst @@ -90,6 +90,20 @@ There is more information being used by the scheduler, such as the battery's cap } .. note:: You can try this right in Swagger UI, too! You should find it at `http://localhost:5000/api/v3_0/docs `_ after starting FlexMeasures locally. + + The API returns a **202 Accepted** response with a ``job`` field to track the scheduling job: + + .. code-block:: json + + { + "status": "ACCEPTED", + "job": "364bfd06-c1fa-430b-8d25-8f5a547651fb", + "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb", + "job-url": "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "results-url": "/api/v3_0/sensors/2/schedules/364bfd06-c1fa-430b-8d25-8f5a547651fb" + } + + **Important:** Use the ``job`` field to reference the scheduling job. You can also follow the returned ``results-url`` to fetch the schedule results once they are ready. For more details, see :ref:`api_background_jobs`. .. tab:: FlexMeasures Client diff --git a/flexmeasures/api/common/responses.py b/flexmeasures/api/common/responses.py index 5b161ffa27..b7adbeaab7 100644 --- a/flexmeasures/api/common/responses.py +++ b/flexmeasures/api/common/responses.py @@ -14,16 +14,34 @@ ResponseTuple = tuple[dict, int] | tuple[dict, int, dict] +DEPRECATED_RESPONSE_FIELDS_HEADER = "FlexMeasures-Deprecated-Response-Fields" + + +def deprecated_response_fields_headers( + fields: Sequence[str], + deprecation_link: str, + deprecation_date: str, +) -> dict[str, str]: + """Headers used when a response contains deprecated fields.""" + return { + "Deprecation": deprecation_date, + "Link": f'<{deprecation_link}>; rel="deprecation"; type="text/html"', + DEPRECATED_RESPONSE_FIELDS_HEADER: ", ".join(fields), + } + + def is_response_tuple(value) -> bool: """Check if an object qualifies as a ResponseTuple""" if not isinstance(value, tuple): return False - if not len(value) == 2: + if len(value) not in (2, 3): return False if not isinstance(value[0], dict): return False if not isinstance(value[1], int): return False + if len(value) == 3 and not isinstance(value[2], dict): + return False return True @@ -383,16 +401,44 @@ def request_processed(message: str) -> ResponseTuple: def request_accepted_for_processing( job_id: str, message: str = "Request has been accepted for processing.", + legacy_key: str | None = None, + job_results_url: str | None = None, + deprecation_link: str | None = None, + deprecation_date: str | None = None, ) -> ResponseTuple: - return ( - dict( - status="ACCEPTED", - message=message, - job_monitor_url=url_for("JobAPI:get_job_status", uuid=job_id), - job_id=job_id, - ), - 202, + """ + Standard 202 response when a background job is accepted. + + Optional backwards-compatibility: if `legacy_key` is provided the response + will include the job id under that legacy key (e.g. `schedule` or + `forecast`) and response headers marking the field deprecation. + + Optional `job_results_url` may be supplied to provide a direct link to the + sensor-specific job results endpoint, e.g. `/api/v3_0/sensors//schedules/`. + """ + resp: dict[str, object] = dict( + status="ACCEPTED", + message=message, + job=job_id, ) + resp["job-url"] = url_for("JobAPI:get_job_status", uuid=job_id) + if job_results_url: + resp["results-url"] = job_results_url + + if legacy_key: + # keep legacy key for backwards compatibility + resp[legacy_key] = job_id + assert deprecation_link is not None + assert deprecation_date is not None + return ( + resp, + 202, + deprecated_response_fields_headers( + [legacy_key], deprecation_link, deprecation_date + ), + ) + + return resp, 202 def request_too_large(message: str) -> ResponseTuple: diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 10b988d648..223c136c86 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -67,8 +67,9 @@ ) from flexmeasures.api.common.responses import ( unprocessable_entity, - request_processed, + request_accepted_for_processing, ) +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.common.schemas.users import AccountIdField from flexmeasures.api.common.schemas.assets import default_response_fields from flexmeasures.ui.utils.view_utils import clear_session, set_session_variables @@ -81,6 +82,11 @@ from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.utils import get_downsample_function_and_value +API_V3_0_DOCS_URL = "https://flexmeasures.readthedocs.io/latest/api/v3_0.html" +ASSET_SCHEDULE_TRIGGER_DOCS_URL = ( + f"{API_V3_0_DOCS_URL}#post--api-v3_0-assets-id-schedules-trigger" +) + asset_type_schema = AssetTypeSchema() asset_schema = AssetSchema() annotation_schema = AnnotationSchema() @@ -1487,8 +1493,24 @@ def trigger_schedule( site-consumption-capacity: {sensor: 32} responses: - 200: - description: PROCESSED + 202: + description: ACCEPTED (Scheduling job queued for processing) + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "schedule" content: application/json: schema: @@ -1496,14 +1518,19 @@ def trigger_schedule( examples: successful_response: description: | - This message indicates that the scheduling request has been processed without any error. + This message indicates that the scheduling request has been accepted for processing (202 Accepted). A scheduling job has been created with some Universally Unique Identifier (UUID), which will be picked up by a worker. - The given UUID may be used to obtain the resulting schedule for each flexible device: see [/sensors/schedules/](#/Sensors/get_api_v3_0_sensors__id__schedules__uuid_). + The given UUID is returned in the canonical `job` field. + For backward-compatibility, the legacy `schedule` field is also included but is deprecated; + use `job` instead. + The given UUID may be used to obtain the resulting schedule for each flexible device via [/sensors/schedules/](#/Sensors/get_api_v3_0_sensors__id__schedules__uuid_). value: - status: PROCESSED + status: ACCEPTED + job: "364bfd06-c1fa-430b-8d25-8f5a547651fb" schedule: "364bfd06-c1fa-430b-8d25-8f5a547651fb" - message: "Request has been processed." + job-url: "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb" + message: "Request has been accepted for processing." 400: description: INVALID_DATA 401: @@ -1544,9 +1571,13 @@ def trigger_schedule( except ValueError as err: return unprocessable_entity(str(err)) - response = dict(schedule=job.id) - d, s = request_processed() - return dict(**response, **d), s + # Keep legacy `schedule` key for backward compatibility; prefer `job`. + return request_accepted_for_processing( + job.id, + legacy_key="schedule", + deprecation_link=ASSET_SCHEDULE_TRIGGER_DOCS_URL, + deprecation_date=JOB_RESPONSE_FIELDS_DEPRECATION_DATE, + ) @route("//kpis", methods=["GET"]) @use_kwargs( diff --git a/flexmeasures/api/v3_0/deprecations.py b/flexmeasures/api/v3_0/deprecations.py new file mode 100644 index 0000000000..ea7b4f506e --- /dev/null +++ b/flexmeasures/api/v3_0/deprecations.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from flexmeasures.utils.time_utils import to_http_time + + +# TODO: In another iteration, this will be saved in a more generic deprecations structure, +# With the ability for hosts to set the dates or other specifics +# to their own cadence. +JOB_RESPONSE_FIELDS_DEPRECATION_DATE = to_http_time( + datetime(2026, 8, 1, tzinfo=timezone.utc) +) diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index 74dccc884f..6d1bcc001d 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -13,10 +13,24 @@ from flexmeasures.data.services.utils import failed_job_exc_info, job_status_description from flexmeasures.auth.policy import check_access +from flexmeasures.api.common.responses import deprecated_response_fields_headers +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.data import db from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.services.utils import get_asset_or_sensor_from_ref +JOBS_API_DOCS_URL = ( + "https://flexmeasures.readthedocs.io/latest/api/v3_0.html" + "#get--api-v3_0-jobs-uuid" +) +JOB_STATUS_DEPRECATED_RESPONSE_FIELDS = ( + "func_name", + "enqueued_at", + "started_at", + "ended_at", + "exc_info", +) + def _isoformat_or_none(dt: datetime | None) -> str | None: """Return an ISO-8601 string for *dt*, or ``None`` when *dt* is absent.""" @@ -111,7 +125,23 @@ def get_job_status(self, job_id: str, **kwargs): type: string responses: 200: - description: Job status retrieved successfully. + description: Finished job status retrieved successfully. + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "func_name, enqueued_at, started_at, ended_at, exc_info" content: application/json: schema: @@ -144,31 +174,58 @@ def get_job_status(self, job_id: str, **kwargs): The ``num-beliefs`` field holds the total number of beliefs (scheduled values) saved to the database. nullable: true - func_name: + func-name: type: string description: Fully-qualified name of the function executed by this job. origin: type: string description: Name of the queue the job was placed on. - enqueued_at: + enqueued-at: type: string format: date-time nullable: true description: ISO-8601 timestamp of when the job was enqueued. - started_at: + started-at: type: string format: date-time nullable: true description: ISO-8601 timestamp of when the job started executing. - ended_at: + ended-at: type: string format: date-time nullable: true description: ISO-8601 timestamp of when the job finished executing. - exc_info: + exc-info: type: string nullable: true description: Traceback information for failed jobs, or null otherwise. + func_name: + type: string + deprecated: true + description: (DEPRECATED) Fully-qualified name of the function executed by this job. Use `func-name` instead. + enqueued_at: + type: string + format: date-time + nullable: true + deprecated: true + description: (DEPRECATED) ISO-8601 timestamp of when the job was enqueued. Use `enqueued-at` instead. + started_at: + type: string + format: date-time + nullable: true + deprecated: true + description: (DEPRECATED) ISO-8601 timestamp of when the job started executing. Use `started-at` instead. + ended_at: + type: string + format: date-time + nullable: true + deprecated: true + description: (DEPRECATED) ISO-8601 timestamp of when the job finished executing. Use `ended-at` instead. + exc_info: + type: string + nullable: true + deprecated: true + description: (DEPRECATED) Traceback information for failed jobs, or null otherwise. Use `exc-info` instead. examples: queued: summary: Queued job @@ -176,8 +233,13 @@ def get_job_status(self, job_id: str, **kwargs): status: QUEUED message: "Scheduling job waiting to be processed." result: null - func_name: "flexmeasures.data.services.scheduling.create_schedule" + func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling + enqueued-at: "2026-04-28T10:00:00+00:00" + started-at: null + ended-at: null + exc-info: null + func_name: "flexmeasures.data.services.scheduling.create_schedule" enqueued_at: "2026-04-28T10:00:00+00:00" started_at: null ended_at: null @@ -197,24 +259,60 @@ def get_job_status(self, job_id: str, **kwargs): violation: "180.0 kWh" resolved: [] num-beliefs: 96 - func_name: "flexmeasures.data.services.scheduling.create_schedule" + func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling - enqueued_at: "2026-04-28T10:00:00+00:00" - started_at: "2026-04-28T10:00:01+00:00" - ended_at: "2026-04-28T10:00:05+00:00" - exc_info: null + enqueued-at: "2026-04-28T10:00:00+00:00" + started-at: "2026-04-28T10:00:01+00:00" + ended-at: "2026-04-28T10:00:05+00:00" + exc-info: null failed: summary: Failed job value: status: FAILED message: "Scheduling job failed with ValueError: ..." result: null - func_name: "flexmeasures.data.services.scheduling.create_schedule" + func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling - enqueued_at: "2026-04-28T10:00:00+00:00" - started_at: "2026-04-28T10:00:01+00:00" - ended_at: "2026-04-28T10:00:02+00:00" - exc_info: "Traceback (most recent call last): ..." + enqueued-at: "2026-04-28T10:00:00+00:00" + started-at: "2026-04-28T10:00:01+00:00" + ended-at: "2026-04-28T10:00:02+00:00" + exc-info: "Traceback (most recent call last): ..." + 202: + description: Job is still queued, scheduled, deferred, or running. + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "func_name, enqueued_at, started_at, ended_at, exc_info" + 422: + description: Job has failed. + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "func_name, enqueued_at, started_at, ended_at, exc_info" 404: description: NOT_FOUND 401: @@ -264,12 +362,32 @@ def get_job_status(self, job_id: str, **kwargs): status=status_name, message=job_status_description(job), result=result, - func_name=job.func_name, origin=job.origin, - enqueued_at=_isoformat_or_none(job.enqueued_at), - started_at=_isoformat_or_none(job.started_at), - ended_at=_isoformat_or_none(job.ended_at), - exc_info=failed_job_exc_info(job), ) + response["func-name"] = job.func_name + response["enqueued-at"] = _isoformat_or_none(job.enqueued_at) + response["started-at"] = _isoformat_or_none(job.started_at) + response["ended-at"] = _isoformat_or_none(job.ended_at) + response["exc-info"] = failed_job_exc_info(job) + response["func_name"] = response["func-name"] + response["enqueued_at"] = response["enqueued-at"] + response["started_at"] = response["started-at"] + response["ended_at"] = response["ended-at"] + response["exc_info"] = response["exc-info"] - return response, 200 + if status_name == JobStatus.FAILED.name: + status_code = 422 + elif status_name == JobStatus.FINISHED.name: + status_code = 200 + else: + status_code = 202 + + return ( + response, + status_code, + deprecated_response_fields_headers( + JOB_STATUS_DEPRECATED_RESPONSE_FIELDS, + JOBS_API_DOCS_URL, + JOB_RESPONSE_FIELDS_DEPRECATION_DATE, + ), + ) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index e28a7f0c4a..d21288dbf8 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -19,6 +19,7 @@ from sqlalchemy import delete, select, or_ from flexmeasures.api.common.responses import ( + request_accepted_for_processing, request_processed, unrecognized_event, unknown_forecast, @@ -26,6 +27,7 @@ unprocessable_entity, fallback_schedule_redirect, ) +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.common.schemas.utils import make_openapi_compatible from flexmeasures.api.common.schemas.sensor_data import ( # noqa F401 SensorDataDescriptionSchema, @@ -85,6 +87,14 @@ ForecastingTriggerSchema, ) +API_V3_0_DOCS_URL = "https://flexmeasures.readthedocs.io/latest/api/v3_0.html" +SENSOR_SCHEDULE_TRIGGER_DOCS_URL = ( + f"{API_V3_0_DOCS_URL}#post--api-v3_0-sensors-id-schedules-trigger" +) +SENSOR_FORECAST_TRIGGER_DOCS_URL = ( + f"{API_V3_0_DOCS_URL}#post--api-v3_0-sensors-id-forecasts-trigger" +) + # Instantiate schemes outside of endpoint logic to minimize response time sensors_schema = SensorSchema(many=True) sensor_schema = SensorSchema() @@ -969,24 +979,67 @@ def trigger_schedule( site-peak-consumption-price: "260 EUR/MW" site-peak-production-price: "260 EUR/MW" responses: - 200: - description: PROCESSED + 202: + description: ACCEPTED (Scheduling job queued for processing) + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "schedule" content: application/json: schema: type: object + properties: + job: + type: string + description: UUID of the queued scheduling job (canonical field; use this). + status: + type: string + enum: ["ACCEPTED"] + message: + type: string + job-url: + type: string + format: uri + description: URL to query the generic job status API. + results-url: + type: string + format: uri + description: URL to fetch the schedule results for this job once it is complete. + schedule: + type: string + deprecated: true + description: (DEPRECATED) UUID of the queued scheduling job. Use `job` instead. examples: schedule_created: summary: Schedule response description: | - This message indicates that the scheduling request has been processed without any error. + This message indicates that the scheduling request has been accepted for processing (202 Accepted). A scheduling job has been created with some Universally Unique Identifier (UUID), which will be picked up by a worker. + The given UUID is returned in the canonical `job` field. + For backward-compatibility, the legacy `schedule` field is also included but is deprecated; + use `job` instead. The given UUID may be used to obtain the resulting schedule: see /sensors//schedules/. value: - status: "PROCESSED" + status: "ACCEPTED" + job: "364bfd06-c1fa-430b-8d25-8f5a547651fb" schedule: "364bfd06-c1fa-430b-8d25-8f5a547651fb" - message: "Request has been processed." + job-url: "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb" + results-url: "/api/v3_0/sensors/3/schedules/364bfd06-c1fa-430b-8d25-8f5a547651fb" + message: "Request has been accepted for processing." 400: description: INVALID_DATA 401: @@ -1034,9 +1087,16 @@ def trigger_schedule( db.session.commit() - response = dict(schedule=job.id) - d, s = request_processed() - return dict(**response, **d), s + # Keep legacy `schedule` key for backward compatibility; prefer `job`. + return request_accepted_for_processing( + job.id, + legacy_key="schedule", + job_results_url=url_for( + "SensorAPI:get_schedule", id=sensor.id, uuid=job.id + ), + deprecation_link=SENSOR_SCHEDULE_TRIGGER_DOCS_URL, + deprecation_date=JOB_RESPONSE_FIELDS_DEPRECATION_DATE, + ) # mark endpoint as deprecated trigger_schedule.after_request = lambda response: add_deprecation_header(response) @@ -1192,6 +1252,27 @@ def get_schedule( # noqa: C901 start: "2015-06-02T10:00:00+00:00" duration: "PT45M" unit: "MW" + 202: + description: Scheduling job status while the job is still running + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: ["QUEUED", "STARTED", "DEFERRED"] + description: Processing status of the scheduling job. + + message: + type: string + description: Human-readable message about current job processing. + examples: + started: + summary: Scheduling job is still running + value: + status: "STARTED" + message: "The scheduling job is currently running." 400: description: INVALID_TIMEZONE, INVALID_DOMAIN, UNKNOWN_SCHEDULE, UNRECOGNIZED_CONNECTION_GROUP 401: @@ -1248,7 +1329,23 @@ def get_schedule( # noqa: C901 _external=True, ), ) + elif job.is_failed: + return unknown_schedule(job_status_description(job, scheduler_info_msg)) else: + if current_app.config.get("FLEXMEASURES_API_SUNSET_ACTIVE"): + job_status = job.get_status() + job_status_name = ( + job_status.upper() + if isinstance(job_status, str) + else job_status.name + ) + return ( + dict( + status=job_status_name, + message=job_status_description(job, scheduler_info_msg), + ), + 202, + ) return unknown_schedule(job_status_description(job, scheduler_info_msg)) schedule_start = job.kwargs["start"] @@ -1891,25 +1988,56 @@ def trigger_forecast(self, id: int, **params): start: "2026-01-15T00:00:00+01:00" duration: "P2D" responses: - 200: - description: PROCESSED + 202: + description: ACCEPTED (Forecasting job queued for processing) + headers: + Deprecation: + description: Indicates that the response contains deprecated fields. + schema: + type: string + example: "Wed, 01 Jul 2026 00:00:00 GMT" + Link: + description: Link to migration guidance for deprecated response fields. + schema: + type: string + example: '; rel="deprecation"; type="text/html"' + FlexMeasures-Deprecated-Response-Fields: + description: Comma-separated response fields that are deprecated. + schema: + type: string + example: "forecast" content: application/json: schema: type: object properties: - forecast: + job: type: string - description: UUID of the queued forecasting job. + description: UUID of the queued forecasting job (canonical field; use this). status: type: string - enum: ["PROCESSED"] + enum: ["ACCEPTED"] message: type: string + job-url: + type: string + format: uri + description: URL to query the generic job status API. + results-url: + type: string + format: uri + description: URL to fetch the forecast results for this job once it is complete. + forecast: + type: string + deprecated: true + description: (DEPRECATED) UUID of the queued forecasting job. Use `job` instead. example: - forecast: "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" - status: "PROCESSED" + status: "ACCEPTED" + job: "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" message: "Forecasting job has been queued." + job-url: "/api/v3_0/jobs/b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" + results-url: "/api/v3_0/sensors/3/forecasts/b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" + forecast: "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" 401: description: UNAUTHORIZED 403: @@ -1950,8 +2078,16 @@ def trigger_forecast(self, id: int, **params): current_app.logger.exception("Forecast job failed to enqueue.") return unprocessable_entity(str(e)) - d, s = request_processed() - return dict(forecast=pipeline_returns["job_id"], **d), s + # Keep legacy `forecast` key for backward compatibility; prefer `job`. + return request_accepted_for_processing( + pipeline_returns["job_id"], + legacy_key="forecast", + job_results_url=url_for( + "SensorAPI:get_forecast", id=id, uuid=pipeline_returns["job_id"] + ), + deprecation_link=SENSOR_FORECAST_TRIGGER_DOCS_URL, + deprecation_date=JOB_RESPONSE_FIELDS_DEPRECATION_DATE, + ) @route("//forecasts/", methods=["GET"]) @use_kwargs( diff --git a/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py index c928c6a701..be810ce3b5 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_asset_schedules_fresh_db.py @@ -9,6 +9,8 @@ from rq.job import Job from flexmeasures import Sensor +from flexmeasures.api.common.responses import DEPRECATED_RESPONSE_FIELDS_HEADER +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.v3_0.tests.utils import message_for_trigger_schedule from flexmeasures.data.models.planning.tests.utils import check_constraints from flexmeasures.data.models.generic_assets import GenericAsset @@ -97,7 +99,20 @@ def test_asset_trigger_and_get_schedule( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 + assert ( + trigger_schedule_response.headers["Deprecation"] + == JOB_RESPONSE_FIELDS_DEPRECATION_DATE + ) + assert 'rel="deprecation"' in trigger_schedule_response.headers["Link"] + assert ( + "post--api-v3_0-assets-id-schedules-trigger" + in trigger_schedule_response.headers["Link"] + ) + assert ( + trigger_schedule_response.headers[DEPRECATED_RESPONSE_FIELDS_HEADER] + == "schedule" + ) job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue @@ -379,7 +394,7 @@ def test_asset_trigger_and_get_aggregate_schedule( url_for("AssetAPI:trigger_schedule", id=charging_hub.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # Process the scheduling queue @@ -588,9 +603,9 @@ def test_asset_trigger_with_multi_commodity_flex_context( url_for("AssetAPI:trigger_schedule", id=charging_hub.id), json=message, ) - if trigger_response.status_code != 200: + if trigger_response.status_code != 202: print(f"Error response: {trigger_response.json}") - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # Process the scheduling queue @@ -737,9 +752,9 @@ def test_asset_trigger_with_flex_context_commodity_not_used( url_for("AssetAPI:trigger_schedule", id=charging_hub.id), json=message, ) - if trigger_response.status_code != 200: + if trigger_response.status_code != 202: print(f"Error response: {trigger_response.json}") - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # Process the scheduling queue @@ -887,9 +902,9 @@ def make_flex_model(sensor: Sensor, soc_sensor: Sensor, group_sensor: Sensor): url_for("AssetAPI:trigger_schedule", id=charging_hub.id), json=message, ) - if trigger_response.status_code != 200: + if trigger_response.status_code != 202: print(f"Error response: {trigger_response.json}") - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # Process the scheduling queue @@ -955,7 +970,7 @@ def get_beliefs_as_series(sensor: Sensor) -> pd.Series: url_for("AssetAPI:trigger_schedule", id=charging_hub.id), json=control_message, ) - assert control_response.status_code == 200 + assert control_response.status_code == 202 control_job_id = control_response.json["schedule"] work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_forecasting_api_fresh_db.py index 6b5a643e15..b45d832d19 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api_fresh_db.py @@ -5,6 +5,8 @@ from rq.job import Job +from flexmeasures.api.common.responses import DEPRECATED_RESPONSE_FIELDS_HEADER +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.tests.utils import get_auth_token from flexmeasures.data.models.forecasting.pipelines import TrainPredictPipeline from flexmeasures.data.services.forecasting import handle_forecasting_exception @@ -48,7 +50,15 @@ def test_trigger_and_fetch_forecasts( trigger_res = client.post( trigger_url, json=payload, headers={"Authorization": token} ) - assert trigger_res.status_code == 200, trigger_res.json + assert trigger_res.status_code == 202, trigger_res.json + assert trigger_res.headers["Deprecation"] == JOB_RESPONSE_FIELDS_DEPRECATION_DATE + assert 'rel="deprecation"' in trigger_res.headers["Link"] + assert "post--api-v3_0-sensors-id-forecasts-trigger" in trigger_res.headers["Link"] + assert trigger_res.headers[DEPRECATED_RESPONSE_FIELDS_HEADER] == "forecast" + assert "deprecated-fields" not in trigger_res.json + assert trigger_res.json["results-url"] == url_for( + "SensorAPI:get_forecast", id=sensor_0.id, uuid=trigger_res.json["forecast"] + ) trigger_json = trigger_res.get_json() wrap_up_job = app.queues["forecasting"].fetch_job(trigger_json["forecast"]) diff --git a/flexmeasures/api/v3_0/tests/test_jobs_api.py b/flexmeasures/api/v3_0/tests/test_jobs_api.py index 00f2c2540e..408ab30dcd 100644 --- a/flexmeasures/api/v3_0/tests/test_jobs_api.py +++ b/flexmeasures/api/v3_0/tests/test_jobs_api.py @@ -10,6 +10,8 @@ from redis.exceptions import ConnectionError as RedisConnectionError from rq.job import Job, JobStatus +from flexmeasures.api.common.responses import DEPRECATED_RESPONSE_FIELDS_HEADER +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.v3_0.tests.utils import message_for_trigger_schedule from flexmeasures.data.services.scheduling import ( create_scheduling_job, @@ -21,6 +23,30 @@ from flexmeasures.utils.job_utils import work_on_rq +JOB_STATUS_DEPRECATED_FIELDS = { + "func_name": "func-name", + "enqueued_at": "enqueued-at", + "started_at": "started-at", + "ended_at": "ended-at", + "exc_info": "exc-info", +} + + +def assert_legacy_job_status_fields(data: dict): + for legacy_key, canonical_key in JOB_STATUS_DEPRECATED_FIELDS.items(): + assert data[legacy_key] == data[canonical_key] + assert "deprecated-fields" not in data + + +def assert_deprecated_response_fields_headers(response): + assert response.headers["Deprecation"] == JOB_RESPONSE_FIELDS_DEPRECATION_DATE + assert 'rel="deprecation"' in response.headers["Link"] + assert "get--api-v3_0-jobs-uuid" in response.headers["Link"] + assert response.headers[DEPRECATED_RESPONSE_FIELDS_HEADER] == ", ".join( + JOB_STATUS_DEPRECATED_FIELDS.keys() + ) + + @pytest.mark.parametrize( "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True ) @@ -43,7 +69,7 @@ def test_get_job_status_unknown_uuid( @pytest.mark.parametrize( "requesting_user, expected_status_code", [ - ("test_prosumer_user@seita.nl", 200), + ("test_prosumer_user@seita.nl", 202), ("test_dummy_user_3@seita.nl", 403), ], indirect=["requesting_user"], @@ -84,8 +110,9 @@ def test_get_job_status_requires_read_access( ) assert response.status_code == expected_status_code - if expected_status_code == 200: + if expected_status_code == 202: assert response.json["status"] == "QUEUED" + assert_deprecated_response_fields_headers(response) else: assert response.json["status"] == "INVALID_SENDER" @@ -111,7 +138,7 @@ def test_get_job_status_queued( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # immediately query the generic job endpoint – job is still queued @@ -120,21 +147,24 @@ def test_get_job_status_queued( ) print("Server responded with:\n%s" % response.json) - assert response.status_code == 200 + assert response.status_code == 202 data = response.json assert data["status"] == "QUEUED" assert "waiting" in data["message"].lower() # metadata fields present - assert "func_name" in data + assert "func-name" in data assert "origin" in data assert data["origin"] == "scheduling" - # enqueued_at is set when a job is queued; started_at and ended_at are not yet - assert data["enqueued_at"] is not None - assert data["started_at"] is None - assert data["ended_at"] is None + # enqueued-at is set when a job is queued; started-at and ended-at are not yet + assert data["enqueued-at"] is not None + assert data["started-at"] is None + assert data["ended-at"] is None # result is not yet available assert data["result"] is None - assert data["exc_info"] is None + assert data["exc-info"] is None + assert_legacy_job_status_fields(data) + assert_deprecated_response_fields_headers(response) + assert_deprecated_response_fields_headers(response) @pytest.mark.parametrize( @@ -157,7 +187,7 @@ def test_get_job_status_started( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # simulate the job being picked up by a worker @@ -169,19 +199,21 @@ def test_get_job_status_started( ) print("Server responded with:\n%s" % response.json) - assert response.status_code == 200 + assert response.status_code == 202 data = response.json assert data["status"] == "STARTED" assert "in progress" in data["message"].lower() # metadata fields present - assert "func_name" in data + assert "func-name" in data assert data["origin"] == "scheduling" - # enqueued_at is set; ended_at is not yet available - assert data["enqueued_at"] is not None - assert data["ended_at"] is None + # enqueued-at is set; ended-at is not yet available + assert data["enqueued-at"] is not None + assert data["ended-at"] is None # result is not yet available assert data["result"] is None - assert data["exc_info"] is None + assert data["exc-info"] is None + assert_legacy_job_status_fields(data) + assert_deprecated_response_fields_headers(response) @pytest.mark.parametrize( @@ -206,7 +238,7 @@ def test_get_job_status_finished( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # run the scheduling job @@ -225,12 +257,12 @@ def test_get_job_status_finished( assert data["status"] == "FINISHED" assert "finished" in data["message"].lower() # metadata fields present - assert "func_name" in data + assert "func-name" in data assert data["origin"] == "scheduling" # timing fields - assert data["enqueued_at"] is not None - assert data["started_at"] is not None - assert data["ended_at"] is not None + assert data["enqueued-at"] is not None + assert data["started-at"] is not None + assert data["ended-at"] is not None # every finished scheduling job now returns an object (not the boolean # True it used to return unconditionally); this is a StorageScheduler # job, so `result` is the soft SoC constraint analysis dict, and since @@ -241,7 +273,8 @@ def test_get_job_status_finished( assert result["unresolved"] == [] assert result["resolved"] == [] assert result["num-beliefs"] == 96 - assert data["exc_info"] is None + assert data["exc-info"] is None + assert_legacy_job_status_fields(data) @pytest.mark.parametrize( @@ -279,7 +312,7 @@ def test_get_job_status_finished_with_unresolved_soc_minima( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] # run the scheduling job @@ -350,7 +383,7 @@ def fail_with_assertion(self): url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message_for_trigger_schedule(), ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] work_on_rq( @@ -361,11 +394,13 @@ def fail_with_assertion(self): response = client.get(url_for("JobAPI:get_job_status", uuid=job_id)) - assert response.status_code == 200 + assert response.status_code == 422 data = response.json assert data["status"] == "FAILED" assert "assert 1 == 2" in data["message"] - assert "AssertionError: assert 1 == 2" in data["exc_info"] + assert "AssertionError: assert 1 == 2" in data["exc-info"] + assert_legacy_job_status_fields(data) + assert_deprecated_response_fields_headers(response) @pytest.mark.parametrize( @@ -386,7 +421,7 @@ def test_get_job_status_failed_infeasible_schedule_includes_exc_info( url_for("SensorAPI:trigger_schedule", id=charging_station.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] work_on_rq( @@ -397,13 +432,15 @@ def test_get_job_status_failed_infeasible_schedule_includes_exc_info( response = client.get(url_for("JobAPI:get_job_status", uuid=job_id)) - assert response.status_code == 200 + assert response.status_code == 422 data = response.json assert data["status"] == "FAILED" assert "infeasible problem" in data["message"].lower() assert ( - "ValueError: The input data yields an infeasible problem." in data["exc_info"] + "ValueError: The input data yields an infeasible problem." in data["exc-info"] ) + assert_legacy_job_status_fields(data) + assert_deprecated_response_fields_headers(response) def test_get_job_status_unauthenticated( diff --git a/flexmeasures/api/v3_0/tests/test_sensor_data.py b/flexmeasures/api/v3_0/tests/test_sensor_data.py index 8ab8063089..25bcc36670 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_data.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_data.py @@ -319,10 +319,10 @@ def test_post_sensor_data_returns_accepted_job( assert response.status_code == 202 assert response.json["status"] == "ACCEPTED" - assert response.json["job_monitor_url"] == url_for( - "JobAPI:get_job_status", uuid=response.json["job_id"] + assert response.json["job-url"] == url_for( + "JobAPI:get_job_status", uuid=response.json["job"] ) - job = current_app.queues["ingestion"].fetch_job(response.json["job_id"]) + job = current_app.queues["ingestion"].fetch_job(response.json["job"]) assert job.kwargs["sensor_id"] == sensor.id assert job.kwargs["sensor_data"] == post_data assert "data" not in job.kwargs diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py index 6928c452ab..b238d9f0f8 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py @@ -8,8 +8,13 @@ from rq.job import Job from sqlalchemy import select -from flexmeasures.api.common.responses import unknown_schedule, unrecognized_event +from flexmeasures.api.common.responses import ( + DEPRECATED_RESPONSE_FIELDS_HEADER, + unknown_schedule, + unrecognized_event, +) from flexmeasures.api.tests.utils import check_deprecation +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.v3_0.tests.utils import ( get_sensor_by_name, message_for_trigger_schedule, @@ -143,7 +148,7 @@ def test_trigger_schedule_preserves_flex_model_datetimes_in_job_kwargs( json=message, ) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 assert len(app.queues["scheduling"]) == 1 job = app.queues["scheduling"].jobs[0] @@ -184,7 +189,7 @@ def test_trigger_schedule_preserves_flex_model_datetimes_when_flooring_disabled( ) sensor.attributes = original_attributes - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 assert len(app.queues["scheduling"]) == 1 job = app.queues["scheduling"].jobs[0] @@ -297,8 +302,20 @@ def test_trigger_and_get_schedule_with_unknown_prices( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - check_deprecation(trigger_schedule_response, deprecation=None, sunset=None) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 + assert ( + trigger_schedule_response.headers["Deprecation"] + == JOB_RESPONSE_FIELDS_DEPRECATION_DATE + ) + assert 'rel="deprecation"' in trigger_schedule_response.headers["Link"] + assert ( + "post--api-v3_0-sensors-id-schedules-trigger" + in trigger_schedule_response.headers["Link"] + ) + assert ( + trigger_schedule_response.headers[DEPRECATED_RESPONSE_FIELDS_HEADER] + == "schedule" + ) job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue @@ -337,6 +354,47 @@ def test_trigger_and_get_schedule_with_unknown_prices( assert "prices unknown" in get_schedule_response.json["message"].lower() +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_get_schedule_unfinished_job_returns_202_when_sunset_active( + app, + add_battery_assets, + keep_scheduling_queue_empty, + requesting_user, +): + sensor = add_battery_assets["Test battery"].sensors[0] + original_sunset_active = app.config.get("FLEXMEASURES_API_SUNSET_ACTIVE") + app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = True + + with app.test_client() as client: + trigger_schedule_response = client.post( + url_for("SensorAPI:trigger_schedule", id=sensor.id), + json=message_for_trigger_schedule(), + ) + assert trigger_schedule_response.status_code == 202 + job_id = trigger_schedule_response.json["schedule"] + + get_schedule_response = client.get( + url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id), + ) + + assert get_schedule_response.status_code == 202 + assert get_schedule_response.json["status"] in {"QUEUED", "STARTED", "DEFERRED"} + assert "message" in get_schedule_response.json + + app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = False + with app.test_client() as client: + get_schedule_response_old = client.get( + url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id), + ) + + app.config["FLEXMEASURES_API_SUNSET_ACTIVE"] = original_sunset_active + + assert get_schedule_response_old.status_code == 400 + assert get_schedule_response_old.json["status"] == unknown_schedule()[0]["status"] + + @pytest.mark.parametrize( "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True ) @@ -410,7 +468,7 @@ def test_get_schedule_fallback( ) # check that the call is successful - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue @@ -567,7 +625,7 @@ def test_get_schedule_fallback_not_redirect( ) # check that the call is successful - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue @@ -655,7 +713,7 @@ def test_get_schedule_with_unit( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_schedule_response.status_code == 200, trigger_schedule_response.json + assert trigger_schedule_response.status_code == 202, trigger_schedule_response.json job_id = trigger_schedule_response.json["schedule"] # process the scheduling queue diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py index 4ee841a502..54b0d437ea 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules_fresh_db.py @@ -7,6 +7,8 @@ from rq.job import Job from unittest.mock import patch +from flexmeasures.api.common.responses import DEPRECATED_RESPONSE_FIELDS_HEADER +from flexmeasures.api.v3_0.deprecations import JOB_RESPONSE_FIELDS_DEPRECATION_DATE from flexmeasures.api.v3_0.tests.utils import message_for_trigger_schedule from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.models.planning.utils import get_power_values @@ -73,7 +75,26 @@ def test_trigger_and_get_schedule( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 + assert ( + trigger_schedule_response.headers["Deprecation"] + == JOB_RESPONSE_FIELDS_DEPRECATION_DATE + ) + assert 'rel="deprecation"' in trigger_schedule_response.headers["Link"] + assert ( + "post--api-v3_0-sensors-id-schedules-trigger" + in trigger_schedule_response.headers["Link"] + ) + assert ( + trigger_schedule_response.headers[DEPRECATED_RESPONSE_FIELDS_HEADER] + == "schedule" + ) + assert "deprecated-fields" not in trigger_schedule_response.json + assert trigger_schedule_response.json["results-url"] == url_for( + "SensorAPI:get_schedule", + id=sensor.id, + uuid=trigger_schedule_response.json["schedule"], + ) job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue @@ -263,7 +284,7 @@ def test_trigger_schedule_uses_state_of_charge_sensor_for_soc_at_start( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 job_id = trigger_schedule_response.json["schedule"] work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) @@ -481,7 +502,7 @@ def test_price_sensor_priority( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 # Patch TimedBelief.search method with patch.object( @@ -572,7 +593,7 @@ def test_inflexible_device_sensors_priority( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 with patch( "flexmeasures.data.models.planning.storage.get_power_values", @@ -653,7 +674,7 @@ def test_multiple_contracts( json=message, ) print("Server responded with:\n%s" % trigger_schedule_response.json) - assert trigger_schedule_response.status_code == 200 + assert trigger_schedule_response.status_code == 202 job_id = trigger_schedule_response.json["schedule"] # process the scheduling queue @@ -860,7 +881,7 @@ def test_get_schedule_sign_convention_json_flex_model( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) @@ -967,7 +988,7 @@ def test_get_schedule_sign_convention_db_flex_model( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, ) - assert trigger_response.status_code == 200 + assert trigger_response.status_code == 202 job_id = trigger_response.json["schedule"] work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) diff --git a/flexmeasures/api/v3_0/tests/test_sensors_api.py b/flexmeasures/api/v3_0/tests/test_sensors_api.py index d8d033ca16..aaccb5453e 100644 --- a/flexmeasures/api/v3_0/tests/test_sensors_api.py +++ b/flexmeasures/api/v3_0/tests/test_sensors_api.py @@ -366,10 +366,10 @@ def test_upload_csv_file_returns_accepted_job( assert response.status_code == 202 assert response.json["status"] == "ACCEPTED" - assert response.json["job_monitor_url"] == url_for( - "JobAPI:get_job_status", uuid=response.json["job_id"] + assert response.json["job-url"] == url_for( + "JobAPI:get_job_status", uuid=response.json["job"] ) - job = current_app.queues["ingestion"].fetch_job(response.json["job_id"]) + job = current_app.queues["ingestion"].fetch_job(response.json["job"]) assert job.kwargs["sensor_id"] == sensor.id assert job.kwargs["uploaded_files"][0]["filename"] == "test.csv" assert job.kwargs["uploaded_files"][0]["content"] == csv_content.encode("utf-8") diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 854f57305b..2864bb4bd7 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "1.0.0" + "version": "0.33.2" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -821,6 +821,40 @@ } } }, + "202": { + "description": "Scheduling job status while the job is still running", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "QUEUED", + "STARTED", + "DEFERRED" + ], + "description": "Processing status of the scheduling job." + }, + "message": { + "type": "string", + "description": "Human-readable message about current job processing." + } + } + }, + "examples": { + "started": { + "summary": "Scheduling job is still running", + "value": { + "status": "STARTED", + "message": "The scheduling job is currently running." + } + } + } + } + } + }, "400": { "description": "INVALID_TIMEZONE, INVALID_DOMAIN, UNKNOWN_SCHEDULE, UNRECOGNIZED_CONNECTION_GROUP" }, @@ -1353,32 +1387,73 @@ } }, "responses": { - "200": { - "description": "PROCESSED", + "202": { + "description": "ACCEPTED (Forecasting job queued for processing)", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "forecast" + } + } + }, "content": { "application/json": { "schema": { "type": "object", "properties": { - "forecast": { + "job": { "type": "string", - "description": "UUID of the queued forecasting job." + "description": "UUID of the queued forecasting job (canonical field; use this)." }, "status": { "type": "string", "enum": [ - "PROCESSED" + "ACCEPTED" ] }, "message": { "type": "string" + }, + "job-url": { + "type": "string", + "format": "uri", + "description": "URL to query the generic job status API." + }, + "results-url": { + "type": "string", + "format": "uri", + "description": "URL to fetch the forecast results for this job once it is complete." + }, + "forecast": { + "type": "string", + "deprecated": true, + "description": "(DEPRECATED) UUID of the queued forecasting job. Use `job` instead." } } }, "example": { - "forecast": "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b", - "status": "PROCESSED", - "message": "Forecasting job has been queued." + "status": "ACCEPTED", + "job": "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b", + "message": "Forecasting job has been queued.", + "job-url": "/api/v3_0/jobs/b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b", + "results-url": "/api/v3_0/sensors/3/forecasts/b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b", + "forecast": "b3d26a8a-7a43-4a9f-93e1-fc2a869ea97b" } } } @@ -1503,21 +1578,77 @@ } }, "responses": { - "200": { - "description": "PROCESSED", + "202": { + "description": "ACCEPTED (Scheduling job queued for processing)", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "schedule" + } + } + }, "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "job": { + "type": "string", + "description": "UUID of the queued scheduling job (canonical field; use this)." + }, + "status": { + "type": "string", + "enum": [ + "ACCEPTED" + ] + }, + "message": { + "type": "string" + }, + "job-url": { + "type": "string", + "format": "uri", + "description": "URL to query the generic job status API." + }, + "results-url": { + "type": "string", + "format": "uri", + "description": "URL to fetch the schedule results for this job once it is complete." + }, + "schedule": { + "type": "string", + "deprecated": true, + "description": "(DEPRECATED) UUID of the queued scheduling job. Use `job` instead." + } + } }, "examples": { "schedule_created": { "summary": "Schedule response", - "description": "This message indicates that the scheduling request has been processed without any error.\nA scheduling job has been created with some Universally Unique Identifier (UUID),\nwhich will be picked up by a worker.\nThe given UUID may be used to obtain the resulting schedule: see /sensors//schedules/.\n", + "description": "This message indicates that the scheduling request has been accepted for processing (202 Accepted).\nA scheduling job has been created with some Universally Unique Identifier (UUID),\nwhich will be picked up by a worker.\nThe given UUID is returned in the canonical `job` field.\nFor backward-compatibility, the legacy `schedule` field is also included but is deprecated;\nuse `job` instead.\nThe given UUID may be used to obtain the resulting schedule: see /sensors//schedules/.\n", "value": { - "status": "PROCESSED", + "status": "ACCEPTED", + "job": "364bfd06-c1fa-430b-8d25-8f5a547651fb", "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb", - "message": "Request has been processed." + "job-url": "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "results-url": "/api/v3_0/sensors/3/schedules/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "message": "Request has been accepted for processing." } } } @@ -4041,8 +4172,31 @@ } }, "responses": { - "200": { - "description": "PROCESSED", + "202": { + "description": "ACCEPTED (Scheduling job queued for processing)", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "schedule" + } + } + }, "content": { "application/json": { "schema": { @@ -4050,11 +4204,13 @@ }, "examples": { "successful_response": { - "description": "This message indicates that the scheduling request has been processed without any error.\nA scheduling job has been created with some Universally Unique Identifier (UUID),\nwhich will be picked up by a worker.\nThe given UUID may be used to obtain the resulting schedule for each flexible device: see [/sensors/schedules/](#/Sensors/get_api_v3_0_sensors__id__schedules__uuid_).\n", + "description": "This message indicates that the scheduling request has been accepted for processing (202 Accepted).\nA scheduling job has been created with some Universally Unique Identifier (UUID),\nwhich will be picked up by a worker.\nThe given UUID is returned in the canonical `job` field.\nFor backward-compatibility, the legacy `schedule` field is also included but is deprecated;\nuse `job` instead.\nThe given UUID may be used to obtain the resulting schedule for each flexible device via [/sensors/schedules/](#/Sensors/get_api_v3_0_sensors__id__schedules__uuid_).\n", "value": { - "status": "PROCESSED", + "status": "ACCEPTED", + "job": "364bfd06-c1fa-430b-8d25-8f5a547651fb", "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb", - "message": "Request has been processed." + "job-url": "/api/v3_0/jobs/364bfd06-c1fa-430b-8d25-8f5a547651fb", + "message": "Request has been accepted for processing." } } } @@ -4290,7 +4446,30 @@ ], "responses": { "200": { - "description": "Job status retrieved successfully.", + "description": "Finished job status retrieved successfully.", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "func_name, enqueued_at, started_at, ended_at, exc_info" + } + } + }, "content": { "application/json": { "schema": { @@ -4318,7 +4497,7 @@ "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-beliefs field holds the total number of beliefs (scheduled values) saved to the database.\n", "nullable": true }, - "func_name": { + "func-name": { "type": "string", "description": "Fully-qualified name of the function executed by this job." }, @@ -4326,28 +4505,60 @@ "type": "string", "description": "Name of the queue the job was placed on." }, - "enqueued_at": { + "enqueued-at": { "type": "string", "format": "date-time", "nullable": true, "description": "ISO-8601 timestamp of when the job was enqueued." }, - "started_at": { + "started-at": { "type": "string", "format": "date-time", "nullable": true, "description": "ISO-8601 timestamp of when the job started executing." }, - "ended_at": { + "ended-at": { "type": "string", "format": "date-time", "nullable": true, "description": "ISO-8601 timestamp of when the job finished executing." }, - "exc_info": { + "exc-info": { "type": "string", "nullable": true, "description": "Traceback information for failed jobs, or null otherwise." + }, + "func_name": { + "type": "string", + "deprecated": true, + "description": "(DEPRECATED) Fully-qualified name of the function executed by this job. Use `func-name` instead." + }, + "enqueued_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "deprecated": true, + "description": "(DEPRECATED) ISO-8601 timestamp of when the job was enqueued. Use `enqueued-at` instead." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "deprecated": true, + "description": "(DEPRECATED) ISO-8601 timestamp of when the job started executing. Use `started-at` instead." + }, + "ended_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "deprecated": true, + "description": "(DEPRECATED) ISO-8601 timestamp of when the job finished executing. Use `ended-at` instead." + }, + "exc_info": { + "type": "string", + "nullable": true, + "deprecated": true, + "description": "(DEPRECATED) Traceback information for failed jobs, or null otherwise. Use `exc-info` instead." } } }, @@ -4358,8 +4569,13 @@ "status": "QUEUED", "message": "Scheduling job waiting to be processed.", "result": null, - "func_name": "flexmeasures.data.services.scheduling.create_schedule", + "func-name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", + "enqueued-at": "2026-04-28T10:00:00+00:00", + "started-at": null, + "ended-at": null, + "exc-info": null, + "func_name": "flexmeasures.data.services.scheduling.create_schedule", "enqueued_at": "2026-04-28T10:00:00+00:00", "started_at": null, "ended_at": null, @@ -4390,12 +4606,12 @@ "resolved": [], "num-beliefs": 96 }, - "func_name": "flexmeasures.data.services.scheduling.create_schedule", + "func-name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", - "enqueued_at": "2026-04-28T10:00:00+00:00", - "started_at": "2026-04-28T10:00:01+00:00", - "ended_at": "2026-04-28T10:00:05+00:00", - "exc_info": null + "enqueued-at": "2026-04-28T10:00:00+00:00", + "started-at": "2026-04-28T10:00:01+00:00", + "ended-at": "2026-04-28T10:00:05+00:00", + "exc-info": null } }, "failed": { @@ -4404,18 +4620,70 @@ "status": "FAILED", "message": "Scheduling job failed with ValueError: ...", "result": null, - "func_name": "flexmeasures.data.services.scheduling.create_schedule", + "func-name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", - "enqueued_at": "2026-04-28T10:00:00+00:00", - "started_at": "2026-04-28T10:00:01+00:00", - "ended_at": "2026-04-28T10:00:02+00:00", - "exc_info": "Traceback (most recent call last): ..." + "enqueued-at": "2026-04-28T10:00:00+00:00", + "started-at": "2026-04-28T10:00:01+00:00", + "ended-at": "2026-04-28T10:00:02+00:00", + "exc-info": "Traceback (most recent call last): ..." } } } } } }, + "202": { + "description": "Job is still queued, scheduled, deferred, or running.", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "func_name, enqueued_at, started_at, ended_at, exc_info" + } + } + } + }, + "422": { + "description": "Job has failed.", + "headers": { + "Deprecation": { + "description": "Indicates that the response contains deprecated fields.", + "schema": { + "type": "string", + "example": "Wed, 01 Jul 2026 00:00:00 GMT" + } + }, + "Link": { + "description": "Link to migration guidance for deprecated response fields.", + "schema": { + "type": "string", + "example": "; rel=\"deprecation\"; type=\"text/html\"" + } + }, + "FlexMeasures-Deprecated-Response-Fields": { + "description": "Comma-separated response fields that are deprecated.", + "schema": { + "type": "string", + "example": "func_name, enqueued_at, started_at, ended_at, exc_info" + } + } + } + }, "404": { "description": "NOT_FOUND" },