diff --git a/alembic/versions/49ef39173f83_add_components_json_field_to_jira_.py b/alembic/versions/49ef39173f83_add_components_json_field_to_jira_.py new file mode 100644 index 0000000..c2f599d --- /dev/null +++ b/alembic/versions/49ef39173f83_add_components_json_field_to_jira_.py @@ -0,0 +1,42 @@ +"""add components_json field to jira_fields table + +Revision ID: 49ef39173f83 +Revises: 54f755dbdb6f +Create Date: 2024-12-18 17:01:39.676895 + +""" +import logging + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "49ef39173f83" +down_revision = "54f755dbdb6f" +branch_labels = None +depends_on = None + + +JIRA_FIELDS_TABLE_NAME = "jira_fields" + + +def upgrade(log: logging.Logger, table_names: set[str]) -> None: + if JIRA_FIELDS_TABLE_NAME not in table_names: + log.info(f"No {JIRA_FIELDS_TABLE_NAME} table; nothing to do") + return + log.info("Add 'components_json'") + + op.add_column( + JIRA_FIELDS_TABLE_NAME, + sa.Column("components_json", sa.JSON(), nullable=True), + ) + + +def downgrade(log: logging.Logger, table_names: set[str]) -> None: + if JIRA_FIELDS_TABLE_NAME not in table_names: + log.info(f"No {JIRA_FIELDS_TABLE_NAME} table; nothing to do") + return + + log.info("Drop 'components_json'") + op.drop_column(JIRA_FIELDS_TABLE_NAME, "components_json") diff --git a/src/narrativelog/create_tables.py b/src/narrativelog/create_tables.py index 9ed05c0..5b95d57 100644 --- a/src/narrativelog/create_tables.py +++ b/src/narrativelog/create_tables.py @@ -8,7 +8,7 @@ import sqlalchemy as sa import sqlalchemy.types as saty -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID # Length of the site_id field. SITE_ID_LEN = 16 @@ -60,8 +60,14 @@ def create_message_table(metadata: sa.MetaData) -> sa.Table: sa.Column("date_invalidated", saty.DateTime(), nullable=True), sa.Column("parent_id", UUID(as_uuid=True), nullable=True), # Added 2022-07-19 + # 'systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead sa.Column("systems", saty.ARRAY(sa.Text), nullable=True), + # 'subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead sa.Column("subsystems", saty.ARRAY(sa.Text), nullable=True), + # 'cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead sa.Column("cscs", saty.ARRAY(sa.Text), nullable=True), # Added 2022-07-37 sa.Column("date_end", saty.DateTime(), nullable=True), @@ -110,10 +116,18 @@ def create_jira_fields_table(metadata: sa.MetaData) -> sa.Table: sa.Column( "id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ), + # Added 2024-12-16 + sa.Column("components_json", JSONB, nullable=True), + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead sa.Column("components", saty.ARRAY(sa.Text), nullable=True), + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead sa.Column( "primary_software_components", saty.ARRAY(sa.Text), nullable=True ), + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead sa.Column( "primary_hardware_components", saty.ARRAY(sa.Text), nullable=True ), diff --git a/src/narrativelog/main.py b/src/narrativelog/main.py index c5931fc..7b7a56e 100644 --- a/src/narrativelog/main.py +++ b/src/narrativelog/main.py @@ -10,6 +10,7 @@ find_messages, get_configuration, get_message, + get_version, ) app = fastapi.FastAPI() @@ -27,6 +28,7 @@ subapp.include_router(find_messages.router) subapp.include_router(get_configuration.router) subapp.include_router(get_message.router) +subapp.include_router(get_version.router) @subapp.get("/", response_class=fastapi.responses.HTMLResponse) diff --git a/src/narrativelog/message.py b/src/narrativelog/message.py index d009a4e..1bc3c83 100644 --- a/src/narrativelog/message.py +++ b/src/narrativelog/message.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field +from .utils import JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK + class Message(BaseModel): id: uuid.UUID = Field(title="Message ID: a UUID that is the primary key.") @@ -46,13 +48,21 @@ class Message(BaseModel): ) # Added 2022-07-19 systems: None | list[str] = Field( - title="Zero or more system names.", + title="Zero or more system names. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", + ) + subsystems: None | list[str] = Field( + title="Zero or more subsystem names. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", ) - subsystems: None | list[str] = Field(title="Zero or more subsystem names.") cscs: None | list[str] = Field( title="Zero or more CSCs names. " "Each entry should be in the form 'name' or 'name:index', " - "where 'name' is the SAL component name and 'index' is the SAL index." + "where 'name' is the SAL component name and 'index' is the SAL index. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", ) # Added 2022-07-27 date_end: None | datetime.datetime = Field( @@ -61,15 +71,21 @@ class Message(BaseModel): # Added 2023-08-10 components: None | list[str] = Field( title="Zero or more component names. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", ) primary_software_components: None | list[str] = Field( title="Zero or more primary software component names. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", ) primary_hardware_components: None | list[str] = Field( title="Zero or more primary hardware component names. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "This field is deprecated and will be removed in v1.0.0. " + "Please use 'components_json' instead.", ) # Added 2023-10-24 category: None | str = Field( @@ -78,6 +94,30 @@ class Message(BaseModel): time_lost_type: None | str = Field( title="Type of time lost.", ) + # Added 2024-12-16 + components_json: None | dict = Field( + default_factory=dict, + title=""" +JSON representation of systems, subsystems and components hierarchy + on the OBS jira project. An example of a valid payload is: + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome", + "children": [ + { + "name": "AT Azimuth Drives" + } + ] + } + ] + } + +For a full list of valid systems, subsystems and components please refer to: """ + f"""{JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK}.""", + ) class Config: orm_mode = True @@ -85,9 +125,16 @@ class Config: JIRA_FIELDS = ( + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead "components", + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_software_components", + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_hardware_components", + "components_json", ) MESSAGE_FIELDS = tuple( set(Message.schema()["properties"].keys()) - set(JIRA_FIELDS) diff --git a/src/narrativelog/routers/add_message.py b/src/narrativelog/routers/add_message.py index 74c41c7..948a722 100644 --- a/src/narrativelog/routers/add_message.py +++ b/src/narrativelog/routers/add_message.py @@ -9,6 +9,7 @@ from ..message import Message from ..shared_state import SharedState, get_shared_state +from ..utils import JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK from .normalize_tags import TAG_DESCRIPTION, normalize_tags router = fastapi.APIRouter() @@ -41,37 +42,73 @@ async def add_message( systems: None | list[str] = fastapi.Body( default=None, - description="Zero or more systems to which the message applies.", + description="Zero or more systems to which the message applies. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), subsystems: None | list[str] = fastapi.Body( default=None, - description="Zero or more subsystems to which the message applies", + description="Zero or more subsystems to which the message applies. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), cscs: None | list[str] = fastapi.Body( default=None, description="Zero or more CSCs to which the message applies. " "Each entry should be in the form 'name' or 'name:index', " - "where 'name' is the SAL component name and 'index' is the SAL index.", + "where 'name' is the SAL component name and 'index' is the SAL index. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), components: None | list[str] = fastapi.Body( default=None, description="Zero or more components to which the message applies. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), primary_software_components: None | list[str] = fastapi.Body( default=None, description="Primary software components to which the message applies. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), primary_hardware_components: None | list[str] = fastapi.Body( default=None, description="Primary hardware components to which the message applies. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", + ), + components_json: None + | dict = fastapi.Body( + default=None, + description=""" +JSON representation of systems, subsystems and components hierarchy + on the OBS jira project. An example of a valid payload is: + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome", + "children": [ + { + "name": "AT Azimuth Drives" + } + ] + } + ] + } + +For a full list of valid systems, subsystems and components please refer to: """ + f"""{JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK}.""", ), urls: list[str] = fastapi.Body( default=[], @@ -147,8 +184,14 @@ async def add_message( user_agent=user_agent, is_human=is_human, date_added=curr_tai.tai.datetime, + # 'systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead systems=systems, + # 'subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead subsystems=subsystems, + # 'cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead cscs=cscs, category=category, time_lost_type=time_lost_type, @@ -166,17 +209,33 @@ async def add_message( if any( field is not None for field in ( + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead components, + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead primary_software_components, + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead primary_hardware_components, + components_json, ) ): result_jira_fields = await connection.execute( jira_fields_table.insert() .values( + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead components=components, + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. + # Please use 'components_json' instead primary_software_components=primary_software_components, + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. + # Please use 'components_json' instead primary_hardware_components=primary_hardware_components, + components_json=components_json, message_id=row_message.id, ) .returning(sa.literal_column("*")) diff --git a/src/narrativelog/routers/edit_message.py b/src/narrativelog/routers/edit_message.py index a1efe7c..c9a1e7e 100644 --- a/src/narrativelog/routers/edit_message.py +++ b/src/narrativelog/routers/edit_message.py @@ -44,13 +44,17 @@ async def edit_message( | list[str] = fastapi.Body( default=None, description="Zero or more systems to which the message applied. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), subsystems: None | list[str] = fastapi.Body( default=None, description="Zero or more subsystems to which the message applies. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), cscs: None | list[str] = fastapi.Body( @@ -58,28 +62,49 @@ async def edit_message( description="Zero or more CSCs to which the message applies. " "Each entry should be in the form 'name' or 'name:index', " "where 'name' is the SAL component name and 'index' is the SAL index. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), components: None | list[str] = fastapi.Body( default=None, description="Zero or more components to which the message applies. " "Each entry should be a valid component name entry on the OBS jira project. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), primary_software_components: None | list[str] = fastapi.Body( default=None, description="Primary software components to which the message applies. " "Each entry should be a valid component name entry on the OBS jira project. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", ), primary_hardware_components: None | list[str] = fastapi.Body( default=None, description="Primary hardware components to which the message applies. " "Each entry should be a valid component name entry on the OBS jira project. " - "If specified, replaces all existing entries.", + "If specified, replaces all existing entries." + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_json' instead.", + ), + components_json: None + | dict = fastapi.Body( + default=None, + description="JSON representation of systems, subsystems and components " + "on the OBS jira project. An example of a valid payload is: " + '`{"systems": ["Simonyi", "AuxTel"], ' + '{"subsystems": ["TMA", "Mount"], ' + '{"components": ["MTMount CSC"]}`. ' + "For a full list of valid systems, subsystems and components " + "please refer to: [Systems, Sub-Systems and Components Proposal " + "for the OBS Jira project](https://rubinobs.atlassian.net/wiki/spaces/LSSTCOM/" + "pages/53741849/Systems+Sub-Systems+and+Components+Proposal+for+JIRA)", ), urls: None | list[str] = fastapi.Body( @@ -143,12 +168,25 @@ async def edit_message( "message_text", "level", "tags", + # 'systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead "systems", + # 'subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead "subsystems", + # 'cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_json' instead "cscs", + # 'components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "components", + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_software_components", + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_hardware_components", + "components_json", "category", "time_lost_type", "urls", @@ -165,9 +203,16 @@ async def edit_message( request_data[name] = value jira_update_params = { + # 'components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "components", + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_software_components", + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_json' instead "primary_hardware_components", + "components_json", } async with state.narrativelog_db.engine.begin() as connection: diff --git a/src/narrativelog/routers/find_messages.py b/src/narrativelog/routers/find_messages.py index e345e88..64337bd 100644 --- a/src/narrativelog/routers/find_messages.py +++ b/src/narrativelog/routers/find_messages.py @@ -3,12 +3,14 @@ import datetime import enum import http +import json import fastapi import sqlalchemy as sa from ..message import MESSAGE_ORDER_BY_VALUES, Message from ..shared_state import SharedState, get_shared_state +from ..utils import JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK from .normalize_tags import TAG_DESCRIPTION, normalize_tags router = fastapi.APIRouter() @@ -101,42 +103,54 @@ async def find_messages( default=None, description="System names or fragments of names. All messages " "with a system that matches any of these are included. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_systems: None | list[str] = fastapi.Query( default=None, description="System names or fragments of names. All messages " "with a system that matches any of these are excluded. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), subsystems: None | list[str] = fastapi.Query( default=None, description="Subsystem names or fragments of names. All messages " "with a subsystem that matches any of these are included. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_subsystems: None | list[str] = fastapi.Query( default=None, description="Subsystem names or fragments of names. All messages " "with a subsystem that matches any of these are excluded. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), cscs: None | list[str] = fastapi.Query( default=None, description="CSC names or fragments of CSC names, " "of which at least one must be present. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_cscs: None | list[str] = fastapi.Query( default=None, description="CSC names or fragments of CSC names, " "of which all must be absent. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), components: None | list[str] = fastapi.Query( @@ -144,7 +158,9 @@ async def find_messages( description="Component names or fragments of names. All messages " "with a component that matches any of these are included. " "Repeat the parameter for each value. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_components: None | list[str] = fastapi.Query( @@ -152,7 +168,9 @@ async def find_messages( description="Component names or fragments of names. All messages " "with a component that matches any of these are excluded. " "Repeat the parameter for each value. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), primary_software_components: None | list[str] = fastapi.Query( @@ -160,7 +178,9 @@ async def find_messages( description="Primary software components names or fragments of names. " "All messages with a component that matches any of these are included. " "Repeat the parameter for each value. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_primary_software_components: None | list[str] = fastapi.Query( @@ -168,7 +188,9 @@ async def find_messages( description="Primary software components names or fragments of names. " "All messages with a component that matches any of these are excluded. " "Repeat the parameter for each value. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), primary_hardware_components: None | list[str] = fastapi.Query( @@ -176,14 +198,171 @@ async def find_messages( description="Primary hardware components names or fragments of names. " "All messages with a component that matches any of these are included. " "Repeat the parameter for each value. " - "Each entry should be a valid component name entry on the OBS jira project.", + "Each entry should be a valid component name entry on the OBS jira project. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", ), exclude_primary_hardware_components: None | list[str] = fastapi.Query( default=None, description="Primary hardware components names or fragments of names. " "All messages with a component that matches any of these are excluded. " - "Repeat the parameter for each value.", + "Repeat the parameter for each value. " + "**This field is deprecated and will be removed in v1.0.0**. " + "Please use 'components_path' instead.", + ), + components_path: None + | list[str] = fastapi.Query( + default=None, + description=""" +List of strings with components structure in JSON format to include. + Matches all messages with at least a JSON path included in the "components_path" list. + Each element in the list is a JSON string as the object that represents the current + hierarchy of systems, subsystems and components on the OBS Jira project: + `System -> Subsystem -> Component` + (check """ + f"""{JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK}""" + """ for valid hierarchies). + E.g. Setting a "components_path" string (you can add multiple) to + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome", + "children": [ + { + "name": "AT Azimuth Drives" + } + ] + } + ] + } + +will match all messages that have "AuxTel" as system AND + "Dome" as subsystem of "AuxTel" AND + "AT Azimuth Drives" as component of "Dome". + Note that setting "components_path" to + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome" + }, + { + "name": "Mount" + } + ] + } + +Is the same as setting it to + + { + "name": "AuxTel", + "children": [ + { + "name": "Mount" + }, + { + "name": "Dome" + } + ] + } + +In other words, the order of the children is not important. + Also setting a `children` key as `[]` is the same as not defining it + (will be ignored). Furthermore setting a "components_path" string + to `{}` will have no effect and any invalid JSON will raise a 400 error. + Take in mind that values are case senstive. + +All defined JSON strings will be joined with an OR operator. + E.g. setting two "components_path" as + + { "name": "AuxTel" } + +and + + { "name": "Simonyi" } + +Will match all messages that have "AuxTel" OR "Simonyi" as system.""", + ), + exclude_components_path: None + | list[str] = fastapi.Query( + default=None, + description=""" +List of strings with components structure in JSON format to exclude. + Exclude all messages with at least a JSON path + included in the "exclude_components_path" list. + Each element in the list is a JSON string as the object that represents the current + hierarchy of systems, subsystems and components on the OBS Jira project: + `System -> Subsystem -> Component` + (check """ + f"""{JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK}""" + """ for valid hierarchies). + E.g. Setting a "exclude_components_path" string (you can add multiple) to + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome", + "children": [ + { + "name": "AT Azimuth Drives" + } + ] + } + ] + } + +will exclude all messages that have "AuxTel" as system AND + "Dome" as subsystem of "AuxTel" AND + "AT Azimuth Drives" as component of "Dome". + Note that setting "exclude_components_path" to + + { + "name": "AuxTel", + "children": [ + { + "name": "Dome" + }, + { + "name": "Mount" + } + ] + } + +Is the same as setting it to + + { + "name": "AuxTel", + "children": [ + { + "name": "Mount" + }, + { + "name": "Dome" + } + ] + } + +In other words, the order of the children is not important. + Also setting a `children` key as `[]` is the same as not defining it + (will be ignored). Furthermore setting a "exclude_components_path" string + to `{}` will have no effect and any invalid JSON will raise a 400 error. + Take in mind that values are case senstive. + +All defined JSON strings will be joined with an OR operator. + E.g. setting two "exclude_components_path" as + + { "name": "AuxTel" } + +and + + { "name": "Simonyi" } + +Will exclude all messages that have "AuxTel" OR "Simonyi" as system.""", ), urls: None | list[str] = fastapi.Query( @@ -299,18 +478,44 @@ async def find_messages( "max_level", "user_ids", "user_agents", + # 'systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "systems", + # 'exclude_systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "exclude_systems", + # 'subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "subsystems", + # 'exclude_subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "exclude_subsystems", + # 'cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "cscs", + # 'exclude_cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "exclude_cscs", + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "components", + # 'exclude_components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "exclude_components", + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "primary_software_components", + # 'exclude_primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_primary_software_components", + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "primary_hardware_components", + # 'exclude_primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_primary_hardware_components", + "components_path", + "exclude_components_path", "tags", "exclude_tags", "urls", @@ -386,8 +591,14 @@ async def find_messages( conditions.append(column == None) # noqa elif key in { "tags", + # 'systems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "systems", + # 'subsystems' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "subsystems", + # 'cscs' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "cscs", "urls", }: @@ -406,16 +617,28 @@ async def find_messages( column = message_table.columns[key] conditions.append(column.op("&&")(value)) elif key in { + # 'components' field is deprecated and will be removed in v1.0.0. + # Please use 'components_path' instead "components", + # 'primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "primary_software_components", + # 'primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "primary_hardware_components", }: column = jira_fields_table.columns[key] conditions.append(column.op("&&")(value)) elif key in { "exclude_tags", + # 'exclude_systems' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_systems", + # 'exclude_subsystems' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_subsystems", + # 'exclude_cscs' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_cscs", }: # Value is a list; field name is the end of the key. @@ -425,13 +648,49 @@ async def find_messages( column = message_table.columns[column_name] conditions.append(sa.sql.not_(column.op("&&")(value))) elif key in { + # 'exclude_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_components", + # 'exclude_primary_software_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_primary_software_components", + # 'exclude_primary_hardware_components' field is deprecated + # and will be removed in v1.0.0. Please use 'components_path' instead "exclude_primary_hardware_components", }: column_name = key[8:] column = jira_fields_table.columns[column_name] conditions.append(sa.sql.not_(column.op("&&")(value))) + elif key in {"components_path"}: + column_name = "components_json" + column = jira_fields_table.columns[column_name] + individual_conditions = [] + for path in value: + try: + parsed_value = json.loads(path) + except json.JSONDecodeError as error: + raise fastapi.HTTPException( + status_code=http.HTTPStatus.BAD_REQUEST, + detail=f"Invalid JSON in {key}: {error}", + ) + individual_conditions.append(column.contains(parsed_value)) + conditions.append(sa.sql.or_(*individual_conditions)) + elif key in {"exclude_components_path"}: + column_name = "components_json" + column = jira_fields_table.columns[column_name] + individual_conditions = [] + for path in value: + try: + parsed_value = json.loads(path) + except json.JSONDecodeError as error: + raise fastapi.HTTPException( + status_code=http.HTTPStatus.BAD_REQUEST, + detail=f"Invalid JSON in {key}: {error}", + ) + individual_conditions.append(column.contains(parsed_value)) + conditions.append( + sa.sql.not_(sa.sql.or_(*individual_conditions)) + ) elif key in { "site_ids", "instruments", diff --git a/src/narrativelog/routers/get_version.py b/src/narrativelog/routers/get_version.py new file mode 100644 index 0000000..c40c348 --- /dev/null +++ b/src/narrativelog/routers/get_version.py @@ -0,0 +1,27 @@ +__all__ = ["get_version"] + +import fastapi +import pydantic + +from .. import __version__ +from ..shared_state import SharedState, get_shared_state + +router = fastapi.APIRouter() + + +class Version(pydantic.BaseModel): + version: str = pydantic.Field(title="Current version of the REST API.") + + class Config: + orm_mode = True + from_attributes = True + + +@router.get("/version", response_model=Version) +@router.get("/version/", response_model=Version, include_in_schema=False) +async def get_version( + state: SharedState = fastapi.Depends(get_shared_state), +) -> Version: + """Get the current version of the package.""" + + return Version(version=__version__) diff --git a/src/narrativelog/testutils.py b/src/narrativelog/testutils.py index 4673cba..0ccf10d 100644 --- a/src/narrativelog/testutils.py +++ b/src/narrativelog/testutils.py @@ -366,6 +366,12 @@ def random_message() -> MessageDictT: elif random_value > 0.25: date_begin = random_date() + test_components_json = { + "systems": random_strings(TEST_SYSTEMS), + "subsystems": random_strings(TEST_SUBSYSTEMS), + "components": random_strings(TEST_COMPONENTS), + } + message = dict( id=None, site_id=TEST_SITE_ID, @@ -396,6 +402,8 @@ def random_message() -> MessageDictT: primary_hardware_components=random_strings( TEST_PRIMARY_HARDWARE_COMPONENTS ), + # Added 2024-12-16 + components_json=test_components_json, # Added 2023-10-24 category=random_str(nchar=CATEGORY_LEN), time_lost_type=random.choice(["fault", "weather"]), @@ -502,11 +510,14 @@ async def create_test_database( pruned_message = message.copy() del pruned_message["is_valid"] # Do not insert "components", - # "primary_software_components", or "primary_hardware_components" + # "primary_software_components", + # "primary_hardware_components", + # or "components_json" # because they are in a separate table. del pruned_message["components"] del pruned_message["primary_software_components"] del pruned_message["primary_hardware_components"] + del pruned_message["components_json"] # Insert the message result_message = await connection.execute( @@ -529,6 +540,7 @@ async def create_test_database( primary_hardware_components=message[ "primary_hardware_components" ], + components_json=message["components_json"], message_id=data_message.id, ) .returning(literal_column("*")) diff --git a/src/narrativelog/utils.py b/src/narrativelog/utils.py new file mode 100644 index 0000000..32e4d0d --- /dev/null +++ b/src/narrativelog/utils.py @@ -0,0 +1,7 @@ +__all__ = ["JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK"] + +JIRA_OBS_SYSTEMS_HIERARCHY_MARKDOWN_LINK = ( + "[Systems, Sub-Systems and Components Proposal for the OBS Jira project]" + "(https://rubinobs.atlassian.net/wiki/spaces/LSSTCOM/pages/53741849/" + "Systems+Sub-Systems+and+Components+Proposal+for+JIRA)" +) diff --git a/tests/test_add_message.py b/tests/test_add_message.py index e2b051d..fc46f26 100644 --- a/tests/test_add_message.py +++ b/tests/test_add_message.py @@ -90,6 +90,13 @@ async def test_add_message(self) -> None: add_args_full["primary_hardware_components"] = random_strings( TEST_PRIMARY_HARDWARE_COMPONENTS ) + + test_components_json = { + "systems": random_strings(TEST_SYSTEMS), + "subsystems": random_strings(TEST_SUBSYSTEMS), + "components": random_strings(TEST_COMPONENTS), + } + add_args_full["components_json"] = test_components_json add_args_full["category"] = "test" add_args_full["time_lost_type"] = random.choice( ["fault", "weather"] diff --git a/tests/test_edit_message.py b/tests/test_edit_message.py index 1b0ea0c..3679a6d 100644 --- a/tests/test_edit_message.py +++ b/tests/test_edit_message.py @@ -85,6 +85,11 @@ async def test_edit_message(self) -> None: "new primary_hardware_component 1", "new primary_hardware_component 2", ], + components_json={ + "systems": ["new system 1", "new system 2"], + "subsystems": ["new subsystem 1", "new subsystem 2"], + "components": ["new component 1", "new component 2"], + }, category="New category", time_lost_type=random.choice(["fault", "weather"]), date_begin="2024-01-01T00:00:00", diff --git a/tests/test_find_messages.py b/tests/test_find_messages.py index 11e9f38..3afe6da 100644 --- a/tests/test_find_messages.py +++ b/tests/test_find_messages.py @@ -1,6 +1,7 @@ import collections.abc import http import itertools +import json import random import typing import unittest @@ -267,7 +268,15 @@ def test_collection( ) # exclude_key for collection arguments for arrays - for field in ("tags",): + for field in ( + "tags", + "systems", + "subsystems", + "cscs", + "components", + "primary_software_components", + "primary_hardware_components", + ): # Scramble the messages and use two field list values # from the first message with at least two values messages_to_search = random.sample(messages, len(messages)) @@ -333,6 +342,50 @@ def test_contains( find_args_predicates.append(({field: value}, test_contains)) + # "Contains" arguments for JSON fields: these specify a + # JSON path to match. + for field in ("components_json",): + # Scramble the messages and use the first + # message with at least a key with two values + messages_to_search = random.sample(messages, len(messages)) + for message in messages_to_search: + components_json = message[field] + for key in components_json: + if len(components_json[key]) >= 2: + first_two_values = components_json[key][0:2] + values = [(key, val) for val in first_two_values] + path = json.dumps({key: first_two_values}) + break + + @doc_str(f"{path!r} in message[{field!r}]") + def test_contains_path( + message: MessageDictT, + field: str = field, + values: list[tuple[str, str]] = values, + ) -> bool: + matches = [ + val in message[field][key] for key, val in values + ] + return any(matches) + + def test_contains_exclude_path( + message: MessageDictT, + field: str = field, + values: list[tuple[str, str]] = values, + ) -> bool: + matches = [ + val in message[field][key] for key, val in values + ] + return not any(matches) + + find_args_predicates += [ + ({"components_path": path}, test_contains_path), + ( + {"exclude_components_path": path}, + test_contains_exclude_path, + ), + ] + # has_ arguments (for fields that may be null). for field in ( "date_begin", @@ -451,7 +504,6 @@ def is_valid_predicate(message: MessageDictT) -> bool: fields = list(MESSAGE_FIELDS) str_fields = set( ( - "instrument", "message_text", "level", "user_id",