diff --git a/common/apps/billing/mixins.py b/common/apps/billing/mixins.py index 10882fe..827a2d0 100644 --- a/common/apps/billing/mixins.py +++ b/common/apps/billing/mixins.py @@ -19,9 +19,10 @@ class DeviceViewSet(QuotaMixin, viewsets.ModelViewSet): import logging -from common.apps.billing.constants import FeatureUsageScope from rest_framework.exceptions import PermissionDenied +from common.apps.billing.constants import FeatureUsageScope +from common.apps.space.models import Space from common.utils.console_client import console_client logger = logging.getLogger(__name__) @@ -158,15 +159,11 @@ def get_scope_id(self, request, view, scope_type): if not space_slug: return None - from common.apps.space.models import Space - return ( Space.objects.filter(slug_name=space_slug) .values_list("id", flat=True) .first() ) - if hasattr(view, "get_quota_scope_id"): - return view.get_quota_scope_id(request, self, scope_type) return None def get_feature_scope(self, request, view, feature): @@ -247,16 +244,17 @@ def release(self, request, view, amount=None): scope_id, reserved_amount, ), features in grouped_features.items(): - try: - console_client.release_quota( - org_slug, - features, - reserved_amount, - scope_type=scope_type, - scope_id=scope_id, - ) - except Exception: # noqa: BLE001 - logger.warning("release_quota failed for %s/%s", org_slug, features) + for feature in features: + try: + console_client.release_quota( + org_slug, + feature, + reserved_amount, + scope_type=scope_type, + scope_id=scope_id, + ) + except Exception: # noqa: BLE001 + logger.warning("release_quota failed for %s/%s", org_slug, feature) view._quota_released = released return None @@ -283,19 +281,19 @@ def _check_features(self, request, view, amount): return False for (scope_type, scope_id), scoped_features in grouped_features.items(): - reserved, error = console_client.reserve_quota( - org_slug, - scoped_features, - amount, - scope_type=scope_type, - scope_id=scope_id, - ) - if not reserved: - self.message = error or self.message - self.release(request, view) - return False - for feature in scoped_features: + reserved, error = console_client.reserve_quota( + org_slug, + feature, + amount, + scope_type=scope_type, + scope_id=scope_id, + ) + if not reserved: + self.message = error or self.message + self.release(request, view) + return False + if amount > 0: view._quota_reserved.append( { @@ -324,20 +322,21 @@ def _release_features(self, request, view, amount): return None for (scope_type, scope_id), scoped_features in grouped_features.items(): - try: - console_client.release_quota( - org_slug, - scoped_features, - amount, - scope_type=scope_type, - scope_id=scope_id, - ) - except Exception: # noqa: BLE001 - logger.warning( - "release_quota failed for %s/%s", - org_slug, - scoped_features, - ) + for feature in scoped_features: + try: + console_client.release_quota( + org_slug, + feature, + amount, + scope_type=scope_type, + scope_id=scope_id, + ) + except Exception: # noqa: BLE001 + logger.warning( + "release_quota failed for %s/%s", + org_slug, + feature, + ) return None diff --git a/common/utils/console_client.py b/common/utils/console_client.py index 4004e87..f78624a 100644 --- a/common/utils/console_client.py +++ b/common/utils/console_client.py @@ -1,9 +1,10 @@ import logging import requests -from common.apps.billing.constants import FeatureUsageScope from requests.exceptions import RequestException +from common.apps.billing.constants import FeatureUsageScope + logger = logging.getLogger(__name__) @@ -14,11 +15,14 @@ def __init__(self, base_url: str | None = None): self.base_url = base_url or "http://console:80/api" self.timeout = 10 - def _headers(self): - return { + def _headers(self, organization_slug: str | None = None): + headers = { "Content-Type": "application/json", "Accept": "application/json", } + if organization_slug: + headers["X-Organization"] = organization_slug + return headers def get_custom_emails( self, @@ -55,12 +59,12 @@ def get_custom_emails( def reserve_quota( self, organization_slug: str, - feature: str | list[str], + feature: str, amount: int = 1, scope_type: str = FeatureUsageScope.ORGANIZATION, scope_id: str | None = None, ) -> tuple[bool, str | None]: - """Reserve quota for one or more features. + """Reserve quota for a feature. Returns ``(reserved, error)``. On network error or missing billing data the reservation is treated as successful (fail-open) so that @@ -68,7 +72,6 @@ def reserve_quota( """ endpoint = f"{self.base_url}/billing/internal/quota/reserve" payload = { - "organization": organization_slug, "feature": feature, "amount": amount, "scope_type": scope_type, @@ -80,7 +83,7 @@ def reserve_quota( response = requests.post( endpoint, json=payload, - headers=self._headers(), + headers=self._headers(organization_slug), timeout=self.timeout, ) except RequestException as exc: @@ -112,15 +115,14 @@ def reserve_quota( def release_quota( self, organization_slug: str, - feature: str | list[str], + feature: str, amount: int = 1, scope_type: str = FeatureUsageScope.ORGANIZATION, scope_id: str | None = None, ) -> None: - """Release previously reserved quota for one or more features.""" + """Release previously reserved quota for a feature.""" endpoint = f"{self.base_url}/billing/internal/quota/release" payload = { - "organization": organization_slug, "feature": feature, "amount": amount, "scope_type": scope_type, @@ -132,7 +134,7 @@ def release_quota( requests.post( endpoint, json=payload, - headers=self._headers(), + headers=self._headers(organization_slug), timeout=self.timeout, ) except RequestException as exc: @@ -146,17 +148,16 @@ def release_quota( def get_quota( self, organization_slug: str, - feature: str | list[str], + feature: str, scope_type: str = FeatureUsageScope.ORGANIZATION, scope_id: str | None = None, ) -> tuple[dict | None, str | None]: - """View current quota usage for one or more features. + """View current quota usage for a feature. Returns ``(data, error)`` matching the reserve/release pattern. """ endpoint = f"{self.base_url}/billing/quota" payload = { - "organization": organization_slug, "feature": feature, "scope_type": scope_type, } @@ -167,7 +168,7 @@ def get_quota( response = requests.post( endpoint, json=payload, - headers=self._headers(), + headers=self._headers(organization_slug), timeout=self.timeout, ) response.raise_for_status() diff --git a/common/views/deactivation.py b/common/views/deactivation.py new file mode 100644 index 0000000..bd6d44b --- /dev/null +++ b/common/views/deactivation.py @@ -0,0 +1,132 @@ +from rest_framework.exceptions import PermissionDenied + + +class DeactivationMixin: + deactivation = None + deactivation_lookup_model = None + deactivation_lookup_kwarg = None + deactivation_lookup_field = "pk" + deactivation_lookup_select_related = None + + def _is_self_reference(self, target, attr): + model_meta = getattr(target, "_meta", None) + model_name = getattr(model_meta, "model_name", None) + class_name = ( + target.__name__.lower() + if isinstance(target, type) + else target.__class__.__name__.lower() + ) + return attr in {model_name, class_name} + + def _resolve_target(self, instance, target_path): + target = instance + if target_path: + for attr in target_path.split("."): + if target is None: + break + if self._is_self_reference(target, attr): + continue + target = getattr(target, attr, None) + return target + + def _get_deactivation_targets(self, instance): + if not self.deactivation: + return [(None, instance)] + return [ + (target_path, self._resolve_target(instance, target_path)) + for target_path in self.deactivation + ] + + def _get_view_model(self): + model = getattr(self, "model", None) + if model: + return model + + queryset = getattr(self, "queryset", None) + return getattr(queryset, "model", None) + + def _get_deactivation_root(self): + if not self.deactivation: + return None + return self.deactivation[0].split(".")[0] + + def _get_deactivation_lookup_model(self): + if self.deactivation_lookup_model: + return self.deactivation_lookup_model + + root = self._get_deactivation_root() + view_model = self._get_view_model() + if not root or not view_model: + return None + + if self._is_self_reference(view_model, root): + return view_model + + try: + field = view_model._meta.get_field(root) + except Exception: + return None + return getattr(field.remote_field, "model", None) + + def _get_deactivation_lookup_kwarg(self, lookup_model): + if self.deactivation_lookup_kwarg: + return self.deactivation_lookup_kwarg + + root = self._get_deactivation_root() + if root: + related_kwarg = f"{root}_id" + if related_kwarg in self.kwargs: + return related_kwarg + + if lookup_model and root and self._is_self_reference(lookup_model, root): + lookup_kwarg = getattr(self, "lookup_url_kwarg", None) + lookup_field = getattr(self, "lookup_field", None) + for candidate in (lookup_kwarg, lookup_field, "pk", "id"): + if candidate and candidate in self.kwargs: + return candidate + + return None + + def get_deactivation_message(self, target, target_path=None): + model_meta = getattr(target, "_meta", None) + label = getattr(model_meta, "verbose_name", None) + if not label and target_path: + label = target_path.split(".")[-1].replace("_", " ") + if not label: + label = "Object" + return f"{label.title()} is deactivated." + + def check_deactivated(self, target, target_path=None): + if target and getattr(target, "is_deactivated", False): + raise PermissionDenied(self.get_deactivation_message(target, target_path)) + + def check_deactivated_object(self, instance): + for target_path, target in self._get_deactivation_targets(instance): + self.check_deactivated(target, target_path) + return instance + + def get_deactivation_lookup_object(self): + lookup_model = self._get_deactivation_lookup_model() + lookup_kwarg = self._get_deactivation_lookup_kwarg(lookup_model) + if not lookup_model or not lookup_kwarg: + return None + + lookup_value = self.kwargs.get(lookup_kwarg) + if not lookup_value: + return None + + queryset = lookup_model.objects.all() + if self.deactivation_lookup_select_related: + queryset = queryset.select_related(*self.deactivation_lookup_select_related) + + return queryset.filter(**{self.deactivation_lookup_field: lookup_value}).first() + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + instance = self.get_deactivation_lookup_object() + if instance: + self.check_deactivated_object(instance) + + def get_object(self): + instance = super().get_object() + return self.check_deactivated_object(instance) diff --git a/common/views/space.py b/common/views/space.py index f135d20..072c8bd 100644 --- a/common/views/space.py +++ b/common/views/space.py @@ -3,9 +3,10 @@ from rest_framework.generics import GenericAPIView from common.apps.space.models import Space +from common.views.deactivation import DeactivationMixin -class SpaceAPIView(GenericAPIView): +class SpaceAPIView(DeactivationMixin, GenericAPIView): space_field = None def get_queryset(self): @@ -24,6 +25,9 @@ def get_queryset(self): if space_slug_name is None: raise ParseError("X-Space header is required") + space = Space.objects.filter(slug_name=space_slug_name).first() + self.check_deactivated(space) + filters = { f"{self.space_field}__slug_name": space_slug_name, f"{self.space_field}__is_active": True, @@ -32,8 +36,14 @@ def get_queryset(self): return queryset.filter(**filters) def create_with_space(self, serializer): + space_slug_name = self.request.headers.get("X-Space") + if space_slug_name is None: + raise ParseError("X-Space header is required") + + space = Space.objects.get(slug_name=space_slug_name) + self.check_deactivated(space) + if "__" not in self.space_field: - space = Space.objects.get(slug_name=self.request.headers.get("X-Space")) return serializer.save(**{self.space_field: space}) return serializer.save()