Skip to content
Draft
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
50 changes: 38 additions & 12 deletions admin/institutional_storage_quota_control/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import logging
from django.contrib.auth.mixins import UserPassesTestMixin
from django.db import connection, transaction, IntegrityError
from django.db.models import Subquery, OuterRef
from django.http import Http404

logger = logging.getLogger(__name__)

from admin.institutions.views import QuotaUserList
from osf.models import Institution, OSFUser, UserQuota
from osf.models import Institution, OSFUser, UserQuota, InstitutionDefaultMaxQuota
from admin.base import settings
from addons.osfstorage.models import Region
from django.views.generic import ListView, View
from django.shortcuts import redirect
from admin.rdm.utils import RdmPermissionMixin
from django.core.urlresolvers import reverse
from api.base import settings as api_settings


class InstitutionStorageList(RdmPermissionMixin, UserPassesTestMixin, ListView):
Expand Down Expand Up @@ -122,6 +126,22 @@ def get_institution(self):
raise Http404
return institution

def get_default_max_quota(self):
"""Get default max quota for the institution, fallback to DEFAULT_MAX_QUOTA when not found."""
try:
institution_default_max_quota = InstitutionDefaultMaxQuota.objects.get(
institution_id=self.institution_id
)
return institution_default_max_quota.default_max_quota
except InstitutionDefaultMaxQuota.DoesNotExist:
return api_settings.DEFAULT_MAX_QUOTA

def get_context_data(self, **kwargs):
""" Add default_max_quota to template context """
context = super().get_context_data(**kwargs)
context['default_max_quota'] = self.get_default_max_quota()
return context


