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

Feature flag to make inventory reports sysadmin only. (PP-1329) #1898

Merged
merged 8 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
56 changes: 56 additions & 0 deletions src/palace/manager/api/admin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,58 @@
from enum import Enum
from urllib.parse import urljoin

from pydantic import Field
from requests import RequestException

from palace.manager.service.configuration.limited_env_override import (
ServiceConfigurationWithLimitedEnvOverride,
)
from palace.manager.util.flask_util import _snake_to_camel_case
from palace.manager.util.http import HTTP, RequestNetworkException
from palace.manager.util.log import LoggerMixin


class AdminClientFeatureFlags(ServiceConfigurationWithLimitedEnvOverride):
# The following CAN be overridden by environment variables.
reports_only_for_sysadmins: bool = Field(
True,
description="Show inventory reports only for sysadmins.",
)

# The following fields CANNOT be overridden by environment variables.
# Setting `const=True` ensures that the default value is not overridden.
# Add them to the one of the `environment_override_*` Config settings
# below to prevent them from being overridden.
# NB: Overriding the `env_prefix` with `env=...` here may lead to
# incorrect values in warnings and exceptions, since `env_prefix`
# is used to generate the full environment variable name.
enable_auto_list: bool = Field(
True,
const=True,
description="Enable auto-list of items.",
)
show_circ_events_download: bool = Field(
True,
const=True,
description="Show download button for Circulation Events.",
)

class Config:
env_prefix = "PALACE_ADMINUI_FEATURE_"

# We use lower camel case aliases, since we're sending to the web.
alias_generator = _snake_to_camel_case

# Add any fields that should not be overridden by environment variables here.
# - environment_override_warning_fields: warnings and ignore environment
# - environment_override_error_fields: raise exception
environment_override_warning_fields: set[str] = {
"enable_auto_list",
"show_circ_events_download",
}
environment_override_error_fields: set[str] = set()


class OperationalMode(str, Enum):
production = "production"
development = "development"
Expand Down Expand Up @@ -53,6 +99,16 @@ class Configuration(LoggerMixin):
# Cache the package version after first lookup.
_version: str | None = None

# Cache the feature flags after the first lookup.
_admin_ui_feature_flags: AdminClientFeatureFlags | None = None

# Admin client feature flags
@classmethod
def admin_feature_flags(cls) -> AdminClientFeatureFlags:
if not cls._admin_ui_feature_flags:
cls._admin_ui_feature_flags = AdminClientFeatureFlags()
return cls._admin_ui_feature_flags

@classmethod
def operational_mode(cls) -> OperationalMode:
return (
Expand Down
8 changes: 4 additions & 4 deletions src/palace/manager/api/admin/controller/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,22 @@ def __call__(self, collection, book, path=None):
admin_js = AdminClientConfig.lookup_asset_url(key="admin_js")
admin_css = AdminClientConfig.lookup_asset_url(key="admin_css")

# We always have local_analytics
show_circ_events_download = True

response = Response(
flask.render_template_string(
admin_template,
app_name=AdminClientConfig.APP_NAME,
csrf_token=csrf_token,
sitewide_tos_href=Configuration.DEFAULT_TOS_HREF,
sitewide_tos_text=Configuration.DEFAULT_TOS_TEXT,
show_circ_events_download=show_circ_events_download,
show_circ_events_download=AdminClientConfig.admin_feature_flags().show_circ_events_download,
setting_up=setting_up,
email=email,
roles=roles,
admin_js=admin_js,
admin_css=admin_css,
feature_flags=AdminClientConfig.admin_feature_flags().json(
by_alias=True
),
)
)

Expand Down
4 changes: 1 addition & 3 deletions src/palace/manager/api/admin/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
settingUp: {{ "true" if setting_up else "false" }},
email: "{{ email }}",
roles: [{% for role in roles %}{"role": "{{role.role}}"{% if role.library %}, "library": "{{role.library.short_name}}"{% endif %} },{% endfor %}],
featureFlags: {
enableAutoList: true,
},
featureFlags: {{ feature_flags| safe }},
});
</script>
</body>
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/analytics/configuration.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class AnalyticsConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/celery/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from pydantic import RedisDsn

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class CeleryConfiguration(ServiceConfiguration):
Expand Down
1 change: 1 addition & 0 deletions src/palace/manager/service/configuration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from __future__ import annotations
135 changes: 135 additions & 0 deletions src/palace/manager/service/configuration/limited_env_override.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import functools
from typing import Any

from pydantic.env_settings import BaseSettings, SettingsSourceCallable
from pydantic.fields import ModelField

from palace.manager.core.config import CannotLoadConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)
from palace.manager.util.log import LoggerMixin


class ServiceConfigurationWithLimitedEnvOverride(ServiceConfiguration, LoggerMixin):
# Fields that can be overridden by environment variables should be specified as normal.

# For non-overridable fields:
# - Set `const=True` on the field, if nothing should override the default..
# - Add the field name to one of the `environment_override_*` Config settings.

