Skip to content

Commit

Permalink
feat(user-profile): async profile revalidation
Browse files Browse the repository at this point in the history
  • Loading branch information
thejoeejoee committed Nov 13, 2023
1 parent 70e4e5a commit bda2e77
Show file tree
Hide file tree
Showing 11 changed files with 88 additions and 28 deletions.
4 changes: 4 additions & 0 deletions fiesta/apps/accounts/middleware/user_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ...accounts.models import User, UserProfile
from ...plugins.utils import target_plugin_app_from_resolver_match
from ...sections.middleware import UserMembershipMiddleware
from ..services.user_profile_state_synchronizer import synchronizer


class UserProfileMiddleware:
Expand Down Expand Up @@ -44,6 +45,9 @@ def process_view(cls, request: HttpRequest, view_func, view_args, view_kwargs):
login_url=reverse(cls.FINISH_PROFILE_URL_NAME),
)

if profile.enforce_revalidation:
synchronizer.revalidate_user_profile(profile=profile)

if profile.state != profile.State.COMPLETE:
# profile is not complete, so redirect to profile page with next= parameter
return redirect_to_login(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2023-11-13 13:41

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0019_remove_userprofile_home_university_or_faculty_and_more'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='enforce_revalidation',
field=models.BooleanField(default=False, verbose_name='enforce revalidation of profile'),
),
]
9 changes: 7 additions & 2 deletions fiesta/apps/accounts/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,20 @@ class Preferences(enum.Flag):
default=State.INCOMPLETE,
)

enforce_revalidation = models.BooleanField(
verbose_name=_("enforce revalidation of profile"),
default=False,
)

class Meta:
verbose_name = _("user profile")
verbose_name_plural = _("user profiles")

@hook(AFTER_SAVE)
def on_save(self):
from apps.accounts.services import UserProfileStateSynchronizer
from apps.accounts.services.user_profile_state_synchronizer import synchronizer

UserProfileStateSynchronizer.on_user_profile_update(profile=self)
synchronizer.revalidate_user_profile(profile=self)

