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

+