Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Service usage cache and submissions suspended flag #5086

Merged
merged 33 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
74211a2
Merge branch 'task-847-refactor-service-usage-serializer' into billin…
Guitlle Aug 28, 2024
e14003e
Add cache utility to cache class properties
Guitlle Aug 29, 2024
27295b2
Use cached class utility for service usage calculations
Guitlle Aug 29, 2024
de74c35
Black re-format
Guitlle Aug 29, 2024
7d6eff4
Minor fixes to formatting
Guitlle Aug 29, 2024
274a788
FIx broken tests due to uncleared cache
Guitlle Aug 29, 2024
bf6fc11
Remove old way of caching service usage counters, use current redis c…
Guitlle Sep 2, 2024
b080ef4
Remove obsolete migrations for organizations app
Guitlle Sep 2, 2024
c39ff2e
Move submissions_suspended flag to a column field in the user profile…
Guitlle Sep 2, 2024
e25e1bf
Fix wrong condition for submissions_suspended
Guitlle Sep 3, 2024
296c2bf
Merge branch 'billing-addons-backend' into task-1005-cache-service-usage
jamesrkiger Sep 3, 2024
37561fb
Add Date header and last_updated field for the cached service usage r…
Guitlle Sep 4, 2024
0eb0f75
Merge remote-tracking branch 'origin/task-1005-cache-service-usage' i…
Guitlle Sep 4, 2024
14dd62f
Set Date header to indicate last updated time of service usage cache …
Guitlle Sep 4, 2024
43cf2b4
Use the ENCPOINT_CACHE_DURATION setting for the service usage calcula…
Guitlle Sep 6, 2024
24a448b
Rename test about date header
Guitlle Sep 6, 2024
29ff512
Avoid using time.sleep in unit test mocking the last updated getter f…
Guitlle Sep 11, 2024
a8901f1
Fix requested changes, improve cache hash and minor refactoring
Guitlle Sep 11, 2024
d99a4aa
Handle cache disabled and disable cache for two unit tests
Guitlle Sep 12, 2024
899d064
Fix error in openrosa backend when cache is disabled
Guitlle Sep 12, 2024
8b09600
Fix migration dependency with a squash migration that replaces 3 migr…
Guitlle Sep 13, 2024
35a1c45
Deduplicate commands on squashed migration
Guitlle Sep 23, 2024
faf0d04
Refactor get_cached_usage function in model and add unit test for org…
Guitlle Sep 24, 2024
c2455ab
Refactor orgs model, move get_cached_usage to usage_calculator utility
Guitlle Sep 24, 2024
7ac34c4
Merge remote-tracking branch 'origin/billing-addons-backend' into tas…
Guitlle Sep 24, 2024
c261c1b
Fix conflict in new migration file after merge
Guitlle Sep 24, 2024
cf1614b
Format and lint code for touched files
Guitlle Sep 24, 2024
b19d559
Use redis hgetall to avoid multiple calls to redis cache
Guitlle Sep 25, 2024
223eb01
Remove cache setup call from the decorator function to avoid multiple…
Guitlle Sep 25, 2024
9eebe41
Fix order of functions
Guitlle Sep 25, 2024
2312afa
Fix CachedClass class and attribute getter decorator
Guitlle Sep 26, 2024
d965eff
Fix linter errors
Guitlle Oct 1, 2024
3ea003d
Add comment that explains why we catch an InvalidCacheBackendError ex…
Guitlle Oct 1, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,9 @@ def test_post_submission_json_without_submission_key(self):
)

def test_submission_blocking_flag(self):
# Set 'submissions_suspended' True in the profile metadata to test if
# Set 'submissions_suspended' True in the profile to test if
# submission do fail with the flag set
self.xform.user.profile.metadata['submissions_suspended'] = True
self.xform.user.profile.submissions_suspended = True
self.xform.user.profile.save()

# No need auth for this test
Expand Down Expand Up @@ -430,7 +430,7 @@ def test_submission_blocking_flag(self):
f.seek(0)

# check that users can submit data again when flag is removed
self.xform.user.profile.metadata['submissions_suspended'] = False
self.xform.user.profile.submissions_suspended = False
self.xform.user.profile.save()

request = self.factory.post(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,15 @@ def suspend_submissions_for_user(self, user: settings.AUTH_USER_MODEL):
user_profile.metadata = {}

# Set the flag `submissions_suspended` to true if it is not already.
if not user_profile.metadata.get('submissions_suspended'):
if not user_profile.submissions_suspended:
# We are using the flag `submissions_suspended` to prevent
# new submissions from coming in while the
# counters are being calculated.
user_profile.metadata['submissions_suspended'] = True
user_profile.save(update_fields=['metadata'])
user_profile.submissions_suspended = True
user_profile.save(update_fields=['submissions_suspended'])

def release_old_locks(self):
updates = {'submissions_suspended': False}
updates = {}

if self._force:
updates['counters_updates_status'] = 'not-complete'
Expand All @@ -231,12 +231,12 @@ def release_old_locks(self):
'metadata',
updates=updates,
),
submissions_suspended=False
)

