diff --git a/.github/ci_config.yml b/.github/ci_config.yml index 8ce72f50..67a26358 100644 --- a/.github/ci_config.yml +++ b/.github/ci_config.yml @@ -4,6 +4,8 @@ app: # API will auto-reload when changes on code files are detected reload: true url_prefix: "" + maintenance_file: operationsgateway_api/maintenance.json.example + scheduled_maintenance_file: operationsgateway_api/scheduled_maintenance.json.example images: thumbnail_size: [50, 50] default_colour_map: viridis diff --git a/operationsgateway_api/config.yml.example b/operationsgateway_api/config.yml.example index 4f369e74..60273540 100644 --- a/operationsgateway_api/config.yml.example +++ b/operationsgateway_api/config.yml.example @@ -6,6 +6,8 @@ app: # API will auto-reload when changes on code files are detected reload: true url_prefix: "" + maintenance_file: operationsgateway_api/maintenance.json.example + scheduled_maintenance_file: operationsgateway_api/scheduled_maintenance.json.example images: # Thumbnail sizes should only ever be two element lists, of a x, y resolution thumbnail_size: [50, 50] diff --git a/operationsgateway_api/maintenance.json.example b/operationsgateway_api/maintenance.json.example new file mode 100644 index 00000000..09029b4a --- /dev/null +++ b/operationsgateway_api/maintenance.json.example @@ -0,0 +1,4 @@ +{ + "show": false, + "message": "" +} \ No newline at end of file diff --git a/operationsgateway_api/scheduled_maintenance.json.example b/operationsgateway_api/scheduled_maintenance.json.example new file mode 100644 index 00000000..65d3b08a --- /dev/null +++ b/operationsgateway_api/scheduled_maintenance.json.example @@ -0,0 +1,5 @@ +{ + "show": false, + "message": "", + "severity": "info" +} \ No newline at end of file diff --git a/operationsgateway_api/src/config.py b/operationsgateway_api/src/config.py index 9038e129..cd4d3cca 100644 --- a/operationsgateway_api/src/config.py +++ b/operationsgateway_api/src/config.py @@ -7,6 +7,7 @@ from pydantic import ( BaseModel, field_validator, + FilePath, StrictBool, StrictInt, StrictStr, @@ -23,6 +24,8 @@ class App(BaseModel): port: Optional[StrictInt] = None reload: Optional[StrictBool] = None url_prefix: StrictStr + maintenance_file: FilePath + scheduled_maintenance_file: FilePath class ImagesConfig(BaseModel): diff --git a/operationsgateway_api/src/main.py b/operationsgateway_api/src/main.py index 6cb3fb99..f6584e33 100644 --- a/operationsgateway_api/src/main.py +++ b/operationsgateway_api/src/main.py @@ -25,6 +25,7 @@ functions, images, ingest_data, + maintenance, records, sessions, user_preferences, @@ -135,6 +136,7 @@ def add_router_to_app(api_router: APIRouter): add_router_to_app(users.router) add_router_to_app(functions.router) add_router_to_app(filters.router) +add_router_to_app(maintenance.router) log.debug("ROUTE_MAPPINGS contents:") for item in ROUTE_MAPPINGS.items(): diff --git a/operationsgateway_api/src/models.py b/operationsgateway_api/src/models.py index 1094cdf9..4177c807 100644 --- a/operationsgateway_api/src/models.py +++ b/operationsgateway_api/src/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import StrEnum from typing import Any, Callable, ClassVar, Dict, List, Literal, Optional, Union from bson.objectid import ObjectId @@ -268,3 +269,17 @@ class FavouriteFilterModel(BaseModel): class Function(BaseModel): name: constr(strip_whitespace=True, min_length=1) expression: constr(strip_whitespace=True, min_length=1) + + +class Severity(StrEnum): + INFO = "info" + WARNING = "warning" + + +class MaintenanceModel(BaseModel): + show: bool + message: str + + +class ScheduledMaintenanceModel(MaintenanceModel): + severity: Severity diff --git a/operationsgateway_api/src/routes/maintenance.py b/operationsgateway_api/src/routes/maintenance.py new file mode 100644 index 00000000..6f8ceee7 --- /dev/null +++ b/operationsgateway_api/src/routes/maintenance.py @@ -0,0 +1,79 @@ +from functools import lru_cache +import json +import logging + +from fastapi import APIRouter, Depends +from typing_extensions import Annotated + +from operationsgateway_api.src.auth.authorisation import authorise_route +from operationsgateway_api.src.config import Config +from operationsgateway_api.src.models import MaintenanceModel, ScheduledMaintenanceModel + + +log = logging.getLogger() +router = APIRouter() +AuthoriseRoute = Annotated[str, Depends(authorise_route)] + + +@router.get( + "/maintenance", + summary="Get the current maintenance message and whether to show it", + response_description="The current maintenance message and whether to show it", + tags=["Maintenance"], +) +def get_maintenance(access_token: AuthoriseRoute) -> MaintenanceModel: + return _get_maintenance() + + +@router.post( + "/maintenance", + summary="Set the current maintenance message and whether to show it", + tags=["Maintenance"], +) +def set_maintenance( + access_token: AuthoriseRoute, + maintenance_body: MaintenanceModel, +) -> None: + with open(Config.config.app.maintenance_file, "w") as f: + f.write(maintenance_body.model_dump_json()) + _get_maintenance.cache_clear() + + +@router.get( + "/maintenance/scheduled", + summary="Get the current scheduled maintenance message and whether to show it", + response_description=( + "The current scheduled maintenance message and whether to show it" + ), + tags=["Maintenance"], +) +def get_scheduled_maintenance( + access_token: AuthoriseRoute, +) -> ScheduledMaintenanceModel: + return _get_scheduled_maintenance() + + +@router.post( + "/maintenance/scheduled", + summary="Set the current scheduled maintenance message and whether to show it", + tags=["Maintenance"], +) +def set_scheduled_maintenance( + access_token: AuthoriseRoute, + maintenance_body: ScheduledMaintenanceModel, +) -> None: + with open(Config.config.app.scheduled_maintenance_file, "w") as f: + f.write(maintenance_body.model_dump_json()) + _get_scheduled_maintenance.cache_clear() + + +@lru_cache +def _get_maintenance() -> dict: + with open(Config.config.app.maintenance_file, "rb") as f: + return json.load(f) + + +@lru_cache +def _get_scheduled_maintenance() -> dict: + with open(Config.config.app.scheduled_maintenance_file, "rb") as f: + return json.load(f) diff --git a/operationsgateway_api/src/users/user.py b/operationsgateway_api/src/users/user.py index b02c4e5b..ef97d3b3 100644 --- a/operationsgateway_api/src/users/user.py +++ b/operationsgateway_api/src/users/user.py @@ -20,6 +20,10 @@ class User: "/users PATCH", "/users GET", "/users/{id_} DELETE", + "/maintenance GET" + "/maintenance POST" + "/maintenance/scheduled GET" + "/maintenance/scheduled POST", ] auth_type_list = [ diff --git a/test/endpoints/test_get_users.py b/test/endpoints/test_get_users.py index 451a2669..89414738 100644 --- a/test/endpoints/test_get_users.py +++ b/test/endpoints/test_get_users.py @@ -28,6 +28,10 @@ async def test_get_users_success( "/users PATCH", "/users/{id_} DELETE", "/users GET", + "/maintenance GET", + "/maintenance POST", + "/maintenance/scheduled GET", + "/maintenance/scheduled POST", ], }, ] diff --git a/test/endpoints/test_maintenance.py b/test/endpoints/test_maintenance.py new file mode 100644 index 00000000..355131d0 --- /dev/null +++ b/test/endpoints/test_maintenance.py @@ -0,0 +1,105 @@ +import json +import tempfile +from unittest.mock import patch + +from fastapi.testclient import TestClient + + +class TestMaintenance: + """ + Both test combine the GET and POST requests in order to test the cache is cleared + after POSTing a new message. + """ + + def test_maintenance(self, test_app: TestClient, login_and_get_token: str): + initial_content = {"show": False, "message": ""} + updated_content = {"show": True, "message": "message"} + target = "operationsgateway_api.src.config.Config.config.app.maintenance_file" + tmp_file = tempfile.NamedTemporaryFile() + with patch(target, tmp_file.name): + # POST the initial contents (we are using a tmpfile, which will start empty) + response = test_app.post( + url="/maintenance", + content=json.dumps(initial_content), + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) is None + assert json.load(tmp_file) == initial_content + tmp_file.seek(0) # Reset so we can read again later + + # Calling GET will save the return value to cache + response = test_app.get( + url="/maintenance", + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) == initial_content + + # Calling POST will clear the cache after writing to file + response = test_app.post( + url="/maintenance", + content=json.dumps(updated_content), + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) is None + assert json.load(tmp_file) == updated_content + + # Cache has been cleared so should get the new message back + response = test_app.get( + url="/maintenance", + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) == updated_content + + def test_scheduled_maintenance( + self, + test_app: TestClient, + login_and_get_token: str, + ): + initial_content = {"show": False, "message": "", "severity": "info"} + updated_content = {"show": True, "message": "message", "severity": "warning"} + target = ( + "operationsgateway_api.src.config.Config.config.app." + "scheduled_maintenance_file" + ) + tmp_file = tempfile.NamedTemporaryFile() + with patch(target, tmp_file.name): + # POST the initial contents (we are using a tmpfile, which will start empty) + response = test_app.post( + url="/maintenance/scheduled", + content=json.dumps(initial_content), + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) is None + assert json.load(tmp_file) == initial_content + tmp_file.seek(0) # Reset so we can read again later + + # Calling GET will save the return value to cache + response = test_app.get( + url="/maintenance/scheduled", + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) == initial_content + + # Calling POST will clear the cache after writing to file + response = test_app.post( + url="/maintenance/scheduled", + content=json.dumps(updated_content), + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) is None + assert json.load(tmp_file) == updated_content + + # Cache has been cleared so should get the new message back + response = test_app.get( + url="/maintenance/scheduled", + headers={"Authorization": f"Bearer {login_and_get_token}"}, + ) + assert response.status_code == 200 + assert json.loads(response.content) == updated_content diff --git a/util/og_api_postman_collection.json b/util/og_api_postman_collection.json index 150f081a..db1e2ff7 100644 --- a/util/og_api_postman_collection.json +++ b/util/og_api_postman_collection.json @@ -2053,6 +2053,139 @@ "response": [] } ] + }, + { + "name": "Maintenance", + "item": [ + { + "name": "Get Maintenance", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{og-access-token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{og-api}}/maintenance", + "host": [ + "{{og-api}}" + ], + "path": [ + "maintenance" + ] + } + }, + "response": [] + }, + { + "name": "Set Maintenance", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{og-access-token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"show\": true,\r\n \"message\": \"message\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{og-api}}/maintenance", + "host": [ + "{{og-api}}" + ], + "path": [ + "maintenance" + ] + } + }, + "response": [] + }, + { + "name": "Get Scheduled Maintenance", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{og-access-token}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "{{og-api}}/maintenance/scheduled", + "host": [ + "{{og-api}}" + ], + "path": [ + "maintenance", + "scheduled" + ] + } + }, + "response": [] + }, + { + "name": "Set Scheduled Maintenance", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{og-access-token}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"show\": true,\r\n \"message\": \"message\",\r\n \"severity\": \"warning\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{og-api}}/maintenance/scheduled", + "host": [ + "{{og-api}}" + ], + "path": [ + "maintenance", + "scheduled" + ] + } + }, + "response": [] + } + ] } ], "event": [ diff --git a/util/users_for_mongoimport.json b/util/users_for_mongoimport.json index d8f9c9ec..d576d0ec 100644 --- a/util/users_for_mongoimport.json +++ b/util/users_for_mongoimport.json @@ -2,7 +2,7 @@ { "_id" : "xfu59478", "auth_type" : "FedID" } { "_id" : "dgs12138", "auth_type" : "FedID" } { "_id" : "frontend", "auth_type" : "local", "sha256_password" : "2d8d693177ac44895fc02c009ec3f6af32e51eb00783c17000d7051d1662b93a" } -{ "_id" : "backend", "auth_type" : "local", "sha256_password" : "3c482346f375027677fa8a0d6830a32714d4f13f9e94c2d9e215e0ac205ad4e5", "authorised_routes" : [ "/submit/hdf POST", "/submit/manifest POST", "/records/{id_} DELETE", "/experiments POST", "/users POST", "/users GET", "/users PATCH", "/users/{id_} DELETE" ] } +{ "_id" : "backend", "auth_type" : "local", "sha256_password" : "3c482346f375027677fa8a0d6830a32714d4f13f9e94c2d9e215e0ac205ad4e5", "authorised_routes" : [ "/submit/hdf POST", "/submit/manifest POST", "/records/{id_} DELETE", "/experiments POST", "/users POST", "/users GET", "/users PATCH", "/users/{id_} DELETE", "/maintenance GET", "/maintenance POST", "/maintenance/scheduled GET", "/maintenance/scheduled POST" ] } { "_id" : "hdf_import", "auth_type" : "local", "sha256_password" : "d942f64886578d8747312e368ed92d9f6b2a8d45556f0f924e2444fe911d15af", "authorised_routes" : [ "/submit/hdf POST", "/submit/manifest POST" ] } { "_id" : "no_auth_type_user" } { "_id" : "invalid_auth_type_user", "auth_type" : "Invalid" }