class Config:
# Handle environment variable overrides, depending on presence of field name in:
# environment_override_error_fields: report field and raise exception; or
# environment_override_warning_fields: report field and log warning.
# If a field is not specified in one of these lists, an override is permitted.
# If a field is specified in both, it is an error and an exception is raised.
# If a field is NOT specified in one of these lists, then an override is allowed.
# The exception, when raised, will be a `CannotLoadConfiguration`.
environment_override_error_fields: set[str] | None = None
environment_override_warning_fields: set[str] | None = None

# See `pydantic` documentation on customizing sources.
# https://docs.pydantic.dev/1.10/usage/settings/#adding-sources
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> tuple[SettingsSourceCallable, ...]:
# We have to wrap the environment settings source in our own function
# so that we can report on/strip out fields that are not overridable
# before `pydantic` sees them.
return (
init_settings,
functools.partial(_restrict_environment, env_settings),
file_secret_settings,
)


def _env_var_for(field: ModelField) -> str:
env_prefix = field.model_config.env_prefix or "" # type: ignore[attr-defined]
return (env_prefix + field.name).upper()


def _restrict_environment(
env_settings: SettingsSourceCallable, settings: BaseSettings
) -> dict[str, Any]:
"""Limit environment variables to those not restricted by the `environment_override_*` settings.

:param env_settings: The environment settings source function, usually indirectly from `pydantic`..
:param settings: A pydantic model instance.
:return: A dictionary by field alias of values from the environment.

:raises CannotLoadConfiguration: Under the following conditions:
- A non-existent field is specified in one of the `environment_override_*` settings.
- A field is specified in more than one `environment_override_*` setting.
- A field specified in `environment_override_error_fields` is overridden in the environment

If a field is (1) specified in `environment_override_warning_fields` and (2) overridden in the
environment, then a warning is logged and the field is NOT overridden.
"""
config = settings.__config__
logger = settings.log # type: ignore[attr-defined]

warning_fields: set[str] = config.environment_override_warning_fields or set() # type: ignore[attr-defined]
error_fields: set[str] = config.environment_override_error_fields or set() # type: ignore[attr-defined]

fields_by_name = settings.__fields__
fields_by_alias = {field.alias: field for name, field in fields_by_name.items()}

if nonexistent_fields := (warning_fields | error_fields) - set(fields_by_name):
raise CannotLoadConfiguration(
"Only existing fields may be specified in any of the `environment_override_*` "
"settings. The following are not the name of an existing field: "
f"{nonexistent_fields}."
)
if overlapping_fields := warning_fields & error_fields:
raise CannotLoadConfiguration(
"A field may not be specified in more than one `environment_override_*` setting. "
"The following field names are specified in multiple settings: "
f"{overlapping_fields}."
)

env_settings_by_alias = env_settings(settings)
if not env_settings_by_alias:
return env_settings_by_alias

env_settings_by_name = {
fields_by_alias[alias].name: value
for alias, value in env_settings_by_alias.items()
if alias in fields_by_alias
}

if warnings := set(env_settings_by_name) & warning_fields:
_msg = (
"Some `environment_override_warning_fields` are overridden in the environment. Please "
"remove from either the environment or the `environment_override_warning_fields` setting."
"The value(s) from the environment will be ignored."
)
for field in (fields_by_name[name] for name in warnings):
_msg += f"\n {field.name}: alias={field.alias}, env={_env_var_for(field)}"
logger.warning(_msg)

if errors := set(env_settings_by_name) & error_fields:
_msg = (
"Some `environment_override_error_fields` are overridden in the environment. Please "
"remove from either the environment or the `environment_override_error_fields` setting."
)
for field in (fields_by_name[name] for name in errors):
_msg += f"\n {field.name}: alias={field.alias}, env={_env_var_for(field)}"
raise CannotLoadConfiguration(_msg)

overridable_names = set(fields_by_name) - warnings - errors
overridable_aliases = {
field.alias
for name, field in fields_by_name.items()
if name in overridable_names
}

return {
alias: value
for alias, value in env_settings_by_alias.items()
if alias in overridable_aliases
}
4 changes: 3 additions & 1 deletion src/palace/manager/service/email/configuration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import EmailStr, PositiveInt

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class EmailConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/fcm/configuration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pathlib import Path

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class FcmConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/logging/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from pydantic import PositiveInt, validator
from watchtower import DEFAULT_LOG_STREAM_NAME

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)

# TODO: Remove this when we drop support for Python 3.10
if sys.version_info >= (3, 11):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/search/configuration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import AnyHttpUrl

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class SearchConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/sitewide.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from pydantic import AnyHttpUrl, NonNegativeInt, validator

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class SitewideConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/storage/configuration.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import boto3
from pydantic import AnyHttpUrl, parse_obj_as, validator

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


class StorageConfiguration(ServiceConfiguration):
Expand Down
4 changes: 3 additions & 1 deletion tests/fixtures/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from pydantic import AnyUrl, Extra
from typing_extensions import Self

from palace.manager.service.configuration import ServiceConfiguration
from palace.manager.service.configuration.service_configuration import (
ServiceConfiguration,
)


@dataclasses.dataclass
Expand Down
Loading