Repository: ansible/eda-server
Pull request #1293: refactor: get feature flags from features module
Author: login: bzwei
Labels: name: run-e2e
Created: 2025-05-09T21:24:07Z | Merged: 2025-05-12T16:44:36Z

<!-- Mandatory: Provide a clear, concise description of the changes and their purpose -->
Get whether a feature flag is on or off as a constant from the new features module.
It improves the code readability and slightly the performance as the flags are now cached.

This work is to prepare the merging of the dispatcherd feature which is guarded by a flag.



Diff:
diff --git a/src/aap_eda/analytics/collector.py b/src/aap_eda/analytics/collector.py
index d6b849ac8..88c4ab620 100644
--- a/src/aap_eda/analytics/collector.py
+++ b/src/aap_eda/analytics/collector.py
@@ -16,13 +16,14 @@
 from datetime import datetime
 from typing import Optional
 
+from django.conf import settings
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import connection
-from flags.state import flag_enabled
 from insights_analytics_collector import Collector
 
 from aap_eda.analytics import analytics_collectors, package, utils
 from aap_eda.conf.settings import application_settings
+from aap_eda.settings import features
 
 
 class AnalyticsCollector(Collector):
@@ -35,10 +36,7 @@ def _package_class() -> package.Package:
         return package.Package
 
     def _is_shipping_configured(self) -> bool:
-        if (
-            not flag_enabled("FEATURE_EDA_ANALYTICS_ENABLED")
-            or not utils.get_insights_tracking_state()
-        ):
+        if not features.ANALYTICS or not utils.get_insights_tracking_state():
             self.logger.warning(
                 "Insights for Event Driven Ansible is not enabled."
             )
@@ -91,8 +89,8 @@ def _save_last_gather(self) -> None:
 def gather(collection_type="scheduled", since=None, until=None, logger=None):
     if not logger:
         logger = logging.getLogger("aap_eda.analytics")
