Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add some helper methods to load and save config settings #1472

Merged
merged 2 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions api/admin/controller/patron_auth_service_self_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,8 @@ def run_tests(self, integration: IntegrationConfiguration) -> Dict[str, Any]:
)

protocol_class = self.get_protocol_class(integration)
settings = protocol_class.settings_class()(**integration.settings_dict)
library_settings = protocol_class.library_settings_class()(
**library_configuration.settings_dict
)
settings = protocol_class.settings_load(integration)
library_settings = protocol_class.library_settings_load(library_configuration)

value, _ = protocol_class.run_self_tests(
self.db,
Expand Down
16 changes: 2 additions & 14 deletions api/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,20 +348,8 @@ def register_provider(
f"Implementation class {impl_cls} is not an AuthenticationProvider."
)
try:
if not isinstance(integration.parent.settings_dict, dict):
raise CannotLoadConfiguration(
f"Settings for {impl_cls.__name__} authentication provider for "
f"library {self.library_short_name} are not a dictionary."
)
if not isinstance(integration.settings_dict, dict):
raise CannotLoadConfiguration(
f"Library settings for {impl_cls.__name__} authentication provider for "
f"library {self.library_short_name} are not a dictionary."
)
settings = impl_cls.settings_class()(**integration.parent.settings_dict)
library_settings = impl_cls.library_settings_class()(
**integration.settings_dict
)
settings = impl_cls.settings_load(integration.parent)
library_settings = impl_cls.library_settings_load(integration)
provider = impl_cls(
self.library_id, # type: ignore[arg-type]
integration.parent_id, # type: ignore[arg-type]
Expand Down
4 changes: 2 additions & 2 deletions api/circulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ def integration_configuration(self) -> IntegrationConfiguration:

@property
def settings(self) -> SettingsType:
return self.settings_class()(**self.integration_configuration().settings_dict)
return self.settings_load(self.integration_configuration())

def library_settings(self, library: Library | int) -> LibrarySettingsType | None:
library_id = library.id if isinstance(library, Library) else library
Expand All @@ -644,7 +644,7 @@ def library_settings(self, library: Library | int) -> LibrarySettingsType | None
libconfig = self.integration_configuration().for_library(library_id=library_id)
if libconfig is None:
return None
config = self.library_settings_class()(**libconfig.settings_dict)
config = self.library_settings_load(libconfig)
return config

@abstractmethod
Expand Down
2 changes: 1 addition & 1 deletion api/discovery/opds_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def for_integration(
if integration_obj is None:
return None

settings = cls.settings_class().construct(**integration_obj.settings_dict)
settings = cls.settings_load(integration_obj)
return cls(integration_obj, settings)

@staticmethod
Expand Down
2 changes: 1 addition & 1 deletion api/overdrive.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def __init__(self, _db, collection):
# from the parent (the main Overdrive account), except for the
# library ID, which we already set.
parent_integration = collection.parent.integration_configuration
parent_config = self.settings_class()(**parent_integration.settings_dict)
parent_config = self.settings_load(parent_integration)
for key in OverdriveConstants.OVERDRIVE_CONFIGURATION_KEYS:
parent_value = getattr(parent_config, key, None)
setattr(self._configuration, key, parent_value)
Expand Down
117 changes: 116 additions & 1 deletion core/integration/base.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,75 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, Generic, Type, TypeVar
from typing import TYPE_CHECKING, Any, Dict, Generic, Mapping, Protocol, Type, TypeVar

from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import Mapped, flag_modified

from core.integration.settings import BaseSettings

if TYPE_CHECKING:
from core.model import IntegrationConfiguration, IntegrationLibraryConfiguration


class IntegrationConfigurationProtocol(Protocol):
settings_dict: Mapped[Dict[str, Any]]


T = TypeVar("T", bound=BaseSettings)


def integration_settings_load(
settings_cls: Type[T],
integration: IntegrationConfigurationProtocol,
) -> T:
"""
Load the settings object for an integration from the database.

These settings ARE NOT validated when loaded from the database. It is assumed that
the settings have already been validated when they were saved to the database. This
speeds up the loading of the settings from the database.

:param settings_cls: The settings class that the settings should be loaded into.
:param integration: The integration to load the settings from. This should be a
SQLAlchemy model with a settings_dict JSONB column.

:return: An instance of the settings class loaded with the settings from the database.
"""
settings_dict = integration.settings_dict
return settings_cls.construct(**settings_dict)


def integration_settings_update(
settings_cls: Type[BaseSettings],
integration: IntegrationConfigurationProtocol,
new_settings: BaseSettings | Mapping[str, Any],
merge: bool = False,
) -> None:
"""
Update the settings for an integration in the database.

The settings are validated before being saved to the database, and SQLAlchemy is
notified that the settings_dict column has been modified.

:param settings_cls: The settings class to use to validate the settings.
:param integration: The integration to update. This should be a SQLAlchemy model
with a settings_dict JSONB column.
:param new_settings: The new settings to update the integration with. This can either
be a BaseSettings object, or a dictionary of settings.
:param merge: If True, the new settings will be merged with the existing settings. With
the new settings taking precedence. If False, the new settings will replace the existing
settings.
"""
settings_dict = integration.settings_dict if merge else {}
new_settings_dict = (
new_settings.dict() if isinstance(new_settings, BaseSettings) else new_settings
)
settings_dict.update(new_settings_dict)
integration.settings_dict = settings_cls(**settings_dict).dict()
flag_modified(integration, "settings_dict")


SettingsType = TypeVar("SettingsType", bound=BaseSettings, covariant=True)
LibrarySettingsType = TypeVar("LibrarySettingsType", bound=BaseSettings, covariant=True)

Expand All @@ -30,6 +93,31 @@ def settings_class(cls) -> Type[SettingsType]:
"""Get the settings for this integration"""
...

@classmethod
def settings_load(cls, integration: IntegrationConfiguration) -> SettingsType:
"""
Load the settings object for this integration from the database.

See the documentation for `integration_settings_load` for more details.
"""
return integration_settings_load(cls.settings_class(), integration)

@classmethod
def settings_update(
cls,
integration: IntegrationConfiguration,
new_settings: BaseSettings | Mapping[str, Any],
merge: bool = False,
) -> None:
"""
Update the settings for this integration in the database.

See the documentation for `integration_settings_update` for more details.
"""
integration_settings_update(
cls.settings_class(), integration, new_settings, merge
)

@classmethod
def protocol_details(cls, db: Session) -> dict[str, Any]:
"""Add any additional details about this protocol to be
Expand All @@ -51,6 +139,33 @@ def library_settings_class(cls) -> Type[LibrarySettingsType]:
"""Get the library settings for this integration"""
...

@classmethod
def library_settings_load(
cls, integration: IntegrationLibraryConfiguration
) -> LibrarySettingsType:
"""
Load the library settings object for this integration from the database.

See the documentation for `integration_settings_load` for more details.
"""
return integration_settings_load(cls.library_settings_class(), integration)

@classmethod
def library_settings_update(
cls,
integration: IntegrationLibraryConfiguration,
new_settings: BaseSettings | Mapping[str, Any],
merge: bool = False,
) -> None:
"""
Update the settings for this library integration in the database.

See the documentation for `integration_settings_update` for more details.
"""
integration_settings_update(
cls.library_settings_class(), integration, new_settings, merge
)


class HasChildIntegrationConfiguration(HasIntegrationConfiguration[SettingsType], ABC):
@classmethod
Expand Down
7 changes: 3 additions & 4 deletions core/model/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, Query, relationship
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.functions import func

from core.configuration.library import LibrarySettings
from core.entrypoint import EntryPoint
from core.facets import FacetConstants
from core.integration.base import integration_settings_load, integration_settings_update
from core.model import Base, get_one
from core.model.announcements import Announcement
from core.model.customlist import customlist_sharedlibrary
Expand Down Expand Up @@ -300,15 +300,14 @@ def settings(self) -> LibrarySettings:
"settings_dict for library %s is not a dict: %r"
% (self.short_name, self.settings_dict)
)
settings = LibrarySettings.construct(**self.settings_dict)
settings = integration_settings_load(LibrarySettings, self)
self._settings = settings
return settings

def update_settings(self, new_settings: LibrarySettings) -> None:
"""Update the settings for this integration"""
self._settings = None
self.settings_dict.update(new_settings.dict())
flag_modified(self, "settings_dict")
integration_settings_update(LibrarySettings, self, new_settings, merge=True)

@property
def all_collections(self) -> Generator[Collection, None, None]:
Expand Down
5 changes: 3 additions & 2 deletions core/opds_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from core.config import IntegrationException
from core.connection_config import ConnectionSetting
from core.coverage import CoverageFailure
from core.integration.base import integration_settings_load
from core.integration.settings import (
BaseSettings,
ConfigurationFormItem,
Expand Down Expand Up @@ -327,8 +328,8 @@ def __init__(
# we don't, e.g. accidentally get our IP banned from
# gutenberg.org.
self.http_get = http_get or Representation.cautious_http_get
self.settings = self.settings_class().construct(
**collection.integration_configuration.settings_dict
self.settings = integration_settings_load(
self.settings_class(), collection.integration_configuration
)

@classmethod
Expand Down
7 changes: 2 additions & 5 deletions tests/api/admin/controller/test_discovery_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,7 @@ def test_discovery_services_post_create(
assert service.id == int(response.get_data(as_text=True))
assert self.protocol == service.protocol
assert (
OpdsRegistrationService.settings_class()(**service.settings_dict).url
== "http://registry.url"
OpdsRegistrationService.settings_load(service).url == "http://registry.url"
)

def test_discovery_services_post_edit(
Expand Down Expand Up @@ -232,9 +231,7 @@ def test_discovery_services_post_edit(
assert self.protocol == discovery_service.protocol
assert (
"http://new_registry_url.com"
== OpdsRegistrationService.settings_class()(
**discovery_service.settings_dict
).url
== OpdsRegistrationService.settings_load(discovery_service).url
)

def test_check_name_unique(
Expand Down
2 changes: 1 addition & 1 deletion tests/api/admin/controller/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def test_libraries_get_with_multiple_libraries(
FacetConstants.ORDER_TITLE,
FacetConstants.ORDER_AUTHOR,
] == settings_dict.get("facets_enabled_order")
assert ["French"] == settings_dict.get("large_collection_languages")
assert ["fre"] == settings_dict.get("large_collection_languages")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious why this has changed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It changed because the call to update settings on line 176 now validates the settings before saving, which normalizes the language codes. That normalization is tested elsewhere, so I think its fine to just update the test here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine, too. I just didn't understand it.


def test_libraries_post_errors(self, settings_ctrl_fixture):
with settings_ctrl_fixture.request_context_with_admin("/", method="POST"):
Expand Down
8 changes: 2 additions & 6 deletions tests/api/admin/controller/test_patron_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,9 +499,7 @@ def test_patron_auth_services_post_create(
assert auth_service is not None
assert auth_service.id == int(response.response[0]) # type: ignore[index]
assert SimpleAuthenticationProvider.__module__ == auth_service.protocol
settings = SimpleAuthenticationProvider.settings_class()(
**auth_service.settings_dict
)
settings = SimpleAuthenticationProvider.settings_load(auth_service)
assert settings.test_identifier == "user"
assert settings.test_password == "pass"
[library_config] = auth_service.library_configurations
Expand Down Expand Up @@ -591,9 +589,7 @@ def test_patron_auth_services_post_edit(
assert auth_service.id == int(response.response[0]) # type: ignore[index]
assert SimpleAuthenticationProvider.__module__ == auth_service.protocol
assert isinstance(auth_service.settings_dict, dict)
settings = SimpleAuthenticationProvider.settings_class()(
**auth_service.settings_dict
)
settings = SimpleAuthenticationProvider.settings_load(auth_service)
assert settings.test_identifier == "user"
assert settings.test_password == "pass"
[library_config] = auth_service.library_configurations
Expand Down
12 changes: 2 additions & 10 deletions tests/api/test_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,24 +682,16 @@ def test_configuration_exception_during_from_config_stored(
# propagated.
# Create an integration destined to raise CannotLoadConfiguration..
library = db.default_library()
misconfigured, _ = create_millenium_auth_integration(library, url="millenium")

# ... and one destined to raise ImportError.
unknown, _ = create_auth_integration_configuration("unknown protocol", library)

auth = LibraryAuthenticator.from_config(db.session, db.default_library())

# The LibraryAuthenticator exists but has no AuthenticationProviders.
assert auth.basic_auth_provider is None

# Both integrations have left their trace in
# initialization_exceptions.
not_configured = auth.initialization_exceptions[(misconfigured.id, library.id)]
assert isinstance(not_configured, CannotLoadConfiguration)
assert "Could not instantiate MilleniumPatronAPI" in str(not_configured)

# The integration has left it trace in initialization_exceptions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: typo -> "...left [its] trace..."

not_found = auth.initialization_exceptions[(unknown.id, library.id)]
assert isinstance(not_configured, CannotLoadConfiguration)
assert isinstance(not_found, CannotLoadConfiguration)
assert "Unable to load implementation for external integration" in str(
not_found
)
Expand Down
16 changes: 12 additions & 4 deletions tests/api/test_axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Axis360CirculationMonitor,
Axis360FulfillmentInfo,
Axis360FulfillmentInfoResponseParser,
Axis360Settings,
AxisCollectionReaper,
AxisNowManifest,
BibliographicParser,
Expand All @@ -35,6 +36,7 @@
from api.web_publication_manifest import FindawayManifest, SpineItem
from core.analytics import Analytics
from core.coverage import CoverageFailure
from core.integration.base import integration_settings_update
from core.metadata_layer import (
CirculationData,
ContributorData,
Expand Down Expand Up @@ -790,16 +792,22 @@ def test_integration_settings_url(
self, setting, setting_value, is_valid, expected, axis360: Axis360Fixture
):
config = axis360.collection.integration_configuration
settings = config.settings_dict.copy()
settings[setting] = setting_value
config.settings_dict = settings
config.settings_dict[setting] = setting_value

if is_valid:
integration_settings_update(
Axis360Settings, config, {setting: setting_value}, merge=True
)
api = MockAxis360API(axis360.db.session, axis360.collection)
assert api.base_url == expected
else:
pytest.raises(
ProblemError, MockAxis360API, axis360.db.session, axis360.collection
ProblemError,
integration_settings_update,
Axis360Settings,
config,
{setting: setting_value},
merge=True,
)


Expand Down
Loading