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

feat: Add metadata fields to core entities (API) #3315

Merged
merged 32 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ed6ffda
feat: Add metadata fields to core entities (API)
novakzaballa Jan 23, 2024
8e4afcb
ADd typing
novakzaballa Feb 13, 2024
e970e93
Add typing
novakzaballa Feb 13, 2024
f851601
Add typing in segment serializers
novakzaballa Feb 13, 2024
9095696
Add typing in tests
novakzaballa Feb 13, 2024
4c3ab51
Rename UpdateFeatureSerializer to UpdateFeatureSerializerWithMetadata
novakzaballa Feb 14, 2024
a9d7ef9
Merge branch 'main' into feat/add-metadata-fields-to-core-entities-api
novakzaballa Feb 14, 2024
b05cab3
Add tests return typing
novakzaballa Feb 15, 2024
e182abc
Solve test error
novakzaballa Feb 15, 2024
a1c0ea1
Solve test error
novakzaballa Feb 15, 2024
11468a5
Add types
novakzaballa Feb 15, 2024
bb3cbcd
Solve correctios un features views test
novakzaballa Feb 28, 2024
cf9c494
Correct segments views test
novakzaballa Feb 28, 2024
2cfe423
Revert changes in the last corrected test
novakzaballa Feb 28, 2024
d8f24fe
Modify the metadata serializer, SUPPORTED_REQUIREMENTS_MAPPING, and t…
novakzaballa Mar 4, 2024
fb04bbc
Rename test, add typing, and change segment view
novakzaballa Mar 6, 2024
de463be
Correct the Segment serializer to validate if the metadata is required
novakzaballa Mar 6, 2024
75719cb
Add test using organisation_content_type
novakzaballa Mar 19, 2024
20477cc
Correct types in environment_metadata fixtures
novakzaballa Mar 21, 2024
7076117
Change get_project in FeatureSerializerWithMetadata
novakzaballa Mar 21, 2024
bab2c9f
Refactor supported_content_types endpoint
novakzaballa Mar 22, 2024
f98f8c3
Remove METADATA_SUPPORTED_MODELS
novakzaballa Mar 25, 2024
50b43bc
Correct SUPPORTED_REQUIREMENTS_MAPPING, and change the test
novakzaballa Mar 26, 2024
c5c1044
Remove Exception
novakzaballa Apr 5, 2024
d7dca4a
Add missing type
novakzaballa Apr 16, 2024
0d24c4c
Merge branch 'main' into feat/add-metadata-fields-to-core-entities-api
novakzaballa Apr 16, 2024
a0cdc13
Property get_org_id deleted
novakzaballa Apr 16, 2024
309e3fe
Generate poetry.lock
novakzaballa Apr 19, 2024
0b9a5df
Fix Create or update metadata into segment
novakzaballa Apr 22, 2024
10bc36b
Merge branch 'main' into feat/add-metadata-fields-to-core-entities-api
novakzaballa May 2, 2024
9bbdfe9
Adding metadata to prefetch_related in the queryset
novakzaballa May 7, 2024
100f8eb
Merge branch 'main' into feat/add-metadata-fields-to-core-entities-api
novakzaballa May 13, 2024
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
110 changes: 97 additions & 13 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,23 +481,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 @@ -511,7 +511,75 @@ 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_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 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 @@ -521,7 +589,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: MetadataModelFieldRequirement,
Copy link
Member

Choose a reason for hiding this comment

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

Type hint in wrong here, right? It should be MetadataModelField

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Corrected.

) -> Metadata:
environment_type = ContentType.objects.get_for_model(environment)
return Metadata.objects.create(
object_id=environment.id,
Expand All @@ -532,7 +603,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: MetadataModelFieldRequirement,
Copy link
Member

Choose a reason for hiding this comment

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

Type hint in wrong here, right? It should be MetadataModelField

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Corrected.

) -> Metadata:
environment_type = ContentType.objects.get_for_model(environment)
return Metadata.objects.create(
object_id=environment.id,
Expand All @@ -543,12 +617,22 @@ 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)


Expand Down
4 changes: 4 additions & 0 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 @@ -72,6 +73,7 @@
STRING,
)
from features.versioning.models import EnvironmentFeatureVersion
from metadata.models import Metadata
from projects.models import Project
from projects.tags.models import Tag

Expand Down Expand Up @@ -126,6 +128,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
29 changes: 26 additions & 3 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 @@ -270,11 +271,33 @@ def get_last_modified_in_current_environment(
return getattr(instance, "last_modified_in_current_environment", None)


class UpdateFeatureSerializer(ListCreateFeatureSerializer):
"""prevent users from changing certain values after creation"""
class FeatureSerializerWithMetadata(
SerializerWithMetadata, ListCreateFeatureSerializer
):
metadata = MetadataSerializer(required=False, many=True)

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

def get_project(self, validated_data: dict = None) -> Project:
view = self.context.get("view")

Copy link
Member

Choose a reason for hiding this comment

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

Can't we get project from self.context["project"]?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, Changed.

if view and (project_pk := view.kwargs.get("project_pk")):
try:
return Project.objects.get(pk=project_pk)
except Project.DoesNotExist:
raise serializers.ValidationError("Project not found.")

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",
Expand Down
13 changes: 7 additions & 6 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
FeatureInfluxDataSerializer,
FeatureOwnerInputSerializer,
FeatureQuerySerializer,
FeatureSerializerWithMetadata,
FeatureStateSerializerBasic,
FeatureStateSerializerCreate,
FeatureStateSerializerFull,
Expand All @@ -66,7 +67,7 @@
ProjectFeatureSerializer,
SDKFeatureStateSerializer,
SDKFeatureStatesQuerySerializer,
UpdateFeatureSerializer,
UpdateFeatureSerializerWithMetadata,
WritableNestedFeatureStateSerializer,
)
from .tasks import trigger_feature_state_change_webhooks
Expand Down Expand Up @@ -103,11 +104,11 @@ class FeatureViewSet(viewsets.ModelViewSet):

