mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-21 21:53:30 +00:00
select inactive users
This commit is contained in:
parent
81a64eed08
commit
6a64e05247
15
core/migrations/0039_alter_user_managers.py
Normal file
15
core/migrations/0039_alter_user_managers.py
Normal 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())]
|
||||||
|
)
|
||||||
|
]
|
105
core/models.py
105
core/models.py
@ -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()
|
||||||
|
@ -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]]
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user