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
9 changes: 9 additions & 0 deletions core/capabilities/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.apps import AppConfig


class CapabilitiesConfig(AppConfig):
name = 'core.capabilities'
verbose_name = "Capabilities"

def ready(self):
from core.capabilities import checks # pylint: disable=unused-variable, unused-import
60 changes: 60 additions & 0 deletions core/capabilities/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import inspect

from django.conf import settings
from django.core.checks import Error, register
from django.utils.module_loading import import_string

from core.capabilities.policy import CapabilityPolicy


def takes(function, *args, **kwargs):
try:
inspect.signature(function).bind(*args, **kwargs)
except TypeError:
return False
return True


def get_arguments(interface):
"""What a call of this CapabilityPolicy method passes: required arguments by position, the others by name."""
args, kwargs = [], {}
for name, param in list(inspect.signature(interface).parameters.items())[1:]: # after `self`
if param.kind == param.VAR_KEYWORD:
kwargs['any_other_keyword'] = None
elif param.default is param.empty:
args.append(None)
else:
kwargs[name] = None
return args, kwargs


def get_misfits(policy):
"""What can't take the arguments it's called with. Reads signatures only: a policy's __init__ doesn't run here."""
bare = object.__new__(policy)
misfits = [] if takes(policy) else ['__init__']
for name, interface in inspect.getmembers(CapabilityPolicy, inspect.isfunction):
args, kwargs = get_arguments(interface)
if not takes(getattr(bare, name), *args, **kwargs):
misfits.append(name)
return misfits


@register()
def check_capability_policy(**_):
"""At start-up (`migrate` runs the checks): a policy that can't be used would fail every use of a capability."""
path = settings.CAPABILITY_POLICY
try:
policy = import_string(path)
except Exception as ex: # a module that imports but fails (a RuntimeError from a model, say) counts too
return [Error(f'CAPABILITY_POLICY {path!r} does not import: {ex}', id='capabilities.E001')]
if not (isinstance(policy, type) and issubclass(policy, CapabilityPolicy)):
return [Error(f'CAPABILITY_POLICY {path!r} is not a subclass of CapabilityPolicy', id='capabilities.E002')]
if inspect.isabstract(policy):
missing = ', '.join(sorted(policy.__abstractmethods__))
return [Error(f'CAPABILITY_POLICY {path!r} does not implement {missing}', id='capabilities.E003')]
misfits = get_misfits(policy)
if misfits:
return [Error(
f'CAPABILITY_POLICY {path!r} can\'t take the arguments of {", ".join(misfits)}', id='capabilities.E004'
)]
return []
38 changes: 22 additions & 16 deletions core/capabilities/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,35 @@
)


class CapabilityExceeded(APIException):
class UsageRefused(APIException):
"""
An APIException (not a plain Exception) so that a call site which forgets to
catch this - there is more than one check_and_consume_capability() caller -
fails as a 403 under DRF's default exception handler instead of an uncaught 500.
Existing callers still catch it explicitly to build their own structured
{detail, error_code, limit, used} response; this only changes the failure mode
for whichever call site doesn't.
A capability policy refused a use. `detail` is the refusal body, sent as the policy built it: a call site adds
only its own sentence as `detail`, when the policy gave none.
An APIException, so a call site that doesn't catch it fails as a 403 rather than a 500.
"""
status_code = status.HTTP_403_FORBIDDEN
default_code = 'capability_limit_reached'

def __init__(self, capability_name, limit, used, requested): # pylint: disable=super-init-not-called
self.capability_name = capability_name
self.limit = limit
self.used = used
self.requested = requested
self.detail = f'{capability_name}: {used}+{requested} > {limit}'
def __init__(self, detail, no_access=False): # pylint: disable=super-init-not-called
# set directly (as MapProjectCapacityExceeded does), so numbers stay numbers in the JSON
self.detail = detail
self.no_access = no_access

@property
def not_entitled(self):

class CapabilityExceeded(UsageRefused):
"""The refusal by limit: no limit or a blocked one, or not enough left."""

def __init__(self, capability_name, limit, used):
# None = no row anywhere, -1 = blocked: both are "not available", never a count
return self.limit is None or self.limit < 0
no_access = limit is None or limit < 0
super().__init__(
{
'error_code': CAPABILITY_NOT_ENTITLED_ERROR_CODE.get(capability_name, 'capability_not_entitled')
if no_access else CAPABILITY_EXCEEDED_ERROR_CODE.get(capability_name, 'capability_limit_reached'),
'limit': None if no_access else limit, 'used': used,
},
no_access=no_access
)


class MapProjectCapacityExceeded(APIException):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-10-24 14:03

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('capabilities', '0002_usage_event_idempotency_key'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterUniqueTogether(
name='usagecounter',
unique_together=set(),
),
migrations.AddField(
model_name='usagecounter',
name='window_start',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='usageevent',
name='reversed_units',
field=models.BigIntegerField(db_default=0, default=0),
),
migrations.AddField(
model_name='usageevent',
name='window_start',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='usagecounter',
name='used',
field=models.BigIntegerField(default=0),
),
migrations.AlterField(
model_name='usageevent',
name='units',
field=models.BigIntegerField(default=1),
),
migrations.AddConstraint(
model_name='usagecounter',
constraint=models.UniqueConstraint(fields=('user', 'capability', 'window_start'), name='usage_counters_unique_window'),
),
migrations.AddConstraint(
model_name='usagecounter',
constraint=models.UniqueConstraint(condition=models.Q(('window_start__isnull', True)), fields=('user', 'capability'), name='usage_counters_unique_unwindowed'),
),
]
91 changes: 73 additions & 18 deletions core/capabilities/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models, transaction
from django.db.models import F


