18 Commits

Author SHA1 Message Date
dependabot[bot]
b927b9c0f2 [UPDATE] Update pytest requirement
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.2...9.0.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 08:06:35 +00:00
thomas girod
2dce0674a2 Merge pull request #1239 from ae-utbm/fix-og
fix: url in news_detail og:image
2025-11-09 23:07:15 +01:00
imperosol
0a3e0fa755 fix: url in news_detail og:image 2025-11-09 23:06:32 +01:00
thomas girod
2984e14746 Merge pull request #1237 from ae-utbm/remove-repair-fs
remove `SithFile._check_fs` and `SithFile.repair_fs`
2025-11-09 21:40:32 +01:00
thomas girod
99f79487aa Merge pull request #1236 from ae-utbm/csrf-api
remove deprecated api csrf argument
2025-11-09 21:38:36 +01:00
imperosol
075c6f16ec make ApiKeyAuth work on non-safe routes 2025-11-09 21:34:26 +01:00
imperosol
2e9e1b6a78 remove deprecated api csrf argument 2025-11-09 21:34:26 +01:00
thomas girod
02f7e10729 Merge pull request #1235 from ae-utbm/update-docs
update doc
2025-11-09 21:32:14 +01:00
imperosol
0ba84c4750 docs: update doc 2025-11-09 21:31:45 +01:00
thomas girod
1e25560a1c Merge pull request #1234 from ae-utbm/og-tags
add missing open graph tags
2025-11-09 21:28:55 +01:00
imperosol
530e851bd1 add missing open graph tags 2025-11-09 21:28:16 +01:00
thomas girod
1d2a90a751 Merge pull request #1232 from ae-utbm/group-simplify
simplify `User.is_in_group`
2025-11-09 21:27:51 +01:00
imperosol
61d51a08d2 simplify User.is_in_group 2025-11-09 21:27:18 +01:00
thomas girod
99b86fb27d Merge pull request #1233 from ae-utbm/fix-duplicated-message
fix: duplicated message on `PermissionGroupsUpdateView`
2025-11-09 21:26:32 +01:00
imperosol
ec9bfd3b7e fix: duplicated message on PermissionGroupsUpdateView 2025-11-09 21:25:44 +01:00
thomas girod
7ef16f027a Merge pull request #1230 from ae-utbm/gala-2025
Custom style for Gala 2025
2025-11-09 21:24:50 +01:00
imperosol
282c4b8f26 remove SithFile._check_fs and SithFile.repair_fs 2025-11-09 17:00:44 +01:00
imperosol
8cbf42d714 Custom style for Gala 2025 2025-11-09 15:33:29 +01:00
53 changed files with 367 additions and 610 deletions

View File

@@ -6,6 +6,8 @@ from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:

View File

@@ -0,0 +1,48 @@
import pytest
from django.test import Client
from django.urls import path
from model_bakery import baker
from ninja import NinjaAPI
from ninja.security import SessionAuth
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
api = NinjaAPI()
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
def post_method(*args, **kwargs) -> None:
"""Dummy POST route authenticated by either api key or session cookie."""
pass
urlpatterns = [path("", api.urls)]
@pytest.mark.django_db
@pytest.mark.urls(__name__)
@pytest.mark.parametrize("user_logged_in", [False, True])
def test_csrf_token(user_logged_in):
"""Test that CSRF check happens only when no api key is used."""
client = Client(enforce_csrf_checks=True)
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
if user_logged_in:
client.force_login(api_client.owner)
response = client.post("")
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"
# if using a valid API key, CSRF check should not occur
response = client.post("", headers={"X-APIKey": key})
assert response.status_code == 200
# if using a wrong API key, ApiKeyAuth should fail,
# leading to a fallback into SessionAuth and a CSRF check
response = client.post("", headers={"X-APIKey": generate_key()[0]})
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"

View File

@@ -1,3 +1,4 @@
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI(
@@ -5,6 +6,6 @@ api = NinjaExtraAPI(
description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0",
urls_namespace="api",
csrf=True,
auth=[SessionAuth()],
)
api.auto_discover_controllers()

View File

@@ -16,7 +16,7 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@@ -27,7 +27,7 @@ class ClubController(ControllerBase):
@route.get(
"/{int:club_id}",
response=ClubSchema,
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("club.view_club")],
url_name="fetch_club",
)

View File

@@ -9,6 +9,18 @@
{{ club.short_description }}
{%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ club.name }}" />
<meta property="og:description" content="{{ club.short_description }}" />
{% if club.logo %}
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
{% else %}
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endif %}
{% endblock %}
{% block content %}
<div id="club_detail">
{% if club.logo %}
@@ -17,7 +29,7 @@
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{% trans %}Club{% endtrans %}</h3>
<h3>{{ club.name }}</h3>
{% endif %}
</div>
{% endblock %}

View File

@@ -5,7 +5,6 @@ from django.utils.cache import add_never_cache_headers
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm
@@ -17,17 +16,13 @@ from core.views.files import send_raw_file
@api_controller("/calendar")
class CalendarController(ControllerBase):
@route.get("/internal.ics", url_name="calendar_internal")
@route.get("/internal.ics", auth=None, url_name="calendar_internal")
def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
@route.get("/unpublished.ics", url_name="calendar_unpublished")
def calendar_unpublished(self):
response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
@@ -74,6 +69,7 @@ class NewsController(ControllerBase):
@route.get(
"/date",
auth=None,
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)

View File

@@ -1,15 +1,20 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
{% from 'core/macros.jinja' import user_profile_link, link_news_logo %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %} -
{{ object.title }}
{% trans %}News{% endtrans %} - {{ object.title }}
{% endblock %}
{% block head %}
{{ super() }}
{{ gen_news_metatags(news) }}
{% block description %}{{ news.summary }}{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
{% endblock %}
@@ -44,8 +49,14 @@
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<a
rel="nofollow"
target="#"
class="share_button facebook"
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
>
{% trans %}Share on Facebook{% endtrans %}
</a>
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}

View File

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

View File

@@ -74,19 +74,9 @@ class UserBanAdmin(admin.ModelAdmin):
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)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page)

View File

@@ -74,7 +74,7 @@ class MailingListController(ControllerBase):
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
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])
def fetch_user(self, user_id: int):
@@ -90,9 +90,7 @@ class UserController(ControllerBase):
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(
User.objects.viewable_by(self.context.request.user).order_by(
F("last_login").desc(nulls_last=True)
)
User.objects.order_by(F("last_login").desc(nulls_last=True))
)
@@ -101,7 +99,7 @@ class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -114,7 +112,7 @@ class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)

View File

@@ -1,40 +0,0 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively check the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._check_fs()

View File

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

View File

@@ -1,41 +0,0 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively repair the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._repair_fs()

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

@@ -23,14 +23,12 @@
#
from __future__ import annotations
import logging
import os
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self
from typing import TYPE_CHECKING, Self
from uuid import uuid4
from django.conf import settings
@@ -97,48 +95,6 @@ def validate_promo(value: int) -> None:
)
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
# Using None as a cache value is a little bit tricky,
# so we use a special string to represent None
return None
elif group is not None:
return group
# if this point is reached, the group is not in cache
if pk is not None:
group = Group.objects.filter(pk=pk).first()
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
@@ -180,15 +136,6 @@ class UserQuerySet(models.QuerySet):
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)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -324,24 +271,13 @@ class User(AbstractUser):
parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default=""
)
is_viewable = models.BooleanField(
_("Profile visible by subscribers"),
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
is_subscriber_viewable = models.BooleanField(
_("is subscriber viewable"), default=True
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self):
return self.get_display_name()
@@ -402,19 +338,18 @@ class User(AbstractUser):
Returns:
True if the user is the group, else False
"""
if pk is not None:
group: Optional[Group] = get_group(pk=pk)
elif name is not None:
group: Optional[Group] = get_group(name=name)
else:
if not pk and not name:
raise ValueError("You must either provide the id or the name of the group")
if group is None:
group_id: int | None = (
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
)
if group_id is None:
return False
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID:
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return group in self.cached_groups
return any(g.id == group_id for g in self.cached_groups)
@cached_property
def cached_groups(self) -> list[Group]:
@@ -474,14 +409,6 @@ class User(AbstractUser):
else:
raise ValidationError(_("A user with that username already exists"))
def get_profile(self):
return {
"last_name": self.last_name,
"first_name": self.first_name,
"nick_name": self.nick_name,
"date_of_birth": self.date_of_birth,
}
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@@ -624,12 +551,8 @@ class User(AbstractUser):
def can_be_edited_by(self, user):
return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool:
return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def can_be_viewed_by(self, user):
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
def get_mini_item(self):
return """
@@ -713,8 +636,8 @@ class AnonymousUser(AuthAnonymousUser):
if pk is not None:
return pk == allowed_id
elif name is not None:
group = get_group(name=name)
return group is not None and group.id == allowed_id
group = Group.objects.get(id=allowed_id)
return group.name == name
else:
raise ValueError("You must either provide the id or the name of the group")
@@ -1040,63 +963,6 @@ class SithFile(models.Model):
self.clean()
self.save()
def _repair_fs(self):
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
if self.is_folder:
for c in self.children.all():
c._repair_fs()
return
elif not self._check_path_consistence():
# First get future parent path and the old file name
# Prepend "." so that we match all relative handling of Django's
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
settings.MEDIA_ROOT + old_path,
settings.MEDIA_ROOT + new_path,
)
# Empty directories may remain, but that's not really a
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
return True
def _check_fs(self):
if self.is_folder:
for c in self.children.all():
c._check_fs()
return
else:
self._check_path_consistence()
@property
def is_file(self):
return not self.is_folder

