3 Commits

Author SHA1 Message Date
Sli
d539b7f906 poc: improve galaxy rendering 2025-11-10 14:10:59 +01:00
Sli
67bdbfdcfc poc: improve forces on galaxy simulation 2025-11-10 14:10:59 +01:00
Sli
95e1246d2b poc: 2D graph for galaxy 2025-11-10 14:10:57 +01:00
57 changed files with 693 additions and 975 deletions

View File

@@ -37,7 +37,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from counter.models import Counter, Selling from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubEditForm(forms.ModelForm): class ClubEditForm(forms.ModelForm):
@@ -192,18 +191,6 @@ class SellingsForm(forms.Form):
required=False, required=False,
) )
def to_filter_schema(self) -> SaleFilterSchema:
products = (
*self.cleaned_data["products"],
*self.cleaned_data["archived_products"],
)
return SaleFilterSchema(
after=self.cleaned_data["begin_date"],
before=self.cleaned_data["end_date"],
counters={c.id for c in self.cleaned_data["counters"]} or None,
products={p.id for p in products} or None,
)
class ClubOldMemberForm(forms.Form): class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField( members_old = forms.ModelMultipleChoiceField(

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/page/macros.jinja' import page_history %} {% from 'core/macros_pages.jinja' import page_history %}
{% block content %} {% block content %}
{{ page_history(club.page) }} {% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block content %} {% block content %}
<h2>{% trans %}Edit page{% endtrans %}</h2> {{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %} {% endblock %}

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual
from club.models import Club, Membership from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
@@ -17,6 +16,7 @@ def test_page_display_on_club_main_page(client: Client):
club = baker.make(Club) club = baker.make(Club)
content = "# foo\nLorem ipsum dolor sit amet" content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content) baker.make(PageRev, page=club.page, revision=1, content=content)
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
@@ -30,42 +30,10 @@ def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty""" """Test the club view works, even if the club page is empty"""
club = baker.make(Club) club = baker.make(Club)
club.page.revisions.all().delete() club.page.revisions.all().delete()
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml") soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail") detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == [] assert detail_html.find_all("markdown") == []
@pytest.mark.django_db
def test_page_revision(client: Client):
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
client.force_login(baker.make(User))
url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
res = client.get(url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
@pytest.mark.django_db
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": content})
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
assert club.page.revisions.last().content == content

View File

@@ -1,6 +1,3 @@
import csv
import itertools
import pytest import pytest
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
@@ -10,20 +7,16 @@ from club.forms import SellingsForm
from club.models import Club from club.models import Club
from core.models import User from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer
@pytest.mark.django_db @pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client): def test_sales_page_doesnt_crash(client: Client):
"""Basic crashtest on club sales view."""
club = baker.make(Club) club = baker.make(Club)
product = baker.make(Product, club=club)
admin = baker.make(User, is_superuser=True) admin = baker.make(User, is_superuser=True)
client.force_login(admin) client.force_login(admin)
url = reverse("club:club_sellings", kwargs={"club_id": club.id}) response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
assert client.get(url).status_code == 200 assert response.status_code == 200
assert client.post(url).status_code == 200
assert client.post(url, data={"products": [product.id]}).status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
@@ -43,62 +36,3 @@ def test_sales_form_counter_filter():
form = SellingsForm(club) form = SellingsForm(club)
form_counters = list(form.fields["counters"].queryset) form_counters = list(form.fields["counters"].queryset)
assert form_counters == [counters[1], counters[2], counters[0]] assert form_counters == [counters[1], counters[2], counters[0]]
@pytest.mark.django_db
def test_club_sales_csv(client: Client):
client.force_login(baker.make(User, is_superuser=True))
club = baker.make(Club)
counter = baker.make(Counter, club=club)
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
sales: list[Selling] = sale_recipe.make(
club=club,
counter=counter,
quantity=2,
unit_price=1.5,
product=iter([product, product, None]),
customer=itertools.cycle(customers),
_quantity=3,
)
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
response = client.post(url, data={"counters": [counter.id]})
assert response.status_code == 200
reader = csv.reader(s.decode() for s in response.streaming_content)
data = list(reader)
sale_rows = [
[
str(s.date),
str(counter),
str(s.seller),
s.customer.user.get_display_name(),
s.label,
"2",
"1.50",
"3.00",
"Compte utilisateur",
]
for s in sales[::-1]
]
sale_rows[2].extend(["0.50", "1.00"])
sale_rows[1].extend(["0.50", "1.00"])
sale_rows[0].extend(["", ""])
assert data == [
["Quantité", "6"],
["Total", "9"],
["Bénéfice", "1"],
[
"Date",
"Comptoir",
"Barman",
"Client",
"Étiquette",
"Quantité",
"Prix unitaire",
"Total",
"Méthode de paiement",
"Prix d'achat",
"Bénéfice",
],
*sale_rows,
]

View File

@@ -22,14 +22,12 @@
# #
# #
from __future__ import annotations
import csv import csv
import itertools import itertools
from typing import TYPE_CHECKING, Any from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
@@ -38,7 +36,7 @@ from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.safestring import SafeString
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -63,14 +61,11 @@ from com.views import (
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev from core.models import PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class ClubTabsMixin(TabedViewMixin): class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
@@ -80,8 +75,6 @@ class ClubTabsMixin(TabedViewMixin):
self.object = self.object.page.club self.object = self.object.page.club
elif isinstance(self.object, Poster): elif isinstance(self.object, Poster):
self.object = self.object.club self.object = self.object.club
elif hasattr(self, "club"):
self.object = self.club
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
@@ -209,7 +202,7 @@ class ClubView(ClubTabsMixin, DetailView):
return kwargs return kwargs
class ClubRevView(LoginRequiredMixin, ClubView): class ClubRevView(ClubView):
"""Display a specific page revision.""" """Display a specific page revision."""
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@@ -223,26 +216,26 @@ class ClubRevView(LoginRequiredMixin, ClubView):
return kwargs return kwargs
class ClubPageEditView(ClubTabsMixin, BasePageEditView): class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
template_name = "club/pagerev_edit.jinja" template_name = "club/pagerev_edit.jinja"
current_tab = "page_edit" current_tab = "page_edit"
@cached_property def dispatch(self, request, *args, **kwargs):
def club(self): self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return get_object_or_404(Club, pk=self.kwargs["club_id"]) if not self.club.page:
raise Http404
return super().dispatch(request, *args, **kwargs)
@cached_property def get_object(self):
def page(self) -> Page: self.page = self.club.page
page = self.club.page return self._get_revision()
page.set_lock(self.request.user)
return page
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id}) return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView): class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification history of the page.""" """Modification hostory of the page."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
@@ -406,14 +399,33 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["result"] = Selling.objects.none() kwargs["result"] = Selling.objects.none()
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0 kwargs["total"] = 0
kwargs["total_quantity"] = 0 kwargs["total_quantity"] = 0
kwargs["benefit"] = 0 kwargs["benefit"] = 0
form: SellingsForm = self.get_form() form = self.get_form()
if form.is_valid() and any(v for v in form.cleaned_data.values()): if form.is_valid():
filters = form.to_filter_schema() qs = Selling.objects.filter(club=self.object)
qs = filters.filter(Selling.objects.filter(club=self.object)) if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
qs = qs.filter(date__lte=form.cleaned_data["end_date"])
if form.cleaned_data["counters"]:
qs = qs.filter(counter__in=form.cleaned_data["counters"])
selected_products = []
if form.cleaned_data["products"]:
selected_products.extend(form.cleaned_data["products"])
if form.cleaned_data["archived_products"]:
selected_products.extend(form.cleaned_data["archived_products"])
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["total"] = qs.annotate( kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price") price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"] ).aggregate(total=Sum("price", default=0))["total"]
@@ -460,15 +472,15 @@ class ClubSellingCSVView(ClubSellingView):
*row, *row,
selling.label, selling.label,
selling.quantity, selling.quantity,
selling.unit_price,
selling.quantity * selling.unit_price, selling.quantity * selling.unit_price,
selling.get_payment_method_display(), selling.get_payment_method_display(),
] ]
if selling.product: if selling.product:
row.append(selling.product.selling_price)
row.append(selling.product.purchase_price) row.append(selling.product.purchase_price)
row.append(selling.unit_price - selling.product.purchase_price) row.append(selling.product.selling_price - selling.product.purchase_price)
else: else:
row = [*row, "", ""] row = [*row, "", "", ""]
return row return row
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@@ -489,9 +501,9 @@ class ClubSellingCSVView(ClubSellingView):
gettext("Customer"), gettext("Customer"),
gettext("Label"), gettext("Label"),
gettext("Quantity"), gettext("Quantity"),
gettext("Unit price"),
gettext("Total"), gettext("Total"),
gettext("Payment method"), gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"), gettext("Purchase price"),
gettext("Benefit"), gettext("Benefit"),
], ],

View File

@@ -240,11 +240,10 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"): if not self.request.user.has_perm("core.view_user"):
return [] return []
return itertools.groupby( return itertools.groupby(
User.objects.viewable_by(self.request.user) User.objects.filter(
.filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_viewable=True, is_subscriber_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"]) .filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),

View File

@@ -74,19 +74,9 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group") autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission) @admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin): class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",) search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page) @admin.register(Page)

View File

@@ -74,7 +74,7 @@ class MailingListController(ControllerBase):
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int): def fetch_user(self, user_id: int):
@@ -90,9 +90,7 @@ class UserController(ControllerBase):
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter( return filters.filter(
User.objects.viewable_by(self.context.request.user).order_by( User.objects.order_by(F("last_login").desc(nulls_last=True))
F("last_login").desc(nulls_last=True)
)
) )

View File

@@ -150,8 +150,7 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
# but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0) self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user( skia = User.objects.create_user(

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]

View File

@@ -54,8 +54,6 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image, ImageOps from PIL import Image, ImageOps
from core.utils import get_last_promo
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
@@ -88,11 +86,12 @@ class Group(AuthGroup):
def validate_promo(value: int) -> None: def validate_promo(value: int) -> None:
last_promo = get_last_promo() start_year = settings.SITH_SCHOOL_START_YEAR
if not 0 < value <= last_promo: delta = (localdate() + timedelta(days=180)).year - start_year
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)"),
params={"value": value, "end": last_promo}, params={"value": value, "end": delta},
) )
@@ -137,15 +136,6 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases)) Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
) )
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)): class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -281,24 +271,13 @@ class User(AbstractUser):
parent_address = models.CharField( parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default="" _("parent address"), max_length=128, blank=True, default=""
) )
is_viewable = models.BooleanField( is_subscriber_viewable = models.BooleanField(
_("Profile visible by subscribers"), _("is subscriber viewable"), default=True
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
) )
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
@@ -572,12 +551,8 @@ class User(AbstractUser):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user):
return ( return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self): def get_mini_item(self):
return """ return """

View File

@@ -65,7 +65,7 @@ footer.bottom-links {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
background-color: $primary-neutral-dark-color; background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px; box-shadow: black 0 8px 15px;
a { a {
color: $white-color; color: $white-color;

View File

@@ -11,7 +11,8 @@ $hovered-red-text-color: #ff4d4d;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: $deepblue;
box-shadow: 3px 3px 3px 0 #dfdfdf; box-shadow: black 0 1px 3px 0,
black 0 4px 8px 3px;
border-radius: 0; border-radius: 0;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -99,7 +100,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0; border-radius: 0;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: transparent;
width: 45px; width: 45px;
height: 25px; height: 25px;
padding: 0; padding: 0;
@@ -331,7 +332,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px; padding: 10px;
z-index: 100; z-index: 100;
border-radius: 10px; border-radius: 10px;
box-shadow: 3px 3px 3px 0 #767676; @include shadow;
>ul { >ul {
list-style-type: none; list-style-type: none;

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -271,8 +271,9 @@ body {
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#content { #content {
padding: 1em 1%; padding: 1.5em 2%;
box-shadow: $shadow-color 0 5px 10px; border-radius: 5px;
box-shadow: black 0 8px 15px;
background: $white-color; background: $white-color;
overflow: auto; overflow: auto;
} }

View File

@@ -7,13 +7,10 @@
.profile { .profile {
&-visible { &-visible {
display: flex; display: flex;
flex-direction: column; justify-content: center;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding-top: 10px; padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
} }
&-pictures { &-pictures {
@@ -119,19 +116,23 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--nf-input-size) 10px; gap: 10px;
justify-content: center; justify-content: center;
} }
&-field { &-field {
display: flex; display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 10px;
width: 100%; width: 100%;
max-width: 330px; max-width: 330px;
min-width: 300px; min-width: 300px;
@media (max-width: 750px) { @media (max-width: 750px) {
gap: 4px;
max-width: 100%; max-width: 100%;
} }
@@ -144,6 +145,22 @@
} }
} }
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea { textarea {
height: 7rem; height: 7rem;
} }

View File

@@ -44,6 +44,18 @@
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
<style>
{# background image must be declared here, because the static names are
changed during the static collection step,
which means we must gather them with the `static` template function #}
.header {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
background-position-y: 80%; {# There are more stars in this part of the picture #}
}
body {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
}
</style>
{% endblock %} {% endblock %}
</head> </head>

View File

@@ -1,6 +1,6 @@
<header class="header"> <header class="header">
<div class="header-logo"> <div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')"> <a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static("core/img/gala25_logo.webp") }}')">
&nbsp; &nbsp;
</a> </a>
<a class="header-logo-text" href="{{ url('core:index') }}"> <a class="header-logo-text" href="{{ url('core:index') }}">

View File

@@ -17,3 +17,12 @@
{%- endfor -%} {%- endfor -%}
</ul> </ul>
{% endmacro %} {% endmacro %}
{% macro page_edit_form(page, form, url, token) %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url }}" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ token }}">
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endmacro %}

View File

@@ -0,0 +1,64 @@
{% extends "core/base.jinja" %}
{% block title %}
{% if page %}
{{ page.get_display_name() }}
{% elif page_list %}
{% trans %}Page list{% endtrans %}
{% elif new_page %}
{% trans %}Create page{% endtrans %}
{% else %}
{% trans %}Not found{% endtrans %}
{% endif %}
{% endblock %}
{% block metatags %}
{% if page %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page %}
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
{% endif %}
</div>
</div>
<hr>
{% if page %}
{% block page %}
{% endblock %}
{% else %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p><a href="{{ url('core:page_new') }}?page={{ request.resolver_match.kwargs['page_name'] }}">
{% trans %}Create it?{% endtrans %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{{ page.get_display_name() }}
{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
</div>
</div>
<hr>
{% block page %}
{% endblock %}
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
{% if revision and revision.id != last_revision.id %}
<h4>
{% trans trimmed rev_id=revision.revision %}
This may not be the last update, you are seeing revision {{ rev_id }}!
{% endtrans %}
</h4>
{% endif %}
{% set current_revision = revision or last_revision %}
<h3>{{ current_revision.title }}</h3>
<div class="page_content">{{ current_revision.content|markdown }}</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('core:page_edit', page_name=page.get_full_name()) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p>
{# This template is rendered when a PageNotFound error is raised,
so the `exception` context variable should always have a page_name attribute #}
<a href="{{ url('core:page_new') }}?page={{ exception.page_name }}">
{% trans %}Create it?{% endtrans %}
</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "core/page.jinja" %}
{% block page %}
{% if rev %}
<h4>{% trans rev_id=rev.revision %}This may not be the last update, you are seeing revision {{ rev_id }}!{% endtrans %}</h4>
<h3>{{ rev.title }}</h3>
<div class="page_content">{{ rev.content|markdown }}</div>
{% else %}
{% if page.revisions.last() %}
<h3>{{ page.revisions.last().title }}</h3>
<div class="page_content">{{ page.revisions.last().content|markdown }}</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "core/page/base.jinja" %} {% extends "core/page.jinja" %}
{% from "core/page/macros.jinja" import page_history %} {% from "core/macros_pages.jinja" import page_history %}
{% block page %} {% block page %}
<h3>{% trans %}Page history{% endtrans %}</h3> <h3>{% trans %}Page history{% endtrans %}</h3>

View File

@@ -1,13 +1,18 @@
{% extends "core/page/base.jinja" %} {% extends "core/page.jinja" %}
{% block page %} {% block content %}
{% if page %}
{{ super() }}
{% endif %}
<h2>{% trans %}Page properties{% endtrans %}</h2> <h2>{% trans %}Page properties{% endtrans %}</h2>
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form> </form>
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a> {% if page %}
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "core/page.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block page %}
{{ page_edit_form(page, form, url('core:page_edit', page_name=page.get_full_name()), csrf_token) }}
{% endblock %}

View File

@@ -116,12 +116,12 @@
{# All fields #} {# All fields #}
<div class="profile-fields"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
{%- endif -%} {%- endif -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} <div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -136,7 +136,7 @@
<div class="profile-fields"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} <div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -149,13 +149,8 @@
{# Checkboxes #} {# Checkboxes #}
<div class="profile-visible"> <div class="profile-visible">
<div class="row"> {{ form.is_subscriber_viewable }}
{{ form.is_viewable }} {{ form.is_subscriber_viewable.label }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div> </div>
<div class="final-actions"> <div class="final-actions">

View File

@@ -23,7 +23,6 @@ from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core import mail from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -36,8 +35,8 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club, Membership from club.models import Club, Membership
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
from counter.models import Customer from counter.models import Customer
from sith import settings from sith import settings
@@ -319,8 +318,9 @@ class TestPageHandling(TestCase):
def test_access_page_not_found(self): def test_access_page_not_found(self):
"""Should not display a page correctly.""" """Should not display a page correctly."""
response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"})) response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"}))
assert response.status_code == 404 assert response.status_code == 200
assert '<a href="/page/create/?page=swagg">' in response.text html = response.text
self.assertIn('<a href="/page/create/?page=swagg">', html)
def test_create_page_markdown_safe(self): def test_create_page_markdown_safe(self):
"""Should format the markdown and escape html correctly.""" """Should format the markdown and escape html correctly."""
@@ -523,21 +523,6 @@ class TestDateUtils(TestCase):
assert get_start_of_semester() == autumn_2023 assert get_start_of_semester() == autumn_2023
@pytest.mark.parametrize(
("current_date", "promo"),
[("2020-10-01", 22), ("2025-03-01", 26), ("2000-11-11", 2)],
)
def test_get_last_promo(current_date: str, promo: int):
with freezegun.freeze_time(current_date):
assert get_last_promo() == promo
@pytest.mark.parametrize("promo", [0, 24])
def test_promo_validator(promo: int):
with freezegun.freeze_time("2021-10-01"), pytest.raises(ValidationError):
validate_promo(promo)
def test_allow_fragment_mixin(): def test_allow_fragment_mixin():
class TestAllowFragmentView(AllowFragment, ContextMixin, View): class TestAllowFragmentView(AllowFragment, ContextMixin, View):
def get(self, *args, **kwargs): def get(self, *args, **kwargs):

View File

@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403 assert response.status_code == 403
def test_fetch_family_hidden_user(self): def test_fetch_family_hidden_user(self):
self.main_user.is_viewable = False self.main_user.is_subscriber_viewable = False
self.main_user.save() self.main_user.save()
for user_to_login, error_code in [ for user_to_login, error_code in [
(self.main_user, 200), (self.main_user, 200),

View File

@@ -1,122 +1,32 @@
from datetime import timedelta
import freezegun
import pytest import pytest
from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown from core.models import AnonymousUser, Page, User
from core.models import AnonymousUser, Page, PageRev, User from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db @pytest.mark.django_db
class TestEditPage: def test_edit_page(client: Client):
def test_edit_page(self, client: Client): user = board_user.make()
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
page.edit_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(
res, reverse("core:page", kwargs={"page_name": page._full_name})
)
revision = page.revisions.last()
assert revision.content == "Hello World"
def test_pagerev_reused(self, client):
"""Test that the previous revision is edited, if same author and small time diff"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(
PageRev, author=user, page=page, date=now(), content="Hello World"
)
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
client.post(url, data={"content": "Hello World!"})
assert page.revisions.count() == 1
assert page.revisions.last() == first_rev
first_rev.refresh_from_db()
assert first_rev.author == user
assert first_rev.content == "Hello World!"
def test_pagerev_not_reused(self, client):
"""Test that a new revision is created if too much time
passed since the last one.
"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(PageRev, author=user, page=page, date=now())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
with freezegun.freeze_time(now() + timedelta(minutes=30)):
client.post(url, data={"content": "Hello World"})
assert page.revisions.count() == 2
assert page.revisions.last() != first_rev
@pytest.mark.django_db
def test_page_revision(client: Client):
"""Test the GET to request to a specific revision page."""
page = baker.prepare(Page) page = baker.prepare(Page)
page.save(force_lock=True) page.save(force_lock=True)
page.view_groups.add(settings.SITH_GROUP_SUBSCRIBERS_ID) page.view_groups.add(user.groups.first())
revisions = baker.make( client.force_login(user)
PageRev, page=page, _quantity=3, content=iter(["foo", "bar", "baz"])
) url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
client.force_login(subscriber_user.make())
url = reverse(
"core:page_rev",
kwargs={"page_name": page._full_name, "rev": revisions[1].id},
)
res = client.get(url) res = client.get(url)
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
res = client.post(url, data={"content": "Hello World"})
@pytest.mark.django_db assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
def test_page_club_redirection(client: Client): revision = page.revisions.last()
club = baker.make(Club) assert revision.content == "Hello World"
url = reverse("core:page", kwargs={"page_name": club.page._full_name})
res = client.get(url)
redirection_url = reverse("club:club_view", kwargs={"club_id": club.id})
assertRedirects(res, redirection_url)
@pytest.mark.django_db
def test_page_revision_club_redirection(client: Client):
client.force_login(subscriber_user.make())
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
url = reverse(
"core:page_rev",
kwargs={"page_name": club.page._full_name, "rev": revisions[1].id},
)
res = client.get(url)
redirection_url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
assertRedirects(res, redirection_url)
@pytest.mark.django_db @pytest.mark.django_db
@@ -125,9 +35,9 @@ def test_viewable_by():
Page.objects.all().delete() Page.objects.all().delete()
view_groups = [ view_groups = [
[settings.SITH_GROUP_PUBLIC_ID], [settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, settings.SITH_GROUP_SUBSCRIBERS_ID], [settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID], [SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID, settings.SITH_GROUP_OLD_SUBSCRIBERS_ID], [SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[], [],
] ]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True) pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
@@ -146,11 +56,3 @@ def test_viewable_by():
) )
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True) viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages} assert set(viewable) == {p.id for p in pages}
@pytest.mark.django_db
def test_page_list_view(client: Client):
baker.make(Page, _quantity=10, _bulk_create=True)
client.force_login(subscriber_user.make())
res = client.get(reverse("core:page_list"))
assert res.status_code == 200

View File

@@ -3,7 +3,6 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -19,7 +18,7 @@ from core.baker_recipes import (
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import AnonymousUser, Group, User from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
@@ -369,38 +368,3 @@ class TestRedirectMe:
def test_promo_has_logo(promo): def test_promo_has_logo(promo):
user = baker.make(User, promo=promo) user = baker.make(User, promo=promo)
assert user.promo_has_logo() assert user.promo_has_logo()
@pytest.mark.django_db
class TestUserQuerySetViewableBy:
@pytest.fixture
def users(self) -> list[User]:
return [
baker.make(User),
subscriber_user.make(),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users)
@pytest.mark.parametrize(
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()

View File

@@ -112,16 +112,6 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:] return "P" + str(start.year)[-2:]
def get_last_promo() -> int:
"""Get the latest promo at the time the function is called.
For example, if called in october 2022 return 24,
if called in march 2026 return 27, etc.
"""
start_year = settings.SITH_SCHOOL_START_YEAR
return (localdate() + timedelta(days=180)).year - start_year
def is_image(file: UploadedFile): def is_image(file: UploadedFile):
try: try:
im = PIL.Image.open(file.file) im = PIL.Image.open(file.file)
@@ -196,7 +186,7 @@ def exif_auto_rotate(image):
def get_client_ip(request: HttpRequest) -> str | None: def get_client_ip(request: HttpRequest) -> str | None:
headers = ( headers = (
"X_FORWARDED_FOR", # Common header for proxies "X_FORWARDED_FOR", # Common header for proixes
"FORWARDED", # Standard header defined by RFC 7239. "FORWARDED", # Standard header defined by RFC 7239.
"REMOTE_ADDR", # Default IP Address (direct connection) "REMOTE_ADDR", # Default IP Address (direct connection)
) )

View File

@@ -21,10 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.http import ( from django.http import (
Http404,
HttpRequest,
HttpResponseForbidden, HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError, HttpResponseServerError,
) )
from django.shortcuts import render from django.shortcuts import render
@@ -33,20 +33,17 @@ from django.views.generic.edit import FormView
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from core.views.forms import LoginForm from core.views.forms import LoginForm
from core.views.page import PageNotFound
def forbidden(request: HttpRequest, exception): def forbidden(request, exception):
context = {"next": request.path, "form": LoginForm()} context = {"next": request.path, "form": LoginForm()}
return HttpResponseForbidden(render(request, "core/403.jinja", context=context)) return HttpResponseForbidden(render(request, "core/403.jinja", context=context))
def not_found(request: HttpRequest, exception: Http404): def not_found(request, exception):
if isinstance(exception, PageNotFound): return HttpResponseNotFound(
template_name = "core/page/not_found.jinja" render(request, "core/404.jinja", context={"exception": exception})
else: )
template_name = "core/404.jinja"
return render(request, template_name, context={"exception": exception}, status=404)
def internal_servor_error(request): def internal_servor_error(request):

View File

@@ -20,9 +20,8 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import difflib
import re import re
from datetime import date, datetime, timedelta from datetime import date, datetime
from io import BytesIO from io import BytesIO
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
@@ -43,12 +42,13 @@ from django.forms import (
Widget, Widget,
) )
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, SithFile, User from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
@@ -56,7 +56,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from core.views.widgets.markdown import MarkdownInput
# Widgets # Widgets
@@ -87,6 +86,30 @@ class NFCTextInput(TextInput):
return context return context
class SelectUser(TextInput):
def render(self, name, value, attrs=None, renderer=None):
if attrs:
attrs["class"] = "select_user"
else:
attrs = {"class": "select_user"}
output = (
'%(content)s<div name="%(name)s" class="choose_user_widget" title="%(title)s"></div>'
% {
"content": super().render(name, value, attrs, renderer),
"title": _("Choose user"),
"name": name,
}
)
output += (
'<span name="'
+ name
+ '" class="choose_user_button">'
+ gettext("Choose user")
+ "</span>"
)
return output
# Fields # Fields
@@ -179,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_viewable", "is_subscriber_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -188,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea, "quote": forms.Textarea,
} }
def __init__(self, *args, label_suffix: str = "", **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs) super().__init__(*args, **kwargs)
# Image fields are injected here to override the file field provided by the model # Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile # This would be better if we could have a SithImage sort of model input instead of a generic SithFile
@@ -381,55 +404,6 @@ class PageForm(forms.ModelForm):
) )
class PageRevisionForm(forms.ModelForm):
"""Form to add a new revision to a page.
Notes:
Saving this form won't always result in a new revision.
If the previous revision on the same page was made :
- less than 20 minutes ago
- by the same author
- with a diff ratio higher than 20%
then the latter will be edited and the new revision won't be created.
"""
TIME_THRESHOLD = timedelta(minutes=20)
DIFF_THRESHOLD = 0.2
class Meta:
model = PageRev
fields = ["title", "content"]
widgets = {"content": MarkdownInput}
def __init__(
self, *args, author: User, page: Page, instance: PageRev | None = None, **kwargs
):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
self.page = page
self.initial_content = instance.content if instance else ""
def diff_ratio(self, new_str: str) -> float:
return difflib.SequenceMatcher(
None, self.initial_content, new_str
).quick_ratio()
def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance
if (
revision._state.adding
or revision.author != self.author
or revision.date + self.TIME_THRESHOLD < now()
or self.diff_ratio(revision.content) < (1 - self.DIFF_THRESHOLD)
):
revision.author = self.author
revision.page = self.page
revision.id = None # if id is None, Django will create a new record
return super().save(commit=commit)
class GiftForm(forms.ModelForm): class GiftForm(forms.ModelForm):
class Meta: class Meta:
model = Gift model = Gift

View File

@@ -13,39 +13,39 @@
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce 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
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditPropMixin, CanViewMixin from core.auth.mixins import (
from core.models import Page, PageRev CanEditMixin,
from core.views.forms import PageForm, PagePropForm, PageRevisionForm CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev
from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput
class PageNotFound(Http404): class CanEditPagePropMixin(CanEditPropMixin):
"""Http404 Exception, but specifically for when the not found object is a Page.""" def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
def __init__(self, page_name: str): if self.object.is_club_page:
self.page_name = page_name raise Http404
return res
def get_page_or_404(full_name: str) -> Page:
"""Like Django's get_object_or_404, but for Page, and with a custom 404 exception."""
page = Page.objects.filter(_full_name=full_name).first()
if not page:
raise PageNotFound(full_name)
return page
class PageListView(ListView): class PageListView(ListView):
model = Page model = Page
template_name = "core/page/list.jinja" template_name = "core/page_list.jinja"
def get_queryset(self): def get_queryset(self):
return ( return (
@@ -64,57 +64,80 @@ class PageListView(ListView):
) )
class BasePageDetailView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):
model = Page model = Page
template_name = "core/page_detail.jinja"
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object and self.object.need_club_redirection:
return redirect("club:club_view", club_id=self.object.club.id)
return res
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "page" not in context:
context["new_page"] = self.kwargs["page_name"]
return context
class PageHistView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_hist.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name" slug_url_kwarg = "page_name"
_cached_object: Page | None = None _cached_object: Page | None = None
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
page = self.get_object() page = self.get_object()
if page.need_club_redirection: if page.need_club_redirection:
return redirect("club:club_view", club_id=page.club.id) return redirect("club:club_hist", club_id=page.club.id)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
if not self._cached_object: if not self._cached_object:
full_name = self.kwargs.get(self.slug_url_kwarg) self._cached_object = super().get_object()
self._cached_object = get_page_or_404(full_name)
return self._cached_object return self._cached_object
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"last_revision": self.object.revisions.last()
}
class PageRevView(CanViewMixin, DetailView):
class PageView(BasePageDetailView): model = Page
template_name = "core/page/detail.jinja" template_name = "core/page_detail.jinja"
class PageHistView(BasePageDetailView):
template_name = "core/page/history.jinja"
class PageRevView(BasePageDetailView):
template_name = "core/page/detail.jinja"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
page = self.get_object() res = super().dispatch(request, *args, **kwargs)
if page.need_club_redirection: self.object = self.get_object()
if self.object is None:
return redirect("core:page_create", page_name=self.kwargs["page_name"])
if self.object.need_club_redirection:
return redirect( return redirect(
"club:club_view_rev", club_id=page.club.id, rev_id=kwargs["rev"] "club:club_view_rev", club_id=self.object.club.id, rev_id=kwargs["rev"]
) )
self.revision = get_object_or_404(page.revisions, id=self.kwargs["rev"]) return res
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"revision": self.revision} context = super().get_context_data(**kwargs)
if not self.page:
return context | {"new_page": self.kwargs["page_name"]}
context["page"] = self.page
context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
return context
class PageCreateView(PermissionRequiredMixin, CreateView): class PageCreateView(PermissionRequiredMixin, CreateView):
model = Page model = Page
form_class = PageForm form_class = PageForm
template_name = "core/create.jinja" template_name = "core/page_prop.jinja"
permission_required = "core.add_page" permission_required = "core.add_page"
def get_initial(self): def get_initial(self):
@@ -129,67 +152,88 @@ class PageCreateView(PermissionRequiredMixin, CreateView):
init["name"] = page_name[-1] init["name"] = page_name[-1]
return init return init
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["new_page"] = True
return context
def form_valid(self, form): def form_valid(self, form):
form.instance.set_lock(self.request.user) form.instance.set_lock(self.request.user)
ret = super().form_valid(form) ret = super().form_valid(form)
return ret return ret
class CanEditPagePropMixin(CanEditPropMixin):
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object.is_club_page:
raise Http404
return res
class PagePropView(CanEditPagePropMixin, UpdateView): class PagePropView(CanEditPagePropMixin, UpdateView):
model = Page model = Page
form_class = PagePropForm form_class = PagePropForm
template_name = "core/page/prop.jinja" template_name = "core/page_prop.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
def get_object(self, queryset=None): def get_object(self, queryset=None):
self.page = get_page_or_404(full_name=self.kwargs["page_name"]) self.page = super().get_object()
self.page.set_lock_recursive(self.request.user) try:
self.page.set_lock_recursive(self.request.user)
except LockError as e:
raise e
return self.page return self.page
class BasePageEditView(UserPassesTestMixin, UpdateView): class PageEditViewBase(CanEditMixin, UpdateView):
model = PageRev model = PageRev
form_class = PageRevisionForm form_class = modelform_factory(
template_name = "core/page/edit.jinja" model=PageRev, fields=["title", "content"], widgets={"content": MarkdownInput}
)
def test_func(self): template_name = "core/pagerev_edit.jinja"
return self.request.user.can_edit(self.page)
@cached_property
def page(self) -> Page:
page = get_page_or_404(full_name=self.kwargs["page_name"])
page.set_lock(self.request.user)
return page
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return self.page.revisions.last() self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision()
def _get_revision(self):
if self.page is not None:
# First edit
if self.page.revisions.all() is None:
rev = PageRev(author=self.request.user)
rev.save()
self.page.revisions.add(rev)
try:
self.page.set_lock(self.request.user)
except LockError as e:
raise e
return self.page.revisions.last()
return None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"page": self.page} context = super().get_context_data(**kwargs)
if self.page is not None:
context["page"] = self.page
else:
context["new_page"] = self.kwargs["page_name"]
return context
def get_form_kwargs(self): def form_valid(self, form):
return super().get_form_kwargs() | { # TODO : factor that, but first make some tests
"author": self.request.user, rev = form.instance
"page": self.page, new_rev = PageRev(title=rev.title, content=rev.content)
} new_rev.author = self.request.user
new_rev.page = self.page
form.instance = new_rev
return super().form_valid(form)
class PageEditView(BasePageEditView): class PageEditView(PageEditViewBase):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if self.page.need_club_redirection: res = super().dispatch(request, *args, **kwargs)
return redirect("club:club_edit_page", club_id=self.page.club.id) if self.object and self.object.page.need_club_redirection:
return super().dispatch(request, *args, **kwargs) return redirect("club:club_edit_page", club_id=self.object.page.club.id)
return res
class PageDeleteView(CanEditPagePropMixin, DeleteView): class PageDeleteView(CanEditPagePropMixin, DeleteView):
model = Page model = Page
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "page_id" pk_url_kwarg = "page_id"
success_url = reverse_lazy("core:page_list")
def get_success_url(self, **kwargs):
return reverse_lazy("core:page_list")

