From 91c89216baa00f128ab49fe4f4d14265361574bd Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Fri, 3 May 2024 21:14:21 +0100 Subject: [PATCH 01/10] Added db models for coverage configuration --- arpav_ppcv/database.py | 5 +- ...51d2_added_dataset_configuration_tables.py | 63 +++++++++ arpav_ppcv/schemas/coverages.py | 127 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py create mode 100644 arpav_ppcv/schemas/coverages.py diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index e9222ee2..da4069ad 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -13,7 +13,10 @@ from geoalchemy2.shape import from_shape from . import config -from .schemas import models +from .schemas import ( + coverages, + models, +) def get_engine( diff --git a/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py b/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py new file mode 100644 index 00000000..fcd97d2b --- /dev/null +++ b/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py @@ -0,0 +1,63 @@ +"""added dataset configuration tables + +Revision ID: c9a3edc651d2 +Revises: bbac959a8e3c +Create Date: 2024-05-03 19:59:56.563140 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'c9a3edc651d2' +down_revision: Union[str, None] = 'bbac959a8e3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('configurationparameter', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('coverageconfiguration', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('thredds_url_pattern', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('palette', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('color_scale_min', sa.Float(), nullable=False), + sa.Column('color_scale_max', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('configurationparametervalue', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('configuration_parameter_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.ForeignKeyConstraint(['configuration_parameter_id'], ['configurationparameter.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('configurationparameterpossiblevalue', + sa.Column('coverage_configuration_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('configuration_parameter_value_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.ForeignKeyConstraint(['configuration_parameter_value_id'], ['configurationparametervalue.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['coverage_configuration_id'], ['coverageconfiguration.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('coverage_configuration_id', 'configuration_parameter_value_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('configurationparameterpossiblevalue') + op.drop_table('configurationparametervalue') + op.drop_table('coverageconfiguration') + op.drop_table('configurationparameter') + # ### end Alembic commands ### diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py new file mode 100644 index 00000000..b5d65e96 --- /dev/null +++ b/arpav_ppcv/schemas/coverages.py @@ -0,0 +1,127 @@ +import uuid +from typing import Optional + +import sqlalchemy +import sqlmodel + + +class ConfigurationParameterValue(sqlmodel.SQLModel, table=True): + __table_args__ = ( + sqlalchemy.ForeignKeyConstraint( + ["configuration_parameter_id",], + ["configurationparameter.id",], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete param value if its related param gets deleted + ), + ) + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + name: str + description: str + configuration_parameter_id: uuid.UUID + + configuration_parameter: "ConfigurationParameter" = sqlmodel.Relationship( + back_populates="allowed_values", + ) + used_in_configurations: "ConfigurationParameterPossibleValue" = sqlmodel.Relationship( + back_populates="configuration_parameter_value", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class ConfigurationParameter(sqlmodel.SQLModel, table=True): + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + name: str + description: str + + allowed_values: list[ConfigurationParameterValue] = sqlmodel.Relationship( + back_populates="configuration_parameter", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class CoverageConfiguration(sqlmodel.SQLModel, table=True): + """Configuration for NetCDF datasets. + + Can refer to either model forecast data or historical data derived from + observations. + """ + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + thredds_url_pattern: str + unit: str = "" + palette: str + color_scale_min: float = 0.0 + color_scale_max: float = 1.0 + + possible_values: list["ConfigurationParameterPossibleValue"] = sqlmodel.Relationship( + back_populates="coverage_configuration", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class ConfigurationParameterPossibleValue(sqlmodel.SQLModel, table=True): + """Possible values for a parameter of a coverage configuration. + + This model mediates an association table that governs a many-to-many relationship + between a coverage configuration and a configuration parameter value.""" + __table_args__ = ( + sqlalchemy.ForeignKeyConstraint( + ["coverage_configuration_id",], + ["coverageconfiguration.id",], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete all possible values if the related coverage configuration gets deleted + ), + sqlalchemy.ForeignKeyConstraint( + ["configuration_parameter_value_id", ], + ["configurationparametervalue.id", ], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete all possible values if the related conf parameter value gets deleted + ), + ) + + coverage_configuration_id: Optional[uuid.UUID] = sqlmodel.Field( + # NOTE: foreign key already defined in __table_args__ in order to be able to + # specify the ondelete behavior + default=None, + primary_key=True, + ) + configuration_parameter_value_id: Optional[uuid.UUID] = sqlmodel.Field( + # NOTE: foreign key already defined in __table_args__ in order to be able to + # specify the ondelete behavior + default=None, + primary_key=True, + ) + + coverage_configuration: CoverageConfiguration = sqlmodel.Relationship( + back_populates="possible_values") + configuration_parameter_value: ConfigurationParameterValue = sqlmodel.Relationship( + back_populates="used_in_configurations") + + +# def _get_subclasses(cls): +# for subclass in cls.__subclasses__(): +# yield from _get_subclasses(subclass) +# yield subclass +# +# +# _models_dict = {cls.__name__: cls for cls in _get_subclasses(sqlmodel.SQLModel)} +# +# for cls in _models_dict.values(): +# cls.model_rebuild(_types_namespace=_models_dict) From 9fd2c2b88a13e3b106891e5d3a1c02b603f0afa6 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Fri, 3 May 2024 21:14:21 +0100 Subject: [PATCH 02/10] Added db models for coverage configuration --- arpav_ppcv/database.py | 5 +- ...51d2_added_dataset_configuration_tables.py | 63 +++++++++ arpav_ppcv/schemas/coverages.py | 127 ++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py create mode 100644 arpav_ppcv/schemas/coverages.py diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index e9222ee2..da4069ad 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -13,7 +13,10 @@ from geoalchemy2.shape import from_shape from . import config -from .schemas import models +from .schemas import ( + coverages, + models, +) def get_engine( diff --git a/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py b/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py new file mode 100644 index 00000000..fcd97d2b --- /dev/null +++ b/arpav_ppcv/migrations/versions/c9a3edc651d2_added_dataset_configuration_tables.py @@ -0,0 +1,63 @@ +"""added dataset configuration tables + +Revision ID: c9a3edc651d2 +Revises: bbac959a8e3c +Create Date: 2024-05-03 19:59:56.563140 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'c9a3edc651d2' +down_revision: Union[str, None] = 'bbac959a8e3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('configurationparameter', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('coverageconfiguration', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('thredds_url_pattern', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('unit', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('palette', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('color_scale_min', sa.Float(), nullable=False), + sa.Column('color_scale_max', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('configurationparametervalue', + sa.Column('id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('configuration_parameter_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.ForeignKeyConstraint(['configuration_parameter_id'], ['configurationparameter.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('configurationparameterpossiblevalue', + sa.Column('coverage_configuration_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column('configuration_parameter_value_id', sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.ForeignKeyConstraint(['configuration_parameter_value_id'], ['configurationparametervalue.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['coverage_configuration_id'], ['coverageconfiguration.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('coverage_configuration_id', 'configuration_parameter_value_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('configurationparameterpossiblevalue') + op.drop_table('configurationparametervalue') + op.drop_table('coverageconfiguration') + op.drop_table('configurationparameter') + # ### end Alembic commands ### diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py new file mode 100644 index 00000000..b5d65e96 --- /dev/null +++ b/arpav_ppcv/schemas/coverages.py @@ -0,0 +1,127 @@ +import uuid +from typing import Optional + +import sqlalchemy +import sqlmodel + + +class ConfigurationParameterValue(sqlmodel.SQLModel, table=True): + __table_args__ = ( + sqlalchemy.ForeignKeyConstraint( + ["configuration_parameter_id",], + ["configurationparameter.id",], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete param value if its related param gets deleted + ), + ) + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + name: str + description: str + configuration_parameter_id: uuid.UUID + + configuration_parameter: "ConfigurationParameter" = sqlmodel.Relationship( + back_populates="allowed_values", + ) + used_in_configurations: "ConfigurationParameterPossibleValue" = sqlmodel.Relationship( + back_populates="configuration_parameter_value", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class ConfigurationParameter(sqlmodel.SQLModel, table=True): + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + name: str + description: str + + allowed_values: list[ConfigurationParameterValue] = sqlmodel.Relationship( + back_populates="configuration_parameter", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class CoverageConfiguration(sqlmodel.SQLModel, table=True): + """Configuration for NetCDF datasets. + + Can refer to either model forecast data or historical data derived from + observations. + """ + id: uuid.UUID = sqlmodel.Field( + default_factory=uuid.uuid4, + primary_key=True + ) + thredds_url_pattern: str + unit: str = "" + palette: str + color_scale_min: float = 0.0 + color_scale_max: float = 1.0 + + possible_values: list["ConfigurationParameterPossibleValue"] = sqlmodel.Relationship( + back_populates="coverage_configuration", + sa_relationship_kwargs={ + "cascade": "all, delete, delete-orphan", + "passive_deletes": True, + } + ) + + +class ConfigurationParameterPossibleValue(sqlmodel.SQLModel, table=True): + """Possible values for a parameter of a coverage configuration. + + This model mediates an association table that governs a many-to-many relationship + between a coverage configuration and a configuration parameter value.""" + __table_args__ = ( + sqlalchemy.ForeignKeyConstraint( + ["coverage_configuration_id",], + ["coverageconfiguration.id",], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete all possible values if the related coverage configuration gets deleted + ), + sqlalchemy.ForeignKeyConstraint( + ["configuration_parameter_value_id", ], + ["configurationparametervalue.id", ], + onupdate="CASCADE", + ondelete="CASCADE", # i.e. delete all possible values if the related conf parameter value gets deleted + ), + ) + + coverage_configuration_id: Optional[uuid.UUID] = sqlmodel.Field( + # NOTE: foreign key already defined in __table_args__ in order to be able to + # specify the ondelete behavior + default=None, + primary_key=True, + ) + configuration_parameter_value_id: Optional[uuid.UUID] = sqlmodel.Field( + # NOTE: foreign key already defined in __table_args__ in order to be able to + # specify the ondelete behavior + default=None, + primary_key=True, + ) + + coverage_configuration: CoverageConfiguration = sqlmodel.Relationship( + back_populates="possible_values") + configuration_parameter_value: ConfigurationParameterValue = sqlmodel.Relationship( + back_populates="used_in_configurations") + + +# def _get_subclasses(cls): +# for subclass in cls.__subclasses__(): +# yield from _get_subclasses(subclass) +# yield subclass +# +# +# _models_dict = {cls.__name__: cls for cls in _get_subclasses(sqlmodel.SQLModel)} +# +# for cls in _models_dict.values(): +# cls.model_rebuild(_types_namespace=_models_dict) From 61914f238ebd691736ea3e72ef0373651953eeed Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Sat, 4 May 2024 15:28:00 +0100 Subject: [PATCH 03/10] Started adding admin section --- arpav_ppcv/database.py | 53 +++++++++++++ arpav_ppcv/schemas/coverages.py | 27 +++++++ arpav_ppcv/webapp/v2/admin/__init__.py | 0 arpav_ppcv/webapp/v2/admin/app.py | 104 +++++++++++++++++++++++++ arpav_ppcv/webapp/v2/app.py | 3 + poetry.lock | 41 +++++++++- pyproject.toml | 1 + 7 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 arpav_ppcv/webapp/v2/admin/__init__.py create mode 100644 arpav_ppcv/webapp/v2/admin/app.py diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index da4069ad..a1b902a3 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -1,5 +1,6 @@ """Database utilities.""" +import logging import uuid from typing import ( Optional, @@ -18,6 +19,8 @@ models, ) +logger = logging.getLogger(__name__) + def get_engine( settings: config.ArpavPpcvSettings, @@ -358,6 +361,56 @@ def collect_all_monthly_measurements( return result +def list_configuration_parameters( + session: sqlmodel.Session, + *, + limit: int = 20, + offset: int = 0, + include_total: bool = False, +) -> tuple[Sequence[coverages.ConfigurationParameter], Optional[int]]: + """List existing configuration parameters.""" + statement = sqlmodel.select(coverages.ConfigurationParameter).order_by( + coverages.ConfigurationParameter.name) + items = session.exec(statement.offset(offset).limit(limit)).all() + num_items = ( + _get_total_num_records(session, statement) if include_total else None) + return items, num_items + + +def collect_all_configuration_parameters( + session: sqlmodel.Session, +) -> Sequence[coverages.ConfigurationParameter]: + _, num_total = list_configuration_parameters(session, limit=1, include_total=True) + result, _ = list_configuration_parameters( + session, limit=num_total, include_total=False) + return result + + +def create_configuration_parameter( + session: sqlmodel.Session, + configuration_parameter_create: coverages.ConfigurationParameterCreate +) -> coverages.ConfigurationParameter: + logger.debug(f"inside database.create_configuration_parameter - {locals()=}") + to_refresh = [] + db_configuration_parameter = coverages.ConfigurationParameter( + name=configuration_parameter_create.name, + description=configuration_parameter_create.description + ) + to_refresh.append(db_configuration_parameter) + for allowed in configuration_parameter_create.allowed_values: + db_conf_param_value = coverages.ConfigurationParameterValue( + name=allowed.name, + description=allowed.description, + ) + db_configuration_parameter.allowed_values.append(db_conf_param_value) + to_refresh.append(db_conf_param_value) + session.add(db_configuration_parameter) + session.commit() + for item in to_refresh: + session.refresh(item) + return db_configuration_parameter + + def _get_total_num_records(session: sqlmodel.Session, statement): return session.exec( sqlmodel.select(sqlmodel.func.count()).select_from(statement) diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py index b5d65e96..5b332224 100644 --- a/arpav_ppcv/schemas/coverages.py +++ b/arpav_ppcv/schemas/coverages.py @@ -51,6 +51,33 @@ class ConfigurationParameter(sqlmodel.SQLModel, table=True): ) +class ConfigurationParameterValueRead(sqlmodel.SQLModel): + name: str + description: str + + +class ConfigurationParameterRead(sqlmodel.SQLModel): + name: str + description: str + allowed_values: list[ConfigurationParameterValueRead] + + +class ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( + sqlmodel.SQLModel +): + name: str + description: str + + +class ConfigurationParameterCreate(sqlmodel.SQLModel): + name: str + description: str + + allowed_values: list[ + ConfigurationParameterValueCreateEmbeddedInConfigurationParameter + ] + + class CoverageConfiguration(sqlmodel.SQLModel, table=True): """Configuration for NetCDF datasets. diff --git a/arpav_ppcv/webapp/v2/admin/__init__.py b/arpav_ppcv/webapp/v2/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/arpav_ppcv/webapp/v2/admin/app.py b/arpav_ppcv/webapp/v2/admin/app.py new file mode 100644 index 00000000..e43a7a93 --- /dev/null +++ b/arpav_ppcv/webapp/v2/admin/app.py @@ -0,0 +1,104 @@ +import functools +import logging +from typing import Dict, Any, Union, Optional, List, Sequence + +import anyio.to_thread +import starlette_admin +from starlette.requests import Request +from starlette_admin.contrib.sqlmodel import ( + Admin, + ModelView, +) + +from ....import ( + config, + database, +) +from ....schemas import coverages + +logger = logging.getLogger(__name__) + + +class ConfigurationParameterView(ModelView): + identity = "configuration_parameter_view" + name = "Configuration Parameter" + label = "Configuration Parameters" + icon = "fa fa-blog" + pk_attr = "id" + + fields = ( + starlette_admin.StringField("name"), + starlette_admin.StringField("description"), + starlette_admin.ListField( + field=starlette_admin.CollectionField( + "allowed_values", + fields=( + starlette_admin.StringField("name"), + starlette_admin.StringField("description"), + ) + ) + ) + ) + + async def create(self, request: Request, data: Dict[str, Any]) -> Any: + logger.debug(f"Inside create - {locals()=}") + try: + data = await self._arrange_data(request, data) + await self.validate(request, data) + config_param_create = coverages.ConfigurationParameterCreate( + name=data["name"], + description=data["description"], + allowed_values=[ + coverages.ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( + name=av["name"], + description=av["description"] + ) for av in data["allowed_values"] + ] + ) + db_configuration_parameter = await anyio.to_thread.run_sync( + database.create_configuration_parameter, + request.state.session, + config_param_create + ) + configuration_parameter_read = coverages.ConfigurationParameterRead( + **db_configuration_parameter.model_dump() + ) + logger.debug("About to leave the create instance") + logger.debug(f"{configuration_parameter_read=}") + return configuration_parameter_read + except Exception as e: + return self.handle_exception(e) + + async def find_all( + self, + request: Request, + skip: int = 0, + limit: int = 100, + where: Union[Dict[str, Any], str, None] = None, + order_by: Optional[List[str]] = None, + ) -> Sequence[Any]: + list_params = functools.partial( + database.list_configuration_parameters, + limit=limit, + offset=skip, + include_total=False + ) + items, _ = await anyio.to_thread.run_sync( + list_params, request.state.session) + return items + + +def create_admin(settings: config.ArpavPpcvSettings) -> Admin: + admin = Admin( + database.get_engine(settings), + debug=settings.debug + ) + # admin.add_view(ModelView(coverages.ConfigurationParameterValue)) + # admin.add_view(ModelView(coverages.ConfigurationParameter)) + admin.add_view( + ConfigurationParameterView( + coverages.ConfigurationParameter, + identity="configuration_parameter_view" + ) + ) + return admin diff --git a/arpav_ppcv/webapp/v2/app.py b/arpav_ppcv/webapp/v2/app.py index 99ed674f..a787793e 100644 --- a/arpav_ppcv/webapp/v2/app.py +++ b/arpav_ppcv/webapp/v2/app.py @@ -1,6 +1,7 @@ import fastapi from ... import config +from .admin.app import create_admin from .routers.thredds import router as thredds_router from .routers.observations import router as observations_router @@ -23,4 +24,6 @@ def create_app(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: app.include_router(thredds_router, prefix="/thredds", tags=["thredds",]) app.include_router( observations_router, prefix="/observations", tags=["observations",]) + admin = create_admin(settings) + admin.mount_to(app) return app diff --git a/poetry.lock b/poetry.lock index 9539acb0..fc36e559 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2161,6 +2161,21 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-multipart" +version = "0.0.9" +description = "A streaming multipart parser for Python" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, +] + +[package.extras] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] + [[package]] name = "pytz" version = "2020.1" @@ -2607,6 +2622,30 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +[[package]] +name = "starlette-admin" +version = "0.13.2" +description = "Fast, beautiful and extensible administrative interface framework for Starlette/FastApi applications" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette_admin-0.13.2-py3-none-any.whl", hash = "sha256:95b60ce71f05696df49d95b8664726abb9e3f76a44b49aeea25d1d6b68c039ed"}, + {file = "starlette_admin-0.13.2.tar.gz", hash = "sha256:4a2ee4e5e24b49aaaa6c034ba7f538a6accdd736c4ae7ea2c38cfb0c2c09bcce"}, +] + +[package.dependencies] +jinja2 = ">=3,<4" +python-multipart = "*" +starlette = "*" + +[package.extras] +cov = ["coverage[toml] (>=7.0.0,<7.4.0)"] +dev = ["pre-commit (>=2.20.0,<4.0.0)", "uvicorn (>=0.20.0,<0.26.0)"] +doc = ["mkdocs (>=1.4.2,<2.0.0)", "mkdocs-material (>=9.0.0,<10.0.0)", "mkdocs-static-i18n (>=0.53.0,<0.57.0)", "mkdocstrings[python] (>=0.19.0,<0.25.0)"] +i18n = ["babel (>=2.13.0)"] +test = ["aiomysql (>=0.1.1,<0.3.0)", "aiosqlite (>=0.17.0,<0.20.0)", "arrow (>=1.2.3,<1.4.0)", "asyncpg (>=0.27.0,<0.30.0)", "backports-zoneinfo", "black (==24.1.1)", "colour (>=0.1.5,<0.2.0)", "fasteners (==0.19)", "httpx (>=0.23.3,<0.27.0)", "itsdangerous (>=2.1.2,<2.2.0)", "mongoengine (>=0.25.0,<0.28.0)", "mypy (==1.8.0)", "odmantic (>=0.9.0,<0.10.0)", "passlib (>=1.7.4,<1.8.0)", "phonenumbers (>=8.13.3,<8.14.0)", "pillow (>=9.4.0,<9.6.0)", "psycopg2-binary (>=2.9.5,<3.0.0)", "pydantic[email] (>=1.10.2,<2.6.0)", "pymysql[rsa] (>=1.0.2,<1.2.0)", "pytest (>=7.2.0,<7.5.0)", "pytest-asyncio (>=0.20.2,<0.24.0)", "ruff (==0.1.15)", "sqlalchemy-file (>=0.5.0,<0.7.0)", "sqlalchemy-utils (>=0.40.0,<0.42.0)", "sqlmodel (>=0.0.11,<0.15.0)", "tinydb (>=4.7.0,<4.9.0)"] + [[package]] name = "threddsclient" version = "0.4.2" @@ -3211,4 +3250,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "e6b66a1221e6a14a3c62151f99b22109d19de55e78b100120cc8359e9264e078" +content-hash = "d4971873927908478d07bd5c8711cddf9d5e961971eaae1478f830576fadf6d3" diff --git a/pyproject.toml b/pyproject.toml index 202aaefe..e6e00760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ alembic = "^1.13.1" geojson-pydantic = "^1.0.2" shapely = "^2.0.3" pyproj = "^3.6.1" +starlette-admin = "^0.13.2" [tool.poetry.group.dev.dependencies] From 89b325b52598767ed5aef90a3e37974e0f99de53 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Sun, 5 May 2024 02:22:48 +0100 Subject: [PATCH 04/10] Added admin model view for creating and editing a config parameter --- arpav_ppcv/database.py | 72 +++++++ ...add_unique_name_for_conf_param_and_cov_.py | 35 +++ arpav_ppcv/schemas/coverages.py | 31 +-- arpav_ppcv/webapp/v2/admin/app.py | 95 +------- arpav_ppcv/webapp/v2/admin/middlewares.py | 54 +++++ arpav_ppcv/webapp/v2/admin/schemas.py | 16 ++ arpav_ppcv/webapp/v2/admin/views.py | 204 ++++++++++++++++++ 7 files changed, 411 insertions(+), 96 deletions(-) create mode 100644 arpav_ppcv/migrations/versions/b9a2363d4257_add_unique_name_for_conf_param_and_cov_.py create mode 100644 arpav_ppcv/webapp/v2/admin/middlewares.py create mode 100644 arpav_ppcv/webapp/v2/admin/schemas.py create mode 100644 arpav_ppcv/webapp/v2/admin/views.py diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index a1b902a3..6e702349 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -361,6 +361,36 @@ def collect_all_monthly_measurements( return result +def get_configuration_parameter_value( + session: sqlmodel.Session, + configuration_parameter_value_id: uuid.UUID +) -> Optional[coverages.ConfigurationParameterValue]: + return session.get( + coverages.ConfigurationParameterValue, configuration_parameter_value_id) + + +def get_configuration_parameter( + session: sqlmodel.Session, + configuration_parameter_id: uuid.UUID +) -> Optional[coverages.ConfigurationParameter]: + return session.get(coverages.ConfigurationParameter, configuration_parameter_id) + + +def get_configuration_parameter_by_name( + session: sqlmodel.Session, + configuration_parameter_name: str +) -> Optional[coverages.ConfigurationParameter]: + """Get a configuration parameter by its name. + + Since a configuration parameter's name is unique, it can be used to uniquely + identify it. + """ + return session.exec( + sqlmodel.select(coverages.ConfigurationParameter) + .where(coverages.ConfigurationParameter.name == configuration_parameter_name) + ).first() + + def list_configuration_parameters( session: sqlmodel.Session, *, @@ -411,6 +441,48 @@ def create_configuration_parameter( return db_configuration_parameter +def update_configuration_parameter( + session: sqlmodel.Session, + db_configuration_parameter: coverages.ConfigurationParameter, + configuration_parameter_update: coverages.ConfigurationParameterUpdate +) -> coverages.ConfigurationParameter: + """Update a configuration parameter.""" + to_refresh = [] + # account for allowed values being: added/modified/deleted + for existing_allowed_value in db_configuration_parameter.allowed_values: + has_been_requested_to_remove = ( + existing_allowed_value.id not in + [i.id for i in configuration_parameter_update.allowed_values] + ) + if has_been_requested_to_remove: + session.delete(existing_allowed_value) + for av in configuration_parameter_update.allowed_values: + if av.id is None: + # this is a new allowed value, need to create it + db_allowed_value = coverages.ConfigurationParameterValue( + name=av.name, + description=av.description, + ) + db_configuration_parameter.allowed_values.append(db_allowed_value) + else: + # this is an existing allowed value, lets update + db_allowed_value = get_configuration_parameter_value(session, av.id) + for prop, value in av.model_dump(exclude_none=True, exclude_unset=True).items(): + setattr(db_allowed_value, prop, value) + session.add(db_allowed_value) + to_refresh.append(db_allowed_value) + data_ = configuration_parameter_update.model_dump( + exclude={"allowed_values"}, exclude_unset=True, exclude_none=True) + for key, value in data_.items(): + setattr(db_configuration_parameter, key, value) + session.add(db_configuration_parameter) + to_refresh.append(db_configuration_parameter) + session.commit() + for item in to_refresh: + session.refresh(item) + return db_configuration_parameter + + def _get_total_num_records(session: sqlmodel.Session, statement): return session.exec( sqlmodel.select(sqlmodel.func.count()).select_from(statement) diff --git a/arpav_ppcv/migrations/versions/b9a2363d4257_add_unique_name_for_conf_param_and_cov_.py b/arpav_ppcv/migrations/versions/b9a2363d4257_add_unique_name_for_conf_param_and_cov_.py new file mode 100644 index 00000000..c7a70442 --- /dev/null +++ b/arpav_ppcv/migrations/versions/b9a2363d4257_add_unique_name_for_conf_param_and_cov_.py @@ -0,0 +1,35 @@ +"""add unique name for conf param and cov conf + +Revision ID: b9a2363d4257 +Revises: c9a3edc651d2 +Create Date: 2024-05-04 22:36:54.203672 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'b9a2363d4257' +down_revision: Union[str, None] = 'c9a3edc651d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_configurationparameter_name'), 'configurationparameter', ['name'], unique=True) + op.add_column('coverageconfiguration', sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + op.create_index(op.f('ix_coverageconfiguration_name'), 'coverageconfiguration', ['name'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_coverageconfiguration_name'), table_name='coverageconfiguration') + op.drop_column('coverageconfiguration', 'name') + op.drop_index(op.f('ix_configurationparameter_name'), table_name='configurationparameter') + # ### end Alembic commands ### diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py index 5b332224..528e7a0d 100644 --- a/arpav_ppcv/schemas/coverages.py +++ b/arpav_ppcv/schemas/coverages.py @@ -30,6 +30,7 @@ class ConfigurationParameterValue(sqlmodel.SQLModel, table=True): sa_relationship_kwargs={ "cascade": "all, delete, delete-orphan", "passive_deletes": True, + "order_by": "ConfigurationParameterPossibleValue.configuration_parameter_value_id" } ) @@ -39,7 +40,7 @@ class ConfigurationParameter(sqlmodel.SQLModel, table=True): default_factory=uuid.uuid4, primary_key=True ) - name: str + name: str = sqlmodel.Field(unique=True, index=True) description: str allowed_values: list[ConfigurationParameterValue] = sqlmodel.Relationship( @@ -47,21 +48,11 @@ class ConfigurationParameter(sqlmodel.SQLModel, table=True): sa_relationship_kwargs={ "cascade": "all, delete, delete-orphan", "passive_deletes": True, + "order_by": "ConfigurationParameterValue.name", } ) -class ConfigurationParameterValueRead(sqlmodel.SQLModel): - name: str - description: str - - -class ConfigurationParameterRead(sqlmodel.SQLModel): - name: str - description: str - allowed_values: list[ConfigurationParameterValueRead] - - class ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( sqlmodel.SQLModel ): @@ -78,6 +69,21 @@ class ConfigurationParameterCreate(sqlmodel.SQLModel): ] +class ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit(sqlmodel.SQLModel): + id: Optional[uuid.UUID] = None + name: Optional[str] = None + description: Optional[str] = None + + +class ConfigurationParameterUpdate(sqlmodel.SQLModel): + name: Optional[str] = None + description: Optional[str] = None + + allowed_values: list[ + ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit + ] + + class CoverageConfiguration(sqlmodel.SQLModel, table=True): """Configuration for NetCDF datasets. @@ -88,6 +94,7 @@ class CoverageConfiguration(sqlmodel.SQLModel, table=True): default_factory=uuid.uuid4, primary_key=True ) + name: str = sqlmodel.Field(unique=True, index=True) thredds_url_pattern: str unit: str = "" palette: str diff --git a/arpav_ppcv/webapp/v2/admin/app.py b/arpav_ppcv/webapp/v2/admin/app.py index e43a7a93..a1e1e83d 100644 --- a/arpav_ppcv/webapp/v2/admin/app.py +++ b/arpav_ppcv/webapp/v2/admin/app.py @@ -1,104 +1,31 @@ -import functools import logging -from typing import Dict, Any, Union, Optional, List, Sequence -import anyio.to_thread -import starlette_admin -from starlette.requests import Request -from starlette_admin.contrib.sqlmodel import ( - Admin, - ModelView, -) +from starlette.middleware import Middleware +from starlette_admin.contrib.sqlmodel import Admin from ....import ( config, database, ) from ....schemas import coverages +from . import views +from .middlewares import SqlModelDbSessionMiddleware logger = logging.getLogger(__name__) -class ConfigurationParameterView(ModelView): - identity = "configuration_parameter_view" - name = "Configuration Parameter" - label = "Configuration Parameters" - icon = "fa fa-blog" - pk_attr = "id" - - fields = ( - starlette_admin.StringField("name"), - starlette_admin.StringField("description"), - starlette_admin.ListField( - field=starlette_admin.CollectionField( - "allowed_values", - fields=( - starlette_admin.StringField("name"), - starlette_admin.StringField("description"), - ) - ) - ) - ) - - async def create(self, request: Request, data: Dict[str, Any]) -> Any: - logger.debug(f"Inside create - {locals()=}") - try: - data = await self._arrange_data(request, data) - await self.validate(request, data) - config_param_create = coverages.ConfigurationParameterCreate( - name=data["name"], - description=data["description"], - allowed_values=[ - coverages.ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( - name=av["name"], - description=av["description"] - ) for av in data["allowed_values"] - ] - ) - db_configuration_parameter = await anyio.to_thread.run_sync( - database.create_configuration_parameter, - request.state.session, - config_param_create - ) - configuration_parameter_read = coverages.ConfigurationParameterRead( - **db_configuration_parameter.model_dump() - ) - logger.debug("About to leave the create instance") - logger.debug(f"{configuration_parameter_read=}") - return configuration_parameter_read - except Exception as e: - return self.handle_exception(e) - - async def find_all( - self, - request: Request, - skip: int = 0, - limit: int = 100, - where: Union[Dict[str, Any], str, None] = None, - order_by: Optional[List[str]] = None, - ) -> Sequence[Any]: - list_params = functools.partial( - database.list_configuration_parameters, - limit=limit, - offset=skip, - include_total=False - ) - items, _ = await anyio.to_thread.run_sync( - list_params, request.state.session) - return items - - def create_admin(settings: config.ArpavPpcvSettings) -> Admin: + engine = database.get_engine(settings) admin = Admin( - database.get_engine(settings), - debug=settings.debug + engine, + debug=settings.debug, + middlewares=[ + Middleware(SqlModelDbSessionMiddleware, engine=engine) + ] ) - # admin.add_view(ModelView(coverages.ConfigurationParameterValue)) - # admin.add_view(ModelView(coverages.ConfigurationParameter)) admin.add_view( - ConfigurationParameterView( + views.ConfigurationParameterView( coverages.ConfigurationParameter, - identity="configuration_parameter_view" ) ) return admin diff --git a/arpav_ppcv/webapp/v2/admin/middlewares.py b/arpav_ppcv/webapp/v2/admin/middlewares.py new file mode 100644 index 00000000..8a6841d0 --- /dev/null +++ b/arpav_ppcv/webapp/v2/admin/middlewares.py @@ -0,0 +1,54 @@ +from contextlib import contextmanager +from typing import Generator + +import sqlalchemy +import sqlmodel +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from starlette.middleware.base import RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette_admin.contrib.sqla.middleware import DBSessionMiddleware + + +@contextmanager +def get_sqlmodel_session( + engine: sqlalchemy.Engine +) -> Generator[sqlmodel.Session, None, None]: + session: sqlmodel.Session = sqlmodel.Session(engine, expire_on_commit=False) + try: + yield session + except Exception as e: # pragma: no cover + session.rollback() + raise e + finally: + session.close() + + +class SqlModelDbSessionMiddleware(DBSessionMiddleware): + """Middleware for DB that uses sqlmodel.Session. + + This is derived from the starlette_admin DBSessionMiddleware because we + want to use sqlmodel `Session` instances in our admin, rather than the + default sqlalchemy `Session`. This is because our DB-handling + functions, defined in `arpav_ppcv.database`, expect to use an + sqlmodel.Session. The main differences between these two sessuin classes + are described in the sqlmodel docs: + + https://sqlmodel.tiangolo.com/tutorial/select/#sqlmodels-sessionexec + + """ + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if isinstance(self.engine, AsyncEngine): + async with AsyncSession( + self.engine, + expire_on_commit=False + ) as session: + request.state.session = session + return await call_next(request) + else: + with get_sqlmodel_session(self.engine) as session: + request.state.session = session + return await call_next(request) \ No newline at end of file diff --git a/arpav_ppcv/webapp/v2/admin/schemas.py b/arpav_ppcv/webapp/v2/admin/schemas.py new file mode 100644 index 00000000..ea5b355a --- /dev/null +++ b/arpav_ppcv/webapp/v2/admin/schemas.py @@ -0,0 +1,16 @@ +import uuid + +import sqlmodel + + +class ConfigurationParameterValueRead(sqlmodel.SQLModel): + id: uuid.UUID + name: str + description: str + + +class ConfigurationParameterRead(sqlmodel.SQLModel): + id: uuid.UUID + name: str + description: str + allowed_values: list[ConfigurationParameterValueRead] diff --git a/arpav_ppcv/webapp/v2/admin/views.py b/arpav_ppcv/webapp/v2/admin/views.py new file mode 100644 index 00000000..7472b42e --- /dev/null +++ b/arpav_ppcv/webapp/v2/admin/views.py @@ -0,0 +1,204 @@ +"""Views for the admin app. + +The classes contained in this module are derived from +starlette_admin.contrib.sqlmodel.ModelView. This is done mostly for two reasons: + +1. To be able to control database access and ensure we are using our handlers + defined in `arpav_ppcv.database` - this is meant for achieving consistency + throughout the code, as the API is also using the mentioned functions for + interacting with the DB + +2. To be able to present inline forms for editing related objects, as is the + case with parameter configuration and its related values. + +""" + +import functools +import logging +from typing import Dict, Any, Union, Optional, List, Sequence + +import anyio.to_thread +import starlette_admin +from starlette.requests import Request +from starlette_admin import RequestAction +from starlette_admin.contrib.sqlmodel import ModelView +from starlette_admin.fields import StringField + +from .... import database +from ....schemas import coverages +from . import schemas as read_schemas + + +logger = logging.getLogger(__name__) + + +class UuidField(StringField): + + async def serialize_value( + self, request: Request, value: Any, action: RequestAction + ) -> Any: + return str(value) + + +class ConfigurationParameterView(ModelView): + identity = "configuration_parameters" + name = "Configuration Parameter" + label = "Configuration Parameters" + icon = "fa fa-blog" + pk_attr = "id" + + exclude_fields_from_list = ( + "id", + ) + exclude_fields_from_detail = ( + "id", + ) + + fields = ( + UuidField("id"), + starlette_admin.StringField("name"), + starlette_admin.StringField("description"), + starlette_admin.ListField( + field=starlette_admin.CollectionField( + "allowed_values", + fields=( + UuidField( + "id", + read_only=True, + disabled=True, + exclude_from_list=True, + exclude_from_detail=True, + exclude_from_create=True, + exclude_from_edit=False, + ), + starlette_admin.StringField("name"), + starlette_admin.StringField( + "description", + exclude_from_list=True, + ), + ) + ) + ) + ) + + async def get_pk_value(self, request: Request, obj: Any) -> Any: + # note: we need to cast the value, which is a uuid.UUID, to a string + # because starlette_admin just assumes that the value of a model's + # pk attribute is always JSON serializable so it doesn't bother with + # calling the respective field's `serialize_value()` method + result = await super().get_pk_value(request, obj) + return str(result) + + async def create(self, request: Request, data: Dict[str, Any]) -> Any: + logger.debug(f"Inside create - {locals()=}") + try: + data = await self._arrange_data(request, data) + await self.validate(request, data) + config_param_create = coverages.ConfigurationParameterCreate( + name=data["name"], + description=data["description"], + allowed_values=[ + coverages.ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( + name=av["name"], + description=av["description"] + ) for av in data["allowed_values"] + ] + ) + db_configuration_parameter = await anyio.to_thread.run_sync( + database.create_configuration_parameter, + request.state.session, + config_param_create + ) + configuration_parameter_read = read_schemas.ConfigurationParameterRead( + **db_configuration_parameter.model_dump() + ) + logger.debug("About to leave the create instance") + logger.debug(f"{configuration_parameter_read=}") + return configuration_parameter_read + except Exception as e: + return self.handle_exception(e) + + async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: + logger.debug(f"inside edit - {locals()=}") + try: + data = await self._arrange_data(request, data, True) + await self.validate(request, data) + config_param_update = coverages.ConfigurationParameterUpdate( + name=data.get("name"), + description=data.get("description"), + allowed_values=[ + coverages.ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit( + id=av["id"], + name=av.get("name"), + description=av.get("description") + ) for av in data["allowed_values"] + ] + ) + db_configuration_parameter = await anyio.to_thread.run_sync( + database.get_configuration_parameter, + request.state.session, + pk + ) + db_configuration_parameter = await anyio.to_thread.run_sync( + database.update_configuration_parameter, + request.state.session, + db_configuration_parameter, + config_param_update + ) + conf_param_read = read_schemas.ConfigurationParameterRead( + **db_configuration_parameter.model_dump(), + allowed_values=[ + read_schemas.ConfigurationParameterValueRead(**av.model_dump()) + for av in db_configuration_parameter.allowed_values + ] + ) + return conf_param_read + except Exception as e: + self.handle_exception(e) + + async def find_by_pk( + self, + request: Request, + pk: Any + ) -> read_schemas.ConfigurationParameterRead: + db_conf_param = await anyio.to_thread.run_sync( + database.get_configuration_parameter, + request.state.session, + pk + ) + return read_schemas.ConfigurationParameterRead( + **db_conf_param.model_dump(), + allowed_values=[ + read_schemas.ConfigurationParameterValueRead(**av.model_dump()) + for av in db_conf_param.allowed_values + ] + ) + + async def find_all( + self, + request: Request, + skip: int = 0, + limit: int = 100, + where: Union[Dict[str, Any], str, None] = None, + order_by: Optional[List[str]] = None, + ) -> Sequence[read_schemas.ConfigurationParameterRead]: + list_params = functools.partial( + database.list_configuration_parameters, + limit=limit, + offset=skip, + include_total=False + ) + db_conf_params, _ = await anyio.to_thread.run_sync( + list_params, request.state.session) + result = [] + for db_conf_param in db_conf_params: + result.append( + read_schemas.ConfigurationParameterRead( + **db_conf_param.model_dump(), + allowed_values=[ + read_schemas.ConfigurationParameterValueRead(**av.model_dump()) + for av in db_conf_param.allowed_values + ] + ) + ) + return result \ No newline at end of file From f5db251db0d715d3201713c9d70025544baddb03 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Sun, 5 May 2024 19:16:59 +0100 Subject: [PATCH 05/10] Added admin model view for creating and editing a coverage configuration --- arpav_ppcv/database.py | 133 +++++++++++++++++ arpav_ppcv/schemas/coverages.py | 27 ++++ arpav_ppcv/webapp/v2/admin/app.py | 7 +- arpav_ppcv/webapp/v2/admin/schemas.py | 16 ++ arpav_ppcv/webapp/v2/admin/views.py | 206 +++++++++++++++++++++++++- 5 files changed, 380 insertions(+), 9 deletions(-) diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index 6e702349..0a633018 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -369,6 +369,31 @@ def get_configuration_parameter_value( coverages.ConfigurationParameterValue, configuration_parameter_value_id) +def list_configuration_parameter_values( + session: sqlmodel.Session, + *, + limit: int = 20, + offset: int = 0, + include_total: bool = False, +) -> tuple[Sequence[coverages.ConfigurationParameterValue], Optional[int]]: + """List existing configuration parameters.""" + statement = sqlmodel.select(coverages.ConfigurationParameterValue).order_by( + coverages.ConfigurationParameterValue.name) + items = session.exec(statement.offset(offset).limit(limit)).all() + num_items = ( + _get_total_num_records(session, statement) if include_total else None) + return items, num_items + + +def collect_all_configuration_parameter_values( + session: sqlmodel.Session, +) -> Sequence[coverages.ConfigurationParameterValue]: + _, num_total = list_configuration_parameter_values(session, limit=1, include_total=True) + result, _ = list_configuration_parameter_values( + session, limit=num_total, include_total=False) + return result + + def get_configuration_parameter( session: sqlmodel.Session, configuration_parameter_id: uuid.UUID @@ -483,6 +508,114 @@ def update_configuration_parameter( return db_configuration_parameter +def get_coverage_configuration( + session: sqlmodel.Session, + coverage_configuration_id: uuid.UUID +) -> Optional[coverages.CoverageConfiguration]: + return session.get(coverages.CoverageConfiguration, coverage_configuration_id) + + +def list_coverage_configurations( + session: sqlmodel.Session, + *, + limit: int = 20, + offset: int = 0, + include_total: bool = False, +) -> tuple[Sequence[coverages.CoverageConfiguration], Optional[int]]: + """List existing coverage configurations.""" + statement = sqlmodel.select(coverages.CoverageConfiguration).order_by( + coverages.CoverageConfiguration.name) + items = session.exec(statement.offset(offset).limit(limit)).all() + num_items = ( + _get_total_num_records(session, statement) if include_total else None) + return items, num_items + + +def collect_all_coverage_configurations( + session: sqlmodel.Session, +) -> Sequence[coverages.CoverageConfiguration]: + _, num_total = list_coverage_configurations(session, limit=1, include_total=True) + result, _ = list_coverage_configurations( + session, limit=num_total, include_total=False) + return result + + +def create_coverage_configuration( + session: sqlmodel.Session, + coverage_configuration_create: coverages.CoverageConfigurationCreate +) -> coverages.CoverageConfiguration: + logger.debug(f"inside database.create_coverage_configuration - {locals()=}") + to_refresh = [] + db_coverage_configuration = coverages.CoverageConfiguration( + name=coverage_configuration_create.name, + thredds_url_pattern=coverage_configuration_create.thredds_url_pattern, + unit=coverage_configuration_create.unit, + palette=coverage_configuration_create.palette, + color_scale_min=coverage_configuration_create.color_scale_min, + color_scale_max=coverage_configuration_create.color_scale_max, + ) + session.add(db_coverage_configuration) + to_refresh.append(db_coverage_configuration) + for possible in coverage_configuration_create.possible_values: + db_conf_param_value = get_configuration_parameter_value( + session, possible.configuration_parameter_value_id) + possible_value = coverages.ConfigurationParameterPossibleValue( + coverage_configuration=db_coverage_configuration, + configuration_parameter_value=db_conf_param_value + ) + session.add(possible_value) + to_refresh.append(possible_value) + session.commit() + for item in to_refresh: + session.refresh(item) + return db_coverage_configuration + + +def update_coverage_configuration( + session: sqlmodel.Session, + db_coverage_configuration: coverages.CoverageConfiguration, + coverage_configuration_update: coverages.CoverageConfigurationUpdate +) -> coverages.CoverageConfiguration: + """Update a coverage configuration.""" + to_refresh = [] + # account for possible values being: added/deleted + for existing_possible_value in db_coverage_configuration.possible_values: + has_been_requested_to_remove = ( + existing_possible_value.configuration_parameter_value_id not in + [ + i.configuration_parameter_value_id + for i in coverage_configuration_update.possible_values + ] + ) + if has_been_requested_to_remove: + session.delete(existing_possible_value) + for pvc in coverage_configuration_update.possible_values: + already_possible = ( + pvc.configuration_parameter_value_id + in [ + i.configuration_parameter_value_id + for i in db_coverage_configuration.possible_values + ] + ) + if not already_possible: + db_possible_value = coverages.ConfigurationParameterPossibleValue( + coverage_configuration=db_coverage_configuration, + configuration_parameter_value_id=pvc.configuration_parameter_value_id + ) + session.add(db_possible_value) + to_refresh.append(db_possible_value) + data_ = coverage_configuration_update.model_dump( + exclude={"possible_values"}, exclude_unset=True, exclude_none=True) + for key, value in data_.items(): + setattr(db_coverage_configuration, key, value) + session.add(db_coverage_configuration) + to_refresh.append(db_coverage_configuration) + session.commit() + for item in to_refresh: + session.refresh(item) + return db_coverage_configuration + + def _get_total_num_records(session: sqlmodel.Session, statement): return session.exec( sqlmodel.select(sqlmodel.func.count()).select_from(statement) diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py index 528e7a0d..3ccb1cc8 100644 --- a/arpav_ppcv/schemas/coverages.py +++ b/arpav_ppcv/schemas/coverages.py @@ -110,6 +110,26 @@ class CoverageConfiguration(sqlmodel.SQLModel, table=True): ) +class CoverageConfigurationCreate(sqlmodel.SQLModel): + name: str + thredds_url_pattern: str + unit: str + palette: str + color_scale_min: float + color_scale_max: float + possible_values: list["ConfigurationParameterPossibleValueCreate"] + + +class CoverageConfigurationUpdate(sqlmodel.SQLModel): + name: Optional[str] = None + thredds_url_pattern: Optional[str] = None + unit: Optional[str] = None + palette: Optional[str] = None + color_scale_min: Optional[float] = None + color_scale_max: Optional[float] = None + possible_values: list["ConfigurationParameterPossibleValueUpdate"] + + class ConfigurationParameterPossibleValue(sqlmodel.SQLModel, table=True): """Possible values for a parameter of a coverage configuration. @@ -149,6 +169,13 @@ class ConfigurationParameterPossibleValue(sqlmodel.SQLModel, table=True): back_populates="used_in_configurations") +class ConfigurationParameterPossibleValueCreate(sqlmodel.SQLModel): + configuration_parameter_value_id: uuid.UUID + + +class ConfigurationParameterPossibleValueUpdate(sqlmodel.SQLModel): + configuration_parameter_value_id: uuid.UUID + # def _get_subclasses(cls): # for subclass in cls.__subclasses__(): # yield from _get_subclasses(subclass) diff --git a/arpav_ppcv/webapp/v2/admin/app.py b/arpav_ppcv/webapp/v2/admin/app.py index a1e1e83d..d125ba53 100644 --- a/arpav_ppcv/webapp/v2/admin/app.py +++ b/arpav_ppcv/webapp/v2/admin/app.py @@ -24,8 +24,7 @@ def create_admin(settings: config.ArpavPpcvSettings) -> Admin: ] ) admin.add_view( - views.ConfigurationParameterView( - coverages.ConfigurationParameter, - ) - ) + views.ConfigurationParameterView(coverages.ConfigurationParameter)) + admin.add_view( + views.CoverageConfigurationView(coverages.CoverageConfiguration)) return admin diff --git a/arpav_ppcv/webapp/v2/admin/schemas.py b/arpav_ppcv/webapp/v2/admin/schemas.py index ea5b355a..db96393c 100644 --- a/arpav_ppcv/webapp/v2/admin/schemas.py +++ b/arpav_ppcv/webapp/v2/admin/schemas.py @@ -14,3 +14,19 @@ class ConfigurationParameterRead(sqlmodel.SQLModel): name: str description: str allowed_values: list[ConfigurationParameterValueRead] + + +class ConfigurationParameterPossibleValueRead(sqlmodel.SQLModel): + configuration_parameter_value_id: uuid.UUID + configuration_parameter_value_name: str + + +class CoverageConfigurationRead(sqlmodel.SQLModel): + id: uuid.UUID + name: str + thredds_url_pattern: str + unit: str + palette: str + color_scale_min: float + color_scale_max: float + possible_values: list[ConfigurationParameterPossibleValueRead] diff --git a/arpav_ppcv/webapp/v2/admin/views.py b/arpav_ppcv/webapp/v2/admin/views.py index 7472b42e..6f8c28c2 100644 --- a/arpav_ppcv/webapp/v2/admin/views.py +++ b/arpav_ppcv/webapp/v2/admin/views.py @@ -22,7 +22,6 @@ from starlette.requests import Request from starlette_admin import RequestAction from starlette_admin.contrib.sqlmodel import ModelView -from starlette_admin.fields import StringField from .... import database from ....schemas import coverages @@ -32,7 +31,7 @@ logger = logging.getLogger(__name__) -class UuidField(StringField): +class UuidField(starlette_admin.StringField): async def serialize_value( self, request: Request, value: Any, action: RequestAction @@ -40,6 +39,30 @@ async def serialize_value( return str(value) +class PossibleConfigurationParameterValuesField(starlette_admin.EnumField): + + def _get_label( + self, + value: read_schemas.ConfigurationParameterPossibleValueRead, + request: Request + ) -> Any: + conf_parameter_value = database.get_configuration_parameter_value( + request.state.session, value.configuration_parameter_value_id) + result = " - ".join(( + conf_parameter_value.configuration_parameter.name, + conf_parameter_value.name + )) + return result + + async def serialize_value( + self, + request: Request, + value: read_schemas.ConfigurationParameterPossibleValueRead, + action: RequestAction + ) -> Any: + return self._get_label(value, request) + + class ConfigurationParameterView(ModelView): identity = "configuration_parameters" name = "Configuration Parameter" @@ -90,7 +113,6 @@ async def get_pk_value(self, request: Request, obj: Any) -> Any: return str(result) async def create(self, request: Request, data: Dict[str, Any]) -> Any: - logger.debug(f"Inside create - {locals()=}") try: data = await self._arrange_data(request, data) await self.validate(request, data) @@ -119,7 +141,6 @@ async def create(self, request: Request, data: Dict[str, Any]) -> Any: return self.handle_exception(e) async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: - logger.debug(f"inside edit - {locals()=}") try: data = await self._arrange_data(request, data, True) await self.validate(request, data) @@ -201,4 +222,179 @@ async def find_all( ] ) ) - return result \ No newline at end of file + return result + + +def possible_values_choices_loader(request: Request) -> Sequence[tuple[str, str]]: + all_conf_parameter_values = database.collect_all_configuration_parameter_values( + request.state.session + ) + result = [] + for conf_param_value in all_conf_parameter_values: + repr_value = " - ".join(( + conf_param_value.configuration_parameter.name, conf_param_value.name)) + result.append((repr_value, repr_value)) + return result + + +class CoverageConfigurationView(ModelView): + identity = "coverage_configurations" + name = "Coverage Configuration" + label = "Coverage Configurations" + icon = "fa fa-blog" + pk_attr = "id" + fields = ( + UuidField("id"), + starlette_admin.StringField("name"), + starlette_admin.StringField("thredds_url_pattern"), + starlette_admin.StringField("unit"), + starlette_admin.StringField("palette"), + starlette_admin.FloatField("color_scale_min"), + starlette_admin.FloatField("color_scale_max"), + starlette_admin.ListField( + field=PossibleConfigurationParameterValuesField( + "possible_values", choices_loader=possible_values_choices_loader) + ), + ) + + exclude_fields_from_list = ( + "id", + ) + + async def get_pk_value(self, request: Request, obj: Any) -> Any: + # note: we need to cast the value, which is a uuid.UUID, to a string + # because starlette_admin just assumes that the value of a model's + # pk attribute is always JSON serializable so it doesn't bother with + # calling the respective field's `serialize_value()` method + result = await super().get_pk_value(request, obj) + return str(result) + + async def find_by_pk( + self, + request: Request, + pk: Any + ) -> read_schemas.CoverageConfigurationRead: + db_cov_conf = await anyio.to_thread.run_sync( + database.get_coverage_configuration, + request.state.session, + pk + ) + return read_schemas.CoverageConfigurationRead( + **db_cov_conf.model_dump(), + possible_values=[ + read_schemas.ConfigurationParameterPossibleValueRead( + configuration_parameter_value_id=pv.configuration_parameter_value_id, + configuration_parameter_value_name=pv.configuration_parameter_value.name) + for pv in db_cov_conf.possible_values + ] + ) + + async def find_all( + self, + request: Request, + skip: int = 0, + limit: int = 100, + where: Union[Dict[str, Any], str, None] = None, + order_by: Optional[List[str]] = None, + ) -> Sequence[read_schemas.CoverageConfigurationRead]: + list_cov_confs = functools.partial( + database.list_coverage_configurations, + limit=limit, + offset=skip, + include_total=False + ) + db_cov_confs, _ = await anyio.to_thread.run_sync( + list_cov_confs, request.state.session) + result = [] + for db_cov_conf in db_cov_confs: + result.append( + read_schemas.CoverageConfigurationRead( + **db_cov_conf.model_dump(), + possible_values=[ + read_schemas.ConfigurationParameterPossibleValueRead( + configuration_parameter_value_id=pv.configuration_parameter_value.id, + configuration_parameter_value_name=pv.configuration_parameter_value.name, + ) + for pv in db_cov_conf.possible_values + ] + ) + ) + return result + + async def create(self, request: Request, data: Dict[str, Any]) -> Any: + logger.debug(f"inside create: {locals()=}") + session = request.state.session + try: + data = await self._arrange_data(request, data) + await self.validate(request, data) + logger.debug(f"{data=}") + possible_values_create = [] + for possible_value in data["possible_values"]: + param_name, param_value = possible_value.partition(" - ")[::2] + conf_param = database.get_configuration_parameter_by_name( + session, param_name) + conf_param_value = [ + pv for pv in conf_param.allowed_values if pv.name == param_value][0] + possible_values_create.append( + coverages.ConfigurationParameterPossibleValueCreate( + configuration_parameter_value_id=conf_param_value.id) + ) + cov_conf_create = coverages.CoverageConfigurationCreate( + name=data["name"], + thredds_url_pattern=data["thredds_url_pattern"], + unit=data["unit"], + palette=data["palette"], + color_scale_min=data["color_scale_min"], + color_scale_max=data["color_scale_max"], + possible_values=possible_values_create + ) + db_cov_conf = database.create_coverage_configuration( + session, cov_conf_create) + + coverage_configuration_read = read_schemas.CoverageConfigurationRead( + **db_cov_conf.model_dump()) + return coverage_configuration_read + except Exception as e: + return self.handle_exception(e) + + async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: + session = request.state.session + try: + data = await self._arrange_data(request, data, True) + await self.validate(request, data) + + possible_values = [] + for pv in data["possible_values"]: + param_name, param_value = pv.rpartition(" - ")[::2] + conf_param = database.get_configuration_parameter_by_name(session, param_name) + conf_param_value = [pv for pv in conf_param.allowed_values if pv.name == param_value][0] + possible_values.append( + coverages.ConfigurationParameterPossibleValueUpdate( + configuration_parameter_value_id=conf_param_value.id) + ) + cov_conv_update = coverages.CoverageConfigurationUpdate( + name=data.get("name"), + thredds_url_pattern=data.get("thredds_url_pattern"), + unit=data.get("data"), + palette=data.get("palette"), + color_scale_min=data.get("color_scale_min"), + color_scale_max=data.get("color_scale_max"), + possible_values=possible_values + ) + db_coverage_configuration = await anyio.to_thread.run_sync( + database.get_coverage_configuration, + session, + pk + ) + db_coverage_configuration = await anyio.to_thread.run_sync( + database.update_coverage_configuration, + session, + db_coverage_configuration, + cov_conv_update + ) + cov_conf_read = read_schemas.CoverageConfigurationRead( + **db_coverage_configuration.model_dump(), + ) + return cov_conf_read + except Exception as e: + self.handle_exception(e) From ca7c5996ad7e11474a23b300162f97a99c9cb882 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Mon, 6 May 2024 02:52:36 +0100 Subject: [PATCH 06/10] Refactored webapp --- arpav_ppcv/config.py | 9 +- arpav_ppcv/database.py | 35 ++++++ arpav_ppcv/main.py | 2 +- arpav_ppcv/schemas/coverages.py | 57 +++++++++- arpav_ppcv/webapp/{v1 => admin}/__init__.py | 0 arpav_ppcv/webapp/{v2 => }/admin/app.py | 4 +- .../webapp/{v2 => }/admin/middlewares.py | 0 arpav_ppcv/webapp/{v2 => }/admin/schemas.py | 2 + arpav_ppcv/webapp/{v2 => }/admin/views.py | 6 +- .../webapp/{v1/routers => api_v1}/__init__.py | 0 arpav_ppcv/webapp/{v1 => api_v1}/app.py | 0 .../webapp/{v1 => api_v1}/dependencies.py | 0 .../webapp/{v2 => api_v1/routers}/__init__.py | 0 .../routers/forecastattributes.py | 0 .../webapp/{v1 => api_v1}/routers/maps.py | 0 .../webapp/{v1 => api_v1}/routers/ncss.py | 0 .../webapp/{v1 => api_v1}/routers/places.py | 0 arpav_ppcv/webapp/{v1 => api_v1}/schemas.py | 0 arpav_ppcv/webapp/{v1 => api_v1}/util.py | 0 .../webapp/{v2/admin => api_v2}/__init__.py | 0 arpav_ppcv/webapp/{v2 => api_v2}/app.py | 7 +- .../webapp/{v2 => api_v2}/routers/__init__.py | 0 .../{routers.py => api_v2/routers/base.py} | 4 +- arpav_ppcv/webapp/api_v2/routers/coverages.py | 106 ++++++++++++++++++ .../{v2 => api_v2}/routers/observations.py | 0 .../webapp/{v2 => api_v2}/routers/thredds.py | 0 .../webapp/{v2 => api_v2}/schemas/__init__.py | 0 .../webapp/{v2 => api_v2}/schemas/base.py | 5 + arpav_ppcv/webapp/api_v2/schemas/coverages.py | 104 +++++++++++++++++ .../{v2 => api_v2}/schemas/observations.py | 0 .../webapp/{v2 => api_v2}/schemas/thredds.py | 0 arpav_ppcv/webapp/app.py | 59 +++++----- arpav_ppcv/webapp/routes.py | 23 ++++ arpav_ppcv/webapp/schemas.py | 6 - arpav_ppcv/webapp/templates/landing_page.html | 20 ++++ 35 files changed, 396 insertions(+), 53 deletions(-) rename arpav_ppcv/webapp/{v1 => admin}/__init__.py (100%) rename arpav_ppcv/webapp/{v2 => }/admin/app.py (93%) rename arpav_ppcv/webapp/{v2 => }/admin/middlewares.py (100%) rename arpav_ppcv/webapp/{v2 => }/admin/schemas.py (93%) rename arpav_ppcv/webapp/{v2 => }/admin/views.py (98%) rename arpav_ppcv/webapp/{v1/routers => api_v1}/__init__.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/app.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/dependencies.py (100%) rename arpav_ppcv/webapp/{v2 => api_v1/routers}/__init__.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/routers/forecastattributes.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/routers/maps.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/routers/ncss.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/routers/places.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/schemas.py (100%) rename arpav_ppcv/webapp/{v1 => api_v1}/util.py (100%) rename arpav_ppcv/webapp/{v2/admin => api_v2}/__init__.py (100%) rename arpav_ppcv/webapp/{v2 => api_v2}/app.py (78%) rename arpav_ppcv/webapp/{v2 => api_v2}/routers/__init__.py (100%) rename arpav_ppcv/webapp/{routers.py => api_v2/routers/base.py} (80%) create mode 100644 arpav_ppcv/webapp/api_v2/routers/coverages.py rename arpav_ppcv/webapp/{v2 => api_v2}/routers/observations.py (100%) rename arpav_ppcv/webapp/{v2 => api_v2}/routers/thredds.py (100%) rename arpav_ppcv/webapp/{v2 => api_v2}/schemas/__init__.py (100%) rename arpav_ppcv/webapp/{v2 => api_v2}/schemas/base.py (98%) create mode 100644 arpav_ppcv/webapp/api_v2/schemas/coverages.py rename arpav_ppcv/webapp/{v2 => api_v2}/schemas/observations.py (100%) rename arpav_ppcv/webapp/{v2 => api_v2}/schemas/thredds.py (100%) create mode 100644 arpav_ppcv/webapp/routes.py delete mode 100644 arpav_ppcv/webapp/schemas.py create mode 100644 arpav_ppcv/webapp/templates/landing_page.html diff --git a/arpav_ppcv/config.py b/arpav_ppcv/config.py index f33ed252..2a84568b 100644 --- a/arpav_ppcv/config.py +++ b/arpav_ppcv/config.py @@ -119,7 +119,8 @@ class DjangoAppSettings(pydantic.BaseModel): secret_key: str = "changeme" mount_prefix: str = "/legacy" static_root: Path = Path.home() / "django_static" - static_mount_prefix: str = "/static/legacy" + # static_mount_prefix: str = "/static/legacy" + static_mount_prefix: str = "/legacy-static" db_engine: str = "django.contrib.gis.db.backends.postgis" db_dsn: pydantic.PostgresDsn = pydantic.PostgresDsn( "postgresql://django_user:django_password@localhost:5432/django_db") @@ -145,9 +146,11 @@ class ArpavPpcvSettings(BaseSettings): # noqa test_db_dsn: Optional[pydantic.PostgresDsn] = None verbose_db_logs: bool = False contact: ContactSettings = ContactSettings() + templates_dir: Optional[Path] = Path(__file__).parent / "webapp/templates" + static_dir: Optional[Path] = Path(__file__).parent / "webapp/static" thredds_server: ThreddsServerSettings = ThreddsServerSettings() - v1_mount_prefix: str = "/v1/api" - v2_mount_prefix: str = "/v2/api" + v1_api_mount_prefix: str = "/api/v1" + v2_api_mount_prefix: str = "/api/v2" django_app: DjangoAppSettings = DjangoAppSettings() log_config_file: Path | None = None diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index 0a633018..bc2d422c 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -1,6 +1,8 @@ """Database utilities.""" +import itertools import logging +import re import uuid from typing import ( Optional, @@ -616,6 +618,39 @@ def update_coverage_configuration( return db_coverage_configuration +def list_allowed_coverage_identifiers( + session: sqlmodel.Session, + *, + coverage_configuration_id: uuid.UUID, +) -> list[str]: + """Build list of legal coverage identifiers.""" + result = [] + db_cov_conf = get_coverage_configuration(session, coverage_configuration_id) + if db_cov_conf is not None: + pattern_parts = re.findall( + r"\{(\w+)\}", + db_cov_conf.coverage_id_pattern.partition("-")[-1]) + values_to_combine = [] + for part in pattern_parts: + part_values = [] + for possible_value in db_cov_conf.possible_values: + param_name_matches = ( + possible_value.configuration_parameter_value.configuration_parameter.name == part + ) + if param_name_matches: + part_values.append(possible_value.configuration_parameter_value.name) + values_to_combine.append(part_values) + # account for the possibility that there is an error in the + # coverage_id_pattern, where some of the parts are not actually configured + for index, container in enumerate(values_to_combine): + if len(container) == 0: + values_to_combine[index] = [pattern_parts[index]] + for combination in itertools.product(*values_to_combine): + dataset_id = "-".join((db_cov_conf.identifier, *combination)) + result.append(dataset_id) + return result + + def _get_total_num_records(session: sqlmodel.Session, statement): return session.exec( sqlmodel.select(sqlmodel.func.count()).select_from(statement) diff --git a/arpav_ppcv/main.py b/arpav_ppcv/main.py index a8418729..99e4bc71 100644 --- a/arpav_ppcv/main.py +++ b/arpav_ppcv/main.py @@ -136,7 +136,7 @@ def run_server(ctx: typer.Context): serving_str = ( f"[dim]Serving at:[/dim] [link]http://{settings.bind_host}:{settings.bind_port}[/link]\n\n" f"[dim]Public URL:[/dim] [link]{settings.public_url}[/link]\n\n" - f"[dim]API docs:[/dim] [link]{settings.public_url}/docs[/link]" + f"[dim]API docs:[/dim] [link]{settings.public_url}{settings.v2_api_mount_prefix}/docs[/link]" ) panel = Panel( ( diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py index 3ccb1cc8..0aacee91 100644 --- a/arpav_ppcv/schemas/coverages.py +++ b/arpav_ppcv/schemas/coverages.py @@ -1,9 +1,19 @@ +import logging +import re import uuid -from typing import Optional +from typing import ( + Annotated, + Optional, + Final, +) +import pydantic import sqlalchemy import sqlmodel +logger = logging.getLogger(__name__) +_NAME_PATTERN: Final[str] = r"^\w+$" + class ConfigurationParameterValue(sqlmodel.SQLModel, table=True): __table_args__ = ( @@ -61,7 +71,17 @@ class ConfigurationParameterValueCreateEmbeddedInConfigurationParameter( class ConfigurationParameterCreate(sqlmodel.SQLModel): - name: str + name: Annotated[ + str, + pydantic.Field( + pattern=_NAME_PATTERN, + help=( + "Parameter name. Only alphanumeric characters and the underscore are " + "allowed. Example: my_param" + ) + ) + ] + # name: str description: str allowed_values: list[ @@ -76,7 +96,7 @@ class ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit(sqlm class ConfigurationParameterUpdate(sqlmodel.SQLModel): - name: Optional[str] = None + name: Annotated[Optional[str], pydantic.Field(pattern=_NAME_PATTERN)] = None description: Optional[str] = None allowed_values: list[ @@ -109,6 +129,19 @@ class CoverageConfiguration(sqlmodel.SQLModel, table=True): } ) + @pydantic.computed_field() + @property + def identifier(self) -> str: + return self.name.translate(str.maketrans({" ": "_", "-": "_"})).lower() + + @pydantic.computed_field() + @property + def coverage_id_pattern(self) -> str: + id_parts = ["{identifier}"] + for match_obj in re.finditer(r"(\{\w+\})", self.thredds_url_pattern): + id_parts.append(match_obj.group(1)) + return "-".join(id_parts) + class CoverageConfigurationCreate(sqlmodel.SQLModel): name: str @@ -119,6 +152,15 @@ class CoverageConfigurationCreate(sqlmodel.SQLModel): color_scale_max: float possible_values: list["ConfigurationParameterPossibleValueCreate"] + @pydantic.field_validator("thredds_url_pattern") + @classmethod + def validate_thredds_url_pattern(cls, v: str) -> str: + for match_obj in re.finditer(r"(\{.*?\})", v): + logger.debug(f"{match_obj.group(1)[1:-1]=}") + if re.match(_NAME_PATTERN, match_obj.group(1)[1:-1]) is None: + raise ValueError(f"configuration parameter {v!r} has invalid name") + return v + class CoverageConfigurationUpdate(sqlmodel.SQLModel): name: Optional[str] = None @@ -129,6 +171,15 @@ class CoverageConfigurationUpdate(sqlmodel.SQLModel): color_scale_max: Optional[float] = None possible_values: list["ConfigurationParameterPossibleValueUpdate"] + @pydantic.field_validator("thredds_url_pattern") + @classmethod + def validate_thredds_url_pattern(cls, v: str) -> str: + for match_obj in re.finditer(r"(\{.*?\})", v): + logger.debug(f"{match_obj.group(1)[1:-1]=}") + if re.match(_NAME_PATTERN, match_obj.group(1)[1:-1]) is None: + raise ValueError(f"configuration parameter {v!r} has invalid name") + return v + class ConfigurationParameterPossibleValue(sqlmodel.SQLModel, table=True): """Possible values for a parameter of a coverage configuration. diff --git a/arpav_ppcv/webapp/v1/__init__.py b/arpav_ppcv/webapp/admin/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v1/__init__.py rename to arpav_ppcv/webapp/admin/__init__.py diff --git a/arpav_ppcv/webapp/v2/admin/app.py b/arpav_ppcv/webapp/admin/app.py similarity index 93% rename from arpav_ppcv/webapp/v2/admin/app.py rename to arpav_ppcv/webapp/admin/app.py index d125ba53..188d88ac 100644 --- a/arpav_ppcv/webapp/v2/admin/app.py +++ b/arpav_ppcv/webapp/admin/app.py @@ -3,11 +3,11 @@ from starlette.middleware import Middleware from starlette_admin.contrib.sqlmodel import Admin -from ....import ( +from ...import ( config, database, ) -from ....schemas import coverages +from ...schemas import coverages from . import views from .middlewares import SqlModelDbSessionMiddleware diff --git a/arpav_ppcv/webapp/v2/admin/middlewares.py b/arpav_ppcv/webapp/admin/middlewares.py similarity index 100% rename from arpav_ppcv/webapp/v2/admin/middlewares.py rename to arpav_ppcv/webapp/admin/middlewares.py diff --git a/arpav_ppcv/webapp/v2/admin/schemas.py b/arpav_ppcv/webapp/admin/schemas.py similarity index 93% rename from arpav_ppcv/webapp/v2/admin/schemas.py rename to arpav_ppcv/webapp/admin/schemas.py index db96393c..94740f53 100644 --- a/arpav_ppcv/webapp/v2/admin/schemas.py +++ b/arpav_ppcv/webapp/admin/schemas.py @@ -23,6 +23,8 @@ class ConfigurationParameterPossibleValueRead(sqlmodel.SQLModel): class CoverageConfigurationRead(sqlmodel.SQLModel): id: uuid.UUID + identifier: str + coverage_id_pattern: str name: str thredds_url_pattern: str unit: str diff --git a/arpav_ppcv/webapp/v2/admin/views.py b/arpav_ppcv/webapp/admin/views.py similarity index 98% rename from arpav_ppcv/webapp/v2/admin/views.py rename to arpav_ppcv/webapp/admin/views.py index 6f8c28c2..0b229ae2 100644 --- a/arpav_ppcv/webapp/v2/admin/views.py +++ b/arpav_ppcv/webapp/admin/views.py @@ -23,8 +23,8 @@ from starlette_admin import RequestAction from starlette_admin.contrib.sqlmodel import ModelView -from .... import database -from ....schemas import coverages +from ... import database +from ...schemas import coverages from . import schemas as read_schemas @@ -247,6 +247,8 @@ class CoverageConfigurationView(ModelView): UuidField("id"), starlette_admin.StringField("name"), starlette_admin.StringField("thredds_url_pattern"), + starlette_admin.StringField("identifier", disabled=True), + starlette_admin.StringField("coverage_id_pattern", disabled=True), starlette_admin.StringField("unit"), starlette_admin.StringField("palette"), starlette_admin.FloatField("color_scale_min"), diff --git a/arpav_ppcv/webapp/v1/routers/__init__.py b/arpav_ppcv/webapp/api_v1/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v1/routers/__init__.py rename to arpav_ppcv/webapp/api_v1/__init__.py diff --git a/arpav_ppcv/webapp/v1/app.py b/arpav_ppcv/webapp/api_v1/app.py similarity index 100% rename from arpav_ppcv/webapp/v1/app.py rename to arpav_ppcv/webapp/api_v1/app.py diff --git a/arpav_ppcv/webapp/v1/dependencies.py b/arpav_ppcv/webapp/api_v1/dependencies.py similarity index 100% rename from arpav_ppcv/webapp/v1/dependencies.py rename to arpav_ppcv/webapp/api_v1/dependencies.py diff --git a/arpav_ppcv/webapp/v2/__init__.py b/arpav_ppcv/webapp/api_v1/routers/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v2/__init__.py rename to arpav_ppcv/webapp/api_v1/routers/__init__.py diff --git a/arpav_ppcv/webapp/v1/routers/forecastattributes.py b/arpav_ppcv/webapp/api_v1/routers/forecastattributes.py similarity index 100% rename from arpav_ppcv/webapp/v1/routers/forecastattributes.py rename to arpav_ppcv/webapp/api_v1/routers/forecastattributes.py diff --git a/arpav_ppcv/webapp/v1/routers/maps.py b/arpav_ppcv/webapp/api_v1/routers/maps.py similarity index 100% rename from arpav_ppcv/webapp/v1/routers/maps.py rename to arpav_ppcv/webapp/api_v1/routers/maps.py diff --git a/arpav_ppcv/webapp/v1/routers/ncss.py b/arpav_ppcv/webapp/api_v1/routers/ncss.py similarity index 100% rename from arpav_ppcv/webapp/v1/routers/ncss.py rename to arpav_ppcv/webapp/api_v1/routers/ncss.py diff --git a/arpav_ppcv/webapp/v1/routers/places.py b/arpav_ppcv/webapp/api_v1/routers/places.py similarity index 100% rename from arpav_ppcv/webapp/v1/routers/places.py rename to arpav_ppcv/webapp/api_v1/routers/places.py diff --git a/arpav_ppcv/webapp/v1/schemas.py b/arpav_ppcv/webapp/api_v1/schemas.py similarity index 100% rename from arpav_ppcv/webapp/v1/schemas.py rename to arpav_ppcv/webapp/api_v1/schemas.py diff --git a/arpav_ppcv/webapp/v1/util.py b/arpav_ppcv/webapp/api_v1/util.py similarity index 100% rename from arpav_ppcv/webapp/v1/util.py rename to arpav_ppcv/webapp/api_v1/util.py diff --git a/arpav_ppcv/webapp/v2/admin/__init__.py b/arpav_ppcv/webapp/api_v2/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v2/admin/__init__.py rename to arpav_ppcv/webapp/api_v2/__init__.py diff --git a/arpav_ppcv/webapp/v2/app.py b/arpav_ppcv/webapp/api_v2/app.py similarity index 78% rename from arpav_ppcv/webapp/v2/app.py rename to arpav_ppcv/webapp/api_v2/app.py index a787793e..8636c375 100644 --- a/arpav_ppcv/webapp/v2/app.py +++ b/arpav_ppcv/webapp/api_v2/app.py @@ -1,9 +1,10 @@ import fastapi from ... import config -from .admin.app import create_admin +from .routers.coverages import router as coverages_router from .routers.thredds import router as thredds_router from .routers.observations import router as observations_router +from .routers.base import router as base_router def create_app(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: @@ -21,9 +22,9 @@ def create_app(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: "email": settings.contact.email }, ) + app.include_router(base_router, prefix="/base", tags=["base",]) + app.include_router(coverages_router, prefix="/coverages", tags=["coverages",]) app.include_router(thredds_router, prefix="/thredds", tags=["thredds",]) app.include_router( observations_router, prefix="/observations", tags=["observations",]) - admin = create_admin(settings) - admin.mount_to(app) return app diff --git a/arpav_ppcv/webapp/v2/routers/__init__.py b/arpav_ppcv/webapp/api_v2/routers/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v2/routers/__init__.py rename to arpav_ppcv/webapp/api_v2/routers/__init__.py diff --git a/arpav_ppcv/webapp/routers.py b/arpav_ppcv/webapp/api_v2/routers/base.py similarity index 80% rename from arpav_ppcv/webapp/routers.py rename to arpav_ppcv/webapp/api_v2/routers/base.py index 6a0b7783..b7fc19fe 100644 --- a/arpav_ppcv/webapp/routers.py +++ b/arpav_ppcv/webapp/api_v2/routers/base.py @@ -4,14 +4,14 @@ from fastapi import APIRouter -from . import schemas +from ..schemas.base import AppInformation logger = logging.getLogger(__name__) router = APIRouter() -@router.get("/", response_model=schemas.AppInformation) +@router.get("/", response_model=AppInformation) async def get_app_info(): """Return information about the ARPAV-PPCV application.""" return { diff --git a/arpav_ppcv/webapp/api_v2/routers/coverages.py b/arpav_ppcv/webapp/api_v2/routers/coverages.py new file mode 100644 index 00000000..f516a9a6 --- /dev/null +++ b/arpav_ppcv/webapp/api_v2/routers/coverages.py @@ -0,0 +1,106 @@ +import logging +import urllib.parse +from typing import Annotated + +import httpx +import pydantic +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + Response, + status, +) +from sqlmodel import Session + +from .... import database +from ....config import ArpavPpcvSettings +from ....operations import thredds as thredds_ops +from ... import dependencies +from ..schemas import coverages +from ..schemas.base import ( + ListMeta, + ListLinks, +) + + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get( + "/coverage-configurations", + response_model=coverages.CoverageConfigurationList +) +async def list_coverage_configurations( + request: Request, + db_session: Annotated[Session, Depends(dependencies.get_db_session)], + list_params: Annotated[dependencies.CommonListFilterParameters, Depends()], +): + """### List coverage configurations. + + A coverage configuration represents a set of multiple NetCDF files that are + available in the ARPAV THREDDS server. + + A coverage configuration can be used to generate ids that refer to individual + NetCDF files by constructing a string based on the `dataset_id_pattern` property. + For example, If there is a dataset configuration with the following properties: + + ```yaml + identifier: myds + dataset_id_pattern: {identifier}-something-{scenario}-{year_period} + allowed_values: + scenario: + - scen1 + - scen2 + year_period: + - winter + - autumn + ``` + + Then the following would be valid dataset identifiers: + + - `myds-something-scen1-winter` + - `myds-something-scen1-autumn` + - `myds-something-scen2-winter` + - `myds-something-scen2-autumn` + + Each of these dataset identifiers could further be used to gain access to the WMS + endpoint. + + """ + coverage_configurations, filtered_total = database.list_coverage_configurations( + db_session, + limit=list_params.limit, + offset=list_params.offset, + include_total=True + ) + _, unfiltered_total = database.list_coverage_configurations( + db_session, limit=1, offset=0, include_total=True + ) + return coverages.CoverageConfigurationList.from_items( + coverage_configurations, + request, + limit=list_params.limit, + offset=list_params.offset, + filtered_total=filtered_total, + unfiltered_total=unfiltered_total + ) + + +@router.get( + "/coverage-configurations/{coverage_configuration_id}", + response_model=coverages.CoverageConfigurationReadDetail, +) +def get_coverage_configuration( + request: Request, + db_session: Annotated[Session, Depends(dependencies.get_db_session)], + coverage_configuration_id: pydantic.UUID4 +): + db_coverage_configuration = database.get_coverage_configuration( + db_session, coverage_configuration_id) + allowed_coverage_identifiers = database.list_allowed_coverage_identifiers( + db_session, coverage_configuration_id=db_coverage_configuration.id) + return coverages.CoverageConfigurationReadDetail.from_db_instance( + db_coverage_configuration, allowed_coverage_identifiers, request) diff --git a/arpav_ppcv/webapp/v2/routers/observations.py b/arpav_ppcv/webapp/api_v2/routers/observations.py similarity index 100% rename from arpav_ppcv/webapp/v2/routers/observations.py rename to arpav_ppcv/webapp/api_v2/routers/observations.py diff --git a/arpav_ppcv/webapp/v2/routers/thredds.py b/arpav_ppcv/webapp/api_v2/routers/thredds.py similarity index 100% rename from arpav_ppcv/webapp/v2/routers/thredds.py rename to arpav_ppcv/webapp/api_v2/routers/thredds.py diff --git a/arpav_ppcv/webapp/v2/schemas/__init__.py b/arpav_ppcv/webapp/api_v2/schemas/__init__.py similarity index 100% rename from arpav_ppcv/webapp/v2/schemas/__init__.py rename to arpav_ppcv/webapp/api_v2/schemas/__init__.py diff --git a/arpav_ppcv/webapp/v2/schemas/base.py b/arpav_ppcv/webapp/api_v2/schemas/base.py similarity index 98% rename from arpav_ppcv/webapp/v2/schemas/base.py rename to arpav_ppcv/webapp/api_v2/schemas/base.py index e4a9a9fb..1c7d1ba9 100644 --- a/arpav_ppcv/webapp/v2/schemas/base.py +++ b/arpav_ppcv/webapp/api_v2/schemas/base.py @@ -11,6 +11,11 @@ R = typing.TypeVar("R", bound="ApiReadableModel") +class AppInformation(pydantic.BaseModel): + version: str + git_commit: str + + @typing.runtime_checkable class ApiReadableModel(typing.Protocol): """Protocol to be used by all schema models that represent API resources. diff --git a/arpav_ppcv/webapp/api_v2/schemas/coverages.py b/arpav_ppcv/webapp/api_v2/schemas/coverages.py new file mode 100644 index 00000000..8b79ca86 --- /dev/null +++ b/arpav_ppcv/webapp/api_v2/schemas/coverages.py @@ -0,0 +1,104 @@ +import uuid + +import pydantic +from fastapi import Request + +from .base import WebResourceList +from ....schemas import coverages as app_models + + +class ForecastModelScenario(pydantic.BaseModel): + name: str + code: str + + +# class CoverageConfigurationRead(pydantic.BaseModel): +# identifier: str +# dataset_id_pattern: str +# unit: str | None = None +# palette: str +# range: list[float] +# allowed_values: dict[str, list[str]] | None = None + + +class ConfigurationParameterPossibleValueRead(pydantic.BaseModel): + configuration_parameter_name: str + configuration_parameter_value: str + + +class CoverageConfigurationReadListItem(pydantic.BaseModel): + url: pydantic.AnyHttpUrl + id: uuid.UUID + identifier: str + coverage_id_pattern: str + name: str + possible_values: list[ConfigurationParameterPossibleValueRead] + + @classmethod + def from_db_instance( + cls, + instance: app_models.CoverageConfiguration, + request: Request, + ) -> "CoverageConfigurationReadListItem": + url = request.url_for( + "get_coverage_configuration", + **{"coverage_configuration_id": instance.id} + ) + return cls( + **instance.model_dump(), + url=str(url), + possible_values=[ + ConfigurationParameterPossibleValueRead( + configuration_parameter_name=pv.configuration_parameter_value.configuration_parameter.name, + configuration_parameter_value=pv.configuration_parameter_value.name + ) for pv in instance.possible_values + ] + ) + + +class CoverageConfigurationReadDetail(CoverageConfigurationReadListItem): + url: pydantic.AnyHttpUrl + unit: str + palette: str + color_scale_min: float + color_scale_max: float + allowed_coverage_identifiers: list[str] + + @classmethod + def from_db_instance( + cls, + instance: app_models.CoverageConfiguration, + allowed_coverage_identifiers: list[str], + request: Request, + ) -> "CoverageConfigurationReadDetail": + url = request.url_for( + "get_coverage_configuration", + **{"coverage_configuration_id": instance.id} + ) + return cls( + **instance.model_dump(), + url=str(url), + possible_values=[ + ConfigurationParameterPossibleValueRead( + configuration_parameter_name=pv.configuration_parameter_value.configuration_parameter.name, + configuration_parameter_value=pv.configuration_parameter_value.name + ) for pv in instance.possible_values + ], + allowed_coverage_identifiers=allowed_coverage_identifiers + ) + + +class CoverageConfigurationList(WebResourceList): + items: list[CoverageConfigurationReadListItem] + list_item_type = CoverageConfigurationReadListItem + path_operation_name = "list_coverage_configurations" + + +class CoverageIdentifierList(WebResourceList): + items: list[str] + list_item_type = str + path_operation_name = "list_coverage_identifiers" + + +class ForecastModelScenarioList(WebResourceList): + items: list[ForecastModelScenario] diff --git a/arpav_ppcv/webapp/v2/schemas/observations.py b/arpav_ppcv/webapp/api_v2/schemas/observations.py similarity index 100% rename from arpav_ppcv/webapp/v2/schemas/observations.py rename to arpav_ppcv/webapp/api_v2/schemas/observations.py diff --git a/arpav_ppcv/webapp/v2/schemas/thredds.py b/arpav_ppcv/webapp/api_v2/schemas/thredds.py similarity index 100% rename from arpav_ppcv/webapp/v2/schemas/thredds.py rename to arpav_ppcv/webapp/api_v2/schemas/thredds.py diff --git a/arpav_ppcv/webapp/app.py b/arpav_ppcv/webapp/app.py index 4f6086b3..674227b7 100644 --- a/arpav_ppcv/webapp/app.py +++ b/arpav_ppcv/webapp/app.py @@ -1,44 +1,41 @@ import fastapi from fastapi.middleware.wsgi import WSGIMiddleware -from fastapi.staticfiles import StaticFiles +from starlette.applications import Starlette +from starlette.staticfiles import StaticFiles +from starlette.templating import Jinja2Templates from .. import config -from .v2.app import create_app as create_v2_app -from .v1.app import create_app as create_v1_app +from .api_v2.app import create_app as create_v2_app +from .admin.app import create_admin +from .api_v1.app import create_app as create_v1_app from .legacy.app import create_django_app -from .routers import router +from .routes import routes def create_app_from_settings(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: - v2_app = create_v2_app(settings) - v2_docs_url = "".join( - (settings.public_url, settings.v2_mount_prefix, v2_app.docs_url)) - v1_app = create_v1_app(settings) - v1_docs_url = "".join( - (settings.public_url, settings.v1_mount_prefix, v1_app.docs_url)) - django_app = create_django_app(settings) - app = fastapi.FastAPI( + app = Starlette( debug=settings.debug, - title="ARPAV PPCV backend", - description=( - f"### Developer API for ARPAV-PPCV backend\n" - f"This is the root of the ARPAV-PPCV application - please head over " - f"to either:\n" - f"- <{v2_docs_url}> for info on version 2 of the API\n" - f"- <{v1_docs_url}> for info on version 1 of the API\n" - f"- <{''.join((settings.public_url, settings.django_app.mount_prefix))}> " - f"for accessing the older django-rest-framework API - " - f"There is no docs URL for this unfortunately\n" - ), - contact={ - "name": settings.contact.name, - "url": settings.contact.url, - "email": settings.contact.email - }, + routes=routes, + ) + settings.static_dir.mkdir(parents=True, exist_ok=True) + app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") + admin = create_admin(settings) + admin.mount_to(app) + v2_api = create_v2_app(settings) + v1_api = create_v1_app(settings) + django_app = create_django_app(settings) + app.state.settings = settings + app.state.templates = Jinja2Templates( + str(settings.templates_dir) ) - app.include_router(router) - app.mount(settings.v1_mount_prefix, v1_app) - app.mount(settings.v2_mount_prefix, v2_app) + app.state.v1_api_docs_url = "".join( + (settings.public_url, settings.v1_api_mount_prefix, v1_api.docs_url)) + app.state.v2_api_docs_url = "".join( + (settings.public_url, settings.v2_api_mount_prefix, v2_api.docs_url)) + app.state.legacy_base_url = "".join( + (settings.public_url, settings.django_app.mount_prefix)) + app.mount(settings.v1_api_mount_prefix, v1_api) + app.mount(settings.v2_api_mount_prefix, v2_api) app.mount(settings.django_app.mount_prefix, WSGIMiddleware(django_app)) settings.django_app.static_root.mkdir(parents=True, exist_ok=True) app.mount( diff --git a/arpav_ppcv/webapp/routes.py b/arpav_ppcv/webapp/routes.py new file mode 100644 index 00000000..4edead51 --- /dev/null +++ b/arpav_ppcv/webapp/routes.py @@ -0,0 +1,23 @@ +"""Routes for the main starlette application.""" + +from starlette.templating import Jinja2Templates +from starlette.requests import Request +from starlette.routing import Route + + +def landing_page(request: Request): + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "landing_page.html", + context={ + "v1_api_docs_url": request.app.state.v1_api_docs_url, + "v2_api_docs_url": request.app.state.v2_api_docs_url, + "legacy_base_url": request.app.state.legacy_base_url, + } + ) + + +routes = [ + Route("/", landing_page), +] \ No newline at end of file diff --git a/arpav_ppcv/webapp/schemas.py b/arpav_ppcv/webapp/schemas.py deleted file mode 100644 index 6e56c7f4..00000000 --- a/arpav_ppcv/webapp/schemas.py +++ /dev/null @@ -1,6 +0,0 @@ -import pydantic - - -class AppInformation(pydantic.BaseModel): - version: str - git_commit: str diff --git a/arpav_ppcv/webapp/templates/landing_page.html b/arpav_ppcv/webapp/templates/landing_page.html new file mode 100644 index 00000000..e7e173ae --- /dev/null +++ b/arpav_ppcv/webapp/templates/landing_page.html @@ -0,0 +1,20 @@ + + + + + Title + + +

ARPAV-PPCV backend

+

This is the root of the ARPAV-PPCV backend web application.

+

Please head over to either:

+ + + + \ No newline at end of file From a2616885173aa65bb1270ba64abaa161eb1b8e92 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Mon, 6 May 2024 13:38:42 +0100 Subject: [PATCH 07/10] Fixed conf parameter edits and refatored wms view to work from db values rather than from config --- arpav_ppcv/database.py | 19 +- arpav_ppcv/operations/__init__.py | 0 arpav_ppcv/operations/thredds.py | 29 --- arpav_ppcv/schemas/coverages.py | 74 +++++- arpav_ppcv/thredds/utils.py | 9 +- arpav_ppcv/webapp/admin/app.py | 10 + arpav_ppcv/webapp/admin/schemas.py | 3 +- arpav_ppcv/webapp/admin/views.py | 53 ++++- arpav_ppcv/webapp/api_v2/app.py | 2 - arpav_ppcv/webapp/api_v2/routers/coverages.py | 120 ++++++++-- arpav_ppcv/webapp/api_v2/routers/thredds.py | 211 ------------------ arpav_ppcv/webapp/api_v2/schemas/coverages.py | 12 +- .../templates/admin/forms/collection.html | 21 ++ 13 files changed, 267 insertions(+), 296 deletions(-) delete mode 100644 arpav_ppcv/operations/__init__.py delete mode 100644 arpav_ppcv/operations/thredds.py delete mode 100644 arpav_ppcv/webapp/api_v2/routers/thredds.py create mode 100644 arpav_ppcv/webapp/templates/admin/forms/collection.html diff --git a/arpav_ppcv/database.py b/arpav_ppcv/database.py index bc2d422c..ec13e1e5 100644 --- a/arpav_ppcv/database.py +++ b/arpav_ppcv/database.py @@ -494,7 +494,7 @@ def update_configuration_parameter( else: # this is an existing allowed value, lets update db_allowed_value = get_configuration_parameter_value(session, av.id) - for prop, value in av.model_dump(exclude_none=True, exclude_unset=True).items(): + for prop, value in av.model_dump(exclude={"id"}, exclude_none=True, exclude_unset=True).items(): setattr(db_allowed_value, prop, value) session.add(db_allowed_value) to_refresh.append(db_allowed_value) @@ -517,6 +517,21 @@ def get_coverage_configuration( return session.get(coverages.CoverageConfiguration, coverage_configuration_id) +def get_coverage_configuration_by_name( + session: sqlmodel.Session, + coverage_configuration_name: str +) -> Optional[coverages.CoverageConfiguration]: + """Get a coverage configuration by its name. + + Since a coverage configuration name is unique, it can be used to uniquely + identify it. + """ + return session.exec( + sqlmodel.select(coverages.CoverageConfiguration) + .where(coverages.CoverageConfiguration.name == coverage_configuration_name) + ).first() + + def list_coverage_configurations( session: sqlmodel.Session, *, @@ -646,7 +661,7 @@ def list_allowed_coverage_identifiers( if len(container) == 0: values_to_combine[index] = [pattern_parts[index]] for combination in itertools.product(*values_to_combine): - dataset_id = "-".join((db_cov_conf.identifier, *combination)) + dataset_id = "-".join((db_cov_conf.name, *combination)) result.append(dataset_id) return result diff --git a/arpav_ppcv/operations/__init__.py b/arpav_ppcv/operations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/arpav_ppcv/operations/thredds.py b/arpav_ppcv/operations/thredds.py deleted file mode 100644 index 6c713dbf..00000000 --- a/arpav_ppcv/operations/thredds.py +++ /dev/null @@ -1,29 +0,0 @@ -import itertools -import re - -from .. import config - - -def list_dataset_configurations( - settings: config.ArpavPpcvSettings -) -> dict[str, config.ThreddsDatasetSettings]: - return settings.thredds_server.datasets - - -def list_dataset_identifiers( - dataset_config_identifier: str, - dataset_config: config.ThreddsDatasetSettings -) -> list[str]: - pattern_parts = re.findall( - r"\{(\w+)\}", - dataset_config.dataset_id_pattern.partition("-")[-1]) - values_to_combine = [] - for part in pattern_parts: - part_allowed_values = dataset_config.allowed_values.get(part, []) - values_to_combine.append(part_allowed_values) - result = [] - for combination in itertools.product(*values_to_combine): - dataset_id = "-".join((dataset_config_identifier, *combination)) - result.append(dataset_id) - return result - diff --git a/arpav_ppcv/schemas/coverages.py b/arpav_ppcv/schemas/coverages.py index 0aacee91..3252dc5a 100644 --- a/arpav_ppcv/schemas/coverages.py +++ b/arpav_ppcv/schemas/coverages.py @@ -12,7 +12,7 @@ import sqlmodel logger = logging.getLogger(__name__) -_NAME_PATTERN: Final[str] = r"^\w+$" +_NAME_PATTERN: Final[str] = r"^[a-z][a-z0-9_]+$" class ConfigurationParameterValue(sqlmodel.SQLModel, table=True): @@ -96,7 +96,10 @@ class ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit(sqlm class ConfigurationParameterUpdate(sqlmodel.SQLModel): - name: Annotated[Optional[str], pydantic.Field(pattern=_NAME_PATTERN)] = None + name: Annotated[ + Optional[str], + pydantic.Field(pattern=_NAME_PATTERN) + ] = None description: Optional[str] = None allowed_values: list[ @@ -129,22 +132,68 @@ class CoverageConfiguration(sqlmodel.SQLModel, table=True): } ) - @pydantic.computed_field() - @property - def identifier(self) -> str: - return self.name.translate(str.maketrans({" ": "_", "-": "_"})).lower() - @pydantic.computed_field() @property def coverage_id_pattern(self) -> str: - id_parts = ["{identifier}"] + id_parts = ["{name}"] for match_obj in re.finditer(r"(\{\w+\})", self.thredds_url_pattern): id_parts.append(match_obj.group(1)) return "-".join(id_parts) + def get_thredds_url_fragment(self, coverage_identifier: str) -> str: + used_values = self.retrieve_used_values(coverage_identifier) + rendered = self.thredds_url_pattern + for used_value in used_values: + param_name = used_value.configuration_parameter_value.configuration_parameter.name + rendered = rendered.replace( + f"{{{param_name}}}", used_value.configuration_parameter_value.name) + return rendered + + def retrieve_used_values( + self, + coverage_identifier: str + ) -> list["ConfigurationParameterPossibleValue"]: + parsed_parameters = self.retrieve_configuration_parameters(coverage_identifier) + result = [] + for param_name, value in parsed_parameters.items(): + for pv in self.possible_values: + matches_param_name = ( + pv.configuration_parameter_value.configuration_parameter.name == param_name + ) + matches_param_value = pv.configuration_parameter_value.name == value + if matches_param_name and matches_param_value: + result.append(pv) + break + else: + raise ValueError( + f"Invalid parameter/value pair: {(param_name, value)}") + return result + + def retrieve_configuration_parameters(self, coverage_identifier) -> dict[str, str]: + pattern_parts = re.finditer( + r"\{(\w+)\}", + self.coverage_id_pattern.partition("-")[-1]) + id_parts = coverage_identifier.split("-")[1:] + result = {} + for index, pattern_match_obj in enumerate(pattern_parts): + id_part = id_parts[index] + configuration_parameter_name = pattern_match_obj.group(1) + result[configuration_parameter_name] = id_part + return result + class CoverageConfigurationCreate(sqlmodel.SQLModel): - name: str + + name: Annotated[ + str, + pydantic.Field( + pattern=_NAME_PATTERN, + help=( + "Coverage configuration name. Only alphanumeric characters and the " + "underscore are allowed. Example: my_name" + ) + ) + ] thredds_url_pattern: str unit: str palette: str @@ -163,7 +212,12 @@ def validate_thredds_url_pattern(cls, v: str) -> str: class CoverageConfigurationUpdate(sqlmodel.SQLModel): - name: Optional[str] = None + name: Annotated[ + Optional[str], + pydantic.Field( + pattern=_NAME_PATTERN + ) + ] = None thredds_url_pattern: Optional[str] = None unit: Optional[str] = None palette: Optional[str] = None diff --git a/arpav_ppcv/thredds/utils.py b/arpav_ppcv/thredds/utils.py index e14d0f0b..99e6a7c7 100644 --- a/arpav_ppcv/thredds/utils.py +++ b/arpav_ppcv/thredds/utils.py @@ -43,7 +43,8 @@ async def proxy_request(url: str, http_client: httpx.AsyncClient) -> httpx.Respo def tweak_wms_get_map_request( query_params: dict[str, str], - dataset_configuration: config.ThreddsDatasetSettings, + ncwms_palette: str, + ncwms_color_scale_range: tuple[float, float], uncertainty_visualization_scale_range: tuple[float, float] ) -> dict[str, str]: # which layer type is being requested? @@ -61,12 +62,12 @@ def tweak_wms_get_map_request( query_params["NUMCOLORBANDS"] = num_color_bands else: if "uncertainty_group" in layer_name: - palette = dataset_configuration.palette + palette = ncwms_palette else: - palette = f"default/{dataset_configuration.palette.rpartition('/')[-1]}" + palette = f"default/{ncwms_palette.rpartition('/')[-1]}" if not (requested_color_scale_range := query_params.get("colorscalerange")): - color_scale_range = ",".join(str(f) for f in dataset_configuration.range) + color_scale_range = ",".join(str(f) for f in ncwms_color_scale_range) if "stippled" in palette: uncert_scale_range = ",".join( str(f) for f in uncertainty_visualization_scale_range) diff --git a/arpav_ppcv/webapp/admin/app.py b/arpav_ppcv/webapp/admin/app.py index 188d88ac..0c92146c 100644 --- a/arpav_ppcv/webapp/admin/app.py +++ b/arpav_ppcv/webapp/admin/app.py @@ -2,6 +2,7 @@ from starlette.middleware import Middleware from starlette_admin.contrib.sqlmodel import Admin +from starlette_admin.views import Link from ...import ( config, @@ -19,6 +20,7 @@ def create_admin(settings: config.ArpavPpcvSettings) -> Admin: admin = Admin( engine, debug=settings.debug, + templates_dir=str(settings.templates_dir / 'admin'), middlewares=[ Middleware(SqlModelDbSessionMiddleware, engine=engine) ] @@ -27,4 +29,12 @@ def create_admin(settings: config.ArpavPpcvSettings) -> Admin: views.ConfigurationParameterView(coverages.ConfigurationParameter)) admin.add_view( views.CoverageConfigurationView(coverages.CoverageConfiguration)) + admin.add_view( + Link( + "V2 API docs", + icon="fa fa-link", + url=f"{settings.public_url}{settings.v2_api_mount_prefix}/docs", + target="blank_" + ) + ) return admin diff --git a/arpav_ppcv/webapp/admin/schemas.py b/arpav_ppcv/webapp/admin/schemas.py index 94740f53..59bb6412 100644 --- a/arpav_ppcv/webapp/admin/schemas.py +++ b/arpav_ppcv/webapp/admin/schemas.py @@ -23,9 +23,8 @@ class ConfigurationParameterPossibleValueRead(sqlmodel.SQLModel): class CoverageConfigurationRead(sqlmodel.SQLModel): id: uuid.UUID - identifier: str - coverage_id_pattern: str name: str + coverage_id_pattern: str thredds_url_pattern: str unit: str palette: str diff --git a/arpav_ppcv/webapp/admin/views.py b/arpav_ppcv/webapp/admin/views.py index 0b229ae2..ed6a363f 100644 --- a/arpav_ppcv/webapp/admin/views.py +++ b/arpav_ppcv/webapp/admin/views.py @@ -32,6 +32,16 @@ class UuidField(starlette_admin.StringField): + """Custom field for handling item identifiers. + + This field, in conjuction with the custom collection template, ensures + that we can have related fields be edited inline, by sending the item's `id` + as a form hidden field. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.input_type = "hidden" async def serialize_value( self, request: Request, value: Any, action: RequestAction @@ -88,7 +98,7 @@ class ConfigurationParameterView(ModelView): UuidField( "id", read_only=True, - disabled=True, + # disabled=True, exclude_from_list=True, exclude_from_detail=True, exclude_from_create=True, @@ -132,7 +142,13 @@ async def create(self, request: Request, data: Dict[str, Any]) -> Any: config_param_create ) configuration_parameter_read = read_schemas.ConfigurationParameterRead( - **db_configuration_parameter.model_dump() + **db_configuration_parameter.model_dump( + exclude={"allowed_values"} + ), + allowed_values=[ + read_schemas.ConfigurationParameterValueRead(**av.model_dump()) + for av in db_configuration_parameter.allowed_values + ] ) logger.debug("About to leave the create instance") logger.debug(f"{configuration_parameter_read=}") @@ -149,7 +165,7 @@ async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: description=data.get("description"), allowed_values=[ coverages.ConfigurationParameterValueUpdateEmbeddedInConfigurationParameterEdit( - id=av["id"], + id=av["id"] or None, name=av.get("name"), description=av.get("description") ) for av in data["allowed_values"] @@ -175,6 +191,7 @@ async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: ) return conf_param_read except Exception as e: + logger.exception("something went wrong") self.handle_exception(e) async def find_by_pk( @@ -247,7 +264,6 @@ class CoverageConfigurationView(ModelView): UuidField("id"), starlette_admin.StringField("name"), starlette_admin.StringField("thredds_url_pattern"), - starlette_admin.StringField("identifier", disabled=True), starlette_admin.StringField("coverage_id_pattern", disabled=True), starlette_admin.StringField("unit"), starlette_admin.StringField("palette"), @@ -261,6 +277,12 @@ class CoverageConfigurationView(ModelView): exclude_fields_from_list = ( "id", + "coverage_id_pattern", + "possible_values", + "unit", + "palette", + "color_scale_min", + "color_scale_max", ) async def get_pk_value(self, request: Request, obj: Any) -> Any: @@ -354,7 +376,17 @@ async def create(self, request: Request, data: Dict[str, Any]) -> Any: session, cov_conf_create) coverage_configuration_read = read_schemas.CoverageConfigurationRead( - **db_cov_conf.model_dump()) + **db_cov_conf.model_dump( + exclude={"possible_values"} + ), + possible_values=[ + read_schemas.ConfigurationParameterPossibleValueRead( + configuration_parameter_value_id=pv.configuration_parameter_value_id, + configuration_parameter_value_name=pv.configuration_parameter_value.name + ) + for pv in db_cov_conf.possible_values + ] + ) return coverage_configuration_read except Exception as e: return self.handle_exception(e) @@ -395,7 +427,16 @@ async def edit(self, request: Request, pk: Any, data: Dict[str, Any]) -> Any: cov_conv_update ) cov_conf_read = read_schemas.CoverageConfigurationRead( - **db_coverage_configuration.model_dump(), + **db_coverage_configuration.model_dump( + exclude={"possible_values"} + ), + possible_values=[ + read_schemas.ConfigurationParameterPossibleValueRead( + configuration_parameter_value_id=pv.configuration_parameter_value_id, + configuration_parameter_value_name=pv.configuration_parameter_value.name + ) + for pv in db_coverage_configuration.possible_values + ] ) return cov_conf_read except Exception as e: diff --git a/arpav_ppcv/webapp/api_v2/app.py b/arpav_ppcv/webapp/api_v2/app.py index 8636c375..830d3233 100644 --- a/arpav_ppcv/webapp/api_v2/app.py +++ b/arpav_ppcv/webapp/api_v2/app.py @@ -2,7 +2,6 @@ from ... import config from .routers.coverages import router as coverages_router -from .routers.thredds import router as thredds_router from .routers.observations import router as observations_router from .routers.base import router as base_router @@ -24,7 +23,6 @@ def create_app(settings: config.ArpavPpcvSettings) -> fastapi.FastAPI: ) app.include_router(base_router, prefix="/base", tags=["base",]) app.include_router(coverages_router, prefix="/coverages", tags=["coverages",]) - app.include_router(thredds_router, prefix="/thredds", tags=["thredds",]) app.include_router( observations_router, prefix="/observations", tags=["observations",]) return app diff --git a/arpav_ppcv/webapp/api_v2/routers/coverages.py b/arpav_ppcv/webapp/api_v2/routers/coverages.py index f516a9a6..2cb52cb1 100644 --- a/arpav_ppcv/webapp/api_v2/routers/coverages.py +++ b/arpav_ppcv/webapp/api_v2/routers/coverages.py @@ -16,13 +16,9 @@ from .... import database from ....config import ArpavPpcvSettings -from ....operations import thredds as thredds_ops +from ....thredds import utils as thredds_utils from ... import dependencies from ..schemas import coverages -from ..schemas.base import ( - ListMeta, - ListLinks, -) logger = logging.getLogger(__name__) @@ -43,30 +39,33 @@ async def list_coverage_configurations( A coverage configuration represents a set of multiple NetCDF files that are available in the ARPAV THREDDS server. - A coverage configuration can be used to generate ids that refer to individual - NetCDF files by constructing a string based on the `dataset_id_pattern` property. - For example, If there is a dataset configuration with the following properties: + A coverage configuration can be used to generate *coverage identifiers* that + refer to individual NetCDF files by constructing a string based on the + `dataset_id_pattern` property. For example, If there is a coverage configuration + with the following properties: ```yaml - identifier: myds - dataset_id_pattern: {identifier}-something-{scenario}-{year_period} - allowed_values: - scenario: - - scen1 - - scen2 - year_period: - - winter - - autumn + name: myds + coverage_id_pattern: {name}-something-{scenario}-{year_period} + possible_values: + - configuration_parameter_name: scenario + configuration_parameter_value: scen1 + - configuration_parameter_name: scenario + configuration_parameter_value: scen2 + - configuration_parameter_name: year_period + configuration_parameter_value: winter + - configuration_parameter_name: year_period + configuration_parameter_value: autumn ``` - Then the following would be valid dataset identifiers: + Then the following would be valid coverage identifiers: - `myds-something-scen1-winter` - `myds-something-scen1-autumn` - `myds-something-scen2-winter` - `myds-something-scen2-autumn` - Each of these dataset identifiers could further be used to gain access to the WMS + Each of these coverage identifiers could further be used to gain access to the WMS endpoint. """ @@ -104,3 +103,86 @@ def get_coverage_configuration( db_session, coverage_configuration_id=db_coverage_configuration.id) return coverages.CoverageConfigurationReadDetail.from_db_instance( db_coverage_configuration, allowed_coverage_identifiers, request) + + +@router.get("/wms/{coverage_identifier}") +async def wms_endpoint( + request: Request, + db_session: Annotated[Session, Depends(dependencies.get_db_session)], + settings: Annotated[ArpavPpcvSettings, Depends(dependencies.get_settings)], + http_client: Annotated[httpx.AsyncClient, Depends(dependencies.get_http_client)], + coverage_identifier: str, + version: str = "1.3.0", +): + """### Serve coverage via OGC Web Map Service. + + Pass additional relevant WMS query parameters directly to this endpoint. + """ + coverage_configuration_name = coverage_identifier.partition("-")[0] + db_coverage_configuration = database.get_coverage_configuration_by_name( + db_session, coverage_configuration_name) + if db_coverage_configuration is not None: + try: + thredds_url_fragment = db_coverage_configuration.get_thredds_url_fragment(coverage_identifier) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid coverage_identifier") + else: + base_wms_url = "/".join(( + settings.thredds_server.base_url, + settings.thredds_server.wms_service_url_fragment, + thredds_url_fragment + )) + parsed_url = urllib.parse.urlparse(base_wms_url) + logger.info(f"{base_wms_url=}") + query_params = {k.lower(): v for k, v in request.query_params.items()} + logger.debug(f"original query params: {query_params=}") + if query_params.get("request") in ("GetMap", "GetLegendGraphic"): + query_params = thredds_utils.tweak_wms_get_map_request( + query_params, + ncwms_palette=db_coverage_configuration.palette, + ncwms_color_scale_range=( + db_coverage_configuration.color_scale_min, + db_coverage_configuration.color_scale_max), + uncertainty_visualization_scale_range=( + settings.thredds_server.uncertainty_visualization_scale_range) + ) + elif query_params.get("request") == "GetCapabilities": + # TODO: need to tweak the reported URLs + # the response to a GetCapabilities request includes URLs for each + # operation and some clients (like QGIS) use them for GetMap and + # GetLegendGraphic - need to ensure these do not refer to the underlying + # THREDDS server + ... + logger.debug(f"{query_params=}") + wms_url = parsed_url._replace( + query=urllib.parse.urlencode( + { + **query_params, + "service": "WMS", + "version": version, + } + ) + ).geturl() + logger.info(f"{wms_url=}") + try: + wms_response = await thredds_utils.proxy_request(wms_url, http_client) + except httpx.HTTPStatusError as err: + logger.exception(msg=f"THREDDS server replied with an error: {err.response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=err.response.text + ) + except httpx.HTTPError as err: + logger.exception(msg=f"THREDDS server replied with an error") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + ) from err + else: + response = Response( + content=wms_response.content, + status_code=wms_response.status_code, + headers=dict(wms_response.headers) + ) + return response + else: + raise HTTPException(status_code=400, detail="Invalid coverage_identifier") \ No newline at end of file diff --git a/arpav_ppcv/webapp/api_v2/routers/thredds.py b/arpav_ppcv/webapp/api_v2/routers/thredds.py deleted file mode 100644 index d2898da2..00000000 --- a/arpav_ppcv/webapp/api_v2/routers/thredds.py +++ /dev/null @@ -1,211 +0,0 @@ -import logging -import urllib.parse -from typing import Annotated - -import httpx -from fastapi import ( - APIRouter, - Depends, - HTTPException, - Request, - Response, - status, -) - -from ....config import ArpavPpcvSettings -from ..schemas.thredds import ( - ThreddsDatasetConfiguration, - ThreddsDatasetConfigurationIdentifierList, - ThreddsDatasetConfigurationList, -) -from ..schemas.base import ( - ListMeta, - ListLinks, -) -from ....thredds import utils as thredds_utils -from ....operations import thredds as thredds_ops -from ... import dependencies - - -logger = logging.getLogger(__name__) -router = APIRouter() - - -@router.get("/") -async def landing_page(): - ... - - -@router.get( - "/thredds-dataset-configurations", - response_model=ThreddsDatasetConfigurationList -) -async def list_thredds_dataset_configurations( - request: Request, - setttings: Annotated[ArpavPpcvSettings, Depends(dependencies.get_settings)] -): - """### List THREDDS dataset configurations. - - A THREDDS dataset configuration represents a set of multiple NetCDF files that are - available in the ARPAV THREDDS server. - - A dataset configuration can be used to generate ids that refer to individual - NetCDF files by constructing a string based on the `dataset_id_pattern` property. - For example, If there is a dataset configuration with the following properties: - - ```yaml - identifier: myds - dataset_id_pattern: {identifier}-something-{scenario}-{year_period} - allowed_values: - scenario: - - scen1 - - scen2 - year_period: - - winter - - autumn - ``` - - Then the following would be valid dataset identifiers: - - - `myds-something-scen1-winter` - - `myds-something-scen1-autumn` - - `myds-something-scen2-winter` - - `myds-something-scen2-autumn` - - Each of these dataset identifiers could further be used to gain access to the WMS - endpoint. - - """ - items = [] - for ds_id, ds in thredds_ops.list_dataset_configurations(setttings).items(): - items.append( - ThreddsDatasetConfiguration( - identifier=ds_id, - dataset_id_pattern=ds.dataset_id_pattern, - unit=ds.unit, - palette=ds.palette, - range=ds.range, - allowed_values=ds.allowed_values, - ) - ) - return ThreddsDatasetConfigurationList( - meta=ListMeta( - returned_records=len(items), - total_records=len(items), - total_filtered_records=len(items) - ), - links=ListLinks( - self=str(request.url_for("list_thredds_dataset_configurations")) - ), - items=items - ) - - -@router.get( - "/thredds_dataset_configurations/{configuration_id}/dataset-ids", - response_model=ThreddsDatasetConfigurationIdentifierList -) -async def list_dataset_identifiers( - request: Request, - settings: Annotated[ArpavPpcvSettings, Depends(dependencies.get_settings)], - configuration_id: str): - ds_config = settings.thredds_server.datasets[configuration_id] - items = thredds_ops.list_dataset_identifiers(configuration_id, ds_config) - return ThreddsDatasetConfigurationIdentifierList( - meta=ListMeta( - returned_records=len(items), - total_records=len(items), - total_filtered_records=len(items), - ), - links=ListLinks( - self=str( - request.url_for( - "list_dataset_identifiers", - configuration_id=configuration_id - ) - ) - ), - items=items - ) - - -@router.get("/wms/{dataset_id}") -async def wms_endpoint( - request: Request, - settings: Annotated[ArpavPpcvSettings, Depends(dependencies.get_settings)], - http_client: Annotated[httpx.AsyncClient, Depends(dependencies.get_http_client)], - dataset_id: str, - version: str = "1.3.0", -): - """### Serve dataset via OGC Web Map Service. - - Pass additional relevant WMS query parameters directly to this endpoint. - """ - ds_config_id = dataset_id.partition("-")[0] - logger.debug(f"{settings=}") - try: - ds_config = settings.thredds_server.datasets[ds_config_id] - except KeyError as err: - raise HTTPException(status_code=400, detail="Invalid dataset_id") from err - else: - id_parameters = ds_config.validate_dataset_id(dataset_id) - parsed_id_parameters = { - k: thredds_utils.get_parameter_internal_value(k, v) - for k, v in id_parameters.items() - } - logger.debug(f"{parsed_id_parameters=}") - base_wms_url = thredds_utils.build_dataset_service_url( - ds_config_id, - parsed_id_parameters, - url_path_pattern=ds_config.thredds_url_pattern, - thredds_base_url=settings.thredds_server.base_url, - service_url_fragment=settings.thredds_server.wms_service_url_fragment - ) - parsed_url = urllib.parse.urlparse(base_wms_url) - logger.info(f"{base_wms_url=}") - query_params = {k.lower(): v for k, v in request.query_params.items()} - logger.debug(f"original query params: {query_params=}") - if query_params.get("request") in ("GetMap", "GetLegendGraphic"): - query_params = thredds_utils.tweak_wms_get_map_request( - query_params, - ds_config, - settings.thredds_server.uncertainty_visualization_scale_range - ) - elif query_params.get("request") == "GetCapabilities": - # TODO: need to tweak the reported URLs - # the response to a GetCapabilities request includes URLs for each - # operation and some clients (like QGIS) use them for GetMap and - # GetLegendGraphic - need to ensure these do not refer to the underlying - # THREDDS server - ... - logger.debug(f"{query_params=}") - wms_url = parsed_url._replace( - query=urllib.parse.urlencode( - { - **query_params, - "service": "WMS", - "version": version, - } - ) - ).geturl() - logger.info(f"{wms_url=}") - try: - wms_response = await thredds_utils.proxy_request(wms_url, http_client) - except httpx.HTTPStatusError as err: - logger.exception(msg=f"THREDDS server replied with an error: {err.response.text}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=err.response.text - ) - except httpx.HTTPError as err: - logger.exception(msg=f"THREDDS server replied with an error") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - ) from err - else: - response = Response( - content=wms_response.content, - status_code=wms_response.status_code, - headers=dict(wms_response.headers) - ) - return response diff --git a/arpav_ppcv/webapp/api_v2/schemas/coverages.py b/arpav_ppcv/webapp/api_v2/schemas/coverages.py index 8b79ca86..47a82f7a 100644 --- a/arpav_ppcv/webapp/api_v2/schemas/coverages.py +++ b/arpav_ppcv/webapp/api_v2/schemas/coverages.py @@ -12,15 +12,6 @@ class ForecastModelScenario(pydantic.BaseModel): code: str -# class CoverageConfigurationRead(pydantic.BaseModel): -# identifier: str -# dataset_id_pattern: str -# unit: str | None = None -# palette: str -# range: list[float] -# allowed_values: dict[str, list[str]] | None = None - - class ConfigurationParameterPossibleValueRead(pydantic.BaseModel): configuration_parameter_name: str configuration_parameter_value: str @@ -29,9 +20,8 @@ class ConfigurationParameterPossibleValueRead(pydantic.BaseModel): class CoverageConfigurationReadListItem(pydantic.BaseModel): url: pydantic.AnyHttpUrl id: uuid.UUID - identifier: str - coverage_id_pattern: str name: str + coverage_id_pattern: str possible_values: list[ConfigurationParameterPossibleValueRead] @classmethod diff --git a/arpav_ppcv/webapp/templates/admin/forms/collection.html b/arpav_ppcv/webapp/templates/admin/forms/collection.html new file mode 100644 index 00000000..55f110a5 --- /dev/null +++ b/arpav_ppcv/webapp/templates/admin/forms/collection.html @@ -0,0 +1,21 @@ +
+ {% for field in field.get_fields_list(request, action) %} + {% if field.input_type == 'hidden' %} + {% set item_data= (data[field.name] if data else None) %} + {% with data=item_data, error=error.get(field.name, None) if error else None %} + {% include field.form_template %} + {% endwith %} + {% else %} +
+ +
+ {% set item_data= (data[field.name] if data else None) %} + {% with data=item_data, error=error.get(field.name, None) if error else None %} + {% include field.form_template %} + {% endwith %} +
+
+ {% endif %} + {% endfor %} +
From cb6ae9dda294a9a6af32a4c5aaa2b07a1acd183f Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Mon, 6 May 2024 16:19:51 +0100 Subject: [PATCH 08/10] Added simple auth provider to admin UI --- arpav_ppcv/config.py | 13 +++ arpav_ppcv/webapp/admin/app.py | 36 ++++++- arpav_ppcv/webapp/admin/auth.py | 79 ++++++++++++++++ arpav_ppcv/webapp/app.py | 2 +- docker/compose.dev.yaml | 3 + poetry.lock | 161 +++++--------------------------- pyproject.toml | 3 + 7 files changed, 156 insertions(+), 141 deletions(-) create mode 100644 arpav_ppcv/webapp/admin/auth.py diff --git a/arpav_ppcv/config.py b/arpav_ppcv/config.py index 2a84568b..545e3911 100644 --- a/arpav_ppcv/config.py +++ b/arpav_ppcv/config.py @@ -129,6 +129,17 @@ class DjangoAppSettings(pydantic.BaseModel): thredds: DjangoThreddsSettings = DjangoThreddsSettings() +class AdminUserSettings(pydantic.BaseModel): + username: str = "arpavadmin" + password: str = "arpavpassword" + name: str = "Admin" + avatar: Optional[str] = None + company_logo_url: Optional[str] = None + roles: list[str] = pydantic.Field( + default_factory=lambda: [ + "read", "create", "edit", "delete", "action_make_published"] + ) + class ArpavPpcvSettings(BaseSettings): # noqa model_config = SettingsConfigDict( @@ -153,6 +164,8 @@ class ArpavPpcvSettings(BaseSettings): # noqa v2_api_mount_prefix: str = "/api/v2" django_app: DjangoAppSettings = DjangoAppSettings() log_config_file: Path | None = None + session_secret_key: str = "changeme" + admin_user: AdminUserSettings = AdminUserSettings() @pydantic.model_validator(mode="after") def ensure_test_db_dsn(self): diff --git a/arpav_ppcv/webapp/admin/app.py b/arpav_ppcv/webapp/admin/app.py index 0c92146c..ffce0a1e 100644 --- a/arpav_ppcv/webapp/admin/app.py +++ b/arpav_ppcv/webapp/admin/app.py @@ -1,6 +1,9 @@ import logging +from starlette.applications import Starlette from starlette.middleware import Middleware +from starlette.middleware.sessions import SessionMiddleware +from starlette.exceptions import HTTPException from starlette_admin.contrib.sqlmodel import Admin from starlette_admin.views import Link @@ -9,21 +12,46 @@ database, ) from ...schemas import coverages -from . import views +from . import ( + auth, + views, +) from .middlewares import SqlModelDbSessionMiddleware logger = logging.getLogger(__name__) -def create_admin(settings: config.ArpavPpcvSettings) -> Admin: +class ArpavPpcvAdmin(Admin): + + def mount_to( + self, app: Starlette, settings: config.ArpavPpcvSettings) -> None: + """Reimplemented in order to pass settings to the admin app.""" + admin_app = Starlette( + routes=self.routes, + middleware=self.middlewares, + debug=self.debug, + exception_handlers={HTTPException: self._render_error}, + ) + admin_app.state.ROUTE_NAME = self.route_name + admin_app.state.settings = settings + app.mount( + self.base_url, + app=admin_app, + name=self.route_name, + ) + + +def create_admin(settings: config.ArpavPpcvSettings) -> ArpavPpcvAdmin: engine = database.get_engine(settings) - admin = Admin( + admin = ArpavPpcvAdmin( engine, debug=settings.debug, templates_dir=str(settings.templates_dir / 'admin'), + auth_provider=auth.UsernameAndPasswordProvider(), middlewares=[ + Middleware(SessionMiddleware, secret_key=settings.session_secret_key), Middleware(SqlModelDbSessionMiddleware, engine=engine) - ] + ], ) admin.add_view( views.ConfigurationParameterView(coverages.ConfigurationParameter)) diff --git a/arpav_ppcv/webapp/admin/auth.py b/arpav_ppcv/webapp/admin/auth.py new file mode 100644 index 00000000..7d598ada --- /dev/null +++ b/arpav_ppcv/webapp/admin/auth.py @@ -0,0 +1,79 @@ +"""Simple authentication provider for the admin interface.""" + +from starlette.requests import Request +from starlette.responses import Response +from starlette_admin.auth import AdminConfig, AdminUser, AuthProvider +from starlette_admin.exceptions import FormValidationError, LoginFailed + +from ... import config + + +class UsernameAndPasswordProvider(AuthProvider): + """Simple authentication provider. + + Inspired by the demo provider shown at: + + https://jowilf.github.io/starlette-admin/tutorial/authentication/ + + """ + + async def login( + self, + username: str, + password: str, + remember_me: bool, + request: Request, + response: Response, + ) -> Response: + if len(username) < 3: + """Form data validation""" + raise FormValidationError( + {"username": "Ensure username has at least 3 characters"} + ) + + settings: config.ArpavPpcvSettings = request.app.state.settings + if ( + username == settings.admin_user.username and + password == settings.admin_user.password + ): + """Save `username` in session""" + request.session.update({"username": username}) + return response + + raise LoginFailed("Invalid username or password") + + async def is_authenticated(self, request) -> bool: + settings: config.ArpavPpcvSettings = request.app.state.settings + if request.session.get("username", None) == settings.admin_user.username: + """ + Save current `user` object in the request state. Can be used later + to restrict access to connected user. + """ + request.state.user = settings.admin_user + return True + + return False + + def get_admin_config(self, request: Request) -> AdminConfig: + user: config.AdminUserSettings = request.state.user # Retrieve current user + # Update app title according to current_user + custom_app_title = "Hello, " + user.name + "!" + # Update logo url according to current_user + custom_logo_url = None + if (logo := user.company_logo_url) is not None: + custom_logo_url = request.url_for("static", path=logo) + return AdminConfig( + app_title=custom_app_title, + logo_url=custom_logo_url, + ) + + def get_admin_user(self, request: Request) -> AdminUser: + user: config.AdminUserSettings = request.state.user # Retrieve current user + photo_url = None + if (avatar := user.avatar) is not None: + photo_url = request.url_for("static", path=avatar) + return AdminUser(username=user.name, photo_url=photo_url) + + async def logout(self, request: Request, response: Response) -> Response: + request.session.clear() + return response diff --git a/arpav_ppcv/webapp/app.py b/arpav_ppcv/webapp/app.py index 674227b7..05363a5a 100644 --- a/arpav_ppcv/webapp/app.py +++ b/arpav_ppcv/webapp/app.py @@ -20,7 +20,7 @@ def create_app_from_settings(settings: config.ArpavPpcvSettings) -> fastapi.Fast settings.static_dir.mkdir(parents=True, exist_ok=True) app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") admin = create_admin(settings) - admin.mount_to(app) + admin.mount_to(app, settings) v2_api = create_v2_app(settings) v1_api = create_v1_app(settings) django_app = create_django_app(settings) diff --git a/docker/compose.dev.yaml b/docker/compose.dev.yaml index 2e31fee7..ca19d5e1 100644 --- a/docker/compose.dev.yaml +++ b/docker/compose.dev.yaml @@ -19,6 +19,9 @@ services: ARPAV_PPCV__PUBLIC_URL: http://localhost:5001 ARPAV_PPCV__DB_DSN: postgresql://arpav:arpavpassword@db:5432/arpav_ppcv ARPAV_PPCV__TEST_DB_DSN: postgresql://arpavtest:arpavtestpassword@test-db:5432/arpav_ppcv_test + ARPAV_PPCV__SESSION_SECRET_KEY: some-key + ARPAV_PPCV__ADMIN_USER__USERNAME: admin + ARPAV_PPCV__ADMIN_USER__PASSWORD: 12345678 ARPAV_PPCV__LOG_CONFIG_FILE: /home/appuser/app/dev-log-config.yml ARPAV_PPCV__DJANGO_APP__DB_DSN: postgres://postgres:postgres@legacy-db:5432/postgres ARPAV_PPCV__DJANGO_APP__THREDDS__PORT: 8081 diff --git a/poetry.lock b/poetry.lock index fc36e559..2759727b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alembic" version = "1.13.1" description = "A database migration tool for SQLAlchemy." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -24,7 +23,6 @@ tz = ["backports.zoneinfo"] name = "amqp" version = "2.6.1" description = "Low-level AMQP client for Python (fork of amqplib)." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -39,7 +37,6 @@ vine = ">=1.1.3,<5.0.0a1" name = "annotated-types" version = "0.6.0" description = "Reusable constraint types to use with typing.Annotated" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -51,7 +48,6 @@ files = [ name = "anyio" version = "4.3.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -74,7 +70,6 @@ trio = ["trio (>=0.23)"] name = "asgiref" version = "3.7.2" description = "ASGI specs, helper code, and adapters" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -92,7 +87,6 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -104,7 +98,6 @@ files = [ name = "attrs" version = "23.2.0" description = "Classes Without Boilerplate" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -124,7 +117,6 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p name = "autobahn" version = "23.6.2" description = "WebSocket client & server library, WAMP real-time framework" -category = "main" optional = false python-versions = ">=3.9" files = [ @@ -153,7 +145,6 @@ xbr = ["base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "click (>=8. name = "automat" version = "22.10.0" description = "Self-service finite-state machines for the programmer on the go." -category = "main" optional = false python-versions = "*" files = [ @@ -172,7 +163,6 @@ visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"] name = "backoff" version = "2.2.1" description = "Function decoration for backoff and retry" -category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -184,7 +174,6 @@ files = [ name = "beartype" version = "0.17.2" description = "Unbearably fast runtime type checking in pure Python." -category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -203,7 +192,6 @@ test-tox-coverage = ["coverage (>=5.5)"] name = "beautifulsoup4" version = "4.12.3" description = "Screen-scraping library" -category = "main" optional = false python-versions = ">=3.6.0" files = [ @@ -225,7 +213,6 @@ lxml = ["lxml"] name = "billiard" version = "3.6.4.0" description = "Python multiprocessing fork with improvements and bugfixes" -category = "main" optional = false python-versions = "*" files = [ @@ -237,7 +224,6 @@ files = [ name = "cattrs" version = "23.2.3" description = "Composable complex class support for attrs and dataclasses." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -263,7 +249,6 @@ ujson = ["ujson (>=5.7.0)"] name = "celery" version = "4.4.2" description = "Distributed Task Queue." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*," files = [ @@ -315,7 +300,6 @@ zstd = ["zstandard"] name = "certifi" version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -327,7 +311,6 @@ files = [ name = "cffi" version = "1.16.0" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -392,7 +375,6 @@ pycparser = "*" name = "channels" version = "2.4.0" description = "Brings async, event-driven capabilities to Django. Django 2.2 and up only." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -412,7 +394,6 @@ tests = ["async-generator (>=1.10,<2.0)", "async-timeout (>=3.0,<4.0)", "coverag name = "chardet" version = "3.0.4" description = "Universal encoding detector for Python 2 and 3" -category = "main" optional = false python-versions = "*" files = [ @@ -424,7 +405,6 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -439,7 +419,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -451,7 +430,6 @@ files = [ name = "constantly" version = "23.10.4" description = "Symbolic constants in Python" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -463,7 +441,6 @@ files = [ name = "coverage" version = "7.4.1" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -531,7 +508,6 @@ toml = ["tomli"] name = "cryptography" version = "42.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -586,7 +562,6 @@ test-randomorder = ["pytest-randomly"] name = "dagger-io" version = "0.9.10" description = "A client package for running Dagger pipelines in Python." -category = "dev" optional = false python-versions = ">=3.10" files = [ @@ -608,7 +583,6 @@ typing-extensions = ">=4.8.0" name = "daphne" version = "2.5.0" description = "Django ASGI (HTTP/WebSocket) server" -category = "main" optional = false python-versions = "*" files = [ @@ -628,7 +602,6 @@ tests = ["hypothesis (==4.23)", "pytest (>=3.10,<4.0)", "pytest-asyncio (>=0.8,< name = "django" version = "3.0.6" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -649,7 +622,6 @@ bcrypt = ["bcrypt"] name = "django-celery-beat" version = "2.0.0" description = "Database-backed Periodic Tasks." -category = "main" optional = false python-versions = "*" files = [ @@ -667,7 +639,6 @@ python-crontab = ">=2.3.4" name = "django-celery-results" version = "1.2.1" description = "Celery result backends for Django." -category = "main" optional = false python-versions = "*" files = [ @@ -682,7 +653,6 @@ celery = ">=4.4,<5.0" name = "django-cors-headers" version = "3.2.1" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -697,7 +667,6 @@ Django = ">=1.11" name = "django-dotenv" version = "1.4.2" description = "foreman reads from .env. manage.py doesn't. Let's fix that." -category = "main" optional = false python-versions = "*" files = [ @@ -709,7 +678,6 @@ files = [ name = "django-extensions" version = "2.2.9" description = "Extensions for Django" -category = "main" optional = false python-versions = "*" files = [ @@ -724,7 +692,6 @@ six = ">=1.2" name = "django-guardian" version = "2.2.0" description = "Implementation of per object permissions for Django." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -739,7 +706,6 @@ Django = ">=2.1" name = "django-oauth-toolkit" version = "1.3.2" description = "OAuth2 Provider for Django" -category = "main" optional = false python-versions = "*" files = [ @@ -756,7 +722,6 @@ requests = ">=2.13.0" name = "django-redis-sessions" version = "0.6.1" description = "Redis Session Backend For Django" -category = "main" optional = false python-versions = "*" files = [ @@ -770,7 +735,6 @@ redis = ">=2.4.10" name = "django-timezone-field" version = "4.2.3" description = "A Django app providing database and form fields for pytz timezone objects." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -789,7 +753,6 @@ rest-framework = ["djangorestframework (>=3.0.0)"] name = "djangorestframework" version = "3.11.0" description = "Web APIs for Django, made easy." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -804,7 +767,6 @@ django = ">=1.11" name = "djangorestframework-bulk" version = "0.2.1" description = "Django REST Framework bulk CRUD view mixins" -category = "main" optional = false python-versions = "*" files = [ @@ -820,7 +782,6 @@ setuptools = "*" name = "djangorestframework-gis" version = "0.15" description = "Geographic add-ons for Django Rest Framework" -category = "main" optional = false python-versions = "*" files = [ @@ -835,7 +796,6 @@ djangorestframework = "*" name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" -category = "main" optional = false python-versions = "*" files = [ @@ -846,7 +806,6 @@ files = [ name = "exceptiongroup" version = "1.2.0" description = "Backport of PEP 654 (exception groups)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -861,7 +820,6 @@ test = ["pytest (>=6)"] name = "fastapi" version = "0.110.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -881,7 +839,6 @@ all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)" name = "geoalchemy2" version = "0.14.7" description = "Using SQLAlchemy with Spatial Databases" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -900,7 +857,6 @@ shapely = ["Shapely (>=1.7)"] name = "geojson-pydantic" version = "1.0.2" description = "Pydantic data models for the GeoJSON spec." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -920,7 +876,6 @@ test = ["pytest", "pytest-cov", "shapely"] name = "gql" version = "3.5.0" description = "GraphQL client for Python" -category = "dev" optional = false python-versions = "*" files = [ @@ -950,7 +905,6 @@ websockets = ["websockets (>=10,<12)"] name = "graphql-core" version = "3.2.3" description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." -category = "dev" optional = false python-versions = ">=3.6,<4" files = [ @@ -962,7 +916,6 @@ files = [ name = "greenlet" version = "3.0.3" description = "Lightweight in-process concurrent programming" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1034,7 +987,6 @@ test = ["objgraph", "psutil"] name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1046,7 +998,6 @@ files = [ name = "httpcore" version = "1.0.3" description = "A minimal low-level HTTP client." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1061,14 +1012,13 @@ h11 = ">=0.13,<0.15" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<0.24.0)"] [[package]] name = "httptools" version = "0.6.1" description = "A collection of framework independent HTTP protocol utils." -category = "main" optional = false python-versions = ">=3.8.0" files = [ @@ -1117,7 +1067,6 @@ test = ["Cython (>=0.29.24,<0.30.0)"] name = "httpx" version = "0.27.0" description = "The next generation HTTP client." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1128,21 +1077,20 @@ files = [ [package.dependencies] anyio = "*" certifi = "*" -httpcore = ">=1.0.0,<2.0.0" +httpcore = "==1.*" idna = "*" sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "hyperlink" version = "21.0.0" description = "A featureful, immutable, and correct URL for Python." -category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1157,7 +1105,6 @@ idna = ">=2.5" name = "idna" version = "2.10" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1169,7 +1116,6 @@ files = [ name = "incremental" version = "22.10.0" description = "\"A small library that versions your Python projects.\"" -category = "main" optional = false python-versions = "*" files = [ @@ -1185,7 +1131,6 @@ scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1193,16 +1138,26 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jinja2" -version = "3.1.3" +version = "3.1.4" description = "A very fast and expressive template engine." -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, - {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] [package.dependencies] @@ -1215,7 +1170,6 @@ i18n = ["Babel (>=2.7)"] name = "kombu" version = "4.6.11" description = "Messaging library for Python." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1246,7 +1200,6 @@ zookeeper = ["kazoo (>=1.3.1)"] name = "lxml" version = "5.1.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1340,7 +1293,6 @@ source = ["Cython (>=3.0.7)"] name = "mako" version = "1.3.3" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1360,7 +1312,6 @@ testing = ["pytest"] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1385,7 +1336,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1455,7 +1405,6 @@ files = [ name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1467,7 +1416,6 @@ files = [ name = "multidict" version = "6.0.5" description = "multidict implementation" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1567,7 +1515,6 @@ files = [ name = "numpy" version = "1.26.4" description = "Fundamental package for array computing in Python" -category = "main" optional = false python-versions = ">=3.9" files = [ @@ -1613,7 +1560,6 @@ files = [ name = "oauthlib" version = "3.2.2" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1630,7 +1576,6 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] name = "packaging" version = "23.2" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1642,7 +1587,6 @@ files = [ name = "pandas" version = "1.5.0" description = "Powerful data structures for data analysis, time series, and statistics" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1687,7 +1631,6 @@ test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"] name = "platformdirs" version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1703,7 +1646,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- name = "pluggy" version = "1.4.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1719,7 +1661,6 @@ testing = ["pytest", "pytest-benchmark"] name = "psycopg2-binary" version = "2.8.5" description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" files = [ @@ -1759,7 +1700,6 @@ files = [ name = "pyasn1" version = "0.5.1" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -1771,7 +1711,6 @@ files = [ name = "pyasn1-modules" version = "0.3.0" description = "A collection of ASN.1-based protocols modules" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" files = [ @@ -1786,7 +1725,6 @@ pyasn1 = ">=0.4.6,<0.6.0" name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1798,7 +1736,6 @@ files = [ name = "pydantic" version = "2.6.4" description = "Data validation using Python type hints" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1818,7 +1755,6 @@ email = ["email-validator (>=2.0.0)"] name = "pydantic-core" version = "2.16.3" description = "" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1910,7 +1846,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" name = "pydantic-settings" version = "2.2.1" description = "Settings management using Pydantic" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1930,7 +1865,6 @@ yaml = ["pyyaml (>=6.0.1)"] name = "pydap" version = "3.4.0" description = "An implementation of the Data Access Protocol." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1961,7 +1895,6 @@ tests = ["WebTest", "beautifulsoup4", "flake8", "pyopenssl", "pytest (>=3.6)", " name = "pygments" version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1977,7 +1910,6 @@ windows-terminal = ["colorama (>=0.4.6)"] name = "pyopenssl" version = "24.0.0" description = "Python wrapper module around the OpenSSL library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1996,7 +1928,6 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"] name = "pyproj" version = "3.6.1" description = "Python interface to PROJ (cartographic projections and coordinate transformations library)" -category = "main" optional = false python-versions = ">=3.9" files = [ @@ -2036,7 +1967,6 @@ certifi = "*" name = "pytest" version = "8.0.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2059,7 +1989,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2078,7 +2007,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-django" version = "4.8.0" description = "A Django plugin for pytest." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2097,7 +2025,6 @@ testing = ["Django", "django-configurations (>=2.0)"] name = "pytest-httpx" version = "0.30.0" description = "Send responses to httpx." -category = "dev" optional = false python-versions = ">=3.9" files = [ @@ -2106,17 +2033,16 @@ files = [ ] [package.dependencies] -httpx = ">=0.27.0,<0.28.0" +httpx = "==0.27.*" pytest = ">=7,<9" [package.extras] -testing = ["pytest-asyncio (>=0.23.0,<0.24.0)", "pytest-cov (>=4.0.0,<5.0.0)"] +testing = ["pytest-asyncio (==0.23.*)", "pytest-cov (==4.*)"] [[package]] name = "python-crontab" version = "3.0.0" description = "Python Crontab API" -category = "main" optional = false python-versions = "*" files = [ @@ -2135,7 +2061,6 @@ cron-schedule = ["croniter"] name = "python-dateutil" version = "2.8.1" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -2150,7 +2075,6 @@ six = ">=1.5" name = "python-dotenv" version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2165,7 +2089,6 @@ cli = ["click (>=5.0)"] name = "python-multipart" version = "0.0.9" description = "A streaming multipart parser for Python" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2180,7 +2103,6 @@ dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatc name = "pytz" version = "2020.1" description = "World timezone definitions, modern and historical" -category = "main" optional = false python-versions = "*" files = [ @@ -2192,7 +2114,6 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2214,6 +2135,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2252,7 +2174,6 @@ files = [ name = "redis" version = "5.0.1" description = "Python client for Redis database and key-value store" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2271,7 +2192,6 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" name = "requests" version = "2.23.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -2293,7 +2213,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] name = "rich" version = "13.7.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -2312,7 +2231,6 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "ruff" version = "0.2.2" description = "An extremely fast Python linter and code formatter, written in Rust." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2339,7 +2257,6 @@ files = [ name = "service-identity" version = "24.1.0" description = "Service identity verification for pyOpenSSL & cryptography." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2364,7 +2281,6 @@ tests = ["coverage[toml] (>=5.0.2)", "pytest"] name = "setuptools" version = "69.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2381,7 +2297,6 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar name = "shapely" version = "2.0.3" description = "Manipulation and analysis of geometric objects" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2432,14 +2347,13 @@ files = [ numpy = ">=1.14,<2" [package.extras] -docs = ["matplotlib", "numpydoc (>=1.1.0,<1.2.0)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] +docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] test = ["pytest", "pytest-cov"] [[package]] name = "shellingham" version = "1.5.4" description = "Tool to Detect Surrounding Shell" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2451,7 +2365,6 @@ files = [ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2463,7 +2376,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2475,7 +2387,6 @@ files = [ name = "soupsieve" version = "2.5" description = "A modern CSS selector implementation for Beautiful Soup." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2487,7 +2398,6 @@ files = [ name = "sqlalchemy" version = "2.0.29" description = "Database Abstraction Library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2575,7 +2485,6 @@ sqlcipher = ["sqlcipher3_binary"] name = "sqlmodel" version = "0.0.16" description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -2591,7 +2500,6 @@ SQLAlchemy = ">=2.0.0,<2.1.0" name = "sqlparse" version = "0.4.4" description = "A non-validating SQL parser." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -2608,7 +2516,6 @@ test = ["pytest", "pytest-cov"] name = "starlette" version = "0.36.3" description = "The little ASGI library that shines." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2626,7 +2533,6 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 name = "starlette-admin" version = "0.13.2" description = "Fast, beautiful and extensible administrative interface framework for Starlette/FastApi applications" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2650,7 +2556,6 @@ test = ["aiomysql (>=0.1.1,<0.3.0)", "aiosqlite (>=0.17.0,<0.20.0)", "arrow (>=1 name = "threddsclient" version = "0.4.2" description = "Thredds catalog client" -category = "main" optional = false python-versions = "*" files = [ @@ -2666,7 +2571,6 @@ requests = "*" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2678,7 +2582,6 @@ files = [ name = "twisted" version = "23.10.0" description = "An asynchronous networking framework written in Python" -category = "main" optional = false python-versions = ">=3.8.0" files = [ @@ -2718,7 +2621,6 @@ windows-platform = ["pywin32 (!=226)", "pywin32 (!=226)", "twisted[all-non-platf name = "twisted-iocpsupport" version = "1.0.4" description = "An extension for use in the twisted I/O Completion Ports reactor." -category = "main" optional = false python-versions = "*" files = [ @@ -2747,7 +2649,6 @@ files = [ name = "txaio" version = "23.1.1" description = "Compatibility API between asyncio/Twisted/Trollius" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2764,7 +2665,6 @@ twisted = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] name = "typer" version = "0.12.3" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2782,7 +2682,6 @@ typing-extensions = ">=3.7.4.3" name = "typing-extensions" version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2794,7 +2693,6 @@ files = [ name = "urllib3" version = "1.25.11" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" files = [ @@ -2811,7 +2709,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "uvicorn" version = "0.29.0" description = "The lightning-fast ASGI server." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2827,7 +2724,7 @@ httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standar python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} @@ -2838,7 +2735,6 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", name = "uvloop" version = "0.19.0" description = "Fast implementation of asyncio event loop on top of libuv" -category = "main" optional = false python-versions = ">=3.8.0" files = [ @@ -2883,7 +2779,6 @@ test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)" name = "vine" version = "1.3.0" description = "Promises, promises, promises." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2895,7 +2790,6 @@ files = [ name = "watchfiles" version = "0.21.0" description = "Simple, modern and high performance file watching and code reload in python." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2983,7 +2877,6 @@ anyio = ">=3.0.0" name = "webob" version = "1.8.7" description = "WSGI request and response object" -category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*" files = [ @@ -2999,7 +2892,6 @@ testing = ["coverage", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"] name = "websockets" version = "12.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -3081,7 +2973,6 @@ files = [ name = "xmltodict" version = "0.12.0" description = "Makes working with XML feel like you are working with JSON" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -3093,7 +2984,6 @@ files = [ name = "yarl" version = "1.9.4" description = "Yet another URL library" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -3197,7 +3087,6 @@ multidict = ">=4.0" name = "zope-interface" version = "6.2" description = "Interfaces for Python" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -3250,4 +3139,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d4971873927908478d07bd5c8711cddf9d5e961971eaae1478f830576fadf6d3" +content-hash = "bb1582dd8f9b100eabeead348983f59b12f998dda7301e32b161147316c0f458" diff --git a/pyproject.toml b/pyproject.toml index e6e00760..f63300a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ geojson-pydantic = "^1.0.2" shapely = "^2.0.3" pyproj = "^3.6.1" starlette-admin = "^0.13.2" +itsdangerous = "^2.2.0" +jinja2 = "^3.1.4" +pyyaml = "^6.0.1" [tool.poetry.group.dev.dependencies] From 9e0662d311cf256672ed7eef5c50099bcb7e97ed Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Mon, 6 May 2024 16:53:28 +0100 Subject: [PATCH 09/10] fixing tests --- tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 409282e4..63f54523 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ import datetime as dt -import os import random import geojson_pydantic @@ -23,7 +22,7 @@ from arpav_ppcv.webapp import dependencies from arpav_ppcv.webapp.app import create_app_from_settings from arpav_ppcv.webapp.legacy.django_settings import get_custom_django_settings -from arpav_ppcv.webapp.v2.app import create_app as create_v2_app +from arpav_ppcv.webapp.api_v2.app import create_app as create_v2_app @pytest.hookimpl From c1d9ea46dbc5ef7a28ac7c104e4c6bc02f516cc8 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Mon, 6 May 2024 17:00:37 +0100 Subject: [PATCH 10/10] fixing tests --- tests/conftest.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 63f54523..947300d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,11 +45,7 @@ def settings() -> config.ArpavPpcvSettings: @pytest.fixture def app(settings): - app = create_app_from_settings(settings) - app.dependency_overrides[dependencies.get_db_session] = _override_get_db_session - app.dependency_overrides[dependencies.get_db_engine] = _override_get_db_engine - app.dependency_overrides[dependencies.get_settings] = _override_get_settings - yield app + yield create_app_from_settings(settings) @pytest.fixture