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 5 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
54 changes: 54 additions & 0 deletions src/palace/manager/api/admin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,54 @@
from enum import Enum
from urllib.parse import urljoin

from pydantic import Field
from requests import RequestException

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
from palace.manager.util.settings import ServiceConfigurationWithLimitedEnvOverride


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):
Expand Down Expand Up @@ -53,6 +97,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
9 changes: 6 additions & 3 deletions src/palace/manager/api/admin/controller/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ 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
# # We always have local_analytics
# show_circ_events_download = True
Copy link
Member

Choose a reason for hiding this comment

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

Minor: Should be able to remove this comment.

Copy link
Contributor Author

@tdilauro tdilauro Jun 12, 2024

Choose a reason for hiding this comment

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

There were two things I was trying to accomplish:

  • Have one home for configuration of feature flags; and
  • Avoid accidentally overriding configuration that we have decided is fixed (see the hard-coded values I pulled back into the feature flags object).

Being able to prevent override by the environment variables preserves the existing behavior of those pre-existing feature flags (i.e., they could not be overridden by the environment -- they were fixed).

I believe the main use case for this is to avoid accidentally reconfiguring some functionality that we have decided is now 'fixed' into the system. I think should mostly apply only to functionality that is in transition. That said, we haven't gone back to the admin UI and removed these fixed feature flags. I'm not sure if that's because we want them there or because we just haven't done anything about them.

I made a separate class for this primarily to make updates to the feature flags a little clearer and less cluttered.

Copy link
Member

Choose a reason for hiding this comment

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

I'm fine with this going in as is, so feel free either way.

My opinion on this though is that we should allow the feature flags to be overridden by env vars and any of the flags we want to be set as fixed, we can put in tickets to remove the flag and clean up the config in the admin ui and CM.


response = Response(
flask.render_template_string(
Expand All @@ -77,12 +77,15 @@ def __call__(self, collection, book, path=None):
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
134 changes: 134 additions & 0 deletions src/palace/manager/util/settings.py
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure that this is the best place for this, but it cannot go in the same file with ServiceConfiguration because of an import loop due to LoggerMixin.

Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import functools
from collections.abc import Callable
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 import ServiceConfiguration
from palace.manager.util.log import LoggerMixin


class ServiceConfigurationWithLimitedEnvOverride(ServiceConfiguration, LoggerMixin):
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense for this to live in palace.manager.service.configuration with the parent ServiceConfiguration class. Rather then in palace.manager.util.settings?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. Probably. I wasn't entirely sure where to put it, but that makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Though, as I noted above, it cannot live in the same file:

Not sure that this is the best place for this, but it cannot go in the same file with ServiceConfiguration because of an import loop due to LoggerMixin.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could move palace.manager.service.configuration.ServiceConfiguration to palace.manager.service.configuration.base.ServiceConfiguration and put this in the palace.manager.service.configuration package. We can just leave it where it is for now as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jonathangreen I didn't see this before I moved them. I put both of them in palace.manager.service.configuration package directory. I can move them, though, if you'd prefer.

Copy link
Member

Choose a reason for hiding this comment

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

No whats here looks good to me.

# 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,
env_settings,
file_secret_settings,
) -> 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 | None:
env_prefix = field.model_config.env_prefix # type: ignore[attr-defined]
return (env_prefix + field.name).upper()


def _restrict_environment(
env_settings: Callable[[BaseSettings], dict[str, Any]], 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
}
72 changes: 72 additions & 0 deletions tests/manager/api/admin/controller/test_view.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import re
from unittest.mock import patch

import flask
import pytest
from _pytest.logging import LogCaptureFixture
from _pytest.monkeypatch import MonkeyPatch
from werkzeug.http import dump_cookie

from palace.manager.api.admin.config import AdminClientFeatureFlags
from palace.manager.api.admin.password_admin_authentication_provider import (
PasswordAdminAuthenticationProvider,
)
from palace.manager.service.logging.configuration import LogLevel
from palace.manager.sqlalchemy.model.admin import AdminRole
from palace.manager.sqlalchemy.model.library import Library
from tests.fixtures.api_admin import AdminControllerFixture
Expand Down Expand Up @@ -163,3 +169,69 @@ def test_roles(self, admin_ctrl_fixture: AdminControllerFixture):
% admin_ctrl_fixture.ctrl.db.default_library().short_name
in html
)

def test_feature_flags_defaults(
self,
admin_ctrl_fixture: AdminControllerFixture,
monkeypatch: pytest.MonkeyPatch,
):
admin_ctrl_fixture.admin.password_hashed = None
html_feature_flags_re = re.compile(
r"featureFlags: {(.*)?}", re.MULTILINE | re.DOTALL
)

with admin_ctrl_fixture.ctrl.app.test_request_context("/admin"):
response = admin_ctrl_fixture.manager.admin_view_controller(None, None)
assert 200 == response.status_code
html = response.get_data(as_text=True)

match = html_feature_flags_re.search(html)
assert match is not None
feature_flags: str = match.groups(0)[0] # type: ignore[assignment]
assert '"enableAutoList": true' in feature_flags
assert '"showCircEventsDownload": true' in feature_flags
assert '"reportsOnlyForSysadmins": true' in feature_flags

def test_feature_flags_overridden(
self,
admin_ctrl_fixture: AdminControllerFixture,
monkeypatch: MonkeyPatch,
caplog: LogCaptureFixture,
):
caplog.set_level(LogLevel.warning)
admin_ctrl_fixture.admin.password_hashed = None
html_feature_flags_re = re.compile(
r"featureFlags: {(.*)?}", re.MULTILINE | re.DOTALL
)

monkeypatch.setenv("PALACE_ADMINUI_FEATURE_ENABLE_AUTO_LIST", "false")
monkeypatch.setenv("PALACE_ADMINUI_FEATURE_SHOW_CIRC_EVENTS_DOWNLOAD", "false")
monkeypatch.setenv("PALACE_ADMINUI_FEATURE_REPORTS_ONLY_FOR_SYSADMINS", "false")

with (
patch(
"palace.manager.api.admin.config.Configuration.admin_feature_flags"
) as admin_feature_flags,
admin_ctrl_fixture.ctrl.app.test_request_context("/admin"),
):
# Use fresh feature flags, instead of using a cached value.
admin_feature_flags.return_value = AdminClientFeatureFlags()
assert (
"Some `environment_override_warning_fields` are overridden in the environment. Please "
in caplog.text
)
assert "enable_auto_list" in caplog.text
assert "show_circ_events_download" in caplog.text
assert "reports_only_for_sysadmins" not in caplog.text

response = admin_ctrl_fixture.manager.admin_view_controller(None, None)
assert 200 == response.status_code
html = response.get_data(as_text=True)

match = html_feature_flags_re.search(html)
assert match is not None

feature_flags: str = match.groups(0)[0] # type: ignore[assignment]
assert '"enableAutoList": true' in feature_flags
assert '"showCircEventsDownload": true' in feature_flags
assert '"reportsOnlyForSysadmins": false' in feature_flags
Loading