-
Notifications
You must be signed in to change notification settings - Fork 0
Add user-based quota enforcement #133
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| from .file import FileAdmin | ||
| from .folder import FolderAdmin | ||
| from .quota import QuotaAdmin | ||
| from .terms import TermsAdmin | ||
|
|
||
| __all__ = ['FileAdmin', 'FolderAdmin', 'TermsAdmin'] | ||
| __all__ = ['FileAdmin', 'FolderAdmin', 'QuotaAdmin', 'TermsAdmin'] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| from django.contrib import admin | ||
| from django.db import models | ||
| from django_admin_display import admin_display | ||
| import humanize | ||
|
|
||
| from dkc.core.models import Quota | ||
|
|
||
|
|
||
| @admin.register(Quota) | ||
| class QuotaAdmin(admin.ModelAdmin): | ||
| list_display = ['id', 'user', 'human_used', 'human_allowed', 'usage_percent'] | ||
| list_select_related = ['user'] | ||
|
|
||
| search_fields = ['user__username'] | ||
|
|
||
| readonly_fields = ['used', 'usage_percent'] | ||
|
|
||
| @admin_display(short_description='Used', admin_order_field='used') | ||
| def human_used(self, obj: Quota) -> str: | ||
| return humanize.naturalsize(obj.used, binary=True) | ||
|
|
||
| @admin_display(short_description='Allowed', admin_order_field='used') | ||
| def human_allowed(self, obj: Quota) -> str: | ||
| return humanize.naturalsize(obj.allowed, binary=True) | ||
|
|
||
| @admin_display( | ||
| short_description='% Used', | ||
| admin_order_field=models.Case( | ||
| # Prevent division by zero, and sort the result as effectively 100% | ||
| models.When(allowed=0, then=1), | ||
| # Multiply by 1.0 to force floating-point division | ||
| default=(models.F('used') * 1.0 / models.F('allowed')), | ||
| output_field=models.FloatField(), | ||
| ), | ||
| ) | ||
| def usage_percent(self, obj: Quota) -> str: | ||
| if obj.allowed == 0: | ||
| return '--' | ||
| return '{:.1%}'.format(obj.used / obj.allowed) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # Generated by Django 3.1.5 on 2021-01-31 23:46 | ||
|
|
||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
| import django.db.models.deletion | ||
| import django.db.models.expressions | ||
|
|
||
| import dkc.core.models.quota | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ('core', '0007_non_nullable_creator'), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RemoveField( | ||
| model_name='quota', | ||
| name='allocation', | ||
| ), | ||
| migrations.AddField( | ||
| model_name='quota', | ||
| name='allowed', | ||
| field=models.PositiveBigIntegerField(default=dkc.core.models.quota._default_user_quota), | ||
| ), | ||
| migrations.AddField( | ||
| model_name='quota', | ||
| name='used', | ||
| field=models.PositiveBigIntegerField(default=0), | ||
| ), | ||
| migrations.AddField( | ||
| model_name='tree', | ||
| name='quota', | ||
| field=models.ForeignKey( | ||
| default=1, | ||
| on_delete=django.db.models.deletion.PROTECT, | ||
| related_name='trees', | ||
| to='core.quota', | ||
| ), | ||
| preserve_default=False, | ||
| ), | ||
| migrations.AlterField( | ||
| model_name='quota', | ||
| name='user', | ||
| field=models.OneToOneField( | ||
| default=1, | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name='quota', | ||
| to='auth.user', | ||
| ), | ||
| preserve_default=False, | ||
| ), | ||
| migrations.AddConstraint( | ||
| model_name='quota', | ||
| constraint=models.CheckConstraint( | ||
| check=models.Q(used__lte=django.db.models.expressions.F('allowed')), | ||
| name='used_lte_allowed', | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,62 @@ | ||
| from typing import Type | ||
|
|
||
| from django.conf import settings | ||
| from django.contrib.auth.models import User | ||
| from django.db import models | ||
| from django.core.exceptions import ValidationError | ||
| from django.db import IntegrityError, models, transaction | ||
| from django.db.models.signals import post_save | ||
| from django.dispatch import receiver | ||
| from girder_utils.models import SelectRelatedManager | ||
|
|
||
|
|
||
| def _default_user_quota() -> int: | ||
| return settings.DKC_DEFAULT_QUOTA | ||
|
|
||
|
|
||
| class Quota(models.Model): | ||
| allocation = models.BigIntegerField(default=0) | ||
| user = models.OneToOneField(User, null=True, on_delete=models.CASCADE) | ||
| # root_set | ||
| class Meta: | ||
| constraints = [ | ||
| models.CheckConstraint( | ||
| check=models.Q(used__lte=models.F('allowed')), | ||
| name='used_lte_allowed', | ||
| ) | ||
| ] | ||
|
|
||
| used = models.PositiveBigIntegerField(default=0) | ||
| allowed = models.PositiveBigIntegerField(default=_default_user_quota) | ||
|
|
||
| # OneToOneField ensures that only one Quota per User exists | ||
| user = models.OneToOneField(User, on_delete=models.CASCADE) | ||
|
|
||
| @transaction.atomic | ||
| def increment(self, amount: int) -> None: | ||
| """ | ||
| Increment or decrement the quota. | ||
|
|
||
| This function increments (or decrements, if ``amount`` is negative) the usage | ||
| values associated with this quota. If this increment would exceed the max | ||
| allotment for this quota, a ``ValidationError`` is raised and the transaction | ||
| is rolled back. | ||
| """ | ||
| if amount == 0: | ||
| return | ||
|
|
||
| # The user is needed to stringify, so join it when directly querying for a quota | ||
| objects = SelectRelatedManager('user') | ||
| try: | ||
| # Use an .update query instead of a .save, to avoid assigning an F-expression on the | ||
| # local instance, which might need to be rolled back on a failure | ||
| Quota.objects.filter(pk=self.pk).update(used=(models.F('used') + amount)) | ||
| except IntegrityError as e: | ||
| if '"used_lte_allowed"' in str(e): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wish we didn't have to do this -- do you think we'd have any luck upstreaming a feature request to psycopg2 to provide more structured info on their IntegrityErrors?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Not to hold up this PR or anything, just an idle thought.)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's where the
So, it looks like it's possible in principle for only this particular case. Psycopg 2 supports >= PostgreSQL 7.4, so support would need to be conditional. @zachmullen I would start by filing an issue in Psycopg 2, but if they decline it there, perhaps they'd consider it in (the apparently actively-developed) Psycopg 3. Let me know if you do so, so I can subscribe to it. |
||
| raise ValidationError( | ||
| 'Root folder size quota would be exceeded: ' f'{self.used}B > {self.allowed}B.' | ||
| ) | ||
| else: | ||
| raise | ||
|
|
||
| def __str__(self): | ||
| return f'Quota for <{self.user}>; {self.allocation} bytes' | ||
| # Update with the new value | ||
| self.refresh_from_db(fields=['used']) | ||
|
|
||
|
|
||
| @receiver(post_save, sender=User) | ||
| def create_user_quota(sender: Type[User], instance: User, created: bool, **kwargs): | ||
| def _create_user_quota(sender: Type[User], instance: User, created: bool, **kwargs): | ||
| if created: | ||
| quota = Quota(user=instance) | ||
| quota.save() | ||
| Quota.objects.create(user=instance) | ||
Uh oh!
There was an error while loading. Please reload this page.