diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index d5de91030824..f2629d8fb054 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.110.2"
+ ".": "2.111.1"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9edbb590a7b..b3777637ce64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+## [2.111.1](https://github.com/Flagsmith/flagsmith/compare/v2.111.0...v2.111.1) (2024-04-30)
+
+
+### Bug Fixes
+
+* edit group ([#3856](https://github.com/Flagsmith/flagsmith/issues/3856)) ([b25e6f8](https://github.com/Flagsmith/flagsmith/commit/b25e6f8ad1256b5556cfc7623064a6fd42b299fb))
+
+## [2.111.0](https://github.com/Flagsmith/flagsmith/compare/v2.110.2...v2.111.0) (2024-04-30)
+
+
+### Features
+
+* Capability for Pydantic-based OpenAPI response schemas ([#3795](https://github.com/Flagsmith/flagsmith/issues/3795)) ([609deaa](https://github.com/Flagsmith/flagsmith/commit/609deaa999c86bc05e1875c58f91c7c34532f3c5))
+* **permissions:** manage permissions from a single location ([#3730](https://github.com/Flagsmith/flagsmith/issues/3730)) ([fc34a53](https://github.com/Flagsmith/flagsmith/commit/fc34a53e92ba6c1870ab9539bfa21b6af08964cc))
+
+
+### Bug Fixes
+
+* Add GitHub app URL to env var ([#3847](https://github.com/Flagsmith/flagsmith/issues/3847)) ([210dbf7](https://github.com/Flagsmith/flagsmith/commit/210dbf7543dfddcbc36422eec0be958d6e8d6589))
+* Filter versioned features ([#3756](https://github.com/Flagsmith/flagsmith/issues/3756)) ([686e1ab](https://github.com/Flagsmith/flagsmith/commit/686e1ab81aae938a4a85680a6ed581d90b7ff11d))
+* Get current api usage InfluxDB query ([#3846](https://github.com/Flagsmith/flagsmith/issues/3846)) ([905c9fb](https://github.com/Flagsmith/flagsmith/commit/905c9fb36a67171464f03fd9565f02db74708974))
+* **hubspot:** create hubspot company with domain ([#3844](https://github.com/Flagsmith/flagsmith/issues/3844)) ([d4c9173](https://github.com/Flagsmith/flagsmith/commit/d4c9173c6c371546f581f2c81572fa2c7395dd17))
+* **sentry-FLAGSMITH-API-4BN:** update permission method ([#3851](https://github.com/Flagsmith/flagsmith/issues/3851)) ([b4e058a](https://github.com/Flagsmith/flagsmith/commit/b4e058a47c807455aefcd846f7a7f48de8c4a320))
+* useHasPermission import ([#3853](https://github.com/Flagsmith/flagsmith/issues/3853)) ([e156609](https://github.com/Flagsmith/flagsmith/commit/e156609570782fa9edbdf52e9e01e627e9bf8ffc))
+* user delete social auth ([#3693](https://github.com/Flagsmith/flagsmith/issues/3693)) ([3372207](https://github.com/Flagsmith/flagsmith/commit/3372207a8db623a6a07ac5df0902f24b8c0b1e4a))
+
## [2.110.2](https://github.com/Flagsmith/flagsmith/compare/v2.110.1...v2.110.2) (2024-04-25)
diff --git a/api/api_keys/user.py b/api/api_keys/user.py
index f058bb5e1b79..409064cf4ae4 100644
--- a/api/api_keys/user.py
+++ b/api/api_keys/user.py
@@ -94,8 +94,12 @@ def get_permitted_projects(
)
def get_permitted_environments(
- self, permission_key: str, project: "Project", tag_ids: typing.List[int] = None
+ self,
+ permission_key: str,
+ project: "Project",
+ tag_ids: typing.List[int] = None,
+ prefetch_metadata: bool = False,
) -> QuerySet["Environment"]:
return get_permitted_environments_for_master_api_key(
- self.key, project, permission_key, tag_ids
+ self.key, project, permission_key, tag_ids, prefetch_metadata
)
diff --git a/api/app/settings/common.py b/api/app/settings/common.py
index 67adec31d27f..2763d7147cb1 100644
--- a/api/app/settings/common.py
+++ b/api/app/settings/common.py
@@ -1095,6 +1095,12 @@
default=None,
)
+# The URL used to install the GitHub integration
+GITHUB_APP_URL = env.int(
+ "GITHUB_APP_URL",
+ default=None,
+)
+
# LDAP setting
LDAP_INSTALLED = importlib.util.find_spec("flagsmith_ldap")
# The URL of the LDAP server.
diff --git a/api/app/views.py b/api/app/views.py
index 197ef359898a..25b2a183d653 100644
--- a/api/app/views.py
+++ b/api/app/views.py
@@ -55,6 +55,7 @@ def project_overrides(request):
"sentry": "SENTRY_API_KEY",
"useSecureCookies": "USE_SECURE_COOKIES",
"cookieSameSite": "COOKIE_SAME_SITE",
+ "githubAppURL": "GITHUB_APP_URL",
}
override_data = {
diff --git a/api/app_analytics/influxdb_wrapper.py b/api/app_analytics/influxdb_wrapper.py
index c8a1b8f565f3..05ea0218b6e1 100644
--- a/api/app_analytics/influxdb_wrapper.py
+++ b/api/app_analytics/influxdb_wrapper.py
@@ -341,6 +341,7 @@ def get_current_api_usage(organisation_id: int, date_range: str) -> int:
),
drop_columns=("_start", "_stop", "_time"),
extra='|> sum() \
+ |> group() \
|> sort(columns: ["_value"], desc: true) ',
)
@@ -349,10 +350,7 @@ def get_current_api_usage(organisation_id: int, date_range: str) -> int:
if len(result.records) == 0:
return 0
- # There should only be one matching result due to the
- # sum part of the query.
- assert len(result.records) == 1
- return result.records[0].get_value()
+ return sum(r.get_value() for r in result.records)
return 0
diff --git a/api/conftest.py b/api/conftest.py
index 00d83ab7ad28..d8aa0cc09ce8 100644
--- a/api/conftest.py
+++ b/api/conftest.py
@@ -62,6 +62,7 @@
from projects.tags.models import Tag
from segments.models import Condition, Segment, SegmentRule
from task_processor.task_run_method import TaskRunMethod
+from tests.test_helpers import fix_issue_3869
from tests.types import (
WithEnvironmentPermissionsCallable,
WithOrganisationPermissionsCallable,
@@ -79,6 +80,10 @@ def pytest_addoption(parser: pytest.Parser) -> None:
)
+def pytest_sessionstart(session: pytest.Session) -> None:
+ fix_issue_3869()
+
+
@pytest.hookimpl(trylast=True)
def pytest_configure(config: pytest.Config) -> None:
if (
@@ -130,12 +135,32 @@ def auth_token(test_user):
@pytest.fixture()
-def admin_client(admin_user):
+def admin_client_original(admin_user):
client = APIClient()
client.force_authenticate(user=admin_user)
return client
+@pytest.fixture()
+def admin_client(admin_client_original):
+ """
+ This fixture will eventually be switched over to what is now
+ called admin_client_new which will run an admin client as well
+ as admin_master_api_key_client automatically.
+
+ In the meantime consider this fixture as deprecated. Use either
+ admin_client_original to preserve a singular admin client or
+ if the test suite can handle it, use admin_client_new to
+ automatically handling both query methods.
+
+ If a test must use pytest.mark.parametrize to differentiate
+ between other required parameters for a test then please
+ use admin_client_original as the parametrized version as this
+ fixture will ultimately be updated to the new approach.
+ """
+ yield admin_client_original
+
+
@pytest.fixture()
def test_user_client(api_client, test_user):
api_client.force_authenticate(test_user)
@@ -344,6 +369,7 @@ def _with_project_permissions(
@pytest.fixture()
def environment_v2_versioning(environment):
enable_v2_versioning(environment.id)
+ environment.refresh_from_db()
return environment
@@ -774,3 +800,18 @@ def github_repository(
repository_name="repositorynametest",
project=project,
)
+
+
+@pytest.fixture(
+ params=[
+ "admin_client_original",
+ "admin_master_api_key_client",
+ ]
+)
+def admin_client_new(request, admin_client_original, admin_master_api_key_client):
+ if request.param == "admin_client_original":
+ yield admin_client_original
+ elif request.param == "admin_master_api_key_client":
+ yield admin_master_api_key_client
+ else:
+ assert False, "Request param mismatch"
diff --git a/api/custom_auth/constants.py b/api/custom_auth/constants.py
index 2dc2110c75c2..03a224046580 100644
--- a/api/custom_auth/constants.py
+++ b/api/custom_auth/constants.py
@@ -1,3 +1,5 @@
USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE = (
"User registration without an invite is disabled for this installation."
)
+INVALID_PASSWORD_ERROR = "Invalid password."
+FIELD_BLANK_ERROR = "This field may not be blank."
diff --git a/api/custom_auth/serializers.py b/api/custom_auth/serializers.py
index 8709eeae12f9..fe680fb5af35 100644
--- a/api/custom_auth/serializers.py
+++ b/api/custom_auth/serializers.py
@@ -1,15 +1,20 @@
from django.conf import settings
-from djoser.serializers import UserCreateSerializer, UserDeleteSerializer
+from djoser.serializers import UserCreateSerializer
from rest_framework import serializers
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import PermissionDenied
from rest_framework.validators import UniqueValidator
from organisations.invites.models import Invite
+from users.auth_type import AuthType
from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE
from users.models import FFAdminUser, SignUpType
-from .constants import USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE
+from .constants import (
+ FIELD_BLANK_ERROR,
+ INVALID_PASSWORD_ERROR,
+ USER_REGISTRATION_WITHOUT_INVITE_ERROR_MESSAGE,
+)
class CustomTokenSerializer(serializers.ModelSerializer):
@@ -72,7 +77,36 @@ def save(self, **kwargs):
return super(CustomUserCreateSerializer, self).save(**kwargs)
-class CustomUserDelete(UserDeleteSerializer):
+class CustomUserDelete(serializers.Serializer):
+ current_password = serializers.CharField(
+ style={"input_type": "password"},
+ required=False,
+ allow_null=True,
+ allow_blank=True,
+ )
+
+ default_error_messages = {
+ "invalid_password": INVALID_PASSWORD_ERROR,
+ "field_blank": FIELD_BLANK_ERROR,
+ }
+
+ def validate_current_password(self, value):
+ user_auth_type = self.context["request"].user.auth_type
+ if (
+ user_auth_type == AuthType.GOOGLE.value
+ or user_auth_type == AuthType.GITHUB.value
+ ):
+ return value
+
+ if not value:
+ return self.fail("field_blank")
+
+ is_password_valid = self.context["request"].user.check_password(value)
+ if is_password_valid:
+ return value
+ else:
+ self.fail("invalid_password")
+
delete_orphan_organisations = serializers.BooleanField(
default=DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE, required=False
)
diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py
index ccb075caa194..e8f307230914 100644
--- a/api/features/feature_external_resources/models.py
+++ b/api/features/feature_external_resources/models.py
@@ -13,6 +13,7 @@
from features.models import Feature, FeatureState
from integrations.github.github import GithubData, generate_data
from integrations.github.tasks import call_github_app_webhook_for_feature_state
+from organisations.models import Organisation
from webhooks.webhooks import WebhookEventType
logger = logging.getLogger(__name__)
@@ -47,12 +48,18 @@ class Meta:
]
@hook(AFTER_SAVE)
- def exectute_after_save_actions(self):
+ def execute_after_save_actions(self):
# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
- if hasattr(self.feature.project.organisation, "github_config"):
- github_configuration = self.feature.project.organisation.github_config
-
- feature_states = FeatureState.objects.filter(feature_id=self.feature_id)
+ if (
+ github_configuration := Organisation.objects.prefetch_related(
+ "github_config"
+ )
+ .get(id=self.feature.project.organisation_id)
+ .github_config.first()
+ ):
+ feature_states = FeatureState.objects.filter(
+ feature_id=self.feature_id, identity_id__isnull=True
+ )
feature_data: GithubData = generate_data(
github_configuration,
self.feature_id,
@@ -68,8 +75,13 @@ def exectute_after_save_actions(self):
@hook(BEFORE_DELETE)
def execute_before_save_actions(self) -> None:
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
- if hasattr(self.feature.project.organisation, "github_config"):
- github_configuration = self.feature.project.organisation.github_config
+ if (
+ github_configuration := Organisation.objects.prefetch_related(
+ "github_config"
+ )
+ .get(id=self.feature.project.organisation_id)
+ .github_config.first()
+ ):
feature_data: GithubData = generate_data(
github_configuration=github_configuration,
feature_id=self.feature_id,
diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py
index d254d9b79252..822c3a07400e 100644
--- a/api/features/feature_external_resources/views.py
+++ b/api/features/feature_external_resources/views.py
@@ -1,3 +1,5 @@
+import re
+
from django.db.utils import IntegrityError
from django.shortcuts import get_object_or_404
from rest_framework import status, viewsets
@@ -6,6 +8,7 @@
from features.models import Feature
from features.permissions import FeatureExternalResourcePermissions
+from organisations.models import Organisation
from .models import FeatureExternalResource
from .serializers import FeatureExternalResourceSerializer
@@ -29,9 +32,15 @@ def create(self, request, *args, **kwargs):
),
)
- if not hasattr(feature.project.organisation, "github_config") or not hasattr(
- feature.project, "github_project"
+ if not (
+ (
+ Organisation.objects.prefetch_related("github_config")
+ .get(id=feature.project.organisation_id)
+ .github_config.first()
+ )
+ or not hasattr(feature.project, "github_project")
):
+
return Response(
data={
"detail": "This Project doesn't have a valid GitHub integration configuration"
@@ -42,10 +51,14 @@ def create(self, request, *args, **kwargs):
try:
return super().create(request, *args, **kwargs)
- except IntegrityError:
- raise ValidationError(
- detail="Duplication error. The feature already has this resource URI"
- )
+
+ except IntegrityError as e:
+ if re.search(r"Key \(feature_id, url\)", str(e)) and re.search(
+ r"already exists.$", str(e)
+ ):
+ raise ValidationError(
+ detail="Duplication error. The feature already has this resource URI"
+ )
def perform_update(self, serializer):
external_resource_id = int(self.kwargs["id"])
diff --git a/api/features/managers.py b/api/features/managers.py
index b7fd39155210..a285e89c2a61 100644
--- a/api/features/managers.py
+++ b/api/features/managers.py
@@ -8,6 +8,8 @@
from ordered_model.models import OrderedModelManager
from softdelete.models import SoftDeleteManager
+from features.versioning.models import EnvironmentFeatureVersion
+
if typing.TYPE_CHECKING:
from environments.models import Environment
from features.models import FeatureState
@@ -31,11 +33,11 @@ def get_live_feature_states(
qs_filter = Q(environment=environment, deleted_at__isnull=True)
if environment.use_v2_feature_versioning:
- qs_filter &= Q(
- environment_feature_version__isnull=False,
- environment_feature_version__published_at__isnull=False,
- environment_feature_version__live_from__lte=now,
+ latest_versions = EnvironmentFeatureVersion.objects.get_latest_versions(
+ environment
)
+ latest_version_uuids = [efv.uuid for efv in latest_versions]
+ qs_filter &= Q(environment_feature_version__uuid__in=latest_version_uuids)
else:
qs_filter &= Q(
live_from__isnull=False,
diff --git a/api/features/tasks.py b/api/features/tasks.py
index f5a45a20dba5..1d0c07feb904 100644
--- a/api/features/tasks.py
+++ b/api/features/tasks.py
@@ -5,6 +5,7 @@
from features.models import Feature, FeatureState
from integrations.github.github import GithubData, generate_data
from integrations.github.tasks import call_github_app_webhook_for_feature_state
+from organisations.models import Organisation
from task_processor.decorators import register_task_handler
from webhooks.constants import WEBHOOK_DATETIME_FORMAT
from webhooks.webhooks import (
@@ -65,8 +66,11 @@ def trigger_feature_state_change_webhooks(
and instance.environment.project.github_project.exists()
and hasattr(instance.environment.project.organisation, "github_config")
):
- github_configuration = instance.environment.project.organisation.github_config
-
+ github_configuration = (
+ Organisation.objects.prefetch_related("github_config")
+ .get(id=instance.environment.project.organisationn_id)
+ .github_config.first()
+ )
feature_state = {
"environment_name": new_state["environment"]["name"],
"feature_value": new_state["enabled"],
@@ -78,11 +82,11 @@ def trigger_feature_state_change_webhooks(
github_configuration=github_configuration,
feature_id=history_instance.feature.id,
feature_name=history_instance.feature.name,
- type=WebhookEventType.FLAG_UPDATED,
+ type=WebhookEventType.FLAG_UPDATED.value,
feature_states=feature_states,
)
- feature_data["feature_states"].append(feature_state)
+ feature_data.feature_states.append(feature_state)
call_github_app_webhook_for_feature_state.delay(
args=(asdict(feature_data),),
diff --git a/api/features/versioning/managers.py b/api/features/versioning/managers.py
new file mode 100644
index 000000000000..32c41ad0a831
--- /dev/null
+++ b/api/features/versioning/managers.py
@@ -0,0 +1,23 @@
+import typing
+from pathlib import Path
+
+from django.db.models.query import RawQuerySet
+from softdelete.models import SoftDeleteManager
+
+if typing.TYPE_CHECKING:
+ from environments.models import Environment
+
+
+with open(Path(__file__).parent.resolve() / "sql/get_latest_versions.sql") as f:
+ get_latest_versions_sql = f.read()
+
+
+class EnvironmentFeatureVersionManager(SoftDeleteManager):
+ def get_latest_versions(self, environment: "Environment") -> RawQuerySet:
+ """
+ Get the latest EnvironmentFeatureVersion objects
+ for a given environment.
+ """
+ return self.raw(
+ get_latest_versions_sql, params={"environment_id": environment.id}
+ )
diff --git a/api/features/versioning/models.py b/api/features/versioning/models.py
index ff70facdcc29..27600284c397 100644
--- a/api/features/versioning/models.py
+++ b/api/features/versioning/models.py
@@ -12,6 +12,7 @@
from django.utils import timezone
from features.versioning.exceptions import FeatureVersioningError
+from features.versioning.managers import EnvironmentFeatureVersionManager
from features.versioning.signals import environment_feature_version_published
if typing.TYPE_CHECKING:
@@ -61,6 +62,8 @@ class EnvironmentFeatureVersion(
blank=True,
)
+ objects = EnvironmentFeatureVersionManager()
+
class Meta:
indexes = [Index(fields=("environment", "feature"))]
ordering = ("-live_from",)
diff --git a/api/features/versioning/sql/get_latest_versions.sql b/api/features/versioning/sql/get_latest_versions.sql
new file mode 100644
index 000000000000..3c1257e8605b
--- /dev/null
+++ b/api/features/versioning/sql/get_latest_versions.sql
@@ -0,0 +1,25 @@
+select
+ efv1."uuid",
+ efv1."published_at",
+ efv1."live_from"
+from
+ feature_versioning_environmentfeatureversion efv1
+join (
+ select
+ efv2."feature_id",
+ efv2."environment_id",
+ MAX(efv2."live_from") as "latest_release"
+ from
+ feature_versioning_environmentfeatureversion efv2
+ where
+ efv2."deleted_at" is null
+ and efv2."published_at" is not null
+ group by
+ efv2."feature_id",
+ efv2."environment_id"
+) latest_release_dates on
+ efv1."feature_id" = latest_release_dates."feature_id"
+ and efv1."environment_id" = latest_release_dates."environment_id"
+ and efv1."live_from" = latest_release_dates."latest_release"
+where
+ efv1.environment_id = %(environment_id)s;
\ No newline at end of file
diff --git a/api/features/versioning/tasks.py b/api/features/versioning/tasks.py
index e44f99237ca5..57c8f2c3b932 100644
--- a/api/features/versioning/tasks.py
+++ b/api/features/versioning/tasks.py
@@ -36,7 +36,7 @@ def enable_v2_versioning(environment_id: int):
def _create_initial_feature_versions(environment: "Environment"):
- from features.models import Feature
+ from features.models import Feature, FeatureSegment
now = timezone.now()
@@ -47,9 +47,16 @@ def _create_initial_feature_versions(environment: "Environment"):
published_at=now,
live_from=now,
)
- get_environment_flags_queryset(environment=environment).filter(
- identity__isnull=True, feature=feature
- ).update(environment_feature_version=ef_version)
+
+ latest_feature_states = get_environment_flags_queryset(
+ environment=environment
+ ).filter(identity__isnull=True, feature=feature)
+ related_feature_segments = FeatureSegment.objects.filter(
+ feature_states__in=latest_feature_states
+ )
+
+ latest_feature_states.update(environment_feature_version=ef_version)
+ related_feature_segments.update(environment_feature_version=ef_version)
@register_task_handler()
diff --git a/api/features/versioning/versioning_service.py b/api/features/versioning/versioning_service.py
index bcac998faebe..66732650c108 100644
--- a/api/features/versioning/versioning_service.py
+++ b/api/features/versioning/versioning_service.py
@@ -3,32 +3,33 @@
from django.db.models import Prefetch, Q, QuerySet
from django.utils import timezone
+from environments.models import Environment
from features.models import FeatureState
from features.versioning.models import EnvironmentFeatureVersion
-if typing.TYPE_CHECKING:
- from environments.models import Environment
-
def get_environment_flags_queryset(
- environment: "Environment", feature_name: str = None
+ environment: Environment, feature_name: str = None
) -> QuerySet[FeatureState]:
"""
Get a queryset of the latest live versions of an environments' feature states
"""
+ if environment.use_v2_feature_versioning:
+ return _get_feature_states_queryset(environment, feature_name)
+
feature_states_list = get_environment_flags_list(environment, feature_name)
return FeatureState.objects.filter(id__in=[fs.id for fs in feature_states_list])
def get_environment_flags_list(
- environment: "Environment",
+ environment: Environment,
feature_name: str = None,
additional_filters: Q = None,
additional_select_related_args: typing.Iterable[str] = None,
additional_prefetch_related_args: typing.Iterable[
typing.Union[str, Prefetch]
] = None,
-) -> typing.List["FeatureState"]:
+) -> list[FeatureState]:
"""
Get a list of the latest committed versions of FeatureState objects that are
associated with the given environment. Can be filtered to remove segment /
@@ -38,26 +39,16 @@ def get_environment_flags_list(
feature states. The logic to grab the latest version is then handled in python
by building a dictionary. Returns a list of FeatureState objects.
"""
- additional_select_related_args = additional_select_related_args or tuple()
- additional_prefetch_related_args = additional_prefetch_related_args or tuple()
-
- feature_states = (
- FeatureState.objects.get_live_feature_states(
- environment=environment, additional_filters=additional_filters
- )
- .select_related(
- "environment",
- "feature",
- "feature_state_value",
- "environment_feature_version",
- "feature_segment",
- *additional_select_related_args,
- )
- .prefetch_related(*additional_prefetch_related_args)
+ feature_states = _get_feature_states_queryset(
+ environment,
+ feature_name,
+ additional_filters,
+ additional_select_related_args,
+ additional_prefetch_related_args,
)
- if feature_name:
- feature_states = feature_states.filter(feature__name__iexact=feature_name)
+ if environment.use_v2_feature_versioning:
+ return list(feature_states)
# Build up a dictionary in the form
# {(feature_id, feature_segment_id, identity_id): feature_state}
@@ -89,3 +80,36 @@ def get_current_live_environment_feature_version(
.order_by("-live_from")
.first()
)
+
+
+def _get_feature_states_queryset(
+ environment: "Environment",
+ feature_name: str = None,
+ additional_filters: Q = None,
+ additional_select_related_args: typing.Iterable[str] = None,
+ additional_prefetch_related_args: typing.Iterable[
+ typing.Union[str, Prefetch]
+ ] = None,
+) -> QuerySet[FeatureState]:
+ additional_select_related_args = additional_select_related_args or tuple()
+ additional_prefetch_related_args = additional_prefetch_related_args or tuple()
+
+ queryset = (
+ FeatureState.objects.get_live_feature_states(
+ environment=environment, additional_filters=additional_filters
+ )
+ .select_related(
+ "environment",
+ "feature",
+ "feature_state_value",
+ "environment_feature_version",
+ "feature_segment",
+ *additional_select_related_args,
+ )
+ .prefetch_related(*additional_prefetch_related_args)
+ )
+
+ if feature_name:
+ queryset = queryset.filter(feature__name__iexact=feature_name)
+
+ return queryset
diff --git a/api/features/views.py b/api/features/views.py
index 4d99588258d8..323bef8dd175 100644
--- a/api/features/views.py
+++ b/api/features/views.py
@@ -258,12 +258,16 @@ def apply_state_to_queryset(
if not getattr(self, "environment", None):
self.environment = Environment.objects.get(id=environment_id)
- feature_states = FeatureState.objects.get_live_feature_states(
+ feature_states = get_environment_flags_list(
environment=self.environment,
- additional_filters=base_q & filter_search_q & filter_enabled_q,
+ additional_filters=base_q,
)
- feature_ids = {fs.feature_id for fs in feature_states}
+ feature_ids = FeatureState.objects.filter(
+ filter_search_q & filter_enabled_q,
+ id__in=[fs.id for fs in feature_states],
+ ).values_list("feature_id", flat=True)
+
return queryset.filter(id__in=feature_ids)
@swagger_auto_schema(
diff --git a/api/integrations/github/exceptions.py b/api/integrations/github/exceptions.py
new file mode 100644
index 000000000000..8d32b1ff099b
--- /dev/null
+++ b/api/integrations/github/exceptions.py
@@ -0,0 +1,6 @@
+from rest_framework.exceptions import APIException
+
+
+class DuplicateGitHubIntegration(APIException):
+ status_code = 400
+ default_detail = "Duplication error. The GitHub integration already created"
diff --git a/api/integrations/github/migrations/0002_auto_20240502_1949.py b/api/integrations/github/migrations/0002_auto_20240502_1949.py
new file mode 100644
index 000000000000..4739e2825ea9
--- /dev/null
+++ b/api/integrations/github/migrations/0002_auto_20240502_1949.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.25 on 2024-05-02 19:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('organisations', '0052_create_hubspot_organisation'),
+ ('github', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='githubconfiguration',
+ name='organisation',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_config', to='organisations.organisation'),
+ ),
+ migrations.AddConstraint(
+ model_name='githubconfiguration',
+ constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('organisation',), name='githubconf_organisation_id_idx'),
+ ),
+ ]
diff --git a/api/integrations/github/models.py b/api/integrations/github/models.py
index 7aebde9ce0bc..22dcd6ee5333 100644
--- a/api/integrations/github/models.py
+++ b/api/integrations/github/models.py
@@ -10,7 +10,7 @@
class GithubConfiguration(SoftDeleteExportableModel):
- organisation = models.OneToOneField(
+ organisation = models.ForeignKey(
Organisation, on_delete=models.CASCADE, related_name="github_config"
)
installation_id = models.CharField(max_length=100, blank=False, null=False)
@@ -21,6 +21,17 @@ def has_github_configuration(organisation_id: int) -> bool:
organisation_id=organisation_id
).exists()
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=[
+ "organisation",
+ ],
+ name="githubconf_organisation_id_idx",
+ condition=models.Q(deleted_at__isnull=True),
+ )
+ ]
+
class GithubRepository(LifecycleModelMixin, SoftDeleteExportableModel):
github_configuration = models.ForeignKey(
diff --git a/api/integrations/github/views.py b/api/integrations/github/views.py
index 580b62aff629..ddbea03bae0e 100644
--- a/api/integrations/github/views.py
+++ b/api/integrations/github/views.py
@@ -1,3 +1,4 @@
+import re
from functools import wraps
import requests
@@ -12,6 +13,7 @@
from integrations.github.client import generate_token
from integrations.github.constants import GITHUB_API_URL, GITHUB_API_VERSION
+from integrations.github.exceptions import DuplicateGitHubIntegration
from integrations.github.models import GithubConfiguration, GithubRepository
from integrations.github.permissions import HasPermissionToGithubConfiguration
from integrations.github.serializers import (
@@ -61,6 +63,13 @@ def get_queryset(self):
organisation_id=self.kwargs["organisation_pk"]
)
+ def create(self, request, *args, **kwargs):
+ try:
+ return super().create(request, *args, **kwargs)
+ except IntegrityError as e:
+ if re.search(r"Key \(organisation_id\)=\(\d+\) already exists", str(e)):
+ raise DuplicateGitHubIntegration
+
class GithubRepositoryViewSet(viewsets.ModelViewSet):
permission_classes = (
@@ -84,10 +93,15 @@ def create(self, request, *args, **kwargs):
try:
return super().create(request, *args, **kwargs)
- except IntegrityError:
- raise ValidationError(
- detail="Duplication error. The Github repository already linked"
- )
+
+ except IntegrityError as e:
+ if re.search(
+ r"Key \(github_configuration_id, project_id, repository_owner, repository_name\)",
+ str(e),
+ ) and re.search(r"already exists.$", str(e)):
+ raise ValidationError(
+ detail="Duplication error. The GitHub repository already linked"
+ )
@api_view(["GET"])
@@ -95,9 +109,11 @@ def create(self, request, *args, **kwargs):
@github_auth_required
def fetch_pull_requests(request, organisation_pk):
organisation = Organisation.objects.get(id=organisation_pk)
-
+ github_configuration = GithubConfiguration.objects.get(
+ organisation=organisation, deleted_at__isnull=True
+ )
token = generate_token(
- organisation.github_config.installation_id,
+ github_configuration.installation_id,
settings.GITHUB_APP_ID,
)
@@ -130,8 +146,11 @@ def fetch_pull_requests(request, organisation_pk):
@github_auth_required
def fetch_issues(request, organisation_pk):
organisation = Organisation.objects.get(id=organisation_pk)
+ github_configuration = GithubConfiguration.objects.get(
+ organisation=organisation, deleted_at__isnull=True
+ )
token = generate_token(
- organisation.github_config.installation_id,
+ github_configuration.installation_id,
settings.GITHUB_APP_ID,
)
diff --git a/api/integrations/lead_tracking/hubspot/client.py b/api/integrations/lead_tracking/hubspot/client.py
index c6e64c9bbd85..60a7328267e6 100644
--- a/api/integrations/lead_tracking/hubspot/client.py
+++ b/api/integrations/lead_tracking/hubspot/client.py
@@ -103,20 +103,14 @@ def create_company(
organisation_id: int = None,
domain: str | None = None,
) -> dict:
- properties = {
- "name": name,
- "active_subscription": active_subscription,
- "orgid": str(organisation_id),
- }
+ properties = {"name": name}
if domain:
properties["domain"] = domain
if active_subscription:
properties["active_subscription"] = active_subscription
-
- # hubspot doesn't allow null values for numeric fields, so we
- # set this to -1 for auto generated organisations.
- properties["orgid"] = organisation_id or -1
+ if organisation_id:
+ properties["orgid_unique"] = organisation_id
simple_public_object_input_for_create = SimplePublicObjectInputForCreate(
properties=properties,
diff --git a/api/integrations/lead_tracking/hubspot/lead_tracker.py b/api/integrations/lead_tracking/hubspot/lead_tracker.py
index 54c45e2f416f..aaa8c6caa5ee 100644
--- a/api/integrations/lead_tracking/hubspot/lead_tracker.py
+++ b/api/integrations/lead_tracking/hubspot/lead_tracker.py
@@ -119,7 +119,10 @@ def update_company_active_subscription(
def _get_or_create_company_by_domain(self, domain: str) -> dict:
company = self.client.get_company_by_domain(domain)
if not company:
- company = self.client.create_company(name=domain)
+ # Since we don't know the company's name, we pass the domain as
+ # both the name and the domain. This can then be manually
+ # updated in Hubspot if needed.
+ company = self.client.create_company(name=domain, domain=domain)
return company
diff --git a/api/organisations/models.py b/api/organisations/models.py
index dfcb92341f1e..7d2bb4e7bf6c 100644
--- a/api/organisations/models.py
+++ b/api/organisations/models.py
@@ -400,13 +400,6 @@ def add_single_seat(self):
add_single_seat(self.subscription_id)
- def get_api_call_overage(self):
- subscription_info = self.organisation.subscription_information_cache
- overage = (
- subscription_info.api_calls_30d - subscription_info.allowed_30d_api_calls
- )
- return overage if overage > 0 else 0
-
def is_in_trial(self) -> bool:
return self.subscription_id == TRIAL_SUBSCRIPTION_ID
diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py
index 137e7093d659..774a09fe89d6 100644
--- a/api/permissions/permission_service.py
+++ b/api/permissions/permission_service.py
@@ -151,13 +151,19 @@ def get_permitted_environments_for_master_api_key(
project: Project,
permission_key: str,
tag_ids: List[int] = None,
+ prefetch_metadata: bool = False,
) -> QuerySet[Environment]:
if is_master_api_key_project_admin(master_api_key, project):
- return project.environments.all()
+ queryset = project.environments.all()
+ else:
+ queryset = get_permitted_environments_for_master_api_key_using_roles(
+ master_api_key, project, permission_key, tag_ids
+ )
- return get_permitted_environments_for_master_api_key_using_roles(
- master_api_key, project, permission_key, tag_ids
- )
+ if prefetch_metadata:
+ queryset = queryset.prefetch_related("metadata")
+
+ return queryset
def user_has_organisation_permission(
diff --git a/api/sales_dashboard/templates/sales_dashboard/home.html b/api/sales_dashboard/templates/sales_dashboard/home.html
index 35c44627b3ce..ef1787ac918b 100644
--- a/api/sales_dashboard/templates/sales_dashboard/home.html
+++ b/api/sales_dashboard/templates/sales_dashboard/home.html
@@ -45,6 +45,7 @@
Organisations
+