View File

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

View File

@@ -11,7 +11,8 @@ $hovered-red-text-color: #ff4d4d;
.header {
box-sizing: border-box;
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;
width: 100%;
display: flex;
@@ -99,7 +100,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: $deepblue;
background-color: transparent;
width: 45px;
height: 25px;
padding: 0;
@@ -331,7 +332,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px;
z-index: 100;
border-radius: 10px;
box-shadow: 3px 3px 3px 0 #767676;
@include shadow;
>ul {
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 {
padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px;
padding: 1.5em 2%;
border-radius: 5px;
box-shadow: black 0 8px 15px;
background: $white-color;
overflow: auto;
}

View File

@@ -7,13 +7,10 @@
.profile {
&-visible {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures {
@@ -119,19 +116,23 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
gap: 10px;
justify-content: center;
}
&-field {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 330px;
min-width: 300px;
@media (max-width: 750px) {
gap: 4px;
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 {
height: 7rem;
}

View File

@@ -4,12 +4,22 @@
{% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block description -%}
<meta
name="description"
content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}">
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
@@ -34,6 +44,18 @@
{% block additional_css %}{% 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 %}
</head>

View File

@@ -1,6 +1,6 @@
<header class="header">
<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;
</a>
<a class="header-logo-text" href="{{ url('core:index') }}">

View File

@@ -21,20 +21,6 @@
{% else %}
<h2>{% trans %}Save{% endtrans %}</h2>
{% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}

View File

@@ -13,30 +13,11 @@
{%- endmacro %}
{% macro link_news_logo(news) -%}
{% if news.club.logo -%}
{%- if news.club.logo -%}
{{ news.club.logo.url }}
{% else -%}
{%- else -%}
{{ static("com/img/news.png") }}
{% endif %}
{%- endmacro %}
{% macro gen_news_metatags(news) -%}
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
{%- endmacro %}
{% macro facebook_share(news) -%}
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
{%- endmacro %}
{% macro tweet(news) -%}
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endif -%}
{%- endmacro %}
{% macro user_mini_profile(user) %}

View File

@@ -12,6 +12,18 @@
{% 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) }} >

View File

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

View File

@@ -421,18 +421,16 @@ class TestUserIsInGroup(TestCase):
# clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is in the group
with self.assertNumQueries(2):
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is not in the group
with self.assertNumQueries(2):
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 403
assert response.status_code == 401
self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get(
@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403
def test_fetch_family_hidden_user(self):
self.main_user.is_viewable = False
self.main_user.is_subscriber_viewable = False
self.main_user.save()
for user_to_login, error_code in [
(self.main_user, 200),

View File

@@ -269,7 +269,7 @@ def test_apply_rights_recursively():
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
403,
401,
),
(
lambda: baker.make(User),

View File

@@ -3,7 +3,6 @@ from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
@@ -19,7 +18,7 @@ from core.baker_recipes import (
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 counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
@@ -369,38 +368,3 @@ class TestRedirectMe:
def test_promo_has_logo(promo):
user = baker.make(User, promo=promo)
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

@@ -202,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school",
"promo",
"forum_signature",
"is_viewable",
"is_subscriber_viewable",
]
widgets = {
"date_of_birth": SelectDate,
@@ -211,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea,
}
def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 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

View File

@@ -103,7 +103,9 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password."""
if not request.user.is_root:
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":
form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid():

View File

@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleProductSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
def fetch_all(self):
return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move")
@route.patch("/{type_id}/move", url_name="reorder_product_type")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type.

View File

@@ -3,11 +3,9 @@ from django.conf import settings
from django.test import Client
from django.urls import reverse
from model_bakery import baker, seq
from ninja_extra.testing import TestClient
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.api import ProductTypeController
from counter.models import ProductType
@@ -19,24 +17,43 @@ def product_types(db) -> list[ProductType]:
return baker.make(ProductType, _quantity=5, order=seq(0))
@pytest.fixture()
def counter_admin_client(db, client: Client) -> Client:
client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
)
return client
@pytest.mark.django_db
def test_fetch_product_types(product_types: list[ProductType]):
def test_fetch_product_types(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that the API returns the right products in the right order"""
client = TestClient(ProductTypeController)
response = client.get("")
response = counter_admin_client.get(reverse("api:fetch_product_types"))
assert response.status_code == 200
assert [i["id"] for i in response.json()] == [t.id for t in product_types]
@pytest.mark.django_db
def test_move_below_product_type(product_types: list[ProductType]):
def test_move_below_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product below another works"""
client = TestClient(ProductTypeController)
response = client.patch(
f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
response = counter_admin_client.patch(
reverse(
"api:reorder_product_type",
kwargs={"type_id": product_types[-1].id},
query={"below": product_types[0].id},
),
)
assert response.status_code == 200
new_order = [i["id"] for i in client.get("").json()]
new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [
product_types[0].id,
product_types[-1].id,
@@ -45,14 +62,22 @@ def test_move_below_product_type(product_types: list[ProductType]):
@pytest.mark.django_db
def test_move_above_product_type(product_types: list[ProductType]):
def test_move_above_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product above another works"""
client = TestClient(ProductTypeController)
response = client.patch(
f"/{product_types[1].id}/move", query={"above": product_types[0].id}
response = counter_admin_client.patch(
reverse(
"api:reorder_product_type",
kwargs={"type_id": product_types[1].id},
query={"above": product_types[0].id},
),
)
assert response.status_code == 200
new_order = [i["id"] for i in client.get("").json()]
new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [
product_types[1].id,
product_types[0].id,

View File

@@ -182,29 +182,19 @@ ainsi même que de l'héritage de templates.
si on souhaite faire des modifications côté client,
il faut utiliser du Javascript, rien ne change à ce niveau-là.
### jQuery
### Typescript
[Site officiel](https://jquery.com/)
[Site officiel](https://www.typescriptlang.org/)
jQuery est une bibliothèque JavaScript
libre et multiplateforme créée pour faciliter
l'écriture de scripts côté client
dans le code HTML des pages web.
La première version est lancée en janvier 2006 par John Resig.
Pour rendre le site interactif, nous n'utilisons
pas directement Javascript, mais Typescript.
Il s'agit d'un langage construit par-dessus Javascript,
en ajoutant un typage statique et des éléments de sucre syntaxique.
Grâce au système de type, le code est plus lisible,
à la fois par les humains et par l'IDE, et plus fiable.
C'est une vieille technologie et certains
feront remarquer à juste titre que le Javascript
moderne permet d'utiliser assez simplement
la majorité de ce que fournit jQuery
sans rien avoir à installer.
Cependant, de nombreuses dépendances du projet
utilisent encore jQuery qui est toujours
très implanté aujourd'hui.
Le sucre syntaxique qu'offre cette librairie
reste très agréable à utiliser et économise
parfois beaucoup de temps.
Ça fonctionne et ça fonctionne très bien.
C'est maintenu et pratique.
Il faut parfois se battre un peu contre le système de types de Typescript,
mais globalement Typescript est une alternative largement préférable à Javascript.
### AlpineJS
@@ -270,17 +260,6 @@ sur tous les navigateurs contrairement
à un simple icône unicode qui s'affiche
lui différemment selon la plate-forme.
!!!note
C'est une dépendance capricieuse qui évolue très vite
et qu'il faut très souvent mettre à jour.
!!!warning
Il a été décidé de **ne pas utiliser**
de CDN puisque le site ralentissait régulièrement.
Il est préférable de fournir cette dépendance avec le site.
## Workflow
### Git

View File

@@ -1,4 +1,4 @@
L'ORM de Django est puissant, très puissant, non par parce qu'il
L'ORM de Django est puissant, très puissant, non pas parce qu'il
est performant (après tout, ce n'est qu'une interface, le gros du boulot,
c'est la db qui le fait), mais parce qu'il permet d'écrire
de manière relativement simple un grand panel de requêtes.

View File

@@ -51,7 +51,7 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en
Le bundler ne génère que des modules javascript.
Ajouter `type="module"` n'est pas optionnel !
### Les imports au sein des fichiers des fichiers javascript bundlés
### Les imports au sein des fichiers javascript bundlés
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.

View File

@@ -36,11 +36,4 @@ SITH_SUBSCRIPTIONS = {
}
```
Une fois ceci fait, il faut créer une nouvelle migration :
```bash
python ./manage.py makemigrations subscription
python ./manage.py migrate
```
N'oubliez pas non plus les traductions (cf. [ici](./translation.md))
Après ça, n'oubliez pas de gérer les traductions (cf. [ici](./translation.md))

View File

@@ -1,4 +1,3 @@
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
Ce sont des librairies relativement simples et qui présentent
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
@@ -49,8 +48,9 @@ Notre API offre deux moyens d'authentification :
- par clef d'API
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
Cette dernière est donc activée par défaut.
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
Pour changer la méthode d'authentification,
utilisez l'attribut `auth` et les classes `SessionAuth` et
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
@@ -60,13 +60,17 @@ utilisez l'attribut `auth` et les classes `SessionAuth` et
@api_controller("/foo")
class FooController(ControllerBase):
# Cette route sera accessible uniquement avec l'authentification
# par cookie de session
@route.get("", auth=[SessionAuth()])
# par clef d'API
@route.get("", auth=[ApiKeyAuth()])
def fetch_foo(self, club_id: int): ...
# Et celle-ci sera accessible peut importe la méthode d'authentification
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
# Celle-ci sera accessible avec les deux méthodes d'authentification
@route.get("/bar", auth=[ApiKeyAuth(), SessionAuth()])
def fetch_bar(self, club_id: int): ...
# Et celle-ci sera accessible aussi aux utilisateurs non-connectés
@route.get("/public", auth=None)
def fetch_public(self, club_id: int): ...
```
### Permissions
@@ -79,9 +83,7 @@ par-dessus `django-ninja`, le système de permissions de django
et notre propre système.
Cette dernière est documentée [ici](../perms.md).
### Limites des clefs d'API
#### Incompatibilité avec certaines permissions
### Incompatibilité avec certaines permissions
Le système des clefs d'API est apparu très tard dans l'histoire du site
(en P25, 10 ans après le début du développement).
@@ -112,10 +114,33 @@ Les principaux points de friction sont :
- `IsLoggedInCounter`, qui utilise encore un autre système
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
#### Incompatibilité avec les tokens csrf
### CSRF
!!!info "A propos du csrf"
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un des multiples facteurs d'attaque sur le web.
est un vecteur d'attaque sur le web consistant
à soumettre des données au serveur à l'insu
de l'utilisateur, en profitant de sa session.
C'est une attaque qui peut se produire lorsque l'utilisateur
est authentifié par cookie de session.
En effet, les cookies sont joints automatiquement à
toutes les requêtes ;
en l'absence de protection contre le CSRF,
un attaquant parvenant à insérer un formulaire
dans la page de l'utilisateur serait en mesure
de faire presque n'importe quoi en son nom,
et ce sans même que l'utilisateur ni les administrateurs
ne s'en rendent compte avant qu'il ne soit largement trop tard !
Sur le CSRF et les moyens de s'en prémunir, voir :
- [https://owasp.org/www-community/attacks/csrf]()
- [https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints]()
- [https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html]()
Le CSRF, c'est dangereux.
Heureusement, Django vient encore une fois à notre aide,
avec des mécanismes intégrés pour s'en protéger.
Ceux-ci incluent notamment un système de
@@ -123,16 +148,39 @@ Ceux-ci incluent notamment un système de
à fournir dans les requêtes POST/PUT/PATCH.
Ceux-ci sont bien adaptés au cycle requêtes/réponses
typique de l'expérience utilisateur sur un navigateur,
typiques de l'expérience utilisateur sur un navigateur,
où les requêtes POST sont toujours effectuées après une requête
GET au cours de laquelle on a pu récupérer un token csrf.
Cependant, le flux des requêtes sur une API est bien différent ;
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
par un client externe n'aient pas de token CSRF et se retrouvent
donc bloquées.
Cependant, ils sont également gênants et moins utiles
dans le cadre d'une API REST, étant donné
que l'authentification cesse d'être implicite :
la clef d'API doit être explicitement jointe aux headers,
pour chaque requête.
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
par un client externe ne marche pas.
Pour ces raisons, la vérification CSRF ne prend place
que pour la vérification de l'authentification
par cookie de session.
!!!warning "L'ordre est important"
Si vous écrivez le code suivant, l'authentification par clef d'API
ne marchera plus :
```python
@api_controller("/foo")
class FooController(ControllerBase):
@route.post("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def post_bar(self, club_id: int): ...
```
En effet, la vérification du cookie de session intègrera
toujours la vérification CSRF.
Or, un échec de cette dernière est traduit par django en un code HTTP 403
au lieu d'un HTTP 401.
L'authentification se retrouve alors court-circuitée,
faisant que la vérification de la clef d'API ne sera jamais appelée.
`SessionAuth` doit donc être déclaré **après** `ApiKeyAuth`.
## Créer un client et une clef d'API
@@ -171,5 +219,3 @@ qui en a besoin.
Dites-lui bien de garder cette clef en lieu sûr !
Si la clef est perdue, il n'y a pas moyen de la récupérer,
vous devrez en recréer une.

View File

@@ -157,16 +157,18 @@ que sont VsCode et Sublime Text.
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
supplémentaire à effectuer.
Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante:
Pour utiliser Biome, placez-vous à la racine du projet et lancez la commande suivante:
```bash
npx @biomejs/biome check # Pour checker le code avec le linter et le formater
npx @biomejs/biome check --write # Pour appliquer les changemnts
npx @biomejs/biome check --write # Pour appliquer les changements
```
Biome va alors faire son travail sur l'ensemble du projet puis vous dire
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
ou bien s'il y a des erreurs à réparer
(si vous avez fait `npx @biomejs/biome lint`)
ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
Appeler Biome en ligne de commandes avant de pousser votre code sur Github
est une technique qui marche très bien.

View File

@@ -30,7 +30,7 @@ opérations, telles que la validation de formulaire.
En effet, valider un formulaire demande beaucoup
de travail de nettoyage des données et d'affichage
des messages d'erreur appropriés.
Or, tout ce travail existe déjà dans django.
Or, tout ce travail existe déjà dans Django.
On veut donc, dans ces cas-là, ne pas demander
toute une page HTML au serveur, mais uniquement
@@ -84,7 +84,7 @@ Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons
une `EditView` très simple, contenant
une `UpdateView` très simple, contenant
uniquement un formulaire.
On peut écrire la vue et le template de la manière
suivante :
@@ -94,8 +94,10 @@ suivante :
```python
from django.views.generic import UpdateView
from core.views import AllowFragment
class FooUpdateView(UpdateView):
class FooUpdateView(AllowFragment, UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
@@ -132,7 +134,7 @@ Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template
qui peut changer les situations, on a plusieurs
qui peut changer selon les situations, on a plusieurs
templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un,
@@ -238,10 +240,10 @@ qui se comportera alors comme une vue normale.
#### La méthode `as_fragment`
Il est à noter que l'instantiation d'un fragment
Il est à noter que l'instanciation d'un fragment
se fait en deux étapes :
- on commence par instantier la vue en tant que renderer.
- on commence par instancier la vue en tant que renderer.
- on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`.

View File

@@ -76,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```bash
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
sudo pacman -S uv gcc git gettext pkgconf npm redis
sudo pacman -S uv gcc git gettext pkgconf npm valkey
```
=== "macOS"

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-09 18:03+0100\n"
"POT-Creation-Date: 2025-11-07 14:50+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -1532,15 +1532,8 @@ msgid "parent address"
msgstr "adresse des parents"
#: core/models.py
msgid "Profile visible by subscribers"
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."
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
#: core/models.py
msgid "A user with that username already exists"
@@ -5119,6 +5112,14 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester 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
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
@@ -5132,9 +5133,7 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member (2 weeks)"
msgid "GA staff member"
msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py
@@ -5678,12 +5677,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "is viewable"
#~ msgstr "profil visible"
#~ msgid "One month for free"
#~ msgstr "Un mois gratuit"
#~ msgid "Two months for free"
#~ msgstr "Deux mois gratuits"

View File

@@ -105,7 +105,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root):
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)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else:
q = []
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:
search_dict = {}
for key, value in self.valid_form.items():

View File

@@ -19,7 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient
class UvController(ControllerBase):
@route.get(
"/{code}",
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[
# this route will almost always be called in the context
# of a UV creation/edition
@@ -45,7 +45,7 @@ class UvController(ControllerBase):
"",
response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("pedagogy.view_uv")],
)
@paginate(PageNumberPaginationExtra, page_size=100)

View File

@@ -73,7 +73,7 @@ dev = [
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=8.4.2,<9.0.0",
"pytest>=8.4.2,<10.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4",

View File

@@ -8,7 +8,6 @@ from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
@@ -41,7 +40,6 @@ class AlbumController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[AlbumSchema],
permissions=[IsAuthenticated],
url_name="search-album",
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -54,7 +52,7 @@ class AlbumController(ControllerBase):
@route.get(
"/autocomplete-search",
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
auth=[SessionAuth(), ApiKeyAuth()],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -74,12 +72,7 @@ class AlbumController(ControllerBase):
@api_controller("/sas/picture")
class PicturesController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated],
url_name="pictures",
)
@route.get("", response=PaginatedResponseSchema[PictureSchema], url_name="pictures")
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters.
@@ -141,18 +134,15 @@ class PicturesController(ControllerBase):
@route.get(
"/{picture_id}/identified",
permissions=[IsAuthenticated, CanView],
permissions=[CanView],
response=list[IdentifiedUserSchema],
url_name="picture_identifications",
)
def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.viewable_by(self.context.request.user).select_related(
"user"
)
return picture.people.select_related("user")
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
@route.put("/{picture_id}/identified", permissions=[CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(
Picture.objects.select_related("parent"), pk=picture_id
@@ -212,7 +202,7 @@ class PicturesController(ControllerBase):
@api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase):
@route.delete("/{relation_id}", permissions=[IsAuthenticated])
@route.delete("/{relation_id}")
def delete_relation(self, relation_id: NonNegativeInt):
"""Untag a user from a SAS picture.

View File

@@ -265,15 +265,6 @@ def sas_notification_callback(notif: Notification):
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):
"""The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -290,8 +281,6 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE,
)
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta:
unique_together = ["user", "picture"]

View File

@@ -55,7 +55,7 @@ class TestPictureSearch(TestSas):
def test_anonymous_user_forbidden(self):
res = self.client.get(self.url)
assert res.status_code == 403
assert res.status_code == 401
def test_filter_by_album(self):
self.client.force_login(self.user_b)
@@ -148,7 +148,7 @@ class TestPictureRelation(TestSas):
relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first()
res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 403
assert res.status_code == 401
for user in baker.make(User), self.user_a:
self.client.force_login(user)
@@ -186,29 +186,6 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404
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):
@classmethod

View File

@@ -1,11 +1,10 @@
import pytest
from django.test import TestCase
from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.baker_recipes import picture_recipe
from sas.models import PeoplePictureRelation, Picture
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@@ -45,25 +44,3 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user))
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]
]