Skip to content

Commit

Permalink
feat: Add metadata fields to core entities (API) (#3315)
Browse files Browse the repository at this point in the history
  • Loading branch information
novakzaballa committed May 15, 2024
1 parent 95b14d1 commit 06eb8a4
Show file tree
Hide file tree
Showing 12 changed files with 634 additions and 75 deletions.
159 changes: 146 additions & 13 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,23 +659,23 @@ def task_processor_synchronously(settings):


@pytest.fixture()
def a_metadata_field(organisation):
def a_metadata_field(organisation: Organisation) -> MetadataField:
return MetadataField.objects.create(name="a", type="int", organisation=organisation)


@pytest.fixture()
def b_metadata_field(organisation):
def b_metadata_field(organisation: Organisation) -> MetadataField:
return MetadataField.objects.create(name="b", type="str", organisation=organisation)


@pytest.fixture()
def required_a_environment_metadata_field(
organisation,
a_metadata_field,
environment,
project,
project_content_type,
):
organisation: Organisation,
a_metadata_field: MetadataField,
environment: Environment,
project: Project,
project_content_type: ContentType,
) -> MetadataModelField:
environment_type = ContentType.objects.get_for_model(environment)
model_field = MetadataModelField.objects.create(
field=a_metadata_field,
Expand All @@ -689,7 +689,119 @@ def required_a_environment_metadata_field(


@pytest.fixture()
def optional_b_environment_metadata_field(organisation, b_metadata_field, environment):
def required_a_feature_metadata_field(
organisation: Organisation,
a_metadata_field: MetadataField,
feature_content_type: ContentType,
project: Project,
project_content_type: ContentType,
) -> MetadataModelField:
model_field = MetadataModelField.objects.create(
field=a_metadata_field,
content_type=feature_content_type,
)

MetadataModelFieldRequirement.objects.create(
content_type=project_content_type, object_id=project.id, model_field=model_field
)

return model_field


@pytest.fixture()
def required_a_feature_metadata_field_using_organisation_content_type(
organisation: Organisation,
a_metadata_field: MetadataField,
feature_content_type: ContentType,
project: Project,
organisation_content_type: ContentType,
) -> MetadataModelField:
model_field = MetadataModelField.objects.create(
field=a_metadata_field,
content_type=feature_content_type,
)

MetadataModelFieldRequirement.objects.create(
content_type=organisation_content_type,
object_id=organisation.id,
model_field=model_field,
)

return model_field


@pytest.fixture()
def required_a_segment_metadata_field(
organisation: Organisation,
a_metadata_field: MetadataField,
segment_content_type: ContentType,
project: Project,
project_content_type: ContentType,
) -> MetadataModelField:
model_field = MetadataModelField.objects.create(
field=a_metadata_field,
content_type=segment_content_type,
)

MetadataModelFieldRequirement.objects.create(
content_type=project_content_type, object_id=project.id, model_field=model_field
)

return model_field


@pytest.fixture()
def required_a_segment_metadata_field_using_organisation_content_type(
organisation: Organisation,
a_metadata_field: MetadataField,
segment_content_type: ContentType,
project: Project,
organisation_content_type: ContentType,
) -> MetadataModelField:
model_field = MetadataModelField.objects.create(
field=a_metadata_field,
content_type=segment_content_type,
)

MetadataModelFieldRequirement.objects.create(
content_type=organisation_content_type,
object_id=organisation.id,
model_field=model_field,
)

return model_field


@pytest.fixture()
def optional_b_feature_metadata_field(
organisation: Organisation, b_metadata_field: MetadataField, feature: Feature
) -> MetadataModelField:
feature_type = ContentType.objects.get_for_model(feature)

return MetadataModelField.objects.create(
field=b_metadata_field,
content_type=feature_type,
)


@pytest.fixture()
def optional_b_segment_metadata_field(
organisation: Organisation, b_metadata_field: MetadataField, segment: Segment
) -> MetadataModelField:
segment_type = ContentType.objects.get_for_model(segment)

return MetadataModelField.objects.create(
field=b_metadata_field,
content_type=segment_type,
)


@pytest.fixture()
def optional_b_environment_metadata_field(
organisation: Organisation,
b_metadata_field: MetadataField,
environment: Environment,
) -> MetadataModelField:
environment_type = ContentType.objects.get_for_model(environment)

return MetadataModelField.objects.create(
Expand All @@ -699,7 +811,10 @@ def optional_b_environment_metadata_field(organisation, b_metadata_field, enviro


@pytest.fixture()
def environment_metadata_a(environment, required_a_environment_metadata_field):
def environment_metadata_a(
environment: Environment,
required_a_environment_metadata_field: MetadataModelField,
) -> Metadata:
environment_type = ContentType.objects.get_for_model(environment)
return Metadata.objects.create(
object_id=environment.id,
Expand All @@ -710,7 +825,10 @@ def environment_metadata_a(environment, required_a_environment_metadata_field):


@pytest.fixture()
def environment_metadata_b(environment, optional_b_environment_metadata_field):
def environment_metadata_b(
environment: Environment,
optional_b_environment_metadata_field: MetadataModelField,
) -> Metadata:
environment_type = ContentType.objects.get_for_model(environment)
return Metadata.objects.create(
object_id=environment.id,
Expand All @@ -721,15 +839,30 @@ def environment_metadata_b(environment, optional_b_environment_metadata_field):


@pytest.fixture()
def environment_content_type():
def environment_content_type() -> ContentType:
return ContentType.objects.get_for_model(Environment)


@pytest.fixture()
def project_content_type():
def feature_content_type() -> ContentType:
return ContentType.objects.get_for_model(Feature)


@pytest.fixture()
def segment_content_type() -> ContentType:
return ContentType.objects.get_for_model(Segment)


@pytest.fixture()
def project_content_type() -> ContentType:
return ContentType.objects.get_for_model(Project)


@pytest.fixture()
def organisation_content_type() -> ContentType:
return ContentType.objects.get_for_model(Organisation)


@pytest.fixture
def manage_user_group_permission(db):
return OrganisationPermissionModel.objects.get(key=MANAGE_USER_GROUPS)
Expand Down
4 changes: 4 additions & 0 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
SoftDeleteExportableModel,
abstract_base_auditable_model_factory,
)
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import (
NON_FIELD_ERRORS,
ObjectDoesNotExist,
Expand Down Expand Up @@ -75,6 +76,7 @@
)
from features.versioning.models import EnvironmentFeatureVersion
from integrations.github.models import GithubConfiguration
from metadata.models import Metadata
from projects.models import Project
from projects.tags.models import Tag

Expand Down Expand Up @@ -129,6 +131,8 @@ class Feature(

objects = FeatureManager()

metadata = GenericRelation(Metadata)

class Meta:
# Note: uniqueness index is added in explicit SQL in the migrations (See 0005, 0050)
# TODO: after upgrade to Django 4.0 use UniqueConstraint()
Expand Down
36 changes: 32 additions & 4 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from environments.sdk.serializers_mixins import (
HideSensitiveFieldsSerializerMixin,
)
from metadata.serializers import MetadataSerializer, SerializerWithMetadata
from projects.models import Project
from users.serializers import (
UserIdsSerializer,
Expand Down Expand Up @@ -296,17 +297,44 @@ def get_last_modified_in_current_environment(
return getattr(instance, "last_modified_in_current_environment", None)


class ListFeatureSerializer(CreateFeatureSerializer):
class FeatureSerializerWithMetadata(SerializerWithMetadata, CreateFeatureSerializer):
metadata = MetadataSerializer(required=False, many=True)

class Meta(CreateFeatureSerializer.Meta):
fields = CreateFeatureSerializer.Meta.fields + ("metadata",)

def get_project(self, validated_data: dict = None) -> Project:
project = self.context.get("project")
if project:
return project
else:
raise serializers.ValidationError(
"Unable to retrieve project for metadata validation."
)


class UpdateFeatureSerializerWithMetadata(FeatureSerializerWithMetadata):
"""prevent users from changing certain values after creation"""

class Meta(FeatureSerializerWithMetadata.Meta):
read_only_fields = FeatureSerializerWithMetadata.Meta.read_only_fields + (
"default_enabled",
"initial_value",
"name",
)


class ListFeatureSerializer(FeatureSerializerWithMetadata):
# This exists purely to reduce the conflicts for the EE repository
# which has some extra behaviour here to support Oracle DB.
pass


class UpdateFeatureSerializer(CreateFeatureSerializer):
class UpdateFeatureSerializer(ListFeatureSerializer):
"""prevent users from changing certain values after creation"""

class Meta(CreateFeatureSerializer.Meta):
read_only_fields = CreateFeatureSerializer.Meta.read_only_fields + (
class Meta(ListFeatureSerializer.Meta):
read_only_fields = ListFeatureSerializer.Meta.read_only_fields + (
"default_enabled",
"initial_value",
"name",
Expand Down
8 changes: 5 additions & 3 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ class FeatureViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
return {
"list": ListFeatureSerializer,
"retrieve": CreateFeatureSerializer,
"create": CreateFeatureSerializer,
"retrieve": ListFeatureSerializer,
"create": ListFeatureSerializer,
"update": UpdateFeatureSerializer,
"partial_update": UpdateFeatureSerializer,
}.get(self.action, ProjectFeatureSerializer)
Expand All @@ -131,7 +131,9 @@ def get_queryset(self):
),
),
)
.prefetch_related("multivariate_options", "owners", "tags", "group_owners")
.prefetch_related(
"multivariate_options", "owners", "tags", "group_owners", "metadata"
)
)

query_serializer = FeatureQuerySerializer(data=self.request.query_params)
Expand Down
12 changes: 3 additions & 9 deletions api/metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,16 @@
from django.db import models

from organisations.models import Organisation
from projects.models import Project

from .fields import GenericObjectID

FIELD_VALUE_MAX_LENGTH = 2000

METADATA_SUPPORTED_MODELS = ["environment"]

# A map of model name to a function that takes the object id and returns the organisation_id
SUPPORTED_REQUIREMENTS_MAPPING = {
"environment": {
"organisation": lambda org_id: org_id,
"project": lambda project_id: Project.objects.get(
id=project_id
).organisation_id,
}
"environment": ["organisation", "project"],
"feature": ["organisation", "project"],
"segment": ["organisation", "project"],
}


Expand Down
23 changes: 7 additions & 16 deletions api/metadata/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
)

from .models import (
SUPPORTED_REQUIREMENTS_MAPPING,
Metadata,
MetadataField,
MetadataModelField,
Expand Down Expand Up @@ -55,21 +54,13 @@ class Meta:
def validate(self, data):
data = super().validate(data)
for requirement in data.get("is_required_for", []):
try:
get_org_id_func = SUPPORTED_REQUIREMENTS_MAPPING[
data["content_type"].model
][requirement["content_type"].model]
except KeyError:
raise serializers.ValidationError(
"Invalid requirement for model {}".format(
data["content_type"].model
)
)

if (
get_org_id_func(requirement["object_id"])
!= data["field"].organisation_id
):
org_id = (
requirement["content_type"]
.model_class()
.objects.get(id=requirement["object_id"])
.organisation_id
)
if org_id != data["field"].organisation_id:
raise serializers.ValidationError(
"The requirement organisation does not match the field organisation"
)
Expand Down

0 comments on commit 06eb8a4

Please sign in to comment.