View File

@@ -103,7 +103,9 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password.""" """Allows a root user to change someone's password."""
if not request.user.is_root: if not request.user.is_root:
raise PermissionDenied raise PermissionDenied
user = get_object_or_404(User, id=user_id) user = User.objects.filter(id=user_id).first()
if not user:
raise Http404("User not found")
if request.method == "POST": if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST) form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid(): if form.is_valid():

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Annotated, Self from typing import Annotated, Self
from annotated_types import MinLen from annotated_types import MinLen
@@ -101,10 +100,3 @@ class ProductFilterSchema(FilterSchema):
product_type: set[int] | None = Field(None, q="product_type__in") product_type: set[int] | None = Field(None, q="product_type__in")
club: set[int] | None = Field(None, q="club__in") club: set[int] | None = Field(None, q="club__in")
counter: set[int] | None = Field(None, q="counters__in") counter: set[int] | None = Field(None, q="counters__in")
class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt")
after: datetime | None = Field(None, q="date__gt")
counters: set[int] | None = Field(None, q="counter__in")
products: set[int] | None = Field(None, q="product__in")

View File

@@ -141,7 +141,7 @@
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_viewable %} {%- if user.is_subscriber_viewable %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}"> <img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %} {% else %}

View File

@@ -199,7 +199,7 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]: ) -> QuerySet[User]:
return ( return (
User.objects.filter(is_viewable=True) User.objects.filter(is_subscriber_viewable=True)
.exclude(subscriptions=None) .exclude(subscriptions=None)
.annotate( .annotate(
pictures_count=Count("pictures"), pictures_count=Count("pictures"),

View File

@@ -1,138 +0,0 @@
import { default as ForceGraph3D } from "3d-force-graph";
import { forceX, forceY, forceZ } from "d3-force-3d";
// biome-ignore lint/style/noNamespaceImport: This is how it should be imported
import * as Three from "three";
import SpriteText from "three-spritetext";
/**
* @typedef GalaxyConfig
* @property {number} nodeId id of the current user node
* @property {string} dataUrl url to fetch the galaxy data from
**/
/**
* Load the galaxy of an user
* @param {GalaxyConfig} config
**/
window.loadGalaxy = (config) => {
window.getNodeFromId = (id) => {
return Graph.graphData().nodes.find((n) => n.id === id);
};
window.getLinksFromNodeId = (id) => {
return Graph.graphData().links.filter(
(l) => l.source.id === id || l.target.id === id,
);
};
window.focusNode = (node) => {
highlightNodes.clear();
highlightLinks.clear();
hoverNode = node || null;
if (node) {
// collect neighbors and links for highlighting
for (const link of window.getLinksFromNodeId(node.id)) {
highlightLinks.add(link);
highlightNodes.add(link.source);
highlightNodes.add(link.target);
}
}
// refresh node and link display
Graph.nodeThreeObject(Graph.nodeThreeObject())
.linkWidth(Graph.linkWidth())
.linkDirectionalParticles(Graph.linkDirectionalParticles());
// Aim at node from outside it
const distance = 42;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
const newPos =
node.x || node.y || node.z
? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }
: { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0)
Graph.cameraPosition(
newPos, // new position
node, // lookAt ({ x, y, z })
3000, // ms transition duration
);
};
const highlightNodes = new Set();
const highlightLinks = new Set();
let hoverNode = null;
const grpahDiv = document.getElementById("3d-graph");
const Graph = ForceGraph3D();
Graph(grpahDiv);
Graph.jsonUrl(config.dataUrl)
.width(
grpahDiv.parentElement.clientWidth > 1200
? 1200
: grpahDiv.parentElement.clientWidth,
) // Not perfect at all. JS-fu master from the future, please fix this :-)
.height(1000)
.enableNodeDrag(false) // allow easier navigation
.onNodeClick((node) => {
const camera = Graph.cameraPosition();
const distance = Math.sqrt(
(node.x - camera.x) ** 2 + (node.y - camera.y) ** 2 + (node.z - camera.z) ** 2,
);
if (distance < 120 || highlightNodes.has(node)) {
window.focusNode(node);
}
})
.linkWidth((link) => (highlightLinks.has(link) ? 0.4 : 0.0))
.linkColor((link) =>
highlightLinks.has(link) ? "rgba(255,160,0,1)" : "rgba(128,255,255,0.6)",
)
.linkVisibility((link) => highlightLinks.has(link))
.nodeVisibility((node) => highlightNodes.has(node) || node.mass > 4)
// .linkDirectionalParticles(link => highlightLinks.has(link) ? 3 : 1) // kinda buggy for now, and slows this a bit, but would be great to help visualize lanes
.linkDirectionalParticleWidth(0.2)
.linkDirectionalParticleSpeed(-0.006)
.nodeThreeObject((node) => {
const sprite = new SpriteText(node.name);
sprite.material.depthWrite = false; // make sprite background transparent
sprite.color = highlightNodes.has(node)
? node === hoverNode
? "rgba(200,0,0,1)"
: "rgba(255,160,0,0.8)"
: "rgba(0,255,255,0.2)";
sprite.textHeight = 2;
sprite.center = new Three.Vector2(1.2, 0.5);
return sprite;
})
.onEngineStop(() => {
window.focusNode(window.getNodeFromId(config.nodeId));
Graph.onEngineStop(() => {
/* nope */
}); // don't call ourselves in a loop while moving the focus
});
// Set distance between stars
Graph.d3Force("link").distance((link) => link.value);
// Set high masses nearer the center of the galaxy
// TODO: quick and dirty strength computation, this will need tuning.
Graph.d3Force(
"positionX",
forceX().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionY",
forceY().strength((node) => {
return 1 - 1 / node.mass;
}),
);
Graph.d3Force(
"positionZ",
forceZ().strength((node) => {
return 1 - 1 / node.mass;
}),
);
};

View File

@@ -0,0 +1,137 @@
import { exportToHtml } from "#core:utils/globals";
import cytoscape from "cytoscape";
import d3Force, { type D3ForceLayoutOptions } from "cytoscape-d3-force";
cytoscape.use(d3Force);
interface GalaxyConfig {
nodeId: number;
dataUrl: string;
}
async function getGraphData(dataUrl: string) {
const response = await fetch(dataUrl);
if (!response.ok) {
return [];
}
const content = await response.json();
const nodes = content.nodes.map((node, i) => {
return {
group: "nodes",
data: {
id: node.id,
name: node.name,
mass: node.mass,
},
};
});
const edges = content.links.map((link) => {
return {
group: "edges",
data: {
id: `edge_${link.source}_${link.value}`,
source: link.source,
target: link.target,
value: link.value,
},
};
});
return { nodes: nodes, edges: edges };
}
exportToHtml("loadGalaxy", async (config: GalaxyConfig) => {
const graphDiv = document.getElementById("3d-graph");
const elements = await getGraphData(config.dataUrl);
const cy = cytoscape({
container: graphDiv,
elements: elements,
style: [
{
selector: "node",
style: {
label: "data(name)",
"background-color": "red",
},
},
{
selector: ".focused",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "black",
"target-arrow-color": "black",
"line-color": "black",
},
},
{
selector: "edge",
style: {
width: 0.1,
},
},
{
selector: ".direct",
style: {
width: "5px",
"line-color": "red",
},
},
],
layout: {
name: "d3-force",
animate: true,
fit: false,
ungrabifyWhileSimulating: true,
fixedAfterDragging: true,
linkId: (node) => {
return node.id;
},
linkDistance: (link) => {
return elements.nodes.length * 10;
},
linkStrength: (link) => {
return 1 / Math.max(1, link?.value);
},
linkIterations: 10,
manyBodyStrength: (node) => {
return node?.mass;
},
// manyBodyDistanceMin: 500,
collideRadius: () => {
return 50;
},
ready: (e) => {
// Center on current user node at the start of the simulation
// Color all direct paths from that citizen to it's neighbor
const citizen = e.cy.nodes(`#${config.nodeId}`)[0];
citizen.addClass("focused");
citizen.connectedEdges().addClass("direct");
e.cy.center(citizen);
},
tick: () => {
// Center on current user node during simulation
const citizen = cy.nodes(`#${config.nodeId}`)[0];
cy.center(citizen);
},
stop: (e) => {
// Disable user grabbing of nodes
// This has to be disabled after the simulation is done
// Otherwise the simulation can't move nodes
e.cy.autolock(true);
},
} as D3ForceLayoutOptions,
});
});

View File

@@ -5,14 +5,14 @@
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.js') }}"></script> <script type="module" src="{{ static('bundled/galaxy/galaxy-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if object.current_star %} {% if object.current_star %}
<div style="display: flex; flex-wrap: wrap;"> <div style="display: flex; flex-wrap: wrap;">
<div id="3d-graph"></div> <div style="width: 100%; height: 70vh; display: block" id="3d-graph"></div>
<div style="margin: 1em;"> <div style="margin: 1em;">
<p><a onclick="window.focusNode(window.getNodeFromId({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p> <p><a onclick="window.focusNode(window.getNodeFromId({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p>

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-12 21:44+0100\n" "POT-Creation-Date: 2025-11-07 14:50+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -247,7 +247,8 @@ msgstr "description"
msgid "past member" msgid "past member"
msgstr "ancien membre" msgstr "ancien membre"
#: club/models.py com/templates/com/mailing_admin.jinja #: club/models.py club/templates/club/club_detail.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja #: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/invoices_call.jinja
@@ -470,7 +471,7 @@ msgstr "Méthode de paiement"
#: core/templates/core/file_detail.jinja #: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja #: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja #: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/macros.jinja core/templates/core/page/prop.jinja #: core/templates/core/macros.jinja core/templates/core/page_prop.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja #: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
@@ -546,12 +547,11 @@ msgstr ""
"Les champs de formulaire suivants sont liées à la description basique d'un " "Les champs de formulaire suivants sont liées à la description basique d'un "
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci." "club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja #: club/templates/club/edit_club.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja #: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja #: com/templates/com/weekmail.jinja core/templates/core/create.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja #: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
#: core/templates/core/file_edit.jinja core/templates/core/page/edit.jinja #: core/templates/core/macros_pages.jinja core/templates/core/page_prop.jinja
#: core/templates/core/page/prop.jinja
#: core/templates/core/user_godfathers.jinja #: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja #: core/templates/core/user_preferences.jinja
@@ -638,9 +638,9 @@ msgstr "Nouvelle liste de diffusion"
msgid "Create mailing list" msgid "Create mailing list"
msgstr "Créer une liste de diffusion" msgstr "Créer une liste de diffusion"
#: club/templates/club/pagerev_edit.jinja core/templates/core/page/edit.jinja #: club/templates/club/page_history.jinja
msgid "Edit page" msgid "No page existing for this club"
msgstr "Éditer la page" msgstr "Aucune page n'existe pour ce club"
#: club/views.py core/views/user.py sas/templates/sas/picture.jinja #: club/views.py core/views/user.py sas/templates/sas/picture.jinja
msgid "Infos" msgid "Infos"
@@ -654,7 +654,7 @@ msgstr "Membres"
msgid "Old members" msgid "Old members"
msgstr "Anciens membres" msgstr "Anciens membres"
#: club/views.py core/templates/core/page/base.jinja #: club/views.py core/templates/core/page.jinja
msgid "History" msgid "History"
msgstr "Historique" msgstr "Historique"
@@ -666,7 +666,7 @@ msgstr "Outils"
#: club/views.py com/templates/com/news_admin_list.jinja #: club/views.py com/templates/com/news_admin_list.jinja
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
#: com/templates/com/weekmail.jinja core/templates/core/file.jinja #: com/templates/com/weekmail.jinja core/templates/core/file.jinja
#: core/templates/core/group_list.jinja core/templates/core/page/base.jinja #: core/templates/core/group_list.jinja core/templates/core/page.jinja
#: core/templates/core/user_tools.jinja core/views/user.py #: core/templates/core/user_tools.jinja core/views/user.py
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja #: counter/templates/counter/counter_list.jinja
@@ -704,8 +704,8 @@ msgid "Benefit"
msgstr "Bénéfice" msgstr "Bénéfice"
#: club/views.py #: club/views.py
msgid "Unit price" msgid "Selling price"
msgstr "Prix unitaire" msgstr "Prix de vente"
#: club/views.py #: club/views.py
msgid "Purchase price" msgid "Purchase price"
@@ -980,7 +980,7 @@ msgid "Dates"
msgstr "Dates" msgstr "Dates"
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja #: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page/base.jinja #: core/templates/core/page.jinja
msgid "View" msgid "View"
msgstr "Voir" msgstr "Voir"
@@ -1017,10 +1017,6 @@ msgstr "Événements à modérer"
msgid "Back to news" msgid "Back to news"
msgstr "Retour aux nouvelles" msgstr "Retour aux nouvelles"
#: com/templates/com/news_detail.jinja
msgid "Share on Facebook"
msgstr "Partager sur Facebook"
#: com/templates/com/news_detail.jinja #: com/templates/com/news_detail.jinja
msgid "Author: " msgid "Author: "
msgstr "Auteur : " msgstr "Auteur : "
@@ -1536,15 +1532,8 @@ msgid "parent address"
msgstr "adresse des parents" msgstr "adresse des parents"
#: core/models.py #: core/models.py
msgid "Profile visible by subscribers" msgid "is subscriber viewable"
msgstr "Profil visible par les cotisants" msgstr "profil visible par les cotisants"
#: core/models.py
msgid ""
"If you disable this option, only admin users will be able to see your "
"profile."
msgstr ""
"Si vous désactivez cette option, seuls les admins pourront voir votre profil."
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
@@ -1960,7 +1949,7 @@ msgstr "Liste de fichiers"
msgid "New file" msgid "New file"
msgstr "Nouveau fichier" msgstr "Nouveau fichier"
#: core/templates/core/file.jinja #: core/templates/core/file.jinja core/templates/core/page.jinja
msgid "Not found" msgid "Not found"
msgstr "Non trouvé" msgstr "Non trouvé"
@@ -1968,7 +1957,7 @@ msgstr "Non trouvé"
msgid "My files" msgid "My files"
msgstr "Mes fichiers" msgstr "Mes fichiers"
#: core/templates/core/file.jinja core/templates/core/page/base.jinja #: core/templates/core/file.jinja core/templates/core/page.jinja
msgid "Prop" msgid "Prop"
msgstr "Propriétés" msgstr "Propriétés"
@@ -2106,6 +2095,14 @@ msgstr "Mot de passe perdu ?"
msgid "Create account" msgid "Create account"
msgstr "Créer un compte" msgstr "Créer un compte"
#: core/templates/core/macros.jinja
msgid "Share on Facebook"
msgstr "Partager sur Facebook"
#: core/templates/core/macros.jinja
msgid "Tweet"
msgstr "Tweeter"
#: core/templates/core/macros.jinja #: core/templates/core/macros.jinja
#, python-format #, python-format
msgid "Subscribed until %(subscription_end)s" msgid "Subscribed until %(subscription_end)s"
@@ -2127,6 +2124,19 @@ msgstr "Tout sélectionner"
msgid "Unselect All" msgid "Unselect All"
msgstr "Tout désélectionner" msgstr "Tout désélectionner"
#: core/templates/core/macros_pages.jinja
#, python-format
msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/macros_pages.jinja
msgid "last"
msgstr "actuel"
#: core/templates/core/macros_pages.jinja
msgid "Edit page"
msgstr "Éditer la page"
#: core/templates/core/new_user_email.jinja #: core/templates/core/new_user_email.jinja
msgid "" msgid ""
"You're receiving this email because you subscribed to the UTBM student " "You're receiving this email because you subscribed to the UTBM student "
@@ -2177,47 +2187,38 @@ msgstr "Nouvelle cotisation à l'Association des Étudiants de l'UTBM"
msgid "Notification list" msgid "Notification list"
msgstr "Liste des notifications" msgstr "Liste des notifications"
#: core/templates/core/page/base.jinja #: core/templates/core/page.jinja core/templates/core/page_list.jinja
msgid "Page" msgid "Page list"
msgstr "Page" msgstr "Liste des pages"
#: core/templates/core/page/base.jinja #: core/templates/core/page.jinja
msgid "Create page"
msgstr "Créer une page"
#: core/templates/core/page.jinja
msgid "Return to club management" msgid "Return to club management"
msgstr "Retourner à la gestion du club" msgstr "Retourner à la gestion du club"
#: core/templates/core/page/detail.jinja #: core/templates/core/page.jinja
msgid "Page does not exist"
msgstr "La page n'existe pas"
#: core/templates/core/page.jinja
msgid "Create it?"
msgstr "La créer ?"
#: core/templates/core/page_detail.jinja
#, python-format #, python-format
msgid "This may not be the last update, you are seeing revision %(rev_id)s!" msgid "This may not be the last update, you are seeing revision %(rev_id)s!"
msgstr "" msgstr ""
"Ceci n'est peut-être pas la dernière version de la page. Vous consultez la " "Ceci n'est peut-être pas la dernière version de la page. Vous consultez la "
"version %(rev_id)s." "version %(rev_id)s."
#: core/templates/core/page/history.jinja #: core/templates/core/page_hist.jinja
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" msgstr "Historique de la page"
#: core/templates/core/page/list.jinja #: core/templates/core/page_prop.jinja
msgid "Page list"
msgstr "Liste des pages"
#: core/templates/core/page/macros.jinja
#, python-format
msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/page/macros.jinja
msgid "last"
msgstr "actuel"
#: core/templates/core/page/not_found.jinja
msgid "Page does not exist"
msgstr "La page n'existe pas"
#: core/templates/core/page/not_found.jinja
msgid "Create it?"
msgstr "La créer ?"
#: core/templates/core/page/prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"
@@ -2840,6 +2841,10 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
msgid "Apply rights recursively" msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement" msgstr "Appliquer les droits récursivement"
#: core/views/forms.py
msgid "Choose user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py #: core/views/forms.py
msgid "Ensure this timestamp is set in the future" msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur" msgstr "Assurez-vous que cet horodatage est dans le futur"
@@ -5107,6 +5112,14 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week" msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "One month for free"
msgstr "Un mois gratuit"
#: sith/settings.py
msgid "Two months for free"
msgstr "Deux mois gratuits"
#: sith/settings.py #: sith/settings.py
msgid "Eurok's volunteer" msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes" msgstr "Bénévole Eurockéennes"
@@ -5120,10 +5133,8 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
#, fuzzy msgid "GA staff member (2 weeks)"
#| msgid "GA staff member" msgstr "Membre staff GA (2 semaines)"
msgid "GA staff member"
msgstr "Membre staff GA"
#: sith/settings.py #: sith/settings.py
msgid "One semester (-20%)" msgid "One semester (-20%)"

View File

@@ -105,7 +105,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.can_see_hidden = True self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root): if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True) self.init_query = self.init_query.exclude(is_subscriber_viewable=False)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else: else:
q = [] q = []
if not self.can_see_hidden and len(q) > 0: if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable] q = [user for user in q if user.is_subscriber_viewable]
else: else:
search_dict = {} search_dict = {}
for key, value in self.valid_form.items(): for key, value in self.valid_form.items():

53
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"country-flag-emoji-polyfill": "^0.1.8", "country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"easymde": "^2.20.0", "easymde": "^2.20.0",
@@ -46,6 +47,7 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -2890,6 +2892,16 @@
"cytoscape": "^3.31" "cytoscape": "^3.31"
} }
}, },
"node_modules/@types/cytoscape-d3-force": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/cytoscape-d3-force/-/cytoscape-d3-force-1.0.0.tgz",
"integrity": "sha512-1eRd9xr/DvJ4MIA5lCEG8DMX2Ha87qAbpP7irpuKZun0ZCBQPpoOBo9mPl0WrkJbXH+hHwG8s3E2CpUz3HxLrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "^3.0.9"
}
},
"node_modules/@types/cytoscape-klay": { "node_modules/@types/cytoscape-klay": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.5.tgz",
@@ -3559,6 +3571,18 @@
"cytoscape": "^3.2.0" "cytoscape": "^3.2.0"
} }
}, },
"node_modules/cytoscape-d3-force": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-d3-force/-/cytoscape-d3-force-1.1.4.tgz",
"integrity": "sha512-8NjI/yEoB3YqVsdf7ud7Oh8Kyi+C9Lhh1fICmtemIo6EC1ZUtm8KcPNLkQySYO8nRS2mQKj5eVdCr7W0L8ONoQ==",
"license": "MIT",
"dependencies": {
"d3-force": "^2.0.1"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/cytoscape-klay": { "node_modules/cytoscape-klay": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", "resolved": "https://registry.npmjs.org/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
@@ -3607,6 +3631,17 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-force": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz",
"integrity": "sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-quadtree": "1 - 2",
"d3-timer": "1 - 2"
}
},
"node_modules/d3-force-3d": { "node_modules/d3-force-3d": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
@@ -3623,6 +3658,24 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-force/node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-quadtree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz",
"integrity": "sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-force/node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-format": { "node_modules/d3-format": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",

View File

@@ -31,6 +31,7 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-d3-force": "^1.0.0",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -55,6 +56,7 @@
"country-flag-emoji-polyfill": "^0.1.8", "country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-d3-force": "^1.1.4",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"easymde": "^2.20.0", "easymde": "^2.20.0",

View File

@@ -43,7 +43,7 @@ dependencies = [
"tomli>=2.3.0,<3.0.0", "tomli>=2.3.0,<3.0.0",
"django-honeypot>=1.3.0,<2", "django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0", "pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<13", "ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0", "redis[hiredis]<7,>=5.3.0",
"environs[django]>=14.5.0,<15.0.0", "environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0", "requests>=2.32.5,<3.0.0",

View File

@@ -136,14 +136,11 @@ class PicturesController(ControllerBase):
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[CanView], permissions=[CanView],
response=list[IdentifiedUserSchema], response=list[IdentifiedUserSchema],
url_name="picture_identifications",
) )
def fetch_identifications(self, picture_id: int): def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture.""" """Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.viewable_by(self.context.request.user).select_related( return picture.people.select_related("user")
"user"
)
@route.put("/{picture_id}/identified", permissions=[CanView]) @route.put("/{picture_id}/identified", permissions=[CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]): def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):

View File

@@ -265,15 +265,6 @@ def sas_notification_callback(notif: Notification):
notif.param = str(count) notif.param = str(count)
class PeoplePictureRelationQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self
if user.was_subscribed:
return self.filter(Q(user_id=user.id) | Q(user__is_viewable=True))
return self.filter(user_id=user.id)
class PeoplePictureRelation(models.Model): class PeoplePictureRelation(models.Model):
"""The PeoplePictureRelation class makes the connection between User and Picture.""" """The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -290,8 +281,6 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta: class Meta:
unique_together = ["user", "picture"] unique_together = ["user", "picture"]

View File

@@ -186,29 +186,6 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404 assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count assert PeoplePictureRelation.objects.count() == relation_count
def test_fetch_relations_including_hidden_users(self):
"""Test that normal subscribers users cannot see hidden profiles"""
picture = self.album_a.children_pictures.last()
self.user_a.is_viewable = False
self.user_a.save()
url = reverse("api:picture_identifications", kwargs={"picture_id": picture.id})
# a normal subscriber user shouldn't see user_a as identified
self.client.force_login(subscriber_user.make())
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_b.id, self.user_c.id}
# an admin should see everyone
self.client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
)
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_a.id, self.user_b.id, self.user_c.id}
class TestPictureModeration(TestSas): class TestPictureModeration(TestSas):
@classmethod @classmethod

View File

@@ -1,11 +1,10 @@
import pytest
from django.test import TestCase from django.test import TestCase
from model_bakery import baker from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import PeoplePictureRelation, Picture from sas.models import Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -45,25 +44,3 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user)) pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]] assert pictures == [self.pictures[1]]
@pytest.mark.django_db
def test_identifications_viewable_by_user():
picture = baker.make(Picture)
identifications = baker.make(
PeoplePictureRelation, picture=picture, _quantity=10, _bulk_create=True
)
identifications[0].user.is_viewable = False
identifications[0].user.save()
assert (
list(picture.people.viewable_by(old_subscriber_user.make()))
== identifications[1:]
)
assert (
list(picture.people.viewable_by(baker.make(User, is_superuser=True)))
== identifications
)
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]

View File

@@ -7,7 +7,6 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
from core.utils import get_last_promo
from core.views.forms import SelectDate, SelectDateTime from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import AutoCompleteSelectUser
from subscription.models import Subscription from subscription.models import Subscription
@@ -126,17 +125,8 @@ class SubscriptionNewUserForm(SubscriptionForm):
"deux-semestres", "deux-semestres",
"cursus-tronc-commun", "cursus-tronc-commun",
"cursus-branche", "cursus-branche",
"cursus-alternant",
]: ]:
member.role = "STUDENT" member.role = "STUDENT"
member.school = "UTBM"
if self.cleaned_data.get("subscription_type") == "cursus-tronc-commun":
member.promo = get_last_promo()
if self.cleaned_data.get("subscription_type") in [
"cursus-branche",
"cursus-alternant",
]:
member.promo = get_last_promo() - 2
member.generate_username() member.generate_username()
member.set_password(secrets.token_urlsafe(nbytes=10)) member.set_password(secrets.token_urlsafe(nbytes=10))
self.instance.member = member self.instance.member = member