def update_user_profile(self, user: settings.AUTH_USER_MODEL):
# Update user's profile (and lock the related row)
updates = {
'submissions_suspended': False,
'counters_updates_status': 'complete',
}
UserProfile.objects.filter(
Expand All @@ -246,4 +246,5 @@ def update_user_profile(self, user: settings.AUTH_USER_MODEL):
'metadata',
updates=updates,
),
submissions_suspended=False,
)
Original file line number Diff line number Diff line change
Expand Up @@ -199,24 +199,19 @@ def _lock_user_profile(self, user: settings.AUTH_USER_MODEL):
user_profile.metadata = {}

# Set the flag to true if it was never set.
if not user_profile.metadata.get('submissions_suspended'):
if not user_profile.submissions_suspended:
# We are using the flag `submissions_suspended` to prevent
# new submissions from coming in while the
# `attachment_storage_bytes` is being calculated.
user_profile.metadata['submissions_suspended'] = True
user_profile.save(update_fields=['metadata'])
user_profile.submissions_suspended = True
user_profile.save(update_fields=['submissions_suspended'])

def _release_locks(self):
# Release any locks on the users' profile from getting submissions
if self._verbosity > 1:
self.stdout.write('Releasing submission locks…')

UserProfile.objects.all().update(
metadata=ReplaceValues(
'metadata',
updates={'submissions_suspended': False},
),
)
UserProfile.objects.all().update(submissions_suspended=False)

def _reset_user_profile_counters(self):

Expand Down Expand Up @@ -251,7 +246,6 @@ def _update_user_profile(self, user: settings.AUTH_USER_MODEL):

# Update user's profile (and lock the related row)
updates = {
'submissions_suspended': False,
'attachments_counting_status': 'complete',
}

Expand All @@ -271,4 +265,5 @@ def _update_user_profile(self, user: settings.AUTH_USER_MODEL):
'metadata',
updates=updates,
),
submissions_suspended=False,
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class Migration(migrations.Migration):

dependencies = [
('logger', '0028_add_user_to_daily_submission_counters'),
('main', '0012_add_validate_password_flag_to_profile'),
('main', '0017_userprofile_submissions_suspended'),
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
]

# We don't do anything when migrating in reverse
Expand Down
2 changes: 1 addition & 1 deletion kobo/apps/openrosa/apps/logger/models/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def check_active(self, force):
# wrapped in try/except
UserProfile = apps.get_model('main', 'UserProfile') # noqa - Avoid circular imports
if profile := UserProfile.objects.filter(user=self.xform.user).first():
if profile.metadata.get('submissions_suspended', False):
if profile.submissions_suspended:
raise TemporarilyUnavailableError()
return

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.15 on 2024-09-02 21:41

from django.db import migrations, models
import kobo.apps.openrosa.apps.main.models.meta_data
import kpi.deployment_backends.kc_access.storage
import kpi.fields.file


class Migration(migrations.Migration):