class Capability(models.Model):
Expand Down Expand Up @@ -42,34 +43,62 @@ class Meta:


class UsageCounter(models.Model):
"""Fast, concurrency-safe running total for one (user, capability) pair."""
"""
Fast, concurrency-safe running total for one (user, capability) pair in one usage window.
`window_start` None is the window that never ends.
"""
class Meta:
db_table = 'usage_counters'
unique_together = ('user', 'capability')
constraints = [
models.UniqueConstraint(
fields=['user', 'capability', 'window_start'], name='usage_counters_unique_window'
),
# PostgreSQL 14 treats nulls as distinct in the constraint above
models.UniqueConstraint(
fields=['user', 'capability'], condition=models.Q(window_start__isnull=True),
name='usage_counters_unique_unwindowed'
),
]

user = models.ForeignKey('users.UserProfile', on_delete=models.CASCADE, related_name='usage_counters')
capability = models.ForeignKey(Capability, on_delete=models.CASCADE, related_name='usage_counters')
used = models.IntegerField(default=0)
used = models.BigIntegerField(default=0)
window_start = models.DateTimeField(null=True, blank=True)

@classmethod
def refund(cls, user, capability_id, units=1):
def get_used(cls, user, capability_id, window_start=None):
return cls.objects.filter(
user=user, capability_id=capability_id, window_start=window_start
).values_list('used', flat=True).first() or 0

@classmethod
def get_locked(cls, user, capability_id, window_start=None):
"""
Manual ledger correction (e.g. staff fixing an over-count after a bug).
Not part of the normal consumption flow: callers should consume a
capability only after the action it gates has actually succeeded (see
UserProfile.check_and_consume_capability), not reserve-then-refund -
that would need a failure path reachable by the same caller that
consumed, and CapabilityRefundView is staff-only precisely so a
cross-service caller (e.g. ocl-ai-assistant) never needs to reach it.
Floors at 0; never goes negative. `capability_id` must be an id of a
capability already seeded (Keycloak/fixtures) - a bad id fails with an
The window's counter, created at 0 on first use, locked until the caller's transaction ends.
`capability_id` must be an id of a capability already seeded (Keycloak/fixtures) - a bad id fails with an
IntegrityError rather than silently creating a Capability row.
"""
cls.objects.get_or_create(user=user, capability_id=capability_id, window_start=window_start)
return cls.objects.select_for_update().get(user=user, capability_id=capability_id, window_start=window_start)

def consume(self, units, idempotency_key=None, **attribution):
"""Adds `units` and logs the event. The caller holds this counter's lock."""
UsageCounter.objects.filter(pk=self.pk).update(used=F('used') + units)
return UsageEvent.objects.create(
user_id=self.user_id, capability_id=self.capability_id, window_start=self.window_start, units=units,
idempotency_key=idempotency_key or None, **attribution
)

@classmethod
def refund(cls, user, capability_id, units=1, window_start=None):
"""
Manual correction (e.g. staff fixing an over-count after a bug) where there's no event to reverse.
CapabilityRefundView is staff-only, so a cross-service caller (e.g. ocl-ai-assistant) never reaches it.
Floors at 0; never goes negative.
"""
with transaction.atomic():
cls.objects.get_or_create(user=user, capability_id=capability_id)
counter = cls.objects.select_for_update().get(user=user, capability_id=capability_id)
new_used = max(counter.used - units, 0)
cls.objects.filter(pk=counter.pk).update(used=new_used)
counter = cls.get_locked(user, capability_id, window_start)
cls.objects.filter(pk=counter.pk).update(used=max(counter.used - units, 0))


class UsageEvent(models.Model):
Expand All @@ -86,7 +115,12 @@ class Meta:

user = models.ForeignKey('users.UserProfile', on_delete=models.CASCADE, related_name='usage_events')
capability = models.ForeignKey(Capability, on_delete=models.CASCADE, related_name='usage_events')
units = models.IntegerField(default=1)
units = models.BigIntegerField(default=1)
# taken back after consumption (e.g. the search they were consumed for failed); `units` stays as consumed
# db_default: code that predates this column, still serving during a deploy, inserts events without it
reversed_units = models.BigIntegerField(default=0, db_default=0)
# the counter window the units counted in
window_start = models.DateTimeField(null=True, blank=True)
action = models.CharField(max_length=100, blank=True, default='')
algorithm = models.CharField(max_length=100, null=True, blank=True)
map_project = models.ForeignKey(
Expand All @@ -100,3 +134,24 @@ class Meta:
# client-sent dedup key (e.g. per run/row): a repeat consume with the same key charges nothing
idempotency_key = models.CharField(max_length=255, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

@classmethod
def find(cls, user, capability_id, idempotency_key):
if not idempotency_key:
return None
return cls.objects.filter(user=user, capability_id=capability_id, idempotency_key=idempotency_key).first()

def reverse(self, units):
"""
Takes back up to `units` of this event's units, from the counter of the window they counted in, even after
that window has ended. Returns the units taken back.
"""
with transaction.atomic():
counter = UsageCounter.get_locked(self.user, self.capability_id, self.window_start)
reversed_units = UsageEvent.objects.filter(pk=self.pk).values_list('reversed_units', flat=True).get()
units = max(min(units, self.units - reversed_units), 0)
if units:
UsageCounter.objects.filter(pk=counter.pk).update(used=max(counter.used - units, 0))
UsageEvent.objects.filter(pk=self.pk).update(reversed_units=F('reversed_units') + units)
self.reversed_units = reversed_units + units
return units
Loading
Loading