Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
81 changes: 40 additions & 41 deletions common/apps/billing/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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(
{
Expand Down Expand Up @@ -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

Expand Down
31 changes: 16 additions & 15 deletions common/utils/console_client.py
Original file line number Diff line number Diff line change
@@ -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__)


Expand All @@ -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,
Expand Down Expand Up @@ -55,20 +59,19 @@ 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
billing infra problems never block the core product.
"""
endpoint = f"{self.base_url}/billing/internal/quota/reserve"
payload = {
"organization": organization_slug,
"feature": feature,
"amount": amount,
"scope_type": scope_type,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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,
}
Expand All @@ -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()
Expand Down
132 changes: 132 additions & 0 deletions common/views/deactivation.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading