Compare commits

..

1 Commits

Author SHA1 Message Date
imperosol
32f63fb57f include only subscribed users and their direct relations in the galaxy 2025-09-11 19:49:04 +02:00
12 changed files with 72 additions and 356 deletions

View File

@@ -153,9 +153,11 @@ form {
margin-bottom: 1rem;
}
.row > label {
.row {
label {
margin: unset;
}
}
// ------------- LABEL
label, legend {

View File

@@ -115,7 +115,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValidationError(_("Ensure this timestamp is set in the future"))
raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):

View File

@@ -1,21 +1,13 @@
import json
import math
from django import forms
from django.db.models import Q
from django.forms.formsets import DELETION_FIELD_NAME, BaseFormSet
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
SelectDate,
SelectDateTime,
)
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
@@ -30,9 +22,7 @@ from counter.models import (
Product,
Refilling,
ReturnableProduct,
ScheduledProductAction,
StudentCard,
get_product_actions,
)
from counter.widgets.ajax_select import (
AutoCompleteSelectMultipleCounter,
@@ -168,110 +158,6 @@ class CounterEditForm(forms.ModelForm):
}
class ScheduledProductActionForm(forms.Form):
"""Form for automatic product archiving.
The `save` method will update or create tasks using celery-beat.
"""
required_css_class = "required"
prefix = "product-action-form"
task = forms.fields_for_model(
ScheduledProductAction,
["task"],
widgets={"task": forms.RadioSelect(choices=get_product_actions)},
labels={"task": _("Action")},
help_texts={"task": ""},
)["task"]
trigger_at = FutureDateTimeField(
label=_("Date and time of action"), widget=SelectDateTime
)
counters = forms.ModelMultipleChoiceField(
label=_("New counters"),
help_text=_("The selected counters will replace the current ones"),
required=False,
widget=AutoCompleteSelectMultipleCounter,
queryset=Counter.objects.all(),
)
def __init__(self, *args, product: Product, **kwargs):
self.product = product
super().__init__(*args, **kwargs)
def save(self):
if not self.changed_data:
return
task = self.cleaned_data["task"]
instance = ScheduledProductAction.objects.filter(
product=self.product, task=task
).first()
if not instance:
instance = ScheduledProductAction(
product=self.product,
clocked=ClockedSchedule.objects.create(
clocked_time=self.cleaned_data["trigger_at"]
),
)
elif "trigger_at" in self.changed_data:
instance.clocked.clocked_time = self.cleaned_data["trigger_at"]
instance.clocked.save()
instance.task = task
instance.kwargs = json.dumps(
{
"product_id": self.product.id,
"counters": [c.id for c in self.cleaned_data["counters"]],
}
)
instance.name = f"{self.cleaned_data['task']} - {self.product}"
instance.enabled = True
instance.save()
return instance
def delete(self):
instance = ScheduledProductAction.objects.get(
product=self.product, task=self.cleaned_data["task"]
)
# if the clocked object linked to the task has no other task,
# delete it and let the task be deleted in cascade,
# else delete only the task.
if instance.clocked.periodictask_set.count() > 1:
return instance.clocked.delete()
return instance.delete()
class BaseScheduledProductActionFormSet(BaseFormSet):
def __init__(self, *args, product: Product, **kwargs):
initial = [
{
"task": action.task,
"trigger_at": action.clocked.clocked_time,
"counters": json.loads(action.kwargs).get("counters"),
}
for action in product.scheduled_actions.filter(enabled=True)
]
kwargs["initial"] = initial
kwargs["form_kwargs"] = {"product": product}
super().__init__(*args, **kwargs)
def save(self):
for form in self.forms:
if form.cleaned_data.get(DELETION_FIELD_NAME, False):
form.delete()
else:
form.save()
ScheduledProductActionFormSet = forms.formset_factory(
ScheduledProductActionForm,
formset=BaseScheduledProductActionFormSet,
absolute_max=None,
can_delete=True,
can_delete_extra=False,
extra=2,
)
class ProductEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -313,21 +199,22 @@ class ProductEditForm(forms.ModelForm):
queryset=Counter.objects.all(),
)
def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
)
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.action_formset.save()
if self.fields["counters"].initial:
# Remove the product from all counter it was added to
# It will then only be added to selected counters
for counter in self.fields["counters"].initial:
counter.products.remove(self.instance)
counter.save()
for counter in self.cleaned_data["counters"]:
counter.products.add(self.instance)
counter.save()
return ret

View File

@@ -1,43 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-14 11:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0031_alter_counter_options"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.CreateModel(
name="ScheduledProductAction",
fields=[
(
"periodictask_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="django_celery_beat.periodictask",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scheduled_actions",
to="counter.product",
),
),
],
options={
"verbose_name": "Product scheduled action",
"ordering": ["-clocked"],
},
bases=("django_celery_beat.periodictask",),
),
]

View File

@@ -34,7 +34,6 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField
from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField
@@ -446,8 +445,7 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
@@ -1364,30 +1362,3 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}"
)
def get_product_actions():
return [
("counter.tasks.archive_product", _("Archiving")),
("counter.tasks.change_counters", _("Counters change")),
]
class ScheduledProductAction(PeriodicTask):
"""Extension of celery-beat tasks dedicated to perform actions on Product."""
product = models.ForeignKey(
Product, related_name="scheduled_actions", on_delete=models.CASCADE
)
class Meta:
verbose_name = _("Product scheduled action")
ordering = ["-clocked"]
def __init__(self, *args, **kwargs):
self._meta.get_field("task").choices = get_product_actions()
super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
self.one_off = True # A product action should occur one time only
return super().save(*args, **kwargs)

View File

@@ -1,20 +0,0 @@
# Create your tasks here
from celery import shared_task
from counter.models import Counter, Product
@shared_task
def archive_product(*, product_id: int):
product = Product.objects.get(id=product_id)
product.archived = True
product.save()
product.counters.clear()
@shared_task
def change_counters(*, product_id: int, counters: list[int]):
product = Product.objects.get(id=product_id)
counters = Counter.objects.filter(id__in=counters)
product.counters.set(counters)

View File

@@ -1,55 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}
<form method="post">
{% csrf_token %}
{{ form.as_p() }}
<br />
<h3>{% trans %}Automatic actions{% endtrans %}</h3>
<p class="margin-bottom">
<em>
{%- trans trimmed -%}
Automatic actions allows to schedule product changes
ahead of time.
{%- endtrans -%}
</em>
</p>
{{ form.action_formset.management_form }}
{% for action_form in form.action_formset.forms %}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
<div class="row gap-2x margin-bottom">
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>
{{ action_form.trigger_at.as_field_group() }}
</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{% if action_form.DELETE %}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{% endif %}
</fieldset>
{% if not loop.last %}
<hr class="margin-bottom">
{% endif %}
{% endfor %}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -147,7 +147,7 @@ class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
model = Product
form_class = ProductEditForm
template_name = "counter/product_form.jinja"
template_name = "core/create.jinja"
current_tab = "products"
@@ -157,7 +157,7 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
model = Product
form_class = ProductEditForm
pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja"
template_name = "core/edit.jinja"
current_tab = "products"

View File

@@ -45,8 +45,9 @@ class Command(BaseCommand):
"verbosity level should be between 0 and 2 included", stacklevel=2
)
if options["verbosity"] == 2:
if options["verbosity"] >= 2:
logger.setLevel(logging.DEBUG)
logging.getLogger("django.db.backends").setLevel(logging.DEBUG)
elif options["verbosity"] == 1:
logger.setLevel(logging.INFO)
else:
@@ -59,6 +60,3 @@ class Command(BaseCommand):
Galaxy.objects.filter(state__isnull=True).delete()
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
if options["verbosity"] > 2:
for q in connection.queries:
logger.debug(q)

View File

