diff --git a/core/migrations/0039_alter_user_managers.py b/core/migrations/0039_alter_user_managers.py new file mode 100644 index 00000000..3073bbe6 --- /dev/null +++ b/core/migrations/0039_alter_user_managers.py @@ -0,0 +1,15 @@ +# Generated by Django 4.2.16 on 2024-10-06 14:52 + +from django.db import migrations + +import core.models + + +class Migration(migrations.Migration): + dependencies = [("core", "0038_alter_preferences_receive_weekmail")] + + operations = [ + migrations.AlterModelManagers( + name="user", managers=[("objects", core.models.CustomUserManager())] + ) + ] diff --git a/core/models.py b/core/models.py index 646cbca8..36d0902d 100644 --- a/core/models.py +++ b/core/models.py @@ -27,15 +27,12 @@ import importlib import logging import os import unicodedata -from datetime import date, timedelta +from datetime import timedelta from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional, Self from django.conf import settings -from django.contrib.auth.models import ( - AbstractBaseUser, - UserManager, -) +from django.contrib.auth.models import AbstractBaseUser, UserManager from django.contrib.auth.models import ( AnonymousUser as AuthAnonymousUser, ) @@ -51,15 +48,18 @@ from django.core.cache import cache from django.core.exceptions import PermissionDenied, ValidationError from django.core.mail import send_mail from django.db import models, transaction +from django.db.models import Exists, OuterRef, Q from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.html import escape +from django.utils.timezone import localdate, now from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField -from pydantic.v1 import NonNegativeInt if TYPE_CHECKING: + from pydantic import NonNegativeInt + from club.models import Club @@ -91,15 +91,15 @@ class Group(AuthGroup): class Meta: ordering = ["name"] - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse("core:group_list") - def save(self, *args, **kwargs): + def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) cache.set(f"sith_group_{self.id}", self) cache.set(f"sith_group_{self.name.replace(' ', '_')}", self) - def delete(self, *args, **kwargs): + def delete(self, *args, **kwargs) -> None: super().delete(*args, **kwargs) cache.delete(f"sith_group_{self.id}") cache.delete(f"sith_group_{self.name.replace(' ', '_')}") @@ -164,9 +164,9 @@ class RealGroup(Group): proxy = True -def validate_promo(value): +def validate_promo(value: int) -> None: start_year = settings.SITH_SCHOOL_START_YEAR - delta = (date.today() + timedelta(days=180)).year - start_year + delta = (localdate() + timedelta(days=180)).year - start_year if value < 0 or delta < value: raise ValidationError( _("%(value)s is not a valid promo (between 0 and %(end)s)"), @@ -174,7 +174,7 @@ def validate_promo(value): ) -def get_group(*, pk: int = None, name: str = None) -> Optional[Group]: +def get_group(*, pk: int = None, name: str = None) -> Group | None: """Search for a group by its primary key or its name. Either one of the two must be set. @@ -216,6 +216,31 @@ def get_group(*, pk: int = None, name: str = None) -> Optional[Group]: return group +class UserQuerySet(models.QuerySet): + def filter_inactive(self) -> Self: + from counter.models import Refilling, Selling + from subscription.models import Subscription + + threshold = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + subscriptions = Subscription.objects.filter( + member_id=OuterRef("pk"), subscription_end__gt=localdate(threshold) + ) + refills = Refilling.objects.filter( + customer__user_id=OuterRef("pk"), date__gt=threshold + ) + purchases = Selling.objects.filter( + customer__user_id=OuterRef("pk"), date__gt=threshold + ) + return self.exclude( + Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases)) + ) + + +class CustomUserManager(UserManager.from_queryset(UserQuerySet)): + # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers + pass + + class User(AbstractBaseUser): """Defines the base user class, useable in every app. @@ -373,36 +398,41 @@ class User(AbstractBaseUser): ) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) - objects = UserManager() + objects = CustomUserManager() USERNAME_FIELD = "username" - def promo_has_logo(self): - return Path( - settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png" - ).exists() - - def has_module_perms(self, package_name): - return self.is_active - - def has_perm(self, perm, obj=None): - return self.is_active and self.is_superuser - - def get_absolute_url(self): - return reverse("core:user_profile", kwargs={"user_id": self.pk}) - def __str__(self): return self.get_display_name() - def to_dict(self): - return self.__dict__ + def save(self, *args, **kwargs): + with transaction.atomic(): + if self.id: + old = User.objects.filter(id=self.id).first() + if old and old.username != self.username: + self._change_username(self.username) + super().save(*args, **kwargs) + + def get_absolute_url(self) -> str: + return reverse("core:user_profile", kwargs={"user_id": self.pk}) + + def promo_has_logo(self) -> bool: + return Path( + settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png" + ).exists() + + def has_module_perms(self, package_name: str) -> bool: + return self.is_active + + def has_perm(self, perm: str, obj: Any = None) -> bool: + return self.is_active and self.is_superuser @cached_property - def was_subscribed(self): + def was_subscribed(self) -> bool: return self.subscriptions.exists() @cached_property - def is_subscribed(self): + def is_subscribed(self) -> bool: s = self.subscriptions.filter( subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now() ) @@ -542,17 +572,6 @@ class User(AbstractBaseUser): ) return age - def save(self, *args, **kwargs): - create = False - with transaction.atomic(): - if self.id: - old = User.objects.filter(id=self.id).first() - if old and old.username != self.username: - self._change_username(self.username) - else: - create = True - super().save(*args, **kwargs) - def make_home(self): if self.home is None: home_root = SithFile.objects.filter(parent=None, name="users").first() diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 9794b9f6..5454a302 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -1,15 +1,18 @@ from datetime import timedelta import pytest +from django.conf import settings from django.core.management import call_command from django.test import Client, TestCase from django.urls import reverse from django.utils.timezone import now from model_bakery import baker, seq -from model_bakery.recipe import Recipe +from model_bakery.recipe import Recipe, related -from core.baker_recipes import subscriber_user +from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import User +from counter.models import Counter, Refilling, Selling +from subscription.models import Subscription class TestSearchUsers(TestCase): @@ -111,3 +114,37 @@ def test_user_account_not_found(client: Client): ) ) assert res.status_code == 404 + + +class TestFilterInactive(TestCase): + @classmethod + def setUpTestData(cls): + time_active = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + timedelta(days=1) + time_inactive = time_active - timedelta(days=3) + very_old_subscriber = old_subscriber_user.extend( + subscriptions=related(Recipe(Subscription, subscription_end=time_inactive)) + ) + counter, seller = baker.make(Counter), baker.make(User) + sale_recipe = Recipe( + Selling, + counter=counter, + club=counter.club, + seller=seller, + is_validated=True, + ) + + cls.users = [ + baker.make(User), + subscriber_user.make(), + old_subscriber_user.make(), + *very_old_subscriber.make(_quantity=3), + ] + sale_recipe.make(customer=cls.users[3].customer, date=time_active) + baker.make( + Refilling, customer=cls.users[4].customer, date=time_active, counter=counter + ) + sale_recipe.make(customer=cls.users[5].customer, date=time_inactive) + + def test_filter_inactive(self): + res = User.objects.filter(id__in=[u.id for u in self.users]).filter_inactive() + assert list(res) == [self.users[0], self.users[5]] diff --git a/counter/views.py b/counter/views.py index 50bf02b0..e5a64bf1 100644 --- a/counter/views.py +++ b/counter/views.py @@ -16,6 +16,7 @@ import re from datetime import datetime, timedelta from datetime import timezone as tz from http import HTTPStatus +from typing import TYPE_CHECKING from urllib.parse import parse_qs from django import forms @@ -49,7 +50,6 @@ from django.views.generic.edit import ( ) from accounting.models import CurrencyField -from core.models import User from core.utils import get_semester_code, get_start_of_semester from core.views import CanEditMixin, CanViewMixin, TabedViewMixin from core.views.forms import LoginForm @@ -78,6 +78,9 @@ from counter.models import ( ) from counter.utils import is_logged_in_counter +if TYPE_CHECKING: + from core.models import User + class CounterAdminMixin(View): """Protect counter admin section.""" diff --git a/sith/settings.py b/sith/settings.py index cd70f49b..a4eca6b3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -40,6 +40,7 @@ import sys from pathlib import Path import sentry_sdk +from dateutil.relativedelta import relativedelta from django.utils.translation import gettext_lazy as _ from sentry_sdk.integrations.django import DjangoIntegration @@ -495,6 +496,9 @@ SITH_ECOCUP_LIMIT = 3 # Defines pagination for cash summary SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 +SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2) +"""Time before which a user account is considered inactive""" + # Defines which product type is the refilling type, and thus increases the account amount SITH_COUNTER_PRODUCTTYPE_REFILLING = 3