class UpdateQuotaUserListByInstitutionStorageID(RdmPermissionMixin, UserPassesTestMixin, View):
""" Change max quota for an institution's users if that institution is not using NII Storage. """
Expand Down Expand Up @@ -163,17 +183,23 @@ def post(self, request, *args, **kwargs):
min_value, max_value = connection.ops.integer_field_range('PositiveIntegerField')
if min_value <= max_quota <= max_value:
# If max quota value is between 0 and 2147483647, update or create used quota for each user in the institution
for user in OSFUser.objects.filter(
affiliated_institutions=self.institution_id):
try:
with transaction.atomic():
UserQuota.objects.update_or_create(
user=user,
storage_type=UserQuota.CUSTOM_STORAGE,
defaults={'max_quota': max_quota}
)
except IntegrityError:
UserQuota.objects.filter(user=user, storage_type=UserQuota.CUSTOM_STORAGE).update(max_quota=max_quota)
with transaction.atomic():
InstitutionDefaultMaxQuota.objects.update_or_create(
institution_id=self.institution_id,
defaults={'default_max_quota': max_quota}
)
for user in OSFUser.objects.filter(
affiliated_institutions=self.institution_id):
try:
with transaction.atomic():
UserQuota.objects.update_or_create(
user=user,
storage_type=UserQuota.CUSTOM_STORAGE,
defaults={'max_quota': max_quota}
)
except IntegrityError as e:
logger.warning(u'IntegrityError while updating UserQuota: user={}, storage_type={}, max_quota={}: {}.'.format(user.id, UserQuota.CUSTOM_STORAGE, max_quota, str(e)))
UserQuota.objects.filter(user=user, storage_type=UserQuota.CUSTOM_STORAGE).update(max_quota=max_quota)
return redirect(
'institutional_storage_quota_control:institution_user_list',
institution_id=self.institution_id
Expand Down
12 changes: 8 additions & 4 deletions admin/quota_recalc/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import logging
from django.http import JsonResponse
from django.db import transaction, IntegrityError

from addons.osfstorage.models import Region
from api.base import settings as api_settings
from osf.models import OSFUser, UserQuota, Node
from osf.models.node import set_project_storage_type
from osf.utils.requests import check_select_for_update
from website.util.quota import used_quota
from website.util.quota import get_default_max_quota, used_quota

logger = logging.getLogger(__name__)


def calculate_quota(user):
Expand All @@ -23,6 +25,7 @@ def calculate_quota(user):
with transaction.atomic():
for storage_type in storage_type_list:
used = used_quota(user._id, storage_type)
max_quota = get_default_max_quota(user, storage_type)
try:
if check_select_for_update():
user_quota = UserQuota.objects.filter(
Expand All @@ -42,10 +45,11 @@ def calculate_quota(user):
UserQuota.objects.create(
user=user,
storage_type=storage_type,
max_quota=api_settings.DEFAULT_MAX_QUOTA,
max_quota=max_quota,
used=used,
)
except IntegrityError:
except IntegrityError as e:
logger.warning(u'IntegrityError while creating UserQuota in calculate_quota: user={}, storage_type={}: {}.'.format(user.id, storage_type, str(e)))
used = used_quota(user._id, storage_type)
if check_select_for_update():
user_quota = UserQuota.objects.filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ <h3>{% trans "Institutional Storage" %} &gt; {{ institution_name }} </h3>
<input type="number" name="maxQuota"
id="storageLimit" class="form-control flex-item"
style="width:70px; max-width: 115px; margin-left: 2px;"
min="0" max="2147483647" required="" value="100"
min="0" max="2147483647" required="" value="{{ default_max_quota|default_if_none:100 }}"
oninvalid="this.setCustomValidity('{% trans 'Please enter Each user Quota for Institutional Storage (GB) within 0~2147483647.' %}')"
oninput="validateMaxQuotaInput()"
onKeyDown="preventDotInput(event)"
Expand Down
215 changes: 211 additions & 4 deletions admin_tests/institutional_storage_quota_control/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,121 @@ def test_post__max_quota_too_large(self):
nt.assert_is_not_none(new_user_quota)
nt.assert_equal(new_user_quota.max_quota, api_settings.DEFAULT_MAX_QUOTA)

def test_post__institution_default_max_quota_created(self):
"""Test that InstitutionDefaultMaxQuota is created/updated on POST"""
from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota

new_max_quota = 200
region = RegionFactory(_id=self.institution01._id)
region.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
region.save()

request = RequestFactory().post(
reverse(self.view_name,
kwargs={'institution_id': self.institution01.id}),
{'maxQuota': new_max_quota})
request.user = self.superuser
response = self.view(request, institution_id=self.institution01.id)

nt.assert_equal(response.status_code, 302)

# Verify InstitutionDefaultMaxQuota record was created
quota = InstitutionDefaultMaxQuota.objects.filter(
institution_id=self.institution01.id
).first()
nt.assert_is_not_none(quota)
nt.assert_equal(quota.default_max_quota, new_max_quota)

def test_post__institution_default_max_quota_updated(self):
"""Test that InstitutionDefaultMaxQuota is updated on subsequent POST"""
from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota

# Create initial record
InstitutionDefaultMaxQuota.objects.update_or_create(
institution_id=self.institution01.id,
defaults={'default_max_quota': 100}
)

# Update via POST
new_max_quota = 250
region = RegionFactory(_id=self.institution01._id)
region.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
region.save()

request = RequestFactory().post(
reverse(self.view_name,
kwargs={'institution_id': self.institution01.id}),
{'maxQuota': new_max_quota})
request.user = self.superuser
response = self.view(request, institution_id=self.institution01.id)

nt.assert_equal(response.status_code, 302)

# Verify InstitutionDefaultMaxQuota record was updated
quota = InstitutionDefaultMaxQuota.objects.filter(
institution_id=self.institution01.id
).first()
nt.assert_is_not_none(quota)
nt.assert_equal(quota.default_max_quota, new_max_quota)

# Verify only one record exists for this institution
count = InstitutionDefaultMaxQuota.objects.filter(
institution_id=self.institution01.id
).count()
nt.assert_equal(count, 1)

def test_post__integrity_error_handles_fallback_update(self):
"""Test that IntegrityError in update_or_create triggers fallback update logic"""
from unittest import mock
from django.db import IntegrityError, transaction

new_max_quota = 300
region = RegionFactory(_id=self.institution01.guid)
region.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
region.save()

# Create initial UserQuota
initial_quota = UserQuota.objects.create(
user=self.institution01_admin,
storage_type=UserQuota.CUSTOM_STORAGE,
max_quota=100
)

def raise_integrity_error(*args, **kwargs):
transaction.set_rollback(True)
raise IntegrityError('Simulated race condition')

# Mock update_or_create to raise IntegrityError and mark transaction for rollback
with mock.patch.object(UserQuota.objects, 'update_or_create') as mock_update:
mock_update.side_effect = raise_integrity_error

request = RequestFactory().post(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
),
{'maxQuota': new_max_quota}
)
request.user = self.institution01_admin

response = self.view(
request,
institution_id=self.institution01.id
)

# Verify response is redirect
nt.assert_equal(response.status_code, 302)

# Verify max_quota was updated via fallback logic
updated_quota = UserQuota.objects.filter(
user=self.institution01_admin,
storage_type=UserQuota.CUSTOM_STORAGE
).first()

nt.assert_is_not_none(updated_quota)
nt.assert_equal(updated_quota.max_quota, new_max_quota)
nt.assert_equal(updated_quota.id, initial_quota.id)


class TestUserListByInstitutionStorageID(AdminTestCase):
def setUp(self):
Expand All @@ -261,7 +376,11 @@ def setUp(self):
self.institution02 = InstitutionFactory(name='inst02')

self.region01 = RegionFactory(_id=self.institution01._id, name='Storage 01')
self.region01.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
self.region01.save()
self.region02 = RegionFactory(_id=self.institution02._id, name='Storage 02')
self.region02.waterbutler_settings['storage']['type'] = Region.INSTITUTIONS
self.region02.save()

self.anon = AnonymousUser()
self.user = AuthUserFactory(fullname='user')
Expand Down Expand Up @@ -441,18 +560,17 @@ def test_get_institution__not_found(self):
view.get_institution()

# Institution use NII Storage
self.institution01._id = ''
self.institution01.save()
nii_institution = InstitutionFactory(_id='', name='nii_inst')
request = RequestFactory().get(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
kwargs={'institution_id': nii_institution.id}
)
)
request.user = self.superuser

