diff --git a/api/audit/models.py b/api/audit/models.py index e027f5f5f9ec..cf9c71b69e74 100644 --- a/api/audit/models.py +++ b/api/audit/models.py @@ -17,6 +17,9 @@ from audit.related_object_type import RelatedObjectType from projects.models import Project +if typing.TYPE_CHECKING: + from organisations.models import Organisation + RELATED_OBJECT_TYPES = ((tag.name, tag.value) for tag in RelatedObjectType) @@ -68,6 +71,21 @@ class Meta: verbose_name_plural = "Audit Logs" ordering = ("-created_date",) + @property + def organisation(self) -> "Organisation | None": + # TODO properly implement organisation relation + # maybe the relation list should not be _that_ exhaustive... + for relation in ( + "project", + "environment", + "author", + "master_api_key", + "history_record", + ): + if hasattr(related_instance := getattr(self, relation), "organisation"): + return related_instance.organisation + return None + @property def environment_document_updated(self) -> bool: if self.related_object_type == RelatedObjectType.CHANGE_REQUEST.name: @@ -126,7 +144,10 @@ def add_created_date(self) -> None: when="environment_document_updated", is_now=True, ) - def process_environment_update(self): + def process_environment_update(self) -> None: + if not self.project: + return + from environments.models import Environment from environments.tasks import process_environment_update diff --git a/api/audit/signals.py b/api/audit/signals.py index 6e6bb0289da0..deb31810fdd3 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -6,6 +6,7 @@ from audit.models import AuditLog, RelatedObjectType from audit.serializers import AuditLogListSerializer +from integrations.common.models import IntegrationsModel from integrations.datadog.datadog import DataDogWrapper from integrations.dynatrace.dynatrace import DynatraceWrapper from integrations.grafana.grafana import GrafanaWrapper @@ -41,12 +42,15 @@ def call_webhooks(sender, instance, **kwargs): ) -def _get_integration_config(instance, integration_name): - if hasattr(instance.project, integration_name): - return getattr(instance.project, integration_name) - elif hasattr(instance.environment, integration_name): - return getattr(instance.environment, integration_name) - +def _get_integration_config( + instance: AuditLog, integration_name: str +) -> IntegrationsModel | None: + if hasattr(project := instance.project, integration_name): + return getattr(project, integration_name) + if hasattr(environment := instance.environment, integration_name): + return getattr(environment, integration_name) + if hasattr(organisation := instance.organisation, integration_name): + return getattr(organisation, integration_name) return None diff --git a/api/environments/urls.py b/api/environments/urls.py index 5489d426c5cd..8615e4fa3e84 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -14,7 +14,7 @@ ) from integrations.amplitude.views import AmplitudeConfigurationViewSet from integrations.dynatrace.views import DynatraceConfigurationViewSet -from integrations.grafana.views import GrafanaConfigurationViewSet +from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.heap.views import HeapConfigurationViewSet from integrations.mixpanel.views import MixpanelConfigurationViewSet from integrations.rudderstack.views import RudderstackConfigurationViewSet @@ -83,7 +83,7 @@ ) environments_router.register( r"integrations/grafana", - GrafanaConfigurationViewSet, + GrafanaProjectConfigurationViewSet, basename="integrations-grafana", ) environments_router.register( diff --git a/api/integrations/common/serializers.py b/api/integrations/common/serializers.py index a6f52a19c48b..bab3a8b6aa20 100644 --- a/api/integrations/common/serializers.py +++ b/api/integrations/common/serializers.py @@ -34,3 +34,7 @@ class BaseEnvironmentIntegrationModelSerializer(_BaseIntegrationModelSerializer) class BaseProjectIntegrationModelSerializer(_BaseIntegrationModelSerializer): one_to_one_field_name = "project_id" + + +class BaseOrganisationIntegrationModelSerializer(_BaseIntegrationModelSerializer): + one_to_one_field_name = "organisation_id" diff --git a/api/integrations/common/views.py b/api/integrations/common/views.py index 40ce6cb5e986..f4ed2d230608 100644 --- a/api/integrations/common/views.py +++ b/api/integrations/common/views.py @@ -9,6 +9,9 @@ from environments.models import Environment from environments.permissions.constants import VIEW_ENVIRONMENT from environments.permissions.permissions import NestedEnvironmentPermissions +from organisations.permissions.permissions import ( + NestedOrganisationEntityPermission, +) from projects.permissions import VIEW_PROJECT, NestedProjectPermissions @@ -74,3 +77,29 @@ def perform_create(self, serializer: BaseSerializer) -> None: def perform_update(self, serializer: BaseSerializer) -> None: serializer.save(project_id=self.kwargs["project_pk"]) + + +class OrganisationIntegrationBaseViewSet(viewsets.ModelViewSet): + serializer_class = None + pagination_class = None + model_class = None + + permission_classes = [IsAuthenticated, NestedOrganisationEntityPermission] + + def get_queryset(self) -> QuerySet: + if getattr(self, "swagger_fake_view", False): + return self.model_class.objects.none() + + return self.model_class.objects.filter( + organisation_id=self.kwargs["organisation_pk"] + ) + + def perform_create(self, serializer: BaseSerializer) -> None: + if self.get_queryset().exists(): + raise ValidationError( + "This integration already exists for this organisation." + ) + serializer.save(organisation_id=self.kwargs["organisation_pk"]) + + def perform_update(self, serializer: BaseSerializer) -> None: + serializer.save(organisation_id=self.kwargs["organisation_pk"]) diff --git a/api/integrations/grafana/migrations/0002_add_grafana_organisation_configuration.py b/api/integrations/grafana/migrations/0002_add_grafana_organisation_configuration.py new file mode 100644 index 000000000000..2ee8183141e7 --- /dev/null +++ b/api/integrations/grafana/migrations/0002_add_grafana_organisation_configuration.py @@ -0,0 +1,61 @@ +# Generated by Django 3.2.25 on 2024-07-27 14:21 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("organisations", "0055_alter_percent_usage"), + ("projects", "0024_add_project_edge_v2_migration_read_capacity_budget"), + ("grafana", "0001_initial"), + ] + + operations = [ + migrations.RenameModel( + old_name="GrafanaConfiguration", + new_name="GrafanaProjectConfiguration", + ), + migrations.CreateModel( + name="GrafanaOrganisationConfiguration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("base_url", models.URLField(null=True)), + ("api_key", models.CharField(max_length=100)), + ( + "organisation", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="grafana_config", + to="organisations.organisation", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/api/integrations/grafana/models.py b/api/integrations/grafana/models.py index ba9e01af9535..37bd10cea453 100644 --- a/api/integrations/grafana/models.py +++ b/api/integrations/grafana/models.py @@ -1,44 +1,50 @@ +""" +Example `integration_data` entry: + +``` + "grafana": { + "perEnvironment": false, + "image": "/static/images/integrations/grafana.svg", + "docs": "https://docs.flagsmith.com/integrations/apm/grafana", + "fields": [ + { + "key": "base_url", + "label": "Base URL", + "default": "https://grafana.com" + }, + { + "key": "api_key", + "label": "Service account token", + "hidden": true + } + ], + "tags": [ + "logging" + ], + "title": "Grafana", + "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes." + }, +``` +""" + import logging from django.db import models from integrations.common.models import IntegrationsModel +from organisations.models import Organisation from projects.models import Project logger = logging.getLogger(__name__) -class GrafanaConfiguration(IntegrationsModel): - """ - Example `integration_data` entry: - - ``` - "grafana": { - "perEnvironment": false, - "image": "/static/images/integrations/grafana.svg", - "docs": "https://docs.flagsmith.com/integrations/apm/grafana", - "fields": [ - { - "key": "base_url", - "label": "Base URL", - "default": "https://grafana.com" - }, - { - "key": "api_key", - "label": "Service account token", - "hidden": true - } - ], - "tags": [ - "logging" - ], - "title": "Grafana", - "description": "Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes." - }, - ``` - """ - - base_url = models.URLField(blank=False, null=True) +class GrafanaProjectConfiguration(IntegrationsModel): project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="grafana_config" ) + + +class GrafanaOrganisationConfiguration(IntegrationsModel): + organisation = models.OneToOneField( + Organisation, on_delete=models.CASCADE, related_name="grafana_config" + ) diff --git a/api/integrations/grafana/serializers.py b/api/integrations/grafana/serializers.py index 47ee252c30cf..3bcdd0ad65de 100644 --- a/api/integrations/grafana/serializers.py +++ b/api/integrations/grafana/serializers.py @@ -1,11 +1,24 @@ from integrations.common.serializers import ( + BaseOrganisationIntegrationModelSerializer, BaseProjectIntegrationModelSerializer, ) +from integrations.grafana.models import ( + GrafanaOrganisationConfiguration, + GrafanaProjectConfiguration, +) + -from .models import GrafanaConfiguration +class GrafanaProjectConfigurationSerializer( + BaseProjectIntegrationModelSerializer, +): + class Meta: + model = GrafanaProjectConfiguration + fields = ("id", "base_url", "api_key") -class GrafanaConfigurationSerializer(BaseProjectIntegrationModelSerializer): +class GrafanaOrganisationConfigurationSerializer( + BaseOrganisationIntegrationModelSerializer, +): class Meta: - model = GrafanaConfiguration + model = GrafanaOrganisationConfiguration fields = ("id", "base_url", "api_key") diff --git a/api/integrations/grafana/views.py b/api/integrations/grafana/views.py index cd36b2c0e9e1..07a1cec53ba6 100644 --- a/api/integrations/grafana/views.py +++ b/api/integrations/grafana/views.py @@ -1,9 +1,24 @@ -from integrations.common.views import ProjectIntegrationBaseViewSet -from integrations.grafana.models import GrafanaConfiguration -from integrations.grafana.serializers import GrafanaConfigurationSerializer +from integrations.common.views import ( + OrganisationIntegrationBaseViewSet, + ProjectIntegrationBaseViewSet, +) +from integrations.grafana.models import ( + GrafanaOrganisationConfiguration, + GrafanaProjectConfiguration, +) +from integrations.grafana.serializers import ( + GrafanaOrganisationConfigurationSerializer, + GrafanaProjectConfigurationSerializer, +) -class GrafanaConfigurationViewSet(ProjectIntegrationBaseViewSet): - serializer_class = GrafanaConfigurationSerializer +class GrafanaProjectConfigurationViewSet(ProjectIntegrationBaseViewSet): + serializer_class = GrafanaProjectConfigurationSerializer pagination_class = None # set here to ensure documentation is correct - model_class = GrafanaConfiguration + model_class = GrafanaProjectConfiguration + + +class GrafanaOrganisationConfigurationViewSet(OrganisationIntegrationBaseViewSet): + serializer_class = GrafanaOrganisationConfigurationSerializer + pagination_class = None # set here to ensure documentation is correct + model_class = GrafanaOrganisationConfiguration diff --git a/api/organisations/urls.py b/api/organisations/urls.py index 5504a0afa24f..56faeedab455 100644 --- a/api/organisations/urls.py +++ b/api/organisations/urls.py @@ -16,6 +16,7 @@ fetch_repo_contributors, fetch_repositories, ) +from integrations.grafana.views import GrafanaOrganisationConfigurationViewSet from metadata.views import MetaDataModelFieldViewSet from organisations.views import ( OrganisationAPIUsageNotificationView, @@ -77,6 +78,12 @@ "audit", OrganisationAuditLogViewSet, basename="audit-log" ) +organisations_router.register( + r"integrations/grafana", + GrafanaOrganisationConfigurationViewSet, + basename="integrations-grafana", +) + organisations_router.register( r"integrations/github", GithubConfigurationViewSet, diff --git a/api/projects/urls.py b/api/projects/urls.py index d68750b97d86..4a38160dce16 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -12,7 +12,7 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet -from integrations.grafana.views import GrafanaConfigurationViewSet +from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet from projects.tags.views import TagViewSet @@ -59,7 +59,7 @@ ) projects_router.register( r"integrations/grafana", - GrafanaConfigurationViewSet, + GrafanaProjectConfigurationViewSet, basename="integrations-grafana", ) projects_router.register( diff --git a/api/tests/unit/api/test_unit_api.py b/api/tests/unit/api/test_unit_api.py index e4959b34d8c6..fdcf58a425c9 100644 --- a/api/tests/unit/api/test_unit_api.py +++ b/api/tests/unit/api/test_unit_api.py @@ -6,9 +6,17 @@ @pytest.mark.parametrize( - "client", (lazy_fixture("api_client"), lazy_fixture("admin_client")) + "url", + ( + reverse("api-v1:schema-json", args=[".json"]), + reverse("api-v1:schema-json", args=[".yaml"]), + reverse("api-v1:schema-swagger-ui"), + ), ) -def test_swagger_docs_generation(client: APIClient) -> None: - url = reverse("api-v1:schema-swagger-ui") +@pytest.mark.parametrize( + "client", + (lazy_fixture("api_client"), lazy_fixture("admin_client")), +) +def test_swagger_docs_generation(url: str, client: APIClient) -> None: response = client.get(url) assert response.status_code == status.HTTP_200_OK diff --git a/api/tests/unit/audit/test_unit_audit_models.py b/api/tests/unit/audit/test_unit_audit_models.py index a6b49229b8f7..07d6ca67c8dd 100644 --- a/api/tests/unit/audit/test_unit_audit_models.py +++ b/api/tests/unit/audit/test_unit_audit_models.py @@ -1,3 +1,4 @@ +import pytest from pytest_mock import MockerFixture from audit.models import AuditLog @@ -228,3 +229,15 @@ def test_creating_audit_logs_for_change_request_does_not_trigger_process_environ # Then process_environment_update.delay.assert_not_called() assert audit_log.created_date != environment.updated_at + + +@pytest.mark.django_db +def test_audit_log__organisation__empty_instance__return_expected() -> None: + # Given + audit_log = AuditLog.objects.create() + + # When + organisation = audit_log.organisation + + # Then + assert organisation is None diff --git a/api/tests/unit/audit/test_unit_audit_signals.py b/api/tests/unit/audit/test_unit_audit_signals.py index 6265276f2ff0..2b7bd6c55f0e 100644 --- a/api/tests/unit/audit/test_unit_audit_signals.py +++ b/api/tests/unit/audit/test_unit_audit_signals.py @@ -3,8 +3,17 @@ from audit.models import AuditLog from audit.related_object_type import RelatedObjectType -from audit.signals import call_webhooks, send_audit_log_event_to_grafana -from integrations.grafana.models import GrafanaConfiguration +from audit.signals import ( + call_webhooks, + send_audit_log_event_to_dynatrace, + send_audit_log_event_to_grafana, +) +from environments.models import Environment +from integrations.dynatrace.models import DynatraceConfiguration +from integrations.grafana.models import ( + GrafanaOrganisationConfiguration, + GrafanaProjectConfiguration, +) from organisations.models import Organisation, OrganisationWebhook from projects.models import Project from webhooks.webhooks import WebhookEventType @@ -90,7 +99,7 @@ def test_send_audit_log_event_to_grafana__project_grafana_config__calls_expected project: Project, ) -> None: # Given - grafana_config = GrafanaConfiguration(base_url="test.com", api_key="test") + grafana_config = GrafanaProjectConfiguration(base_url="test.com", api_key="test") project.grafana_config = grafana_config audit_log_record = AuditLog.objects.create( project=project, @@ -113,3 +122,71 @@ def test_send_audit_log_event_to_grafana__project_grafana_config__calls_expected grafana_wrapper_instance_mock.track_event_async.assert_called_once_with( event=grafana_wrapper_instance_mock.generate_event_data.return_value ) + + +def test_send_audit_log_event_to_grafana__organisation_grafana_config__calls_expected( + mocker: MockerFixture, + organisation: Organisation, + project: Project, +) -> None: + # Given + grafana_config = GrafanaOrganisationConfiguration( + base_url="test.com", api_key="test" + ) + organisation.grafana_config = grafana_config + audit_log_record = AuditLog.objects.create( + project=project, + related_object_type=RelatedObjectType.FEATURE.name, + ) + grafana_wrapper_mock = mocker.patch("audit.signals.GrafanaWrapper", autospec=True) + grafana_wrapper_instance_mock = grafana_wrapper_mock.return_value + + # When + send_audit_log_event_to_grafana(AuditLog, audit_log_record) + + # Then + grafana_wrapper_mock.assert_called_once_with( + base_url=grafana_config.base_url, + api_key=grafana_config.api_key, + ) + grafana_wrapper_instance_mock.generate_event_data.assert_called_once_with( + audit_log_record + ) + grafana_wrapper_instance_mock.track_event_async.assert_called_once_with( + event=grafana_wrapper_instance_mock.generate_event_data.return_value + ) + + +def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_expected( + mocker: MockerFixture, + environment: Environment, +) -> None: + # Given + dynatrace_config = DynatraceConfiguration.objects.create( + base_url="http://test.com", api_key="api_123", environment=environment + ) + environment.refresh_from_db() + audit_log_record = AuditLog.objects.create( + environment=environment, + related_object_type=RelatedObjectType.FEATURE.name, + ) + dynatrace_wrapper_mock = mocker.patch( + "audit.signals.DynatraceWrapper", autospec=True + ) + dynatrace_wrapper_instance_mock = dynatrace_wrapper_mock.return_value + + # When + send_audit_log_event_to_dynatrace(AuditLog, audit_log_record) + + # Then + dynatrace_wrapper_mock.assert_called_once_with( + base_url=dynatrace_config.base_url, + api_key=dynatrace_config.api_key, + entity_selector=dynatrace_config.entity_selector, + ) + dynatrace_wrapper_instance_mock.generate_event_data.assert_called_once_with( + audit_log_record + ) + dynatrace_wrapper_instance_mock.track_event_async.assert_called_once_with( + event=dynatrace_wrapper_instance_mock.generate_event_data.return_value + ) diff --git a/api/tests/unit/integrations/grafana/test_views.py b/api/tests/unit/integrations/grafana/test_views.py new file mode 100644 index 000000000000..aa68e0f33821 --- /dev/null +++ b/api/tests/unit/integrations/grafana/test_views.py @@ -0,0 +1,194 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from integrations.grafana.models import GrafanaOrganisationConfiguration +from organisations.models import Organisation + + +@pytest.fixture +def grafana_organisation_configuration( + organisation: Organisation, +) -> GrafanaOrganisationConfiguration: + return GrafanaOrganisationConfiguration.objects.create( + organisation=organisation, + base_url="http://test.com", + api_key="abc-123", + ) + + +def test_grafana_organisation_view__create_configuration__persist_expected( + admin_client_new: APIClient, + organisation: Organisation, +) -> None: + # Given + data = { + "base_url": "http://test.com", + "api_key": "abc-123", + } + url = reverse( + "api-v1:organisations:integrations-grafana-list", args=[organisation.id] + ) + + # When + response = admin_client_new.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert ( + GrafanaOrganisationConfiguration.objects.filter( + organisation=organisation + ).count() + == 1 + ) + + created_config = GrafanaOrganisationConfiguration.objects.filter( + organisation=organisation + ).first() + assert created_config.base_url == data["base_url"] + assert created_config.api_key == data["api_key"] + + +def test_grafana_organisation_view__create_configuration__existing__return_expected( + admin_client_new: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + data = { + "base_url": "http://test.com", + "api_key": "abc-123", + } + url = reverse( + "api-v1:organisations:integrations-grafana-list", args=[organisation.id] + ) + + # When + response = admin_client_new.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_grafana_organisation_view__update_configuration__persist_expected( + admin_client_new: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + data = { + "base_url": "http://updated.test.com", + "api_key": "updated-abc-123", + } + url = reverse( + "api-v1:organisations:integrations-grafana-detail", + args=[organisation.id, grafana_organisation_configuration.id], + ) + + # When + response = admin_client_new.put( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + updated_config = GrafanaOrganisationConfiguration.objects.filter( + organisation=organisation + ).first() + assert updated_config.base_url == data["base_url"] + assert updated_config.api_key == data["api_key"] + + +def test_grafana_organisation_view__delete_configuration__return_expected( + admin_client_new: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:organisations:integrations-grafana-detail", + args=[organisation.id, grafana_organisation_configuration.id], + ) + + # When + response = admin_client_new.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not GrafanaOrganisationConfiguration.objects.filter( + organisation=organisation + ).exists() + + +def test_grafana_organisation_view__create_configuration__non_admin__return_expected( + test_user_client: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + data = { + "base_url": "http://test.com", + "api_key": "abc-123", + } + url = reverse( + "api-v1:organisations:integrations-grafana-list", args=[organisation.id] + ) + + # When + response = test_user_client.post( + url, + data=json.dumps(data), + content_type="application/json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_grafana_organisation_view__get_configuration__non_admin__return_expected( + test_user_client: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:organisations:integrations-grafana-detail", + args=[organisation.id, grafana_organisation_configuration.id], + ) + + # When + response = test_user_client.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_grafana_organisation_view__delete_configuration__non_admin__return_expected( + test_user_client: APIClient, + organisation: Organisation, + grafana_organisation_configuration: GrafanaOrganisationConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:organisations:integrations-grafana-detail", + args=[organisation.id, grafana_organisation_configuration.id], + ) + + # When + response = test_user_client.delete(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN