Compare commits

..

5 Commits

Author SHA1 Message Date
imperosol
59ded530ff docs: more details on AI guideline rationals 2025-09-16 10:53:43 +02:00
imperosol
e85d0a2449 docs: AI guideline 2025-09-16 09:51:49 +02:00
thomas girod
b767079c5a Merge pull request #1167 from ae-utbm/page-n+1
Page n+1
2025-09-08 11:28:55 +02:00
imperosol
37961e437b fix: N+1 queries on PageListView 2025-09-04 17:39:17 +02:00
imperosol
b97a1a2e56 improve User.can_view and User.can_edit 2025-09-04 17:38:58 +02:00
12 changed files with 216 additions and 306 deletions

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
return True
return self.is_root
@@ -569,9 +569,15 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
for pk in obj.edit_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
if (
hasattr(obj, "_prefetched_objects_cache")
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj)
@@ -581,9 +587,18 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
for pk in obj.view_groups.values_list("pk", flat=True):
if self.is_in_group(pk=pk):
return True
# if "view_groups" has already been prefetched, use
# the prefetch cache, else fetch only the ids, to make
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
return self.can_edit(obj)
def can_be_edited_by(self, user):
@@ -1384,9 +1399,9 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
return club_root_page is not None and (
self == club_root_page or club_root_page in self.get_parent_list()
return (
self.name == settings.SITH_CLUB_ROOT_PAGE
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
)
@cached_property

View File

@@ -514,10 +514,6 @@ th {
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul {
margin-top: 0;
}

View File

@@ -5,16 +5,12 @@
{% endblock %}
{% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
<h3>{% trans %}Page list{% endtrans %}</h3>
<ul>
{% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -12,7 +12,10 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
@@ -43,6 +46,20 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView):
model = Page
template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView):

View File

@@ -5,7 +5,6 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import NFCTextInput, SelectDate, SelectDateTime
@@ -20,7 +19,6 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
@@ -375,39 +373,3 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month, clubs: list[Club] | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
self.clubs = clubs
if self.clubs is None:
self.clubs = []
invoices = {
i["club_id"]: i["is_validated"]
for i in InvoiceCall.objects.filter(
club__in=self.clubs, month=self.month
).values("club_id", "is_validated")
}
for club in self.clubs:
is_validated = invoices.get(club.id, False)
self.fields[f"club_{club.id}"] = forms.BooleanField(
required=False, initial=is_validated
)
def save(self):
for club in self.clubs:
field_name = f"club_{club.id}"
is_validated = self.cleaned_data.get(field_name, False)
InvoiceCall.objects.update_or_create(
month=self.month, club=club, defaults={"is_validated": is_validated}
)
def get_club_name(self, club_id):
return f"club_{club_id}"

View File

@@ -1,45 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-09 10:24
import django.db.models.deletion
from django.db import migrations, models
import counter.models
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("counter", "0031_alter_counter_options"),
]
operations = [
migrations.CreateModel(
name="InvoiceCall",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"is_validated",
models.BooleanField(default=False, verbose_name="is validated"),
),
("month", counter.models.MonthField(verbose_name="invoice date")),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="club.club"
),
),
],
options={
"verbose_name": "Invoice call",
"verbose_name_plural": "Invoice calls",
},
),
]

View File

@@ -1362,49 +1362,3 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}"
)
class MonthField(models.DateField):
description = _("Year + month field (day forced to 1)")
default_error_messages = {
"invalid": _(
"%(value)s” value has an invalid date format. It must be "
"in YYYY-MM format."
),
"invalid_date": _(
"%(value)s” value has the correct format (YYYY-MM) "
"but it is an invalid date."
),
}
def to_python(self, value):
if isinstance(value, date):
return value.replace(day=1)
if isinstance(value, str):
try:
year, month = map(int, value.split("-"))
return date(year, month, 1)
except (ValueError, TypeError) as err:
raise ValueError(
self.error_messages["invalid"] % {"value": value}
) from err
return super().to_python(value)
class InvoiceCall(models.Model):
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
club = models.ForeignKey(Club, on_delete=models.CASCADE)
month = MonthField(verbose_name=_("invoice date"))
class Meta:
verbose_name = _("Invoice call")
verbose_name_plural = _("Invoice calls")
def __str__(self):
return f"invoice call of {self.month} made by {self.club}"
def save(self, *args, **kwargs):
self.month = self._meta.get_field("month").to_python(self.month)
super().save(*args, **kwargs)

View File