view = setup_view(self.view_instance, request,
institution_id=self.institution01.id)
institution_id=nii_institution.id)
with nt.assert_raises(Http404):
view.get_institution()

Expand All @@ -476,6 +594,95 @@ def test__institution_id_not_valid(self):
)
)

def test_get_default_max_quota__institution_has_default(self):
"""Test get_default_max_quota returns value from DB when record exists"""
from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota

# Create default max quota for institution
InstitutionDefaultMaxQuota.objects.update_or_create(
institution_id=self.institution01.id,
defaults={'default_max_quota': 500}
)

request = RequestFactory().get(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
)
)
request.user = self.institution01_admin

view = setup_view(self.view_instance, request,
institution_id=self.institution01.id)
view.institution_id = self.institution01.id
default_max_quota = view.get_default_max_quota()

nt.assert_equal(default_max_quota, 500)

def test_get_default_max_quota__institution_no_default(self):
"""Test get_default_max_quota returns DEFAULT_MAX_QUOTA when record doesn't exist"""
request = RequestFactory().get(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
)
)
request.user = self.institution01_admin

view = setup_view(self.view_instance, request,
institution_id=self.institution01.id)
view.institution_id = self.institution01.id
default_max_quota = view.get_default_max_quota()

nt.assert_equal(default_max_quota, api_settings.DEFAULT_MAX_QUOTA)

def test_get_context_data__includes_default_max_quota(self):
"""Test get_context_data includes default_max_quota in context"""
from osf.models.institution_default_max_quota import InstitutionDefaultMaxQuota

# Create default max quota for institution
InstitutionDefaultMaxQuota.objects.update_or_create(
institution_id=self.institution01.id,
defaults={'default_max_quota': 300}
)

request = RequestFactory().get(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
)
)
request.user = self.institution01_admin

view = setup_view(self.view_instance, request,
institution_id=self.institution01.id)
view.institution_id = self.institution01.id
view.object_list = view.get_queryset()
context = view.get_context_data()

nt.assert_in('default_max_quota', context)
nt.assert_equal(context['default_max_quota'], 300)

def test_get_context_data__default_max_quota_fallback(self):
"""Test get_context_data uses DEFAULT_MAX_QUOTA as fallback"""
request = RequestFactory().get(
reverse(
self.view_name,
kwargs={'institution_id': self.institution01.id}
)
)
request.user = self.institution01_admin

view = setup_view(self.view_instance, request,
institution_id=self.institution01.id)
view.institution_id = self.institution01.id
view.object_list = view.get_queryset()
context = view.get_context_data()

nt.assert_in('default_max_quota', context)
nt.assert_equal(context['default_max_quota'], api_settings.DEFAULT_MAX_QUOTA)


class TestAccessInstitutionStorageList(AdminTestCase):
def setUp(self):
super(TestAccessInstitutionStorageList, self).setUp()
Expand Down
Loading
Loading