(backend) add organization metrics to usage API

Add account_type=organization support to the usage metrics
endpoint. When requested, the API filters users by a shared
OIDC claim (account_id_key/account_id_value) and returns
aggregated storage across all matching users.

Add handling of account_id_key/account_id_value pair, allowing
filtering on allowed parameters in a more generic way. At the
moment `account_email` filter is still needed.

Also include the context of the entitlement in the /entitlements
response.
This commit is contained in:
Nathan Vasse
2026-04-29 10:17:25 +02:00
parent 8360aeca88
commit aa099068e9
5 changed files with 321 additions and 16 deletions
+49 -3
View File
@@ -12,6 +12,8 @@ You must provide an authorization header as follows:
Authorization: Api-Key <key>
```
You should also provide `account_type=user|organization` query parameter.
## Enabling
By default, this route is not available. You need to set the setting `METRICS_ENABLED` to `True`.
@@ -69,9 +71,22 @@ The `storage_used` is, by default, the sum of file sizes created by the user, in
## Filtering
You can filter this by using `account_id` query parameter like so:
You can filter this by using `account_id_key` and `account_id_value` query parameter like so:
`GET /external_api/v1.0/metrics/usage/?account_id=<uuid>`
- `account_type` | `user` or `organization`
- `account_id_key` | The key to filter on (`sub` or `email` for `account_type=user`). Required if `account_type=organization`, it could be any oidc stored claim.
- `account_id_value` | The value to filter on. Required if `account_type=organization`.
- `account_email` | This will soon be migrated to \_key \_value pattern, but still needed.
Examples:
```
GET /external_api/v1.0/metrics/usage/?account_type=user
GET /external_api/v1.0/metrics/usage/?account_type=user&account_id_key=sub&account_id_value=<sub value>
GET /external_api/v1.0/metrics/usage/?account_type=user&account_email=<email>
GET /external_api/v1.0/metrics/usage/?account_type=organization # Forbidden
GET /external_api/v1.0/metrics/usage/?account_type=organization&account_id_key=siret&account_id_value=<siret value>
```
## How to customize the storage used computation?
@@ -83,7 +98,7 @@ You can see the default implementation `CreatorStorageComputeBackend` as an exam
For aggregation purposes, you might want to expose some OIDC claims from this route that your external tool will use.
For instance, with the ANCT, the implementation needs to get access to the `siret` claim.
For instance, with the deploy center, the implementation needs to get access to the `siret` claim.
Simply customize the setting `METRICS_USER_CLAIMS_EXPOSED=your_claim1,your_claim2` and the response will look like this:
@@ -111,3 +126,34 @@ Simply customize the setting `METRICS_USER_CLAIMS_EXPOSED=your_claim1,your_claim
```
🚨 Make sure the claims you want to expose are stored by using the `OIDC_STORE_CLAIMS` setting as well. Otherwise, it will not work. 🚨
## Organization metrics
It is possibe to fetch usage metric grouped by organization, do to so, provide `account_type=organization`, when using account_type organization it is required that you also provide `account_id_key=siret` and `account_id_value=1234...`.
It will sum the storage_used by user that have the same "claims.siret".
> You should mind to add `siret` into OIDC_STORE_CLAIMS so the siret are stored on user records.
Example:
```
GET /external_api/v1.0/metrics/usage?account_type=organization&account_id_key=siret&account_id_value=12345678900001
{
"count": 123,
"next": None,
"previous": None,
"results": [
{
"account": {
"type": "organization",
},
"siret": "12345678900001"
"metrics": {
"storage_used": 10000000,
},
},
...
],
}
```
+65
View File
@@ -167,3 +167,68 @@ class ListItemFilter(ItemFilter):
return queryset
return queryset.filter(is_favorite=bool(value))
class UsageMetricAccountTypeChoices(TextChoices):
"""Choices for the usage metrics `account_type` query param."""
USER = "user", _("User")
ORGANIZATION = "organization", _("Organization")
class UsageMetricAccountIdKeyChoices(TextChoices):
"""Allowed keys for filtering users by account id in the usage metrics endpoint."""
SUB = "sub", _("Sub")
EMAIL = "email", _("Email")
class BaseUsageMetricFilter(django_filters.FilterSet):
"""Shared `account_id_key`/`account_id_value` handling for usage metrics filters.
Subclasses declare their own `account_id_key` filter (with the right validation
rules and `required` flag) and override `ACCOUNT_ID_LOOKUP` to point at the field
path used to filter the queryset.
"""
ACCOUNT_ID_LOOKUP = "{key}"
account_id_value = django_filters.CharFilter(method="filter_noop")
# pylint: disable=unused-argument
def filter_account_id_key(self, queryset, name, value):
"""Apply the account_id_key/account_id_value pair as a single filter."""
account_id_value = self.data.get("account_id_value")
if not account_id_value:
return queryset
lookup = self.ACCOUNT_ID_LOOKUP.format(key=value)
return queryset.filter(**{lookup: account_id_value})
# pylint: disable=unused-argument
def filter_noop(self, queryset, name, value):
"""No-op: `account_id_value` is consumed by `filter_account_id`."""
return queryset
class UsageMetricFilter(BaseUsageMetricFilter):
"""Filter for the usage metrics endpoint (user listing)."""
account_id_key = django_filters.ChoiceFilter(
choices=UsageMetricAccountIdKeyChoices.choices,
method="filter_account_id_key",
)
account_email = django_filters.CharFilter(field_name="email")
class OrganizationUsageMetricFilter(BaseUsageMetricFilter):
"""Filter for the organization variant of the usage metrics endpoint.
Both `account_id_key` and `account_id_value` are required, the key is an
arbitrary OIDC claim name, and the lookup goes through the User's `claims`
JSON field.
"""
ACCOUNT_ID_LOOKUP = "claims__{key}"
account_id_key = django_filters.CharFilter(method="filter_account_id_key", required=True)
account_id_value = django_filters.CharFilter(method="filter_noop", required=True)
+21 -3
View File
@@ -65,8 +65,8 @@ class UserLightSerializer(UserSerializer):
# pylint: disable=abstract-method
class UsageMetricSerializer(serializers.BaseSerializer):
"""Serialize usage metrics."""
class UserUsageMetricSerializer(serializers.BaseSerializer):
"""Serialize usage metrics for a single user."""
def to_representation(self, instance):
"""Return the usage metric."""
@@ -78,7 +78,9 @@ class UsageMetricSerializer(serializers.BaseSerializer):
"email": instance.email,
},
"metrics": {
"storage_used": storage_compute_backend.compute_storage_used(instance),
"storage_used": storage_compute_backend.compute_storage_used(
models.User.objects.filter(pk=instance.pk)
),
},
}
for claim in settings.METRICS_USER_CLAIMS_EXPOSED:
@@ -86,6 +88,22 @@ class UsageMetricSerializer(serializers.BaseSerializer):
return output
class OrganizationUsageMetricSerializer(serializers.Serializer):
"""Serialize aggregated usage metrics for an organization."""
account_id_key = serializers.CharField()
account_id_value = serializers.CharField()
total_storage = serializers.IntegerField()
def to_representation(self, instance):
"""Return the organization usage metric."""
return {
"account": {"type": "organization"},
instance["account_id_key"]: instance["account_id_value"],
"metrics": {"storage_used": instance["total_storage"]},
}
class ItemLightSerializer(serializers.ModelSerializer):
"""Minimal item serializer for nesting in item accesses."""
+54 -9
View File
@@ -50,13 +50,22 @@ from core.services.search_indexers import (
get_file_indexer,
get_visited_items_ids_of,
)
from core.storage import get_storage_compute_backend
from core.tasks.item import duplicate_file, process_item_purge, rename_file
from core.utils.analytics import posthog_capture
from wopi.services import access as access_service
from wopi.utils import compute_wopi_launch_url, get_wopi_client_config
from . import permissions, serializers, utils
from .filters import ItemFilter, ItemOrdering, ListItemFilter, SearchItemFilter
from .filters import (
ItemFilter,
ItemOrdering,
ListItemFilter,
OrganizationUsageMetricFilter,
SearchItemFilter,
UsageMetricAccountTypeChoices,
UsageMetricFilter,
)
logger = logging.getLogger(__name__)
@@ -2216,19 +2225,54 @@ class UsageMetricViewset(drf.mixins.ListModelMixin, viewsets.GenericViewSet):
permission_classes = [HasAPIKey]
queryset = models.User.objects.all().filter(is_active=True)
serializer_class = serializers.UsageMetricSerializer
serializer_class = serializers.UserUsageMetricSerializer
pagination_class = Pagination
def get_queryset(self):
"""
Return the queryset applying the filters from the query params.
"""
queryset = self.queryset
"""Return the queryset filtered through `UsageMetricFilter`."""
filterset = UsageMetricFilter(
self.request.GET, queryset=self.queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
return filterset.filter_queryset(self.queryset)
if self.request.query_params.get("account_id"):
queryset = queryset.filter(sub=self.request.query_params.get("account_id"))
def list(self, request, *args, **kwargs):
"""Handle listing with account_type branching."""
account_type = request.query_params.get("account_type", UsageMetricAccountTypeChoices.USER)
return queryset
if account_type == UsageMetricAccountTypeChoices.ORGANIZATION:
return self._list_organization(request)
return super().list(request, *args, **kwargs)
def _list_organization(self, request):
"""Aggregate storage metrics across users of an organization."""
base_qs = models.User.objects.filter(is_active=True)
filterset = OrganizationUsageMetricFilter(request.GET, queryset=base_qs, request=request)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
users = filterset.filter_queryset(base_qs)
storage_backend = get_storage_compute_backend()
total_storage = storage_backend.compute_storage_used(users)
serializer = serializers.OrganizationUsageMetricSerializer(
{
"account_id_key": filterset.form.cleaned_data["account_id_key"],
"account_id_value": filterset.form.cleaned_data["account_id_value"],
"total_storage": total_storage,
}
)
return drf.response.Response(
{
"count": 1,
"next": None,
"previous": None,
"results": [serializer.data],
}
)
class EntitlementsViewset(viewsets.ViewSet):
@@ -2247,4 +2291,5 @@ class EntitlementsViewset(viewsets.ViewSet):
method = getattr(entitlements_backend, method_name)
if callable(method):
entitlements[method_name] = method(request.user)
entitlements["context"] = entitlements_backend.get_context(request.user)
return drf.response.Response(entitlements)
@@ -134,7 +134,57 @@ def test_usage_metrics_list_filter_by_account_id(api_key):
factories.ItemFactory(creator=user1, size=100)
response = client.get(
f"/external_api/v1.0/metrics/usage/?account_id={user1.sub}",
f"/external_api/v1.0/metrics/usage/?account_id_key=sub&account_id_value={user1.sub}",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"account": {
"type": "user",
"id": str(user1.sub),
"email": user1.email,
},
"metrics": {
"storage_used": 100,
},
},
],
}
@override_settings(METRICS_ENABLED=True)
def test_usage_metrics_list_invalid_account_id_key(api_key):
"""An unsupported account_id_key should be rejected with a 400."""
client = APIClient()
response = client.get(
"/external_api/v1.0/metrics/usage/?account_id_key=bogus&account_id_value=42",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 400
body = response.json()
assert body["type"] == "validation_error"
assert any(error["attr"] == "account_id_key" for error in body["errors"])
@override_settings(METRICS_ENABLED=True)
def test_usage_metrics_list_filter_by_account_email(api_key):
"""
API keys should be allowed to list usage metrics for a specific account.
"""
client = APIClient()
user1 = factories.UserFactory()
factories.UserFactory()
factories.ItemFactory(creator=user1, size=100)
response = client.get(
f"/external_api/v1.0/metrics/usage/?account_email={user1.email}",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 200
@@ -207,3 +257,84 @@ def test_usage_metrics_exposed_claims(api_key):
},
],
}
@override_settings(METRICS_ENABLED=True)
def test_usage_metrics_organization_type(api_key):
"""
Organization metrics should aggregate storage across users
sharing the same claim value.
"""
client = APIClient()
siret = "12345678901234"
user1 = factories.UserFactory(claims={"siret": siret})
user2 = factories.UserFactory(claims={"siret": siret})
factories.UserFactory(claims={"siret": "99999999999999"})
factories.ItemFactory(creator=user1, size=100)
factories.ItemFactory(creator=user2, size=250)
response = client.get(
"/external_api/v1.0/metrics/usage/"
f"?account_type=organization&account_id_key=siret"
f"&account_id_value={siret}",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"account": {"type": "organization"},
"siret": siret,
"metrics": {"storage_used": 350},
},
],
}
@override_settings(METRICS_ENABLED=True)
def test_usage_metrics_organization_type_missing_params(api_key):
"""
Organization metrics should return 400 when account_id_key
or account_id_value is missing.
"""
client = APIClient()
response = client.get(
"/external_api/v1.0/metrics/usage/?account_type=organization",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 400
@override_settings(METRICS_ENABLED=True)
def test_usage_metrics_organization_type_no_matching_users(api_key):
"""
Organization metrics should return storage_used=0 when no users
match the given claim.
"""
client = APIClient()
factories.UserFactory(claims={"siret": "11111111111111"})
response = client.get(
"/external_api/v1.0/metrics/usage/"
"?account_type=organization&account_id_key=siret"
"&account_id_value=00000000000000",
HTTP_AUTHORIZATION=f"Api-Key {api_key}",
)
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"account": {"type": "organization"},
"siret": "00000000000000",
"metrics": {"storage_used": 0},
},
],
}