-    if not flag_enabled("FEATURE_EDA_ANALYTICS_ENABLED"):
-        logger.info("FEATURE_EDA_ANALYTICS_ENABLED is set to False.")
+    if not features.ANALYTICS:
+        logger.info(f"{settings.ANALYTICS_FEATURE_FLAG_NAME} is set to False.")
         return
     collector = AnalyticsCollector(
         collector_module=analytics_collectors,
diff --git a/src/aap_eda/core/management/commands/create_initial_data.py b/src/aap_eda/core/management/commands/create_initial_data.py
index c2b7e992c..1f5c2fe10 100644
--- a/src/aap_eda/core/management/commands/create_initial_data.py
+++ b/src/aap_eda/core/management/commands/create_initial_data.py
@@ -23,13 +23,13 @@
 from django.core.management import BaseCommand
 from django.db import transaction
 from django.db.models import Q
-from flags.state import flag_enabled
 
 from aap_eda.conf import settings_registry
 from aap_eda.core import enums, models
 from aap_eda.core.models.utils import get_default_organization
 from aap_eda.core.tasking import enable_redis_prefix
 from aap_eda.core.utils.credentials import inputs_to_store
+from aap_eda.settings import features
 
 NEW_HELP_TEXT = (
     "Red Hat Ansible Automation Platform base URL to authenticate with.",
@@ -1255,7 +1255,7 @@ def populate_credential_types(
     for credential_type_data in credential_types:
         # Analytics credential types are only available when it's enabled
         if (
-            not flag_enabled("FEATURE_EDA_ANALYTICS_ENABLED")
+            not features.ANALYTICS
             and credential_type_data.get("name")
             in enums.SINGLETON_CREDENTIAL_TYPES
         ):
diff --git a/src/aap_eda/core/management/commands/gather_analytics.py b/src/aap_eda/core/management/commands/gather_analytics.py
index 9995145ff..9ca2a9343 100644
--- a/src/aap_eda/core/management/commands/gather_analytics.py
+++ b/src/aap_eda/core/management/commands/gather_analytics.py
@@ -16,10 +16,11 @@
 from datetime import timezone
 
 from dateutil import parser
+from django.conf import settings
 from django.core.management.base import BaseCommand, CommandParser
-from flags.state import flag_enabled
 
 from aap_eda.analytics import collector
+from aap_eda.settings import features
 
 
 class Command(BaseCommand):
@@ -71,8 +72,10 @@ def handle(self, *args, **options):
         opt_since = options.get("since")
         opt_until = options.get("until")
 
-        if not flag_enabled("FEATURE_EDA_ANALYTICS_ENABLED"):
-            self.logger.error("FEATURE_EDA_ANALYTICS_ENABLED is set to False.")
+        if not features.ANALYTICS:
+            self.logger.error(
+                f"{settings.ANALYTICS_FEATURE_FLAG_NAME} is set to False."
+            )
             return
 
         since = parser.parse(opt_since) if opt_since else None
diff --git a/src/aap_eda/core/management/commands/rqworker.py b/src/aap_eda/core/management/commands/rqworker.py
index e50635298..bd4b72a38 100644
--- a/src/aap_eda/core/management/commands/rqworker.py
+++ b/src/aap_eda/core/management/commands/rqworker.py
@@ -17,7 +17,8 @@
 from django.conf import settings
 from django.core.management.base import BaseCommand, CommandParser
 from django_rq.management.commands import rqworker
-from flags.state import flag_enabled
+
+from aap_eda.settings import features
 
 
 class Command(BaseCommand):
@@ -33,7 +34,7 @@ def add_arguments(self, parser: CommandParser) -> None:
         return rqworker.Command.add_arguments(self, parser)
 
     def handle(self, *args, **options) -> None:
-        if flag_enabled(settings.DISPATCHERD_FEATURE_FLAG_NAME):
+        if features.DISPATCHERD:
             self.stderr.write(
                 self.style.ERROR(
                     "DISPATCHERD feature not implemented yet. "
diff --git a/src/aap_eda/core/management/commands/scheduler.py b/src/aap_eda/core/management/commands/scheduler.py
index 406547bc8..170f29a77 100644
--- a/src/aap_eda/core/management/commands/scheduler.py
+++ b/src/aap_eda/core/management/commands/scheduler.py
@@ -77,9 +77,9 @@
 import rq_scheduler
 from django.conf import settings
 from django_rq.management.commands import rqscheduler
-from flags.state import flag_enabled
 
 from aap_eda.core import tasking
+from aap_eda.settings import features
 from aap_eda.utils.logging import startup_logging
 
 logger = logging.getLogger(__name__)
@@ -166,7 +166,7 @@ class Command(rqscheduler.Command):
     help = "Runs RQ scheduler with configured jobs."
 
     def handle(self, *args, **options) -> None:
-        if flag_enabled(settings.DISPATCHERD_FEATURE_FLAG_NAME):
+        if features.DISPATCHERD:
             self.stderr.write(
                 self.style.ERROR(
                     "This command is not supported when "
diff --git a/src/aap_eda/settings/core.py b/src/aap_eda/settings/core.py
index 8295f0349..ed8451f05 100644
--- a/src/aap_eda/settings/core.py
+++ b/src/aap_eda/settings/core.py
@@ -17,9 +17,10 @@
 # Defines feature flags, and their conditions.
 # See https://cfpb.github.io/django-flags/
 DISPATCHERD_FEATURE_FLAG_NAME = "FEATURE_DISPATCHERD_ENABLED"
+ANALYTICS_FEATURE_FLAG_NAME = "FEATURE_EDA_ANALYTICS_ENABLED"
 
 FLAGS = {
-    "FEATURE_EDA_ANALYTICS_ENABLED": [
+    ANALYTICS_FEATURE_FLAG_NAME: [
         {
             "condition": "boolean",
             "value": False,
diff --git a/src/aap_eda/settings/features.py b/src/aap_eda/settings/features.py
new file mode 100644
index 000000000..2914061af
--- /dev/null
+++ b/src/aap_eda/settings/features.py
@@ -0,0 +1,46 @@
+#  Copyright 2025 Red Hat, Inc.
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+from functools import cache
+from typing import Any, Dict
+
+from django.conf import settings
+from flags.state import flag_enabled
+
+# Type hints for module attributes
+DISPATCHERD: bool
+ANALYTICS: bool
+
+# Mapping of feature names to their corresponding setting names
+_FEATURE_FLAGS: Dict[str, str] = {
+    "DISPATCHERD": "DISPATCHERD_FEATURE_FLAG_NAME",
+    "ANALYTICS": "ANALYTICS_FEATURE_FLAG_NAME",
+}
+
+
+@cache
+def _get_feature(name: str) -> bool:
+    """Get the current value of a feature flag."""
+    return flag_enabled(name)
+
+
+def __getattr__(name: str) -> Any:
+    """Dynamically provide access to feature flags as module attributes."""
+    try:
+        setting_name = _FEATURE_FLAGS[name]
+        return _get_feature(getattr(settings, setting_name))
+    except KeyError:
+        raise AttributeError(
+            f"module {__name__} has no attribute {name}"
+        ) from None
diff --git a/src/aap_eda/tasks/analytics.py b/src/aap_eda/tasks/analytics.py
index c03158086..32cb647e4 100644
--- a/src/aap_eda/tasks/analytics.py
+++ b/src/aap_eda/tasks/analytics.py
@@ -16,10 +16,10 @@
 
 import django_rq
 from ansible_base.lib.utils.db import advisory_lock
-from flags.state import flag_enabled
 
 from aap_eda.analytics import collector, utils
 from aap_eda.core import tasking
+from aap_eda.settings import features
 
 logger = logging.getLogger(__name__)
 
@@ -36,7 +36,7 @@
 def schedule_gather_analytics(
     queue_name: str = ANALYTICS_TASKS_QUEUE, cancel: bool = False
 ) -> None:
-    if not flag_enabled("FEATURE_EDA_ANALYTICS_ENABLED"):
+    if not features.ANALYTICS:
         return
     interval = utils.get_analytics_interval()
     logger.info(f"Schedule analytics to run in {interval} seconds")
diff --git a/tests/integration/analytics/test_collector.py b/tests/integration/analytics/test_collector.py
index 165617acc..aa4559a7b 100644
--- a/tests/integration/analytics/test_collector.py
+++ b/tests/integration/analytics/test_collector.py
@@ -18,7 +18,6 @@
 
 import pytest
 from django.db import connection
-from django.test import override_settings
 
 from aap_eda.analytics.collector import AnalyticsCollector, gather
 
@@ -42,12 +41,11 @@ def collector():
     return collector
 
 
-@patch("aap_eda.analytics.collector.flag_enabled")
+@patch("aap_eda.analytics.collector.features.ANALYTICS", True)
 @patch("aap_eda.analytics.collector.AnalyticsCollector")
-def test_gather_when_enabled(mock_collector_cls, mock_flag_enabled):
+def test_gather_when_enabled(mock_collector_cls):
     """Test gather function when FEATURE_EDA_ANALYTICS_ENABLED
     is set to True"""
-    mock_flag_enabled.return_value = True
     mock_collector_cls.return_value = MagicMock()
     mock_logger = MagicMock()
 
@@ -58,7 +56,6 @@ def test_gather_when_enabled(mock_collector_cls, mock_flag_enabled):
         logger=mock_logger,
     )
 
-    mock_flag_enabled.assert_called_once_with("FEATURE_EDA_ANALYTICS_ENABLED")
     mock_collector_cls.assert_called_once_with(
         collector_module=ANY,
         collection_type="manual",
@@ -67,27 +64,19 @@ def test_gather_when_enabled(mock_collector_cls, mock_flag_enabled):
     assert result is not None
 
 
-@patch("aap_eda.analytics.collector.flag_enabled")
-def test_gather_when_disabled(mock_flag_enabled):
+@patch("aap_eda.analytics.collector.features.ANALYTICS", False)
+def test_gather_when_disabled():
     """Test gather function when FEATURE_EDA_ANALYTICS_ENABLED
     is set to False"""
-    mock_flag_enabled.return_value = False
     mock_logger = MagicMock()
-
     result = gather(logger=mock_logger)
-
-    mock_logger.info.assert_called_once_with(
-        "FEATURE_EDA_ANALYTICS_ENABLED is set to False."
-    )
     assert result is None
 
 
 @pytest.mark.django_db
-@patch("aap_eda.analytics.collector.flag_enabled")
+@patch("aap_eda.analytics.collector.features.ANALYTICS", True)
 @patch("aap_eda.analytics.collector.AnalyticsCollector")
-def test_gather_uses_default_logger(mock_collector_cls, mock_flag_enabled):
-    mock_flag_enabled.return_value = True
-
+def test_gather_uses_default_logger(mock_collector_cls):
     with patch(
         "aap_eda.analytics.collector.logging.getLogger"
     ) as mock_get_logger:
@@ -119,20 +108,15 @@ def test_shipping_disabled_logs_warning(
     with patch(
         "aap_eda.analytics.utils.get_insights_tracking_state",
         return_value=insights_tracking_state,
+    ), patch(
+        "aap_eda.analytics.collector.features.ANALYTICS", feature_flag_state
     ):
-        with override_settings(
-            FLAGS={
-                "FEATURE_EDA_ANALYTICS_ENABLED": [
-                    ("boolean", feature_flag_state)
-                ]
-            }
-        ):
-            assert collector._is_shipping_configured() is expected
-
-            if not expected:
-                collector.logger.warning.assert_called_once_with(
-                    "Insights for Event Driven Ansible is not enabled."
-                )
+        assert collector._is_shipping_configured() is expected
+
+        if not expected:
+            collector.logger.warning.assert_called_once_with(
+                "Insights for Event Driven Ansible is not enabled."
+            )
 
 
 @pytest.mark.django_db
diff --git a/tests/integration/analytics/test_gather_analytics.py b/tests/integration/analytics/test_gather_analytics.py
index 751e3956f..c7b7b7630 100644
--- a/tests/integration/analytics/test_gather_analytics.py
+++ b/tests/integration/analytics/test_gather_analytics.py
@@ -17,6 +17,7 @@
 from unittest import mock
 
 import pytest
+from django.conf import settings
 from django.core.management import call_command
 from django.test import override_settings
 
@@ -35,9 +36,12 @@
 )
 @pytest.mark.django_db
 @override_settings(
-    FLAGS={"FEATURE_EDA_ANALYTICS_ENABLED": [("boolean", True)]},
     AUTOMATION_AUTH_METHOD="user-pass",
 )
+@mock.patch(
+    "aap_eda.core.management.commands.gather_analytics.features.ANALYTICS",
+    True,
+)
 def test_gather_analytics_invalid_settings(
     analytics_settings,
     caplog_factory,
@@ -124,14 +128,15 @@ def test_gather_analytics_invalid_settings(
     ],
 )
 @pytest.mark.django_db
-@override_settings(
-    FLAGS={"FEATURE_EDA_ANALYTICS_ENABLED": [("boolean", True)]}
-)
 @mock.patch(
     "aap_eda.core.management.commands.gather_analytics.collector.gather"
 )
+@mock.patch(
+    "aap_eda.core.management.commands.gather_analytics.features.ANALYTICS",
+    True,
+)
 def test_gather_analytics_command(
-    mock_gather, analytics_settings, caplog_factory, args, log_level, expected
+    analytics_settings, caplog_factory, args, log_level, expected
 ):
     with mock.patch(
         "aap_eda.analytics.collector.application_settings",
@@ -161,7 +166,7 @@ def test_gather_analytics_command(
     "feature_flag_state, expected",
     [
         (True, "Either --ship or --dry-run needs to be set."),
-        (False, "FEATURE_EDA_ANALYTICS_ENABLED is set to False."),
+        (False, f"{settings.ANALYTICS_FEATURE_FLAG_NAME} is set to False."),
     ],
 )
 def test_gather_analytics_command_by_ff_state(
@@ -170,20 +175,15 @@ def test_gather_analytics_command_by_ff_state(
     with mock.patch(
         "aap_eda.analytics.collector.application_settings",
         new=analytics_settings,
+    ), mock.patch(
+        "aap_eda.core.management.commands.gather_analytics.features.ANALYTICS",
+        feature_flag_state,
     ):
         analytics_settings.INSIGHTS_TRACKING_STATE = True
         out = StringIO()
         logger = logging.getLogger("aap_eda.analytics")
         eda_log = caplog_factory(logger)
-        with override_settings(
-            FLAGS={
-                "FEATURE_EDA_ANALYTICS_ENABLED": [
-                    ("boolean", feature_flag_state)
-                ]
-            }
-        ):
-            command = "gather_analytics"
-            call_command(command, stdout=out)
+        call_command("gather_analytics", stdout=out)
 
         assert any(
             record.levelname == "ERROR" and record.message == expected
diff --git a/tests/integration/api/test_feature_flags.py b/tests/integration/api/test_feature_flags.py
index fdcb14793..da6f540e7 100644
--- a/tests/integration/api/test_feature_flags.py
+++ b/tests/integration/api/test_feature_flags.py
@@ -15,7 +15,7 @@ def test_feature_flags_list_endpoint(admin_client):
     # Validates expected default feature flags
     # Modify each time a flag is added to default settings
     assert len(response.data) == 2
-    assert response.data["FEATURE_EDA_ANALYTICS_ENABLED"] is False
+    assert response.data[settings.ANALYTICS_FEATURE_FLAG_NAME] is False
     assert response.data[settings.DISPATCHERD_FEATURE_FLAG_NAME] is False
 
 
diff --git a/tests/integration/tasks/test_analytics.py b/tests/integration/tasks/test_analytics.py
index d15ce4997..59f91955f 100644
--- a/tests/integration/tasks/test_analytics.py
+++ b/tests/integration/tasks/test_analytics.py
@@ -56,8 +56,7 @@ def test_schedule_gather_analytics_success():
     ) as mock_enqueue, mock.patch(
         "aap_eda.tasks.analytics.logger.info"
     ) as mock_logger, mock.patch(
-        "aap_eda.tasks.analytics.flag_enabled",
-        return_value=True,
+        "aap_eda.tasks.analytics.features.ANALYTICS", True
     ):
         mock_interval.return_value = test_interval
 
@@ -82,8 +81,7 @@ def test_schedule_gather_analytics_with_default_queue():
     ) as mock_interval, mock.patch(
         "aap_eda.tasks.analytics.tasking.enqueue_delay"
     ) as mock_enqueue, mock.patch(
-        "aap_eda.tasks.analytics.flag_enabled",
-        return_value=True,
+        "aap_eda.tasks.analytics.features.ANALYTICS", True
     ):
         mock_interval.return_value = 300
         analytics.schedule_gather_analytics()
@@ -104,8 +102,7 @@ def test_schedule_gather_analytics_error_handling():
     ), mock.patch(
         "aap_eda.tasks.analytics.tasking.enqueue_delay"
     ) as mock_enqueue, mock.patch(
-        "aap_eda.tasks.analytics.flag_enabled",
-        return_value=True,
+        "aap_eda.tasks.analytics.features.ANALYTICS", True
     ):
         mock_enqueue.side_effect = RuntimeError("Queue connection failed")
 
@@ -124,8 +121,7 @@ def test_reschedule_gather_analytics():
     ) as mock_enqueue, mock.patch(
         "aap_eda.tasks.analytics.tasking.queue_cancel_job"
     ) as mock_cancel_job, mock.patch(
-        "aap_eda.tasks.analytics.flag_enabled",
-        return_value=True,
+        "aap_eda.tasks.analytics.features.ANALYTICS", True
     ):
         mock_interval.return_value = 300
         analytics.reschedule_gather_analytics()
diff --git a/tests/unit/commands/test_rq_cmd_wrapper.py b/tests/unit/commands/test_rq_cmd_wrapper.py
index 52c69e8b7..0fa1d4d42 100644
--- a/tests/unit/commands/test_rq_cmd_wrapper.py
+++ b/tests/unit/commands/test_rq_cmd_wrapper.py
@@ -15,20 +15,14 @@
 from unittest import mock
 
 import pytest
-from django.conf import settings
 from django.core.management import call_command
-from django.test import override_settings
 from django_rq.management.commands import rqscheduler, rqworker
 
 
-@override_settings(
-    FLAGS={
-        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
-            {"condition": "boolean", "value": True},
-        ],
-    }
-)
 @pytest.mark.django_db
+@mock.patch(
+    "aap_eda.core.management.commands.rqworker.features.DISPATCHERD", True
+)
 def test_rqworker_command_feature_flag_enabled(capsys):
     with pytest.raises(SystemExit) as exc_info:
         call_command(
@@ -40,14 +34,10 @@ def test_rqworker_command_feature_flag_enabled(capsys):
     assert "DISPATCHERD feature not implemented yet" in captured.err
 
 
-@override_settings(
-    FLAGS={
-        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
-            {"condition": "boolean", "value": False},
-        ],
-    }
-)
 @pytest.mark.django_db
+@mock.patch(
+    "aap_eda.core.management.commands.rqworker.features.DISPATCHERD", False
+)
 @mock.patch.object(rqworker.Command, "handle", return_value=None)
 def test_rqworker_command_feature_flag_disabled(
     mock_rqworker_handle,
@@ -60,14 +50,10 @@ def test_rqworker_command_feature_flag_disabled(
     mock_rqworker_handle.assert_called_once()
 
 
-@override_settings(
-    FLAGS={
-        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
-            {"condition": "boolean", "value": False},
-        ],
-    }
-)
 @pytest.mark.django_db
+@mock.patch(
+    "aap_eda.core.management.commands.scheduler.features.DISPATCHERD", False
+)
 @mock.patch.object(rqscheduler.Command, "handle", return_value=None)
 def test_rqscheduler_command_feature_flag_disabled(
     mock_rqscheduler_handle,
@@ -77,14 +63,10 @@ def test_rqscheduler_command_feature_flag_disabled(
     mock_rqscheduler_handle.assert_called_once()
 
 
-@override_settings(
-    FLAGS={
-        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
-            {"condition": "boolean", "value": True},
-        ],
-    }
-)
 @pytest.mark.django_db
+@mock.patch(
+    "aap_eda.core.management.commands.scheduler.features.DISPATCHERD", True
+)
 @mock.patch.object(rqscheduler.Command, "handle", return_value=None)
 def test_rqscheduler_command_feature_flag_enabled(
     mock_rqscheduler_handle,
diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py
new file mode 100644
index 000000000..742277764
--- /dev/null
+++ b/tests/unit/test_features.py
@@ -0,0 +1,89 @@
+#  Copyright 2025 Red Hat, Inc.
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+"""Unit tests for feature flags functionality."""
+
+import pytest
+
+from aap_eda.settings import features
+from aap_eda.settings.features import _get_feature
+
+
+@pytest.fixture(autouse=True)
+def clear_feature_cache():
+    """Clear the feature flag cache before each test."""
+    _get_feature.cache_clear()
+
+
+@pytest.mark.django_db
+def test_get_feature_flag(settings):
+    """Test getting feature flag values."""
+    settings.FLAGS = {
+        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
+            {"condition": "boolean", "value": True}
+        ],
+        settings.ANALYTICS_FEATURE_FLAG_NAME: [
+            {"condition": "boolean", "value": False}
+        ],
+    }
+
+    assert features.DISPATCHERD is True
+    assert features.ANALYTICS is False
+
+
+@pytest.mark.django_db
+def test_feature_flag_caching(settings):
+    """Test that feature flag values are properly cached."""
+    settings.FLAGS = {
+        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
+            {"condition": "boolean", "value": True}
+        ]
+    }
+
+    # First access - should cache the value
+    assert features.DISPATCHERD is True
+
+    # Change the underlying flag value
+    settings.FLAGS[settings.DISPATCHERD_FEATURE_FLAG_NAME][0]["value"] = False
+
+    # Should still get the cached value
+    assert features.DISPATCHERD is True
+
+
+@pytest.mark.django_db
+def test_cache_invalidation(settings):
+    """Test that cache invalidation works as expected."""
+    settings.FLAGS = {
+        settings.DISPATCHERD_FEATURE_FLAG_NAME: [
+            {"condition": "boolean", "value": True}
+        ]
+    }
+
+    # Populate cache
+    assert features.DISPATCHERD is True
+
+    # Change the flag value and clear cache
+    settings.FLAGS[settings.DISPATCHERD_FEATURE_FLAG_NAME][0]["value"] = False
+    _get_feature.cache_clear()
+
+    # Should get the new value after cache clear
+    assert features.DISPATCHERD is False
+
+
+@pytest.mark.django_db
+def test_invalid_attribute():
+    """Test accessing non-existent feature flag raises AttributeError."""
+    with pytest.raises(AttributeError) as excinfo:
+        _ = features.NON_EXISTENT_FEATURE
+    assert "has no attribute NON_EXISTENT_FEATURE" in str(excinfo.value)


Reviews:
user: login: Alex-Izquierdo
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-05-12T13:25:48Z
user: login: ttuffin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:26:01Z
user: login: Alex-Izquierdo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T14:13:26Z
user: login: ttuffin
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T15:17:39Z
user: login: Alex-Izquierdo
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T16:43:42Z

Review comments:
diff_hunk: @@ -0,0 +1,37 @@
+#  Copyright 2025 Red Hat, Inc.
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+from typing import Any
+
+from django.conf import settings
+from flags.state import flag_enabled
+
+DISPATCHERD: bool
+ANALYTICS: bool
+
+_caches = {}
path: src/aap_eda/settings/features.py
user: login: ttuffin
body: Should this be the singular form, cache? Maybe a more descriptive name `_feature_flag_cache`
created_at: 2025-05-12T13:26:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+#  Copyright 2025 Red Hat, Inc.
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+from typing import Any
+
+from django.conf import settings
+from flags.state import flag_enabled
+
+DISPATCHERD: bool
+ANALYTICS: bool
+
+_feature_flag_cache = {}
path: src/aap_eda/settings/features.py
user: login: Alex-Izquierdo
body: Suggestion: no need to implement custom cache, you can use https://docs.python.org/3/library/functools.html#functools.cache 
created_at: 2025-05-12T14:13:26Z
author_association: COLLABORATOR

PR comments:
user: login: codecov-commenter
created_at: 2025-05-12T13:16:56Z
body: ## [Codecov](https://app.codecov.io/gh/ansible/eda-server/pull/1293?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.54%. Comparing base [(`dfa3d0f`)](https://app.codecov.io/gh/ansible/eda-server/commit/dfa3d0f16e5da7be7259b1d5d0e47443ea959b3b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) to head [(`8e0fce8`)](https://app.codecov.io/gh/ansible/eda-server/commit/8e0fce8efb866493e60543b84199914b60557575?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible).

```diff
@@            Coverage Diff             @@
##             main    #1293      +/-   ##
==========================================
+ Coverage   94.53%   94.54%   +0.01%     
==========================================
  Files         316      318       +2     
  Lines       18510    18550      +40     
==========================================
+ Hits        17498    17538      +40     
  Misses       1012     1012              
```

| [Flag](https://app.codecov.io/gh/ansible/eda-server/pull/1293/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) | Coverage Δ | |
|---|---|---|
| [unit-int-tests-3.11](https://app.codecov.io/gh/ansible/eda-server/pull/1293/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) | `94.48% <100.00%> (+0.01%)` | :arrow_up: |
| [unit-int-tests-3.12](https://app.codecov.io/gh/ansible/eda-server/pull/1293/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) | `94.54% <100.00%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files with missing lines](https://app.codecov.io/gh/ansible/eda-server/pull/1293?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) | Coverage Δ | |
|---|---|---|
| [src/aap\_eda/analytics/collector.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fanalytics%2Fcollector.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvYW5hbHl0aWNzL2NvbGxlY3Rvci5weQ==) | `100.00% <100.00%> (ø)` | |
| [...da/core/management/commands/create\_initial\_data.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fcore%2Fmanagement%2Fcommands%2Fcreate_initial_data.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvY29yZS9tYW5hZ2VtZW50L2NvbW1hbmRzL2NyZWF0ZV9pbml0aWFsX2RhdGEucHk=) | `98.73% <100.00%> (ø)` | |
| [...p\_eda/core/management/commands/gather\_analytics.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fcore%2Fmanagement%2Fcommands%2Fgather_analytics.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvY29yZS9tYW5hZ2VtZW50L2NvbW1hbmRzL2dhdGhlcl9hbmFseXRpY3MucHk=) | `100.00% <100.00%> (ø)` | |
| [src/aap\_eda/core/management/commands/rqworker.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fcore%2Fmanagement%2Fcommands%2Frqworker.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvY29yZS9tYW5hZ2VtZW50L2NvbW1hbmRzL3Jxd29ya2VyLnB5) | `100.00% <100.00%> (ø)` | |
| [src/aap\_eda/core/management/commands/scheduler.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fcore%2Fmanagement%2Fcommands%2Fscheduler.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvY29yZS9tYW5hZ2VtZW50L2NvbW1hbmRzL3NjaGVkdWxlci5weQ==) | `83.33% <100.00%> (ø)` | |
| [src/aap\_eda/settings/core.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fsettings%2Fcore.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvc2V0dGluZ3MvY29yZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/aap\_eda/settings/features.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Fsettings%2Ffeatures.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvc2V0dGluZ3MvZmVhdHVyZXMucHk=) | `100.00% <100.00%> (ø)` | |
| [src/aap\_eda/tasks/analytics.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=src%2Faap_eda%2Ftasks%2Fanalytics.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-c3JjL2FhcF9lZGEvdGFza3MvYW5hbHl0aWNzLnB5) | `93.61% <100.00%> (ø)` | |
| [tests/integration/analytics/test\_collector.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=tests%2Fintegration%2Fanalytics%2Ftest_collector.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-dGVzdHMvaW50ZWdyYXRpb24vYW5hbHl0aWNzL3Rlc3RfY29sbGVjdG9yLnB5) | `100.00% <100.00%> (ø)` | |
| [...sts/integration/analytics/test\_gather\_analytics.py](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree&filepath=tests%2Fintegration%2Fanalytics%2Ftest_gather_analytics.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible#diff-dGVzdHMvaW50ZWdyYXRpb24vYW5hbHl0aWNzL3Rlc3RfZ2F0aGVyX2FuYWx5dGljcy5weQ==) | `100.00% <100.00%> (ø)` | |
| ... and [4 more](https://app.codecov.io/gh/ansible/eda-server/pull/1293?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ansible) | |

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-12T14:56:43Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ansible_eda-server&pullRequest=1293) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=ansible_eda-server&pullRequest=1293&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ansible_eda-server&pullRequest=1293&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ansible_eda-server&pullRequest=1293&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ansible_eda-server&pullRequest=1293&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ansible_eda-server&pullRequest=1293&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ansible_eda-server&pullRequest=1293)


author_association: NONE