dependencies = [
('main', '0016_drop_old_restservice_tables'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='submissions_suspended',
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions kobo/apps/openrosa/apps/main/models/user_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class UserProfile(models.Model):
metadata = models.JSONField(default=dict, blank=True)
is_mfa_active = LazyDefaultBooleanField(default=False)
validated_password = models.BooleanField(default=True)
submissions_suspended = models.BooleanField(default=False)

class Meta:
app_label = 'main'
Expand Down
36 changes: 0 additions & 36 deletions kobo/apps/organizations/migrations/0005_add_cached_usage_data.py

This file was deleted.

31 changes: 0 additions & 31 deletions kobo/apps/organizations/migrations/0006_auto_20240412_2049.py

This file was deleted.

23 changes: 0 additions & 23 deletions kobo/apps/organizations/migrations/0007_auto_20240415_1659.py

This file was deleted.

28 changes: 16 additions & 12 deletions kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@

class Organization(AbstractOrganization):
id = KpiUidField(uid_prefix='org', primary_key=True)
asr_seconds_limit = models.PositiveIntegerField(blank=True, null=True, default=None)
mt_character_limit = models.PositiveIntegerField(blank=True, null=True, default=None)
usage_updated = models.DateTimeField(blank=True, null=True, default=None)

@property
def email(self):
Expand Down Expand Up @@ -84,27 +81,34 @@ def canceled_subscription_billing_cycle_anchor(self):

return None

def update_usage_cache(self, service_usage: dict):
def get_cached_usage(self, usage_key: str):
if not (billing_details := self.active_subscription_billing_details()):
return None

interval = billing_details['recurring_interval']
self.asr_seconds_limit = service_usage['total_nlp_usage'][f'asr_seconds_current_{interval}']
self.mt_character_limit = service_usage['total_nlp_usage'][f'mt_characters_current_{interval}']
self.usage_updated = timezone.now()
return self.save()
service_usage = ServiceUsageSerializer(
get_database_user(request.user),
context=context,
).data

cached_usage = {
'asr_seconds': service_usage['total_nlp_usage'][f'asr_seconds_current_{interval}'],
'mt_character': service_usage['total_nlp_usage'][f'mt_characters_current_{interval}'],
}

return cached_usage[usage_key]

@cache_for_request
def is_organization_over_plan_limit(self, limit_type: UsageType) -> Union[bool, None]:
"""
Check if an organization is over their plan's limit for a given usage type
Returns None if Stripe isn't enabled or the limit status couldn't be determined
"""

if not settings.STRIPE_ENABLED:
return None
if timezone.now() - self.usage_updated > ORGANIZATION_USAGE_MAX_CACHE_AGE:
# TODO: re-fetch service usage data if stale
pass
cached_usage = self.serializable_value(f'{USAGE_LIMIT_MAP[limit_type]}_limit')

cached_usage = self.get_cached_usage(USAGE_LIMIT_MAP[limit_type])
stripe_key = f'{USAGE_LIMIT_MAP_STRIPE[limit_type]}_limit'
current_limit = Organization.objects.filter(
id=self.id,
Expand Down
13 changes: 13 additions & 0 deletions kobo/apps/organizations/tests/test_organizations_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import time
from datetime import datetime

from django.urls import reverse
from django.utils.http import parse_http_date
from model_bakery import baker
from rest_framework import status

Expand Down Expand Up @@ -72,3 +76,12 @@ def test_update(self):
org_user = self.organization.add_user(user=user)
res = self.client.patch(self.url_detail, data)
self.assertEqual(res.status_code, 403)

def test_service_usage_date_header(self):
self._insert_data()
self.client.get(self.url_detail + 'service_usage/')
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
time.sleep(3)
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
response = self.client.get(self.url_detail + 'service_usage/')
last_updated_date = parse_http_date(response.headers["Date"])
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
now = datetime.now().timestamp()
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
assert (now - last_updated_date) > 3
18 changes: 10 additions & 8 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import QuerySet
from django.utils.decorators import method_decorator
from django.utils.http import http_date
from django.views.decorators.cache import cache_page
from django_dont_vary_on.decorators import only_vary_on
from kpi import filters
Expand All @@ -24,10 +25,6 @@
from ..stripe.constants import ACTIVE_STRIPE_STATUSES


@method_decorator(cache_page(settings.ENDPOINT_CACHE_DURATION), name='service_usage')
# django uses the Vary header in its caching, and each middleware can potentially add more Vary headers
# we use this decorator to remove any Vary headers except 'origin' (we don't want to cache between different installs)
@method_decorator(only_vary_on('Origin'), name='service_usage')
class OrganizationViewSet(viewsets.ModelViewSet):
"""
Organizations are groups of users with assigned permissions and configurations
Expand Down Expand Up @@ -88,6 +85,7 @@ def service_usage(self, request, pk=None, *args, **kwargs):
> "current_month_start": {string (date), ISO format},
> "current_year_start": {string (date), ISO format},
> "billing_period_end": {string (date), ISO format}|{None},
> "last_updated": {string (date), ISO format},
> }
### CURRENT ENDPOINT
"""
Expand All @@ -101,11 +99,15 @@ def service_usage(self, request, pk=None, *args, **kwargs):
get_database_user(request.user),
context=context,
)
response = Response(data=serializer.data)
response = Response(
data=serializer.data,
headers={
'Date': http_date(
serializer.calculator.get_last_updated().timestamp()
)
},
)

# update the cached usage data only if we successfully got results for this organization
if (response.status_code == 200) and (organization := context.get('organization')):
organization.update_usage_cache(serializer.data)
return response

@action(detail=True, methods=['get'])
Expand Down
3 changes: 2 additions & 1 deletion kobo/apps/project_ownership/tests/api/v2/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from constance.test import override_config
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.utils import timezone
from mock import patch, MagicMock
from rest_framework import status
Expand All @@ -13,7 +14,6 @@
InviteStatusChoices,
Transfer,
)
from kobo.apps.project_ownership.tests.utils import MockServiceUsageSerializer
from kobo.apps.trackers.utils import update_nlp_counter

from kpi.constants import PERM_VIEW_ASSET
Expand Down Expand Up @@ -432,6 +432,7 @@ def test_account_usage_transferred_to_new_user(self):
assert response.status_code == status.HTTP_201_CREATED

# someuser should have no usage reported anymore
cache.clear()
Guitlle marked this conversation as resolved.
Show resolved Hide resolved
response = self.client.get(service_usage_url)
assert response.data['total_nlp_usage'] == expected_empty_data['total_nlp_usage']
assert response.data['total_storage_bytes'] == expected_empty_data['total_storage_bytes']
Expand Down
Loading
Loading