@@ -15,32 +15,24 @@
</select>
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</thead>
<tbody>
{% for i in sums %}
<tr>
<td>{{ i['club__name'] }}</td>
<td>{{ i['selling_sum'] }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
<form method="post" action="">
{% csrf_token %}
<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
<td>{% trans %}Validated{% endtrans %}</td>
</thead>
<tbody>
{% for data in club_data %}
<tr>
<td>{{ data.club.name }}</td>
<td>{{"%.2f"|format(data.sum)}} €</td>
<td>
{{ form[form.get_club_name(data.club.id)] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
<button type="submit">{% trans %}Save validation{% endtrans %}</button>
</form>
{% endblock %}

View File

@@ -1,33 +0,0 @@
from datetime import date
import pytest
from model_bakery import baker
from club.models import Club
from counter.models import InvoiceCall
@pytest.mark.django_db
def test_invoice_date_with_date():
club = baker.make(Club)
invoice = InvoiceCall.objects.create(club=club, month=date(2025, 10, 20))
assert not invoice.is_validated
assert str(invoice) == f"invoice call of {invoice.month} made by {club}"
assert invoice.month == date(2025, 10, 1)
@pytest.mark.django_db
def test_invoice_date_with_string():
club = baker.make(Club)
invoice = InvoiceCall.objects.create(club=club, month="2025-10")
assert invoice.month == date(2025, 10, 1)
@pytest.mark.django_db
def test_invoice_call_invalid_month_string():
club = baker.make(Club)
with pytest.raises(ValueError):
InvoiceCall.objects.create(club=club, month="2025-13")

View File

@@ -12,17 +12,15 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from datetime import timezone as tz
from django.db.models import Exists, F, OuterRef
from django.shortcuts import redirect
from django.db.models import F
from django.utils import timezone
from django.views.generic import TemplateView
from counter.fields import CurrencyField
from counter.forms import InvoiceCallForm
from counter.models import Club, InvoiceCall, Refilling, Selling
from counter.models import Refilling, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@@ -30,30 +28,12 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call"
def get(self, request, *args, **kwargs):
month_str = request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(month_str, "%Y-%m").date()
today = timezone.now().date().replace(day=1)
if start_date > today:
return redirect("counter:invoices_call")
except ValueError:
return redirect("counter:invoices_call")
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""Add sums to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
month_str = self.request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
except ValueError:
return redirect("counter:invoices_call")
if "month" in self.request.GET:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
else:
start_date = datetime(
year=timezone.now().year,
@@ -66,23 +46,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
)
from django.db.models import Case, Sum, When
kwargs["sum_cb"] = Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
).aggregate(amount=Sum(F("amount"), default=0))["amount"]
kwargs["sum_cb"] += Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"]
kwargs["sum_cb"] = sum(
[
r.amount
for r in Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
)
kwargs["sum_cb"] += sum(
[
s.quantity * s.unit_price
for s in Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
)
kwargs["start_date"] = start_date
kwargs["sums"] = list(
kwargs["sums"] = (
Selling.objects.values("club__name")
.annotate(
selling_sum=Sum(
@@ -99,56 +86,4 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
.exclude(selling_sum=None)
.order_by("-selling_sum")
)
club_names = [i["club__name"] for i in kwargs["sums"]]
clubs = Club.objects.filter(name__in=club_names)
invoice_calls = InvoiceCall.objects.filter(month=month_str, club__in=clubs)
invoice_statuses = {ic.club.name: ic.is_validated for ic in invoice_calls}
kwargs["form"] = InvoiceCallForm(clubs=clubs, month=month_str)
kwargs["club_data"] = []
for club in clubs:
selling_sum = next(
(
item["selling_sum"]
for item in kwargs["sums"]
if item["club__name"] == club.name
),
0,
)
kwargs["club_data"].append(
{
"club": club,
"sum": selling_sum,
"validated": invoice_statuses.get(club.name, False),
}
)
return kwargs
def post(self, request, *args, **kwargs):
month_str = request.POST.get("month")
if not month_str:
return self.get(request, *args, **kwargs)
try:
start_date = datetime.strptime(month_str, "%Y-%m")
start_date = date(start_date.year, start_date.month, 1)
except ValueError:
return redirect(request.path)
selling_subquery = Selling.objects.filter(
club=OuterRef("pk"),
date__year=start_date.year,
date__month=start_date.month,
)
clubs = Club.objects.filter(Exists(selling_subquery))
form = InvoiceCallForm(request.POST, clubs=clubs, month=month_str)
if form.is_valid():
form.save()
return redirect(f"{request.path}?month={request.POST.get('month', '')}")

120
docs/explanation/ia.md Normal file
View File

@@ -0,0 +1,120 @@
Cette page expose la politique du Pôle informatique de l'AE
en ce qui concerne l'usage et l'implémentation de systèmes d'IA
dans le cadre de l'AE et du développement de ses outils.
## Cadre
En accord avec le règlement européen sur
l'intelligence artificielle du 13 juin 2024,
nous définissons comme IA :
> Un système basé sur une machine qui est
> conçu pour fonctionner avec différents niveaux d'autonomie
> et qui peut faire preuve d'adaptabilité après son déploiement,
> et qui, pour des objectifs explicites ou implicites, déduit,
> à partir des données qu'il reçoit,
> comment générer des résultats tels que des prédictions,
> du contenu, des recommandations ou des décisions
> qui peuvent influencer des environnements physiques ou virtuels.
Cette définition recouvre toutes les IAs génératives, ce qui inclut
ChatGPT, DeepSeek, Claude, Copilot, Mistral, Llama et autres outils similaires.
## Utilisation dans le développement
!!!abstract
La soumission de code généré par IA est strictement interdite.
Aucune contribution contenant du code généré par IA n'est acceptée.
Toute PR contenant en proportion significative du code duquel
on peut raisonnablement penser qu'il a été généré par IA
ne sera acceptée.
Nous déconseillons également fortement l'usage de tout
recours à un système d'IA dans le processus de développement,
quel que soit son usage (debug, recherche d'information ou autres).
A la place de l'IA, Référez-vous en priorité à la documentation du site,
à celle de Django et à l'aide des autres développeurs.
## Intégration dans le site
L'intégration sur le site AE de systèmes d'IA
et de toute fonctionnalité basée sur des systèmes d'IA
est strictement prohibée, quel qu'en soit l'objectif.
Toute tâche de modération, de génération
ou de détection de contenu ne doit être accomplie
que par des êtres humains ou par des algorithmes
déterministes, testés et compris.
L'usage des données du site a des fins d'entrainement d'IA,
ainsi que la transmission de ces données à un système d'IA
est strictement interdit.
Tout acte de cette nature sera considéré comme une violation
grave de la politique de gestion des données de l'AE.
## Motifs
Le site AE est un programme écrit par des humains, pour des humains.
C'est un logiciel dont la complexité nécessite des connaissances
plus approfondies que ce qui est attendu de la part d'un
étudiant en TC ou en base branche.
À ce titre, l'interdiction de l'IA dans le cadre de son
développement est pensée avant tout dans une optique
de formation des développeurs, de stabilité de la base de code
et de transmission des connaissances.
### Formation des développeurs
Travailler sur le site AE est possiblement le meilleur moyen de
monter en compétences en informatique pour un étudiant de l'UTBM.
Automatisation des tests, gestion des données et de la sécurité,
infrastructure, maintenance du code existant...
Le site AE est un logiciel complet, dont le développement
possède une dimension pédagogique réelle.
En utilisant l'IA, le développement n'est plus un moyen efficace
de se former.
### Stabilité de la base de code
Les développeurs du site AE sont pour la plupart en cours de formation,
sans compréhension globale de la base de code du site,
des outils logiciels sur lesquels il se base et des bonnes
pratiques permettant d'écrire du code viable.
En se reposant sur un système d'IA sans être capacité
de comprendre intégralement le code proposé ni de le mettre
en perspective avec le reste de la base de code,
c'est toute la maintenance de la base de code qui se retrouve compromise.
### Transmission des connaissances
L'équipe du pôle informatique se renouvelle très souvent.
À ce titre, les nouveaux développeurs se doivent d'hériter
d'une base de code viable.
Quant aux anciens développeurs, ils se doivent d'en avoir
compris le fonctionnement, afin d'être en mesure
de guider et d'aider leurs successeurs.
Comme développé dans les deux points précédents,
cet objectif est incompatible avec l'usage de systèmes d'IA.
### Autres motifs
En plus de ces aspects purement liés à la qualité
du code et à la pédagogie, l'IA pose des problèmes
écologiques et éthiques :
- Les projets commerciaux d'IA se livrent fréquemment
à des violations flagrantes du droit d'auteur
et à un mépris complet des petits acteurs du web
(dont le site AE fait partie) pour entraîner leurs modèles.
- Leurs activités nécessitent une consommation massive d'énergie et d'eau,
ainsi qu'une quantité massive de matériel, entrainant conflits d'usage,
et intensification des dommages sociaux et environnementaux de l'industrie.
- La promotion et l'utilisation des modèles d'IA ont causé un préjudice
important aux salariés, une aliénation de leur travail et une baisse de la qualité des services.
- Les LLM ont favorisé les activités de spam et d'escroquerie.
- L'IA est devenu un maillon essentiel du fichage des individus,
du renforcement des biais sociétaux, et de la désinformation de masse.

View File

@@ -57,6 +57,7 @@ nav:
- Accueil: explanation/index.md
- Technologies utilisées: explanation/technos.md
- Conventions: explanation/conventions.md
- Politique IA: explanation/ia.md
- Archives: explanation/archives.md
- Tutoriels:
- Installer le projet: tutorial/install.md