def get_serializer_class(self):
return {
"list": ListCreateFeatureSerializer,
"retrieve": ListCreateFeatureSerializer,
"create": ListCreateFeatureSerializer,
"update": UpdateFeatureSerializer,
"partial_update": UpdateFeatureSerializer,
"list": FeatureSerializerWithMetadata,
"retrieve": FeatureSerializerWithMetadata,
"create": FeatureSerializerWithMetadata,
"update": UpdateFeatureSerializerWithMetadata,
"partial_update": UpdateFeatureSerializerWithMetadata,
}.get(self.action, ProjectFeatureSerializer)

def get_queryset(self):
Expand Down
16 changes: 14 additions & 2 deletions api/metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

FIELD_VALUE_MAX_LENGTH = 2000

METADATA_SUPPORTED_MODELS = ["environment"]
METADATA_SUPPORTED_MODELS = ["environment", "feature", "project", "segment"]

# A map of model name to a function that takes the object id and returns the organisation_id
SUPPORTED_REQUIREMENTS_MAPPING = {
Expand All @@ -21,7 +21,19 @@
"project": lambda project_id: Project.objects.get(
id=project_id
).organisation_id,
}
},
"feature": {
"organisation": lambda org_id: org_id,
"project": lambda project_id: Project.objects.get(
id=project_id
).organisation_id,
},
"segment": {
"organisation": lambda org_id: org_id,
"project": lambda project_id: Project.objects.get(
id=project_id
).organisation_id,
},
}


Expand Down
4 changes: 4 additions & 0 deletions api/segments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
abstract_base_auditable_model_factory,
)
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from flag_engine.segments import constants

from audit.constants import SEGMENT_CREATED_MESSAGE, SEGMENT_UPDATED_MESSAGE
from audit.related_object_type import RelatedObjectType
from features.models import Feature
from metadata.models import Metadata
from projects.models import Project

logger = logging.getLogger(__name__)
Expand All @@ -40,6 +42,8 @@ class Segment(
Feature, on_delete=models.CASCADE, related_name="segments", null=True
)

metadata = GenericRelation(Metadata)

class Meta:
ordering = ("id",) # explicit ordering to prevent pagination warnings

Expand Down
24 changes: 24 additions & 0 deletions api/segments/serializers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import typing

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from flag_engine.segments.constants import PERCENTAGE_SPLIT
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import ListSerializer
from rest_framework_recursive.fields import RecursiveField

from metadata.models import Metadata
from metadata.serializers import MetadataSerializer
from projects.models import Project
from segments.models import Condition, Segment, SegmentRule

Expand Down Expand Up @@ -42,6 +45,7 @@ class Meta:

class SegmentSerializer(serializers.ModelSerializer):
rules = RuleSerializer(many=True)
metadata = MetadataSerializer(required=False, many=True)

class Meta:
model = Segment
Expand All @@ -59,13 +63,15 @@ def create(self, validated_data):
self.validate_project_segment_limit(project)

rules_data = validated_data.pop("rules", [])
metadata_data = validated_data.pop("metadata", [])
self.validate_segment_rules_conditions_limit(rules_data)

# create segment with nested rules and conditions
segment = Segment.objects.create(**validated_data)
self._update_or_create_segment_rules(
rules_data, segment=segment, is_create=True
)
self._update_or_create_metadata(metadata_data, segment=segment)
return segment

def update(self, instance, validated_data):
Expand Down Expand Up @@ -156,6 +162,24 @@ def _update_or_create_segment_rules(
child_rules, rule=child_rule, is_create=is_create
)

def _update_or_create_metadata(
Copy link
Member

Choose a reason for hiding this comment

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

Do we have to do this because we are not using NestedModelSerializer Here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

Copy link
Member

@gagantrivedi gagantrivedi Mar 22, 2024

Choose a reason for hiding this comment

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

I understand that it is not related to your code, but do we know why are we not using NestedModelSerializer here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think the decision to not employ NestedModelSerializer here is based on the simplicity and structure of the models and data relationships we are dealing with.

self, metadata_data: typing.Dict, segment: typing.Optional[Segment] = None
) -> None:
for metadata_item in metadata_data:
metadata_id = metadata_item.pop("id", None)
if metadata_item.get("delete"):
Metadata.objects.filter(id=metadata_id).delete()
continue

Metadata.objects.update_or_create(
id=metadata_id,
defaults={
**metadata_item,
"content_type": ContentType.objects.get_for_model(Segment),
"object_id": segment.id,
},
)

@staticmethod
def _update_or_create_segment_rule(
rule_data: dict, segment: Segment = None, rule: SegmentRule = None
Expand Down
Loading
Loading