select inactive users

This commit is contained in:
imperosol 2024-10-06 13:22:09 +02:00
parent 81a64eed08
commit 6a64e05247
5 changed files with 124 additions and 46 deletions

View File

@ -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())]
)
]

View File

@ -27,15 +27,12 @@ import importlib
import logging import logging
import os import os
import unicodedata import unicodedata
from datetime import date, timedelta from datetime import timedelta
from pathlib import Path 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.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import AbstractBaseUser, UserManager
AbstractBaseUser,
UserManager,
)
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser, AnonymousUser as AuthAnonymousUser,
) )
@ -51,15 +48,18 @@ from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pydantic.v1 import NonNegativeInt
if TYPE_CHECKING: if TYPE_CHECKING:
from pydantic import NonNegativeInt
from club.models import Club from club.models import Club
@ -91,15 +91,15 @@ class Group(AuthGroup):
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
def get_absolute_url(self): def get_absolute_url(self) -> str:
return reverse("core:group_list") return reverse("core:group_list")
def save(self, *args, **kwargs): def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs) super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self) cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", 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) super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}") cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}") cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
@ -164,9 +164,9 @@ class RealGroup(Group):
proxy = True proxy = True
def validate_promo(value): def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR 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: if value < 0 or delta < value:
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"), _("%(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. """Search for a group by its primary key or its name.
Either one of the two must be set. 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 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): class User(AbstractBaseUser):
"""Defines the base user class, useable in every app. """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) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = UserManager() objects = CustomUserManager()
USERNAME_FIELD = "username" 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): def __str__(self):
return self.get_display_name() return self.get_display_name()
def to_dict(self): def save(self, *args, **kwargs):
return self.__dict__ 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 @cached_property
def was_subscribed(self): def was_subscribed(self) -> bool:
return self.subscriptions.exists() return self.subscriptions.exists()
@cached_property @cached_property
def is_subscribed(self): def is_subscribed(self) -> bool:
s = self.subscriptions.filter( s = self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now() subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
) )
@ -542,17 +572,6 @@ class User(AbstractBaseUser):
) )
return age 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): def make_home(self):
if self.home is None: if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first() home_root = SithFile.objects.filter(parent=None, name="users").first()

View File

@ -1,15 +1,18 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker, seq 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 core.models import User
from counter.models import Counter, Refilling, Selling
from subscription.models import Subscription
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@ -111,3 +114,37 @@ def test_user_account_not_found(client: Client):
) )
) )
assert res.status_code == 404 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]]

View File

@ -16,6 +16,7 @@ import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django import forms from django import forms
@ -49,7 +50,6 @@ from django.views.generic.edit import (
) )
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.models import User
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -78,6 +78,9 @@ from counter.models import (
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from core.models import User
class CounterAdminMixin(View): class CounterAdminMixin(View):
"""Protect counter admin section.""" """Protect counter admin section."""

View File

@ -40,6 +40,7 @@ import sys
from pathlib import Path from pathlib import Path
import sentry_sdk import sentry_sdk
from dateutil.relativedelta import relativedelta
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -495,6 +496,9 @@ SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 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 # Defines which product type is the refilling type, and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3 SITH_COUNTER_PRODUCTTYPE_REFILLING = 3