def __str__(self):
return (
Expand Down
51 changes: 39 additions & 12 deletions fiesta/apps/accounts/services/user_profile_state_synchronizer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import functools
import logging
from contextlib import contextmanager

from django.core.exceptions import ValidationError
from django.forms import model_to_dict
Expand All @@ -12,14 +14,26 @@
logger = logging.getLogger(__name__)


def _if_enabled(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
if self.ENABLED:
return func(self, *args, **kwargs)
return None

return wrapper


class UserProfileStateSynchronizer:
"""
Defines synchronization behaviour for `UserProfile.state` attribute
regarding AccountsConfiguration for each Section.
"""

@staticmethod
def on_user_profile_update(profile: UserProfile):
ENABLED = True

@_if_enabled
def revalidate_user_profile(self, profile: UserProfile):
"""
User profile of user was updated, so it's needed to resolve new state for profile.
Expand Down Expand Up @@ -65,31 +79,44 @@ def on_user_profile_update(profile: UserProfile):
profile.state = final_state
profile.save(update_fields=["state"], skip_hooks=True)

@classmethod
def on_accounts_configuration_update(cls, conf: SectionsConfiguration):
@_if_enabled
def on_accounts_configuration_update(self, conf: SectionsConfiguration):
"""
After change of Accounts configuration, checks all COMPLETED profiles if they're fine for new configuration.
If not, profile is set to UNCOMPLETED.
Implements only change to more strict conditions, which is O(n).
Implementation with less strict conditions leads to O(n^2).
"""
# for each connected user profile
for profile in UserProfile.objects.filter(
if not self.ENABLED:
return

# for each connected user profile enforce revalidation
UserProfile.objects.filter(
user__memberships__section__plugins__configuration=conf,
state=UserProfile.State.COMPLETE,
):
cls.on_user_profile_update(profile=profile)
).update(enforce_revalidation=True)

@classmethod
def on_membership_update(cls, membership: SectionMembership):
@_if_enabled
def on_membership_update(self, membership: SectionMembership):
if not self.ENABLED:
return None
try:
# membership could be created for user without profile (usually the first one membership)
profile: UserProfile = membership.user.profile
except UserProfile.DoesNotExist:
return None

return cls.on_user_profile_update(profile=profile)
return self.revalidate_user_profile(profile=profile)

@contextmanager
def without_profile_revalidation(self):
prev = self.ENABLED
self.ENABLED = False
yield
self.ENABLED = prev


synchronizer = UserProfileStateSynchronizer()

__all__ = ["UserProfileStateSynchronizer"]
__all__ = ["synchronizer"]
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ <h2 class="card-title">{% trans "👤 Profile Details" %}</h2>
{{ object.user.profile.nationality|default:"-" }}
</td>
</tr>
<tr>
<th>{% trans "Gender" %}</th>
<td>{{ object.user.profile.get_gender_display }}</td>
</tr>
</table>
<table class="table table-zebra w-1/2">
<tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def setUp(self):
app_label="accounts",
)
self.country = "CZ"
self.synchronizer = UserProfileStateSynchronizer()

def test_required_attr(self):
"""
Expand All @@ -48,7 +49,7 @@ def test_required_attr(self):
self.configuration.save()

# run synchronizer
UserProfileStateSynchronizer.on_user_profile_update(self.profile)
self.synchronizer.revalidate_user_profile(self.profile)
# should be incomplete
self.assertEqual(self.profile.state, UserProfile.State.INCOMPLETE)

Expand All @@ -63,7 +64,7 @@ def test_optional_attr(self):
self.configuration.save()

# run synchronizer
UserProfileStateSynchronizer.on_user_profile_update(self.profile)
self.synchronizer.revalidate_user_profile(self.profile)
# should stay complete
self.assertEqual(self.profile.state, UserProfile.State.COMPLETE)

Expand Down Expand Up @@ -92,7 +93,7 @@ def test_sync_keeps_required_value(self):
self.configuration.save()

# run synchronizer
UserProfileStateSynchronizer.on_user_profile_update(self.profile)
self.synchronizer.revalidate_user_profile(self.profile)

# should stay complete with filled nationality
self.assertEqual(self.profile.state, UserProfile.State.COMPLETE)
Expand All @@ -109,7 +110,7 @@ def test_syncer_keeps_not_required_value(self):
self.configuration.save()

# run synchronizer
UserProfileStateSynchronizer.on_user_profile_update(self.profile)
self.synchronizer.revalidate_user_profile(self.profile)

# should stay complete with filled nationality
self.assertEqual(self.profile.state, UserProfile.State.COMPLETE)
Expand All @@ -126,7 +127,7 @@ def test_syncer_keeps_not_wanted_value(self):
self.configuration.save()

# run synchronizer
UserProfileStateSynchronizer.on_user_profile_update(self.profile)
self.synchronizer.revalidate_user_profile(self.profile)

# should stay complete with filled nationality
self.assertEqual(self.profile.state, UserProfile.State.COMPLETE)
Expand Down
4 changes: 2 additions & 2 deletions fiesta/apps/esnaccounts/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from apps.accounts.models import User, UserProfile
from apps.accounts.models.profile import user_profile_picture_storage
from apps.accounts.services import UserProfileStateSynchronizer
from apps.accounts.services.user_profile_state_synchronizer import synchronizer
from apps.esnaccounts import logger
from apps.sections.models import Section, SectionMembership
from apps.sections.models.services.section_plugins_reconciler import SectionPluginsReconciler
Expand Down Expand Up @@ -127,7 +127,7 @@ def update_section_membership(
picture_url=sa.extra_data.get("picture"),
)

UserProfileStateSynchronizer.on_user_profile_update(user_profile)
synchronizer.revalidate_user_profile(user_profile)

@staticmethod
def sync_profile_picture(profile: UserProfile, picture_url: str | None):
Expand Down
4 changes: 2 additions & 2 deletions fiesta/apps/fiestatables/views/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ def get_export_filename(self, export_format):


class FiestaSingleTableMixin(HtmxTableMixin, SingleTableMixin, FiestaExportMixin):
paginate_by = 20
paginate_by = 15
paginator_class = LazyPaginator
template_name = "fiestatables/page.html"


class FiestaMultiTableMixin(HtmxTableMixin, MultiTableMixin, FiestaExportMixin):
paginate_by = 20
paginate_by = 15
paginator_class = LazyPaginator
template_name = "fiestatables/page.html"

Expand Down
4 changes: 2 additions & 2 deletions fiesta/apps/sections/models/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ class Meta:

@hook(AFTER_SAVE)
def on_save(self):
from apps.accounts.services import UserProfileStateSynchronizer
from apps.accounts.services.user_profile_state_synchronizer import synchronizer

UserProfileStateSynchronizer.on_accounts_configuration_update(conf=self)
synchronizer.on_accounts_configuration_update(conf=self)


__all__ = ["SectionsConfiguration"]
4 changes: 2 additions & 2 deletions fiesta/apps/sections/models/membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,12 @@ def available_plugins_filter(self) -> Q:
@hook(AFTER_CREATE)
@hook(AFTER_SAVE, when_any=["role", "state"], has_changed=True)
def update_user_profile_state(self):
from apps.accounts.services import UserProfileStateSynchronizer
from apps.accounts.services.user_profile_state_synchronizer import synchronizer

# revalidate user profile on change of membership --> e.g., if membership is revoked,
# the user profile is not validated by that section configuration anymore

UserProfileStateSynchronizer.on_membership_update(membership=self)
synchronizer.on_membership_update(membership=self)

@property
def is_international(self):
Expand Down
3 changes: 2 additions & 1 deletion fiesta/apps/utils/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from django.db import models
from django.db.models import UUIDField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import CreationDateTimeField, ModificationDateTimeField
from mptt.models import MPTTModel
Expand All @@ -22,7 +23,7 @@ class Meta:
class BaseTimestampedModel(BaseModel):
"""Base model with stored creation and last modification date."""

created = CreationDateTimeField(_("created"))
created = CreationDateTimeField(_("created"), auto_now_add=False, default=timezone.now)
modified = ModificationDateTimeField(_("modified"))

class Meta:
Expand Down

0 comments on commit bda2e77

Please sign in to comment.