@@ -31,13 +31,14 @@ from collections import defaultdict
from typing import NamedTuple, TypedDict
from django.db import models
from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate
from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from club.models import Membership
from core.models import User
from sas.models import PeoplePictureRelation, Picture
from subscription.models import Subscription
class GalaxyStar(models.Model):
@@ -198,8 +199,16 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
User.objects.filter(is_subscriber_viewable=True)
.exclude(subscriptions=None)
.annotate(
pictures_count=Count("pictures"),
is_active_in_galaxy=Exists(
Subscription.objects.filter(
member=OuterRef("id"), subscription_end__gt=now()
)
),
)
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@@ -290,9 +299,9 @@ class Galaxy(models.Model):
31/12/2022 (also two years, but with an offset of one year), then their
club score is 365.
"""
memberships = user.memberships.only("start_date", "end_date", "club_id")
memberships = user.memberships.values("start_date", "end_date", "club_id")
result = defaultdict(int)
now = localdate()
today = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
@@ -300,23 +309,23 @@ class Galaxy(models.Model):
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
start_date__lte=membership["start_date"],
end_date__gte=membership["start_date"],
)
| Q( # start2 <= start1 <= now
start_date__lte=membership.start_date, end_date=None
| Q( # start2 <= start1 <= today
start_date__lte=membership["start_date"], end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
start_date__gte=membership["start_date"],
start_date__lte=membership["end_date"] or today,
),
club_id=membership.club_id,
club_id=membership["club_id"],
)
.only("start_date", "end_date", "user_id")
)
for other in common_memberships:
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
start = max(membership["start_date"], other.start_date)
end = min(membership["end_date"] or today, other.end_date or today)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
@@ -382,18 +391,22 @@ class Galaxy(models.Model):
# this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient
rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users)
rulable_users_qs = self.get_rulable_users(picture_count_threshold)
active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count()
rulable_users = list(rulable_users_qs)
user1_count = 0
self.logger.info(
f"{rulable_users_count} citizen have been listed. Starting to rule."
f" {len(rulable_users)} citizens (with {active_users_count} active ones) "
f"have been listed. Starting to rule."
)
self.logger.info("Creating stars for all citizen")
individual_scores = self.compute_individual_scores()
GalaxyStar.objects.bulk_create(
[
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
GalaxyStar(
owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
)
for user in rulable_users
]
)
@@ -405,9 +418,9 @@ class Galaxy(models.Model):
t_global_start = time.time()
while len(rulable_users) > 0:
user1 = rulable_users.pop()
if not user1.is_active_in_galaxy:
continue
user1_count += 1
rulable_users_count2 = len(rulable_users)
star1 = stars[user1.id]
lanes = []
@@ -448,17 +461,20 @@ class Galaxy(models.Model):
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
f"Progression: {user1_count}/{active_users_count} "
f"citizen -- {active_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = rulable_users_count2 // global_avg_speed
eta = len(rulable_users) // global_avg_speed
self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
)
self.logger.info("#" * 60)
t_global_start = time.time()
count, _ = self.stars.filter(Q(lanes1=None) & Q(lanes2=None)).delete()
self.logger.info(f"{count} orphan stars have been trimmed.")
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
# should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
old_galaxies_pks = list(

View File

@@ -122,7 +122,7 @@ class TestGalaxyModel(TestCase):
self.com,
]
with self.assertNumQueries(44):
with self.assertNumQueries(38):
while len(users) > 0:
user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
@@ -150,7 +150,7 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable.
"""
galaxy = Galaxy.objects.create()
with self.assertNumQueries(39):
with self.assertNumQueries(36):
galaxy.rule(0) # We want everybody here

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-14 17:09+0200\n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -117,7 +117,7 @@ msgstr "S'abonner"
msgid "Remove"
msgstr "Retirer"
#: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
#: club/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
@@ -561,7 +561,6 @@ msgstr ""
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja
@@ -1714,8 +1713,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts"
@@ -2158,6 +2157,10 @@ msgstr ""
msgid "Page history"
msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja
msgid "Page properties"
msgstr "Propriétés de la page"
@@ -2893,18 +2896,6 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
#: counter/forms.py
msgid "New counters"
msgstr "Nouveaux comptoirs"
#: counter/forms.py
msgid "The selected counters will replace the current ones"
msgstr "Les comptoirs sélectionnés remplaceront les comptoirs actuels"
#: counter/forms.py
msgid ""
"Describe the product. If it's an event's click, give some insights about it, "
@@ -3239,18 +3230,6 @@ msgid "The returnable product cannot be the same as the returned one"
msgstr ""
"Le produit consigné ne peut pas être le même que le produit de déconsigne"
#: counter/models.py
msgid "Archiving"
msgstr "Archivage"
#: counter/models.py
msgid "Counters change"
msgstr "Changement des comptoirs"
#: counter/models.py
msgid "Product scheduled action"
msgstr "Actions sur produit planifiées"
#: counter/templates/counter/activity.jinja
#, python-format
msgid "%(counter_name)s activity"
@@ -3569,25 +3548,6 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
msgid "Product creation"
msgstr "Création de produit"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions"
msgstr "Actions automatiques"
#: counter/templates/counter/product_form.jinja
msgid "Automatic actions allows to schedule product changes ahead of time."
msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance."
#: counter/templates/counter/product_list.jinja
msgid "Product list"
msgstr "Liste des produits"