116 Commits

Author SHA1 Message Date
dfb545b15e add feedback when moving reservation slot 2025-06-30 22:50:44 +02:00
f9fa4c0643 extract AlertMessage to its own file 2025-06-30 22:50:27 +02:00
72bb4788f2 test: room and slots creation/edition 2025-06-30 22:36:17 +02:00
0baaf69714 fix: rebase issues 2025-06-30 16:16:24 +02:00
edd8b9a385 test: ReservationForm 2025-06-30 16:11:59 +02:00
a322a0895a add translations 2025-06-30 16:11:59 +02:00
d4e853fa60 Room reservation form 2025-06-30 16:11:59 +02:00
21416dc27a Room reservations planning 2025-06-30 14:05:43 +02:00
b2d97ab138 room management views 2025-06-30 14:05:43 +02:00
f092d44ef7 fix: FutureDateTime form field 2025-06-30 14:05:43 +02:00
08abc62e56 reservable rooms API 2025-06-30 14:05:43 +02:00
5f2caf9d61 generate test data for the reservations 2025-06-30 14:05:43 +02:00
c45be81bb3 create reservation models 2025-06-30 14:05:43 +02:00
Sli
af014e419f Adapt calendar to new tooltip library 2025-06-30 14:05:43 +02:00
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +02:00
eb4fbcbda4 Merge pull request #1140 from Juknum/feature/update-footer-on-mobile
Màj du footer sur mobile
2025-06-26 16:01:20 +02:00
570510f18d Merge pull request #1135 from ae-utbm/group
Small group tweak
2025-06-25 22:04:56 +02:00
7f371984d8 Merge pull request #1143 from ae-utbm/fix/mail-enumeration
fix: enumeration attack vector on login form
2025-06-25 17:53:53 +02:00
abf7bf6bfa rename location_admin to campus_admin 2025-06-25 17:13:24 +02:00
02ef8fdb88 fix: enumeration attack vector on login form 2025-06-25 17:03:53 +02:00
a7f4630d13 Merge pull request #1138 from ae-utbm/counter-admin
improve counter admin pages
2025-06-25 17:03:03 +02:00
c7087c6e7e Merge pull request #1137 from ae-utbm/fix-user-pictures
fix: user pictures ordering
2025-06-25 16:40:23 +02:00
f38926c4a3 fix: user pictures ordering 2025-06-25 16:25:51 +02:00
9a19f34ea2 Merge pull request #1141 from ae-utbm/fix-permanences
Fix permanences
2025-06-25 14:55:36 +02:00
67884017f8 fix old permanences having end replaced by activity 2025-06-25 01:22:13 +02:00
Sli
f474edc84f Style adjustment on the new footer 2025-06-24 17:04:52 +02:00
f5a8228358 Rework footer's UX on small devices 2025-06-22 20:01:22 +02:00
59a714af9f Merge pull request #1134 from ae-utbm/family
Add zoom controls to family graph
2025-06-21 15:20:47 +02:00
9049d8779c improve counter admin pages 2025-06-21 15:06:08 +02:00
Sli
d111023363 Apply review comments 2025-06-21 12:37:01 +02:00
cdfa76ad57 add missing "Respo site" group 2025-06-18 18:01:37 +02:00
88b70bf51f rename main groups to their real production version 2025-06-18 18:01:37 +02:00
Sli
ca593c7d81 Avoid click on graph when zooming 2025-06-18 16:24:53 +02:00
Sli
94bdc5e615 Remove useless closures 2025-06-18 14:13:06 +02:00
Sli
7d454749e0 Add style to zoom controls on family graph 2025-06-18 14:10:26 +02:00
06090e0cd9 Merge pull request #1133 from ae-utbm/api-fixes
fix: api title typo (again)
2025-06-18 12:25:31 +02:00
a1ae67da7d Merge pull request #1132 from ae-utbm/missing-perm
Missing SAS permission
2025-06-18 12:25:15 +02:00
Sli
10d5b9d63f Add zoom control of family graph 2025-06-18 12:22:30 +02:00
Sli
cc96c93d23 Convert family tree to typescript 2025-06-18 11:59:46 +02:00
8cc0b01e9c fix: api title typo (again) 2025-06-17 21:01:51 +02:00
88755358a6 fix: add missing sas permission 2025-06-17 21:00:38 +02:00
0e850e5486 Merge pull request #1131 from ae-utbm/api-fixes
Api fixes
2025-06-17 15:57:33 +02:00
af67c5fc27 Merge pull request #1130 from ae-utbm/navbar-keyboard-navigation
Fix click on navbar
2025-06-17 15:41:42 +02:00
Sli
30809a69c9 Move navbar script to dedicated file 2025-06-17 15:39:35 +02:00
0c442a8f03 fix: select only active club members on GET /club/{club_id} 2025-06-17 15:35:49 +02:00
f1b69dd47d fix: typo in API name 2025-06-17 15:35:49 +02:00
Sli
b5ebf09fcb Fix click on navbar 2025-06-17 15:31:51 +02:00
9d9ce5b30a Merge pull request #1129 from ae-utbm/fix-docs
fix: documentation CI/CD
2025-06-17 15:09:06 +02:00
a87460fa3e fix: documentation CI/CD 2025-06-17 14:45:51 +02:00
48fae33651 Merge pull request #1119 from ae-utbm/notifs
Improve notification on picture identification
2025-06-17 11:22:06 +02:00
6fec250658 display album name on picture identification notif 2025-06-16 18:36:08 +02:00
75b37cd6e3 fix album grouping on user pictures page 2025-06-16 18:36:08 +02:00
9c3820f986 Merge pull request #1127 from ae-utbm/deps
Update dependencies
2025-06-16 18:35:50 +02:00
28b60c7bae Merge pull request #1097 from ae-utbm/api-key
Basic api key management
2025-06-16 18:21:19 +02:00
efbbfcda76 update js deps 2025-06-16 15:51:11 +02:00
9e1fe7a296 update python deps 2025-06-16 15:51:04 +02:00
50d7b7e731 Move api urls to api app 2025-06-16 15:00:30 +02:00
ae7784a973 rename apikey to api 2025-06-16 14:54:42 +02:00
a23604383b doc: incompatibility between api keys and csrf 2025-06-16 13:44:43 +02:00
80866086a8 Forbid authentication with revoked keys 2025-06-16 13:44:43 +02:00
2c7eb99f31 use 54 bytes keys and sha512 hashing 2025-06-16 13:44:43 +02:00
189081f5a8 api key doc for developers 2025-06-16 13:44:43 +02:00
52e53da9ef adapt CanAccessLookup to api key auth 2025-06-16 13:44:43 +02:00
b5d65133f3 add doc for external API consumers 2025-06-16 13:44:43 +02:00
44e1902693 Add GET /api/club/{club_id} to fetch details about a club 2025-06-16 13:44:43 +02:00
1d55a5c2da Make HasPerm work with ApiKeyAuth 2025-06-16 13:44:43 +02:00
853aa34c18 adapt pedagogy api to api key auth 2025-06-16 13:44:43 +02:00
dc72789c14 feat: basic api key management 2025-06-16 13:44:41 +02:00
2f0454355f Merge pull request #1126 from ae-utbm/hey-api
Upgrade hey-api
2025-06-16 12:25:50 +02:00
1c14bb22a0 Merge pull request #1125 from ae-utbm/navbar-keyboard-navigation
Disable mouse click on navbar for desktop
2025-06-16 12:25:33 +02:00
d1f11216c7 Merge pull request #1124 from ae-utbm/tabs
Add tab widget and remove jquery-ui
2025-06-16 12:25:14 +02:00
Sli
2299e3f966 Upgrade hey-api 2025-06-16 11:20:41 +02:00
Sli
0f55bcc513 Disable mouse click on navbar for desktop 2025-06-16 09:17:40 +02:00
Sli
b19973ec9c Move ts files at the wrong place in com module 2025-06-16 09:05:19 +02:00
Sli
17129af1bb Remove unused popup system and jquery-ui 2025-06-16 09:05:19 +02:00
Sli
42434d10ca Remove jquery-ui tabs from counter 2025-06-16 09:05:19 +02:00
Sli
c904e41ea3 Replace tab macro with new tab web component 2025-06-16 09:05:19 +02:00
Sli
2dd4fd5c71 Initial tab concept 2025-06-16 09:05:18 +02:00
dad09deab7 Merge pull request #1123 from ae-utbm/fix-com-dates
fix: datetime format in main page news list
2025-06-15 20:20:16 +02:00
6782638a5d Merge pull request #1122 from ae-utbm/fix-election-css
Fix election css
2025-06-15 20:20:01 +02:00
c7e4de7df2 fix: datetime format in main page news list 2025-06-14 11:54:58 +02:00
dcc84894e5 fix: bad role title alignment in election.scss 2025-06-14 10:43:02 +02:00
9d841cd606 Merge pull request #1121 from ae-utbm/fix-sales
Fix counter selection performance on SellingForm
2025-06-13 13:58:38 +02:00
9f54e8362d Merge pull request #1117 from ae-utbm/lit-html
Add lit-html and use it for ics-calendar popups
2025-06-13 13:25:15 +02:00
c62c09f603 fix: counter selection queryset performance on SellingForm 2025-06-12 14:35:39 +02:00
9c8e3b7cac Merge pull request #1118 from ae-utbm/notifs
Notification improvements
2025-06-11 17:58:42 +02:00
c07f0c33cb fix permanent notification callback 2025-06-11 17:38:21 +02:00
7b778d3e6b Merge pull request #1114 from ae-utbm/accordions
Improve accordion animation
2025-06-11 14:00:46 +02:00
Sli
4c67bb1e2a Support animation with calc-size and detect browser features 2025-06-11 13:43:00 +02:00
Sli
96f91138dd Fix accordion transition on chrome 2025-06-11 00:20:46 +02:00
Sli
7b8102c242 Add lit-html and use it for ics-calendar popups 2025-06-10 23:08:04 +02:00
Sli
36d4a02a45 Remove js size animation and only use the opacity one 2025-06-10 15:06:59 +02:00
Sli
4774a7b741 Improve accordion animation 2025-06-05 20:38:32 +02:00
d58c713fc5 Merge pull request #1113 from ae-utbm/accordions
Fix bad css scoping on accordions
2025-06-05 20:37:23 +02:00
Sli
6f48a9a151 Fix bad css scoping on accordions 2025-06-05 19:57:25 +02:00
99be8a56f3 Merge pull request #1109 from ae-utbm/remove-laundry
Remove remaining laundry code
2025-06-05 18:28:16 +02:00
e04a99cabd Merge pull request #1111 from ae-utbm/calendar-cache
Disable calendar cache on API
2025-06-05 18:27:51 +02:00
Sli
bfea0989fb Disable cache on ics calendar on API response headers 2025-06-05 18:20:25 +02:00
be32486115 Merge pull request #1106 from ae-utbm/navbar-keyboard-navigation
Refactor navbar css and use details instead of div for better semantics
2025-06-05 18:17:58 +02:00
861447ae36 Merge pull request #1105 from ae-utbm/accordions
Remove jquery-ui accordions
2025-06-05 18:09:45 +02:00
Sli
5f701d1a17 Fix centering of detail elements 2025-06-05 18:04:45 +02:00
Sli
64fd123a85 Remove shadow and disable hovering on mobile view 2025-06-05 17:49:47 +02:00
Sli
7090254658 Refactor navbar css and use details instead of div for better semantics 2025-06-05 17:49:47 +02:00
d80f2e73e8 Merge pull request #1110 from ae-utbm/fix-old-promo
fix promo logo older than promo 10
2025-06-05 16:38:10 +02:00
ee3646594b fix promo logo older than promo 10 2025-06-05 16:31:36 +02:00
b0d9063153 remove remaining laundry code 2025-06-04 12:53:22 +02:00
Sli
0980fccf93 Add accordion animation with js 2025-06-03 20:48:45 +02:00
Sli
fb3fd9536e Improve accordion icon 2025-06-03 20:48:45 +02:00
Sli
3892e1cee2 Add fade animation 2025-06-03 20:48:45 +02:00
Sli
c10b488080 Remove jquery-ui accordions 2025-06-03 20:48:45 +02:00
ad91c8ed4f Merge pull request #1108 from ae-utbm/revert-python
Revert "bump python to 3.13"
2025-06-03 17:15:07 +02:00
3b90bd54fc Revert "bump python to 3.13"
This reverts commit f0fa27a8b5.
2025-06-03 10:37:04 +02:00
350a92bc44 Merge pull request #1102 from ae-utbm/update-deps
Update dependencies
2025-06-02 18:26:51 +02:00
f0fa27a8b5 bump python to 3.13 2025-05-26 12:56:35 +02:00
6d16e35624 update dependencies 2025-05-26 12:35:24 +02:00
157 changed files with 6453 additions and 4187 deletions

View File

@ -1,15 +1,24 @@
name: "Setup project"
description: "Setup Python and Poetry"
inputs:
full:
description: >
If true, do a full setup, else install
only python, uv and non-xapian python deps
required: false
default: "false"
runs:
using: composite
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
- name: Install Redis
if: ${{ inputs.full == 'true' }}
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
@ -37,15 +46,20 @@ runs:
shell: bash
- name: Install Xapian
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
shell: bash

View File

@ -37,6 +37,8 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
with:
full: true
env:
# To avoid race conditions on environment cache
CACHE_SUFFIX: ${{ matrix.pytest-mark }}

View File

@ -2,11 +2,7 @@ name: deploy_docs
on:
push:
branches:
- master
env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
- taiste
permissions:
contents: write
jobs:

View File

@ -1,10 +1,10 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.4
rev: v0.11.13
hooks:
- id: ruff # just check the code, and print the errors
- id: ruff # actually fix the fixable errors, but print nothing
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
args: ["--fix", "--silent"]
# Run the formatter.
- id: ruff-format

55
api/admin.py Normal file
View File

@ -0,0 +1,55 @@
from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
@admin.register(ApiClient)
class ApiClientAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "created_at", "updated_at")
search_fields = (
"name",
"owner__first_name",
"owner__last_name",
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
list_display = ("name", "client", "created_at", "revoked")
list_filter = ("revoked",)
date_hierarchy = "created_at"
readonly_fields = ("prefix", "hashed_key")
actions = ("revoke_keys",)
def save_model(self, request: HttpRequest, obj: ApiKey, form, change):
if not change:
key, hashed = generate_key()
obj.prefix = key[: ApiKey.PREFIX_LENGTH]
obj.hashed_key = hashed
self.message_user(
request,
_(
"The API key for %(name)s is: %(key)s. "
"Please store it somewhere safe: "
"you will not be able to see it again."
)
% {"name": obj.name, "key": key},
level=messages.WARNING,
)
return super().save_model(request, obj, form, change)
def get_readonly_fields(self, request, obj: ApiKey | None = None):
if obj is None or obj.revoked:
return ["revoked", *self.readonly_fields]
return self.readonly_fields
@admin.action(description=_("Revoke selected API keys"))
def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]):
queryset.update(revoked=True)

6
api/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "api"

20
api/auth.py Normal file
View File

@ -0,0 +1,20 @@
from django.http import HttpRequest
from ninja.security import APIKeyHeader
from api.hashers import get_hasher
from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader):
param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
if not key or len(key) != ApiKey.KEY_LENGTH:
return None
hasher = get_hasher()
hashed_key = hasher.encode(key)
try:
key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key)
except ApiKey.DoesNotExist:
return None
return key_obj.client

43
api/hashers.py Normal file
View File

@ -0,0 +1,43 @@
import functools
import hashlib
import secrets
from django.contrib.auth.hashers import BasePasswordHasher
from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher):
"""
An API key hasher using the sha256 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing
high entropy, randomly generated API keys.
"""
algorithm = "sha512"
def salt(self) -> str:
# No need for a salt on a high entropy key.
return ""
def encode(self, password: str, salt: str = "") -> str:
hashed = hashlib.sha512(password.encode()).hexdigest()
return f"{self.algorithm}$${hashed}"
def verify(self, password: str, encoded: str) -> bool:
encoded_2 = self.encode(password, "")
return constant_time_compare(encoded, encoded_2)
@functools.cache
def get_hasher():
return Sha512ApiKeyHasher()
def generate_key() -> tuple[str, str]:
"""Generate a [key, hash] couple."""
# this will result in key with a length of 72
key = str(secrets.token_urlsafe(54))
hasher = get_hasher()
return key, hasher.encode(key)

View File

@ -0,0 +1,113 @@
# Generated by Django 5.2 on 2025-06-01 08:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("core", "0046_permissionrights"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ApiClient",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=64, verbose_name="name")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"client_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this api client.",
related_name="clients",
to="auth.permission",
verbose_name="client permissions",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
related_name="api_clients",
to="core.group",
verbose_name="groups",
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_clients",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
],
options={
"verbose_name": "api client",
"verbose_name_plural": "api clients",
},
),
migrations.CreateModel(
name="ApiKey",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(blank=True, default="", verbose_name="name")),
(
"prefix",
models.CharField(
editable=False, max_length=5, verbose_name="prefix"
),
),
(
"hashed_key",
models.CharField(
db_index=True,
editable=False,
max_length=136,
verbose_name="hashed key",
),
),
("revoked", models.BooleanField(default=False, verbose_name="revoked")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"client",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_keys",
to="api.apiclient",
verbose_name="api client",
),
),
],
options={
"verbose_name": "api key",
"verbose_name_plural": "api keys",
"permissions": [("revoke_apikey", "Revoke API keys")],
},
),
]

View File

94
api/models.py Normal file
View File

@ -0,0 +1,94 @@
from typing import Iterable
from django.contrib.auth.models import Permission
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from core.models import Group, User
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
User,
verbose_name=_("owner"),
related_name="api_clients",
on_delete=models.CASCADE,
)
groups = models.ManyToManyField(
Group, verbose_name=_("groups"), related_name="api_clients", blank=True
)
client_permissions = models.ManyToManyField(
Permission,
verbose_name=_("client permissions"),
blank=True,
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
def __str__(self):
return self.name
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)
class ApiKey(models.Model):
PREFIX_LENGTH = 5
KEY_LENGTH = 72
HASHED_KEY_LENGTH = 136
name = models.CharField(_("name"), blank=True, default="")
prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False)
hashed_key = models.CharField(
_("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False
)
client = models.ForeignKey(
ApiClient,
verbose_name=_("api client"),
related_name="api_keys",
on_delete=models.CASCADE,
)
revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("api key")
verbose_name_plural = _("api keys")
permissions = [("revoke_apikey", "Revoke API keys")]
def __str__(self):
return f"{self.name} ({self.prefix}***)"

View File

@ -39,7 +39,7 @@ Example:
import operator
from functools import reduce
from typing import Any
from typing import Any, Callable
from django.contrib.auth.models import Permission
from django.http import HttpRequest
@ -67,21 +67,26 @@ class HasPerm(BasePermission):
Example:
```python
# this route will require both permissions
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
def foo(self): ...
@api_controller("/foo")
class FooController(ControllerBase):
# this route will require both permissions
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
def foo(self): ...
# This route will require at least one of the perm,
# but it's not mandatory to have all of them
@route.put(
"/bar",
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
)
def bar(self): ...
# This route will require at least one of the perm,
# but it's not mandatory to have all of them
@route.put(
"/bar",
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
)
def bar(self): ...
```
"""
def __init__(
self, perms: str | Permission | list[str | Permission], op=operator.and_
self,
perms: str | Permission | list[str | Permission],
op: Callable[[bool, bool], bool] = operator.and_,
):
"""
Args:
@ -96,7 +101,16 @@ class HasPerm(BasePermission):
self._perms = perms
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
# if the request has the `auth` property,
# it means that the user has been explicitly authenticated
# using a django-ninja authentication backend
# (whether it is SessionAuth or ApiKeyAuth).
# If not, this authentication has not been done, but the user may
# still be implicitly authenticated through AuthenticationMiddleware
user = request.auth if hasattr(request, "auth") else request.user
# `user` may either be a `core.User` or an `api.ApiClient` ;
# they are not the same model, but they both implement the `has_perm` method
return reduce(self._operator, (user.has_perm(p) for p in self._perms))
class IsRoot(BasePermission):
@ -180,4 +194,4 @@ class IsLoggedInCounter(BasePermission):
return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")

0
api/tests/__init__.py Normal file
View File

29
api/tests/test_api_key.py Normal file
View File

@ -0,0 +1,29 @@
import pytest
from django.test import RequestFactory
from model_bakery import baker
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
@pytest.mark.django_db
def test_api_key_auth():
key, hashed = generate_key()
client = baker.make(ApiClient)
baker.make(ApiKey, client=client, hashed_key=hashed)
auth = ApiKeyAuth()
assert auth.authenticate(RequestFactory().get(""), key) == client
@pytest.mark.django_db
@pytest.mark.parametrize(
("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")]
)
def test_api_key_auth_invalid(key, hashed):
client = baker.make(ApiClient)
baker.make(ApiKey, client=client, hashed_key=hashed)
auth = ApiKeyAuth()
assert auth.authenticate(RequestFactory().get(""), key) is None

10
api/urls.py Normal file
View File

@ -0,0 +1,10 @@
from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0",
urls_namespace="api",
csrf=True,
)
api.auto_discover_controllers()

View File

@ -1,22 +1,42 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Prefetch
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club
from club.schemas import ClubSchema
from core.auth.api_permissions import CanAccessLookup
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, SimpleClubSchema
@api_controller("/club")
class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ClubSchema],
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values()
@route.get(
"/{int:club_id}",
response=ClubSchema,
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[HasPerm("club.view_club")],
url_name="fetch_club",
)
def fetch_club(self, club_id: int):
prefetch = Prefetch(
"members", queryset=Membership.objects.ongoing().select_related("user")
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
)

View File

@ -163,15 +163,16 @@ class SellingsForm(forms.Form):
def __init__(self, club, *args, **kwargs):
super().__init__(*args, **kwargs)
counters_qs = (
Counter.objects.filter(
Q(club=club)
| Q(products__club=club)
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
)
.distinct()
.order_by(Lower("name"))
# postgres struggles really hard with a single query having three WHERE conditions,
# but deals perfectly fine with UNION of multiple queryset with their own WHERE clause,
# so we do this to get the ids, which we use to build another queryset that can be used by django.
club_sales_subquery = Selling.objects.filter(counter=OuterRef("pk"), club=club)
ids = (
Counter.objects.filter(Q(club=club) | Q(products__club=club))
.union(Counter.objects.filter(Exists(club_sales_subquery)))
.values_list("id", flat=True)
)
counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name"))
self.fields["counters"] = forms.ModelMultipleChoiceField(
counters_qs, label=_("Counter"), required=False
)

View File

@ -1,9 +1,10 @@
from ninja import ModelSchema
from club.models import Club
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
class ClubSchema(ModelSchema):
class SimpleClubSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name"]
@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema):
@staticmethod
def resolve_url(obj: Club) -> str:
return obj.get_absolute_url()
class ClubMemberSchema(ModelSchema):
class Meta:
model = Membership
fields = ["start_date", "end_date", "role", "description"]
user: SimpleUserSchema
class ClubSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]

View File

@ -1,25 +1,63 @@
{% extends "core/base.jinja" %}
{% from "reservation/macros.jinja" import room_detail %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block content %}
<h3>{% trans %}Club tools{% endtrans %}</h3>
<h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
<div>
<h4>{% trans %}Communication:{% endtrans %}</h4>
<ul>
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
<li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
<li>
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
{% trans %}Edit Trombi{% endtrans %}</a>
</li>
{% else %}
<li> <a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li> <a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li><a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
{% endif %}
</ul>
<h4>{% trans %}Reservable rooms{% endtrans %}</h4>
<a
href="{{ url("reservation:room_create") }}?club={{ object.id }}"
class="btn btn-blue"
>
{% trans %}Add a room{% endtrans %}
</a>
{%- if reservable_rooms|length > 0 -%}
<ul class="card-group">
{%- for room in reservable_rooms -%}
{{ room_detail(
room,
can_edit=user.can_edit(room),
can_delete=request.user.has_perm("reservation.delete_room")
) }}
{%- endfor -%}
</ul>
{%- else -%}
<p>
{% trans %}This club manages no reservable room{% endtrans %}
</p>
{%- endif -%}
<h4>{% trans %}Counters:{% endtrans %}</h4>
<ul>
{% for c in object.counters.filter(type="OFFICE") %}
<li>{{ c }}:
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
{% for counter in counters %}
<li>{{ counter }}:
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
</li>
{% endfor %}
</ul>

View File

@ -0,0 +1,43 @@
from datetime import date, timedelta
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
class TestFetchClub:
@pytest.fixture()
def club(self):
club = baker.make(Club)
last_month = date.today() - timedelta(days=30)
yesterday = date.today() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
return club
def test_fetch_club_members(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
member_ids = {member["user"]["id"] for member in res.json()["members"]}
assert member_ids == set(
club.members.ongoing().values_list("user_id", flat=True)
)
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(6):
# - 4 queries for authentication
# - 2 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200

View File

@ -241,6 +241,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
template_name = "club/club_tools.jinja"
current_tab = "tools"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"reservable_rooms": list(self.object.reservable_rooms.all()),
"counters": list(self.object.counters.filter(type="OFFICE")),
}
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
"""View of a club's members."""

View File

@ -1,7 +1,7 @@
from pydantic import TypeAdapter
from club.models import Club
from club.schemas import ClubSchema
from club.schemas import SimpleClubSchema
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultiple,
@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"]
class AutoCompleteSelectClub(AutoCompleteSelect):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
adapter = TypeAdapter(list[SimpleClubSchema])
js = _js
@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect):
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
component_name = "club-ajax-select"
model = Club
adapter = TypeAdapter(list[ClubSchema])
adapter = TypeAdapter(list[SimpleClubSchema])
js = _js

View File

@ -1,16 +1,17 @@
from typing import Literal
from django.http import HttpResponse
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
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm
from core.views.files import send_raw_file
@ -18,7 +19,9 @@ from core.views.files import send_raw_file
class CalendarController(ControllerBase):
@route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self):
return send_raw_file(IcsCalendar.get_internal())
response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get(
"/unpublished.ics",
@ -26,10 +29,12 @@ class CalendarController(ControllerBase):
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
return HttpResponse(
response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar",
)
add_never_cache_headers(response)
return response
@api_controller("/news")

View File

@ -160,14 +160,16 @@ class News(models.Model):
)
def news_notification_callback(notif):
def news_notification_callback(notif: Notification):
# the NewsDate linked to the News
# which creation triggered this callback may not exist yet,
# so it's important to filter by "not past date" rather than by "future date"
count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_published=False
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count:
notif.viewed = False
notif.param = str(count)
notif.date = timezone.now()
else:
notif.viewed = True
@ -191,7 +193,7 @@ class NewsDateQuerySet(models.QuerySet):
class NewsDate(models.Model):
"""A date associated with news.
A [News][] can have multiple dates, for example if it is a recurring event.
A [News][com.models.News] can have multiple dates, for example if it is a recurring event.
"""
news = models.ForeignKey(

View File

@ -7,6 +7,7 @@ import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@ -176,29 +177,25 @@ export class IcsCalendar extends inheritHtmlElement("div") {
oldPopup.remove();
}
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
const row = document.createElement("div");
const icon = document.createElement("i");
row.setAttribute("class", "event-details-row");
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
row.appendChild(icon);
row.appendChild(info);
return row;
const makePopupInfo = (info: HTMLTemplateResult, iconClass: string) => {
return html`
<div class="event-details-row">
<i class="event-detail-row-icon fa-xl ${iconClass}"></i>
${info}
</div>
`;
};
const makePopupTitle = (event: EventImpl) => {
const row = document.createElement("div");
row.innerHTML = `
<h4 class="event-details-row-content">
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
const row = html`
<div>
<h4 class="event-details-row-content">
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
</div>
`;
return makePopupInfo(
row,
@ -210,9 +207,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
if (event.extendedProps.location === null) {
return null;
}
const info = document.createElement("div");
info.innerText = event.extendedProps.location;
const info = html`
<div>
${event.extendedProps.location}
</div>
`;
return makePopupInfo(info, "fa-solid fa-location-dot");
};
@ -220,10 +219,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
if (event.url === "") {
return null;
}
const url = document.createElement("a");
url.href = event.url;
url.textContent = gettext("More info");
const url = html`<a href="${event.url}">${gettext("More info")}</a>`;
return makePopupInfo(url, "fa-solid fa-link");
};
@ -232,64 +228,59 @@ export class IcsCalendar extends inheritHtmlElement("div") {
return null;
}
const newsId = this.getNewsId(event);
const div = document.createElement("div");
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.publishNews(newsId);
};
div.appendChild(button);
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
</button>
`;
buttons.push(button);
} else {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
button.setAttribute("class", "btn btn-orange");
button.onclick = () => {
this.unpublishNews(newsId);
};
div.appendChild(button);
const button = html`
<button class="btn btn-orange" @click="${() => this.unpublishNews(newsId)}">
<i class="fa fa-times"></i>${gettext("Unpublish")}
</button>
`;
buttons.push(button);
}
}
if (this.canDelete) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
button.setAttribute("class", "btn btn-red");
button.onclick = () => {
this.deleteNews(newsId);
};
div.appendChild(button);
const button = html`
<button class="btn btn-red" @click="${() => this.deleteNews(newsId)}">
<i class="fa fa-trash-can"></i>${gettext("Delete")}
</button>
`;
buttons.push(button);
}
return makePopupInfo(div, "fa-solid fa-toolbox");
return makePopupInfo(html`<div>${buttons}</div>`, "fa-solid fa-toolbox");
};
// Create new popup
const popup = document.createElement("div");
const popupContainer = document.createElement("div");
popup.setAttribute("id", "event-details");
popupContainer.setAttribute("class", "event-details-container");
popupContainer.appendChild(makePopupTitle(event.event));
const infos = [] as HTMLTemplateResult[];
infos.push(makePopupTitle(event.event));
const location = makePopupLocation(event.event);
if (location !== null) {
popupContainer.appendChild(location);
infos.push(location);
}
const url = makePopupUrl(event.event);
if (url !== null) {
popupContainer.appendChild(url);
infos.push(url);
}
const tools = makePopupTools(event.event);
if (tools !== null) {
popupContainer.appendChild(tools);
infos.push(tools);
}
popup.appendChild(popupContainer);
const popup = document.createElement("div");
popup.setAttribute("id", "event-details");
render(html`<div class="event-details-container">${infos}</div>`, popup);
// We can't just add the element relative to the one we want to appear under
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells

View File

@ -8,13 +8,17 @@ interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
startDate: startDate,
currentPage: 1,
pageSize: 6,
hasNext: true,
loading: false,
newsDates: [] as NewsDateSchema[],
dateFormat: new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}),
async loadMore() {
this.loading = true;

View File

@ -81,9 +81,8 @@
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
height: 20em;
padding: .5rem;
h4 {
margin-left: 5px;

View File

@ -18,7 +18,7 @@
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %}

View File

@ -1,13 +1,11 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}
{% trans %}News{% endtrans %}
{% endblock %}
{% block title %}AE UTBM{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
{# Atom feed discovery, not really css but also goes there #}
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
@ -15,8 +13,8 @@
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/upcoming-news-loader-index.ts") }}></script>
{% endblock %}
{% block content %}
@ -84,11 +82,11 @@
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|date(DATETIME_FORMAT) }},
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|date(DATETIME_FORMAT) }},
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
@ -103,7 +101,7 @@
</div>
</div>
{% endfor %}
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'), '{{ get_language() }}')">
<template x-for="newsList in Object.values(groupedDates())">
<div class="news_events_group">
<div class="news_events_group_date">
@ -139,11 +137,11 @@
<div class="news_date">
<time
:datetime="newsDate.start_date.toISOString()"
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
x-text="dateFormat.format(newsDate.start_date)"
></time> -
<time
:datetime="newsDate.end_date.toISOString()"
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
x-text="dateFormat.format(newsDate.end_date)"
></time>
</div>
</div>
@ -213,6 +211,12 @@
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
{% if user.has_perm("reservation.view_reservationslot") %}
<li>
<i class="fa-solid fa-thumbtack fa-xl"></i>
<a href="{{ url("reservation:main") }}">{% trans %}Room reservation{% endtrans %}</a>
</li>
{% endif %}
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>

View File

@ -0,0 +1,23 @@
import pytest
from django.conf import settings
from model_bakery import baker
from com.models import News
from core.models import Group, Notification, User
@pytest.mark.django_db
def test_notification_created():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
for i in range(2):
# news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one.
baker.make(News)
notifications = list(Notification.objects.all())
assert len(notifications) == 1
assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1)

View File

@ -5,13 +5,15 @@ from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
@ -90,6 +92,7 @@ class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@ -102,6 +105,7 @@ class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)

View File

@ -4,13 +4,13 @@
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
unset CPATH
unset LIBRARY_PATH
unset CFLAGS
unset LDFLAGS
unset CCFLAGS
unset CXXFLAGS
unset CPPFLAGS
# prepare
rm -rf "$VIRTUAL_ENV/packages"

View File

@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
counter_admin: Group
accounting_admin: Group
pedagogy_admin: Group
campus_admin: Group
class Command(BaseCommand):
@ -784,13 +785,17 @@ class Command(BaseCommand):
# public has no permission.
# Its purpose is not to link users to permissions,
# but to other objects (like products)
public_group = Group.objects.create(name="Public")
public_group = Group.objects.create(name="Publique")
subscribers = Group.objects.create(name="Subscribers")
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
*list(
perms.filter(
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
)
)
)
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
*list(
perms.filter(
@ -805,12 +810,14 @@ class Command(BaseCommand):
"add_peoplepicturerelation",
"add_page",
"add_quickuploadimage",
"view_club",
"access_lookup",
]
)
)
)
accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True
name="Admin comptabilité", is_manually_manageable=True
)
accounting_admin.permissions.add(
*list(
@ -831,7 +838,7 @@ class Command(BaseCommand):
)
)
com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True
name="Admin communication", is_manually_manageable=True
)
com_admin.permissions.add(
*list(
@ -839,7 +846,7 @@ class Command(BaseCommand):
)
)
counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True
name="Admin comptoirs", is_manually_manageable=True
)
counter_admin.permissions.add(
*list(
@ -849,14 +856,14 @@ class Command(BaseCommand):
)
)
)
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
sas_admin.permissions.add(
*list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
)
)
forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True
name="Admin forum", is_manually_manageable=True
)
forum_admin.permissions.add(
*list(
@ -866,7 +873,7 @@ class Command(BaseCommand):
)
)
pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True
name="Admin pédagogie", is_manually_manageable=True
)
pedagogy_admin.permissions.add(
*list(
@ -875,6 +882,16 @@ class Command(BaseCommand):
.values_list("pk", flat=True)
)
)
campus_admin = Group.objects.create(
name="Respo site", is_manually_manageable=True
)
campus_admin.permissions.add(
*counter_admin.permissions.values_list("pk", flat=True),
*perms.filter(content_type__app_label="reservation").values_list(
"pk", flat=True
),
)
self.reset_index("core", "auth")
return PopulatedGroups(
@ -887,6 +904,7 @@ class Command(BaseCommand):
accounting_admin=accounting_admin,
sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin,
campus_admin=campus_admin,
)
def _create_ban_groups(self):

View File

@ -1,6 +1,7 @@
import random
from datetime import date, timedelta
from datetime import timezone as tz
from math import ceil
from typing import Iterator
from dateutil.relativedelta import relativedelta
@ -24,6 +25,7 @@ from counter.models import (
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from reservation.models import ReservationSlot, Room
from subscription.models import Subscription
@ -40,45 +42,20 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
# len(subscribers) is approximately 480
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.create_club_memberships(subscribers)
self.stdout.write("Creating rooms and reservation...")
self.create_resources_and_reservations(random.sample(subscribers, k=40))
self.stdout.write("Creating uvs...")
self.create_uvs()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100)
sellers = list(User.objects.order_by("?")[:100])
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@ -188,6 +165,97 @@ class Command(BaseCommand):
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_club_memberships(self, users: list[User]):
users_qs = User.objects.filter(id__in=[s.id for s in users])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
def create_resources_and_reservations(self, users: list[User]):
"""Generate reservable rooms and reservations slots for those rooms.
Contrary to the other data generator,
this one generates more data than what is expected on the real db.
"""
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
troll = Club.objects.get(name="Troll Penché")
rooms = [
Room(
name=name,
club=club,
location=location,
description=self.faker.text(100),
)
for name, club, location in [
("Champi", ae, "BELFORT"),
("Muzik", ae, "BELFORT"),
("Pôle Tech", ae, "BELFORT"),
("Jolly", troll, "BELFORT"),
("Cookut", pdf, "BELFORT"),
("Lucky", pdf, "BELFORT"),
("Potards", pdf, "SEVENANS"),
("Bureau AE", ae, "SEVENANS"),
]
]
rooms = Room.objects.bulk_create(rooms)
reservations = []
for room in rooms:
# how much people use this room.
# The higher the number, the more reservations exist,
# the smaller the interval between two slot is,
# and the more future reservations have already been made ahead of time
affluence = random.randint(2, 6)
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
generate_until = make_aware(
self.faker.future_datetime(timedelta(days=1) * affluence**2)
)
while slot_start < generate_until:
if slot_start.hour < 8:
# if a reservation would start in the middle of the night
# make it start the next morning instead
slot_start += timedelta(hours=10 - slot_start.hour)
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
reservations.append(
ReservationSlot(
room=room,
author=random.choice(users),
start_at=slot_start,
end_at=slot_start + duration,
created_at=slot_start - self.faker.time_delta("+7d"),
)
)
slot_start += duration + (
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
)
reservations.sort(key=lambda slot: slot.created_at)
ReservationSlot.objects.bulk_create(reservations)
def create_uvs(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
@ -238,7 +306,13 @@ class Command(BaseCommand):
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
Group.objects.filter(
id__in=[
settings.SITH_GROUP_SUBSCRIBERS_ID,
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
settings.SITH_GROUP_PUBLIC_ID,
]
)
)
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
@ -379,7 +453,7 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100)
forumers = list(User.objects.order_by("?")[:100])
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
@ -397,7 +471,7 @@ class Command(BaseCommand):
for _ in range(100)
]
ForumTopic.objects.bulk_create(new_topics)
topics = list(ForumTopic.objects.all())
topics = list(ForumTopic.objects.values_list("id", flat=True))
def get_author():
if random.random() > 0.5:
@ -405,7 +479,7 @@ class Command(BaseCommand):
return random.choice(forumers)
messages = []
for t in topics:
for topic_id in topics:
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
dates = sorted(
[
@ -417,7 +491,7 @@ class Command(BaseCommand):
messages.extend(
[
ForumMessage(
topic=t,
topic_id=topic_id,
author=get_author(),
date=d,
message="\n\n".join(

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2 on 2025-05-20 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0045_quickuploadimage")]
operations = [
migrations.CreateModel(
name="GlobalPermissionRights",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
],
options={
"permissions": [("access_lookup", "Can access any lookup in the sith")],
"managed": False,
"default_permissions": [],
},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.1 on 2025-06-11 16:10
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [("core", "0046_permissionrights")]
operations = [
migrations.AlterField(
model_name="notification",
name="date",
field=models.DateTimeField(auto_now=True, verbose_name="date"),
),
migrations.AlterField(
model_name="notification",
name="type",
field=models.CharField(
choices=core.models.get_notification_types,
default="GENERIC",
max_length=32,
verbose_name="type",
),
),
]

View File

@ -23,7 +23,6 @@
#
from __future__ import annotations
import importlib
import logging
import os
import string
@ -51,6 +50,7 @@ from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.module_loading import import_string
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
@ -341,8 +341,8 @@ class User(AbstractUser):
return reverse("core:user_profile", kwargs={"user_id": self.pk})
def promo_has_logo(self) -> bool:
return Path(
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
return (
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo:02d}.png"
).exists()
@cached_property
@ -754,6 +754,23 @@ class UserBan(models.Model):
return f"Ban of user {self.user.id}"
class GlobalPermissionRights(models.Model):
"""Little hack to have permissions not linked to a specific db table."""
class Meta:
# No database table creation or deletion
# operations will be performed for this model.
managed = False
# disable "add", "change", "delete" and "view" default permissions
default_permissions = []
permissions = [("access_lookup", "Can access any lookup in the sith")]
def __str__(self):
return self.__class__.__name__
class Preferences(models.Model):
user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE
@ -1434,6 +1451,10 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def get_notification_types():
return settings.SITH_NOTIFICATIONS
class Notification(models.Model):
user = models.ForeignKey(
User, related_name="notifications", on_delete=models.CASCADE
@ -1441,9 +1462,9 @@ class Notification(models.Model):
url = models.CharField(_("url"), max_length=255)
param = models.CharField(_("param"), max_length=128, default="")
type = models.CharField(
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
_("type"), max_length=32, choices=get_notification_types, default="GENERIC"
)
date = models.DateTimeField(_("date"), default=timezone.now)
date = models.DateTimeField(_("date"), auto_now=True)
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
def __str__(self):
@ -1452,22 +1473,24 @@ class Notification(models.Model):
return self.get_type_display()
def save(self, *args, **kwargs):
if not self.id and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
if self._state.adding and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
old_notif = self.user.notifications.filter(type=self.type).last()
if old_notif:
old_notif.callback()
old_notif.save()
return
# if this permanent notification is the first one,
# go into the callback nonetheless, because the logic
# to set Notification.param is here
# (please don't be mad at me, I'm not the one who cooked this spaghetti)
self.callback()
super().save(*args, **kwargs)
def callback(self):
# Get the callback defined in settings to update existing
# notifications
mod_name, func_name = settings.SITH_PERMANENT_NOTIFICATIONS[self.type].rsplit(
".", 1
)
mod = importlib.import_module(mod_name)
getattr(mod, func_name)(self)
func_name = settings.SITH_PERMANENT_NOTIFICATIONS.get(self.type)
if not func_name:
return
import_string(func_name)(self)
class Gift(models.Model):

View File

@ -1,7 +1,8 @@
import { morph } from "@alpinejs/morph";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin(sort);
Alpine.plugin([sort, morph]);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@ -0,0 +1,120 @@
import { registerComponent } from "#core:utils/web-components";
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {
static observedAttributes = ["title", "active"];
private description = "";
private inner = "";
private active = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
const activeOld = this.active;
this.active = this.hasAttribute("active");
if (this.active !== activeOld && this.active) {
this.dispatchEvent(
new CustomEvent("ui-tab-activated", { detail: this, bubbles: true }),
);
}
if (name === "title") {
this.description = newValue;
}
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
}
getButtonTemplate() {
return html`
<button
role="tab"
?aria-selected=${this.active}
class="tab-header clickable ${this.active ? "active" : ""}"
@click="${() => this.setActive(true)}"
>
${this.description}
</button>
`;
}
getContentTemplate() {
return html`
<section
class="tab-section"
?hidden=${!this.active}
>
${unsafeHTML(this.getContentHtml())}
</section>
`;
}
setActive(value: boolean) {
if (value) {
this.setAttribute("active", "");
} else {
this.removeAttribute("active");
}
}
connectedCallback() {
this.inner = this.innerHTML;
this.innerHTML = "";
}
getContentHtml() {
const content = this.getElementsByClassName("tab-section")[0];
if (content !== undefined) {
return content.innerHTML;
}
return this.inner;
}
setContentHtml(value: string) {
const content = this.getElementsByClassName("tab-section")[0];
if (content !== undefined) {
content.innerHTML = value;
}
this.inner = value;
}
}
@registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement {
private node: HTMLDivElement;
connectedCallback() {
this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow");
this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = event.detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) {
tab.setActive(false);
}
}
});
this.addEventListener("ui-tab-updated", () => {
this.render();
});
this.render();
}
render() {
const tabs = Array.prototype.slice.call(
this.getElementsByTagName("ui-tab"),
) as Tab[];
render(
html`
<div class="tab-headers">
${tabs.map((tab) => tab.getButtonTemplate())}
</div>
<div class="tab-content">
${tabs.map((tab) => tab.getContentTemplate())}
</div>
`,
this.node,
);
}
}

View File

@ -0,0 +1,36 @@
import { exportToHtml } from "#core:utils/globals";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
});
document.addEventListener("alpine:init", () => {
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
};
for (const item of menuItems) {
item.addEventListener("mouseover", () => {
if (isDesktop()) {
item.setAttribute("open", "");
}
});
item.addEventListener("mouseout", () => {
if (isDesktop()) {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
return;
}
if (isDesktop()) {
event.preventDefault();
}
});
}
});

View File

@ -1,4 +1,5 @@
import htmx from "htmx.org";
import "htmx-ext-alpine-morph";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.target.ariaBusy = true;

View File

@ -1,2 +0,0 @@
// This is only used to import jquery-ui css files
import "jquery-ui/themes/base/all.css";

View File

@ -1,274 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
import { familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
/**
* @typedef FamilyGraphConfig
* @property {number} activeUser Id of the user to fetch the tree from
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
**/
/**
* Create a family graph of an user
* @param {FamilyGraphConfig} config
**/
window.loadFamilyGraph = (config) => {
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
function getInitialDepth(prop) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value, History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
updateQueryString("reverse", value, History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
$(this.$refs.graph),
this.graphData,
config.activeUser,
);
this.loading = false;
},
}));
});
};

View File

@ -0,0 +1,287 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() {
return window.innerWidth < 500;
}
async function getGraphData(
userId: number,
godfathersDepth: number,
godchildrenDepth: number,
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data as ElementDefinition[],
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": 0.5,
"background-opacity": 0.5,
"background-image-opacity": 0.5,
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
} as KlayLayoutOptions,
});
const activeUser = cy
.getElementById(activeUserId.toString())
.style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements().removeClass("traversed not-traversed");
};
const onNodeTap = (el: Singular) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements().addClass("not-traversed");
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
interface FamilyGraphConfig {
/**Id of the user to fetch the tree from*/
activeUser: number;
/**Minimum tree depth for godfathers and godchildren*/
depthMin: number;
/**Maximum tree depth for godfathers and godchildren*/
depthMax: number;
}
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
Alpine.data("graph", (config: FamilyGraphConfig) => ({
loading: false,
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core,
graphData: {},
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
},
async init() {
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value: number) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value.toString(), History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
this.$watch("isZoomEnabled", () => {
this.graph.userZoomingEnabled(this.isZoomEnabled);
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
this.$refs.graph as HTMLDivElement,
this.graphData,
config.activeUser,
);
this.graph.userZoomingEnabled(this.isZoomEnabled);
this.loading = false;
},
}));
});

View File

@ -0,0 +1,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@ -0,0 +1,89 @@
details.accordion>summary {
margin: 2px 0 0 0;
padding: .5em .5em .5em .7em;
cursor: pointer;
user-select: none;
display: block;
border-top-right-radius: 3px;
border-top-left-radius: 3px;
}
details[open].accordion>summary {
border: 1px solid #003eff;
background: #007fff;
color: #ffffff;
}
details:not([open]).accordion>summary {
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
border: 1px solid #c5c5c5;
background: #f6f6f6;
color: #454545;
}
details.accordion>summary::before {
font-family: FontAwesome;
content: '\f0da';
margin-right: 5px;
transition: 700ms;
font-size: 0.8em;
}
details[open].accordion>summary::before {
font-family: FontAwesome;
content: '\f0d7';
}
details.accordion>.accordion-content {
background: #ffffff;
color: #333333;
padding: 1em 2.2em;
border: 1px solid #dddddd;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
overflow: hidden;
}
@mixin animation($selector) {
details.accordion#{$selector} {
opacity: 0;
@supports (max-height: calc-size(max-content, size)) {
max-height: 0px;
}
}
details[open].accordion#{$selector} {
opacity: 1;
// Setting a transition on all states of the content
// will create a strange behavior where the transition
// continues without being shown, creating inconsistenties
transition: all 300ms ease-out;
@supports (max-height: calc-size(max-content, size)) {
max-height: calc-size(max-content, size);
}
}
}
// ::details-content isn't available on firefox yet
// we use .accordion-content as a workaround
// But we need to use ::details-content for chrome because it's
// not working correctly otherwise
// it only happen in chrome, not safari or firefox
// Note: `selector` is not supported by scss so we comment it out to
// avoid compiling it and sending it straight to the css
// This is a trick that comes from here :
// https://stackoverflow.com/questions/62665318/using-supports-selector-despite-sass-not-supporting-it
@supports #{'selector(details::details-content)'} {
@include animation("::details-content")
}
@supports #{'not selector(details::details-content)'} {
@include animation(">.accordion-content")
}

View File

@ -16,14 +16,74 @@
--event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
}
ics-calendar {
ics-calendar,
room-scheduler {
border: none;
box-shadow: none;
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
td {
overflow: visible; // Show events on multiple days
}
td, th {
text-align: unset;
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0;
-moz-border-radius: 0;
margin: 0;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
}
ics-calendar {
#event-details {
z-index: 10;
max-width: 1151px;
@ -60,82 +120,60 @@ ics-calendar {
align-items: start;
flex-direction: row;
background-color: var(--event-details-background-color);
margin-top: 0px;
margin-top: 0;
margin-bottom: 4px;
}
}
}
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
td {
overflow: visible; // Show events on multiple days
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
-moz-border-radius: 0px;
margin: 0px;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
// Reset from style.scss
tbody>tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6);
}
.fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6);
}
.tooltip.calendar-copy-tooltip {

View File

@ -16,6 +16,13 @@
}
}
.card-group {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.card {
background-color: $primary-neutral-light-color;
border-radius: 5px;
@ -92,13 +99,23 @@
}
@media screen and (max-width: 765px) {
@include row-layout
@include row-layout;
}
// When combined with card, card-row display the card in a row layout,
// whatever the size of the screen.
&.card-row {
@include row-layout
@include row-layout;
&.card-row-m {
//width: 50%;
max-width: 50%;
}
&.card-row-s {
//width: 33%;
max-width: 33%;
}
}
}

View File

@ -0,0 +1,53 @@
@import "core/static/core/colors";
ui-tab-group {
*[hidden] {
display: none;
}
.tabs {
border-radius: 5px;
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: $primary-neutral-light-color;
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
.tab-header {
border: none;
padding-right: 0;
padding-left: 0;
font-size: 120%;
background-color: unset;
position: relative;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%);
}
&.active:after {
border-bottom-color: $primary-dark-color;
}
}
}
section {
padding: 20px;
}
}
}

View File

@ -0,0 +1,89 @@
@import "colors";
@import "devices";
footer.bottom-links {
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: $primary-neutral-dark-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25em;
>section {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.8em;
}
a {
color: $white-color;
width: auto;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
.fa-github {
color: $white-color;
}
hr {
width: 100%;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
}
}
@media (min-width: $small-devices) {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
}
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
}
}

View File

@ -1,42 +1,4 @@
$(() => {
// const buttons = $('.choose_file_button')
const popups = $(".choose_file_widget");
popups.dialog({
autoOpen: false,
modal: true,
width: "90%",
create: (event) => {
const target = $(event.target);
target.parent().css({
position: "fixed",
top: "5%",
bottom: "5%",
});
target.css("height", "300px");
},
buttons: [
{
text: "Choose",
click: function () {
$(`input[name=${$(this).attr("name")}]`).attr(
"value",
$("#file_id").attr("value"),
);
$(this).dialog("close");
},
disabled: true,
},
],
});
$(".choose_file_button")
.button()
.on("click", function () {
const popup = popups.filter(`[name=${$(this).attr("name")}]`);
popup.html(
'<iframe src="/file/popup" width="100%" height="95%"></iframe><div id="file_id" value="null" />',
);
popup.dialog({ title: $(this).text() }).dialog("open");
});
$("#quick_notif li").click(function () {
$(this).hide();
});

View File

@ -1,5 +1,7 @@
@import "colors";
$desktop-size: 500px;
nav.navbar {
background-color: $primary-dark-color;
margin: 1em;
@ -7,15 +9,24 @@ nav.navbar {
border-radius: 0.6em;
min-height: 40px;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
position: relative;
flex-direction: column;
align-items: flex-start;
gap: 0;
margin: .2em;
>.content[mobile-display="hidden"] {
display: none;
}
>.content[mobile-display="revealed"] {
display: block;
}
}
> .expand-button {
>.expand-button {
background-color: transparent;
display: none;
position: relative;
@ -27,27 +38,27 @@ nav.navbar {
align-items: center;
margin: 0;
> i {
>i {
font-size: 1.5em;
color: white;
}
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
display: flex;
}
}
> .content {
@media (min-width: 500px) {display: flex;
>.content {
@media (min-width: $desktop-size) {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
display: flex !important;
display: flex;
}
> .menu,
> .link {
>.menu,
>.link {
box-sizing: border-box;
width: 130px;
height: 52px;
@ -56,7 +67,7 @@ nav.navbar {
justify-content: center;
cursor: pointer;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
width: 100%;
height: auto;
justify-content: flex-start;
@ -64,80 +75,75 @@ nav.navbar {
&:last-child {
border-radius: 0 0 .6em .6em;
> .content {
>.content {
box-shadow: 3px 3px 3px 0 #dfdfdf;
}
}
}
}
> .menu > .head,
> .link {
>.menu>.head,
>.link {
color: white;
padding: 10px 20px;
box-sizing: border-box;
}
@media (max-width: 500px) {
>.menu>summary,
>.link {
@media (max-width: $desktop-size) {
padding: 10px;
}
}
>.link {
padding: 10px 20px;
box-sizing: border-box;
}
.link:hover,
.menu:hover {
background-color: rgba(0, 0, 0, .2);
}
> .menu > .head,
> .link {
color: white;
padding: 10px 20px;
box-sizing: border-box;
details.menu {
cursor: pointer;
user-select: none;
z-index: 10;
align-items: center;
display: inline-block;
@media (max-width: 500px) {
padding: 10px;
summary {
list-style: none;
display: flex;
align-items: center;
height: 100%;
padding-left: 20px;
padding-right: 20px;
@media (min-width: $desktop-size) {
justify-content: center;
}
}
}
.link:hover,
.menu:hover {
background-color: rgba(0, 0, 0, .2);
}
> .menu:hover > .content,
> .menu > .head:hover + .content,
> .menu > .content:hover {
display: flex;
}
> .menu {
display: flex;
position: relative;
> .content {
z-index: 10;
summary::-webkit-details-marker {
display: none;
position: absolute;
top: 100%;
background-color: white;
margin: 0;
list-style-type: none;
width: 130px;
box-shadow: 3px 3px 3px 0 #dfdfdf;
flex-direction: column;
}
@media (max-width: 500px) {
position: absolute;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
box-shadow: inset 3px 3px 3px 0 #dfdfdf;
ul.content {
list-style-type: none;
background: white;
margin: 0;
@media (min-width: $desktop-size) {
box-shadow: 3px 3px 3px 0 #dfdfdf;
}
> li > a {
>li>a {
display: flex;
padding: 15px 20px;
@media (max-width: 500px) {
@media (max-width: $desktop-size) {
padding: 10px;
}

View File

@ -111,12 +111,6 @@ body {
/*--------------------------------HEADER-------------------------------*/
#popupheader {
width: 88%;
margin: 0 auto;
padding: 0.3em 1%;
}
#info_boxes {
display: flex;
flex-wrap: wrap;
@ -352,52 +346,6 @@ body {
text-align: center;
}
.tabs {
border-radius: 5px;
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: $primary-neutral-light-color;
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
.tab-header {
border: none;
padding-right: 0;
padding-left: 0;
font-size: 120%;
background-color: unset;
position: relative;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%);
}
&.active:after {
border-bottom-color: $primary-dark-color;
}
}
}
section {
padding: 20px;
}
}
.tool_bar {
overflow: auto;
padding: 4px;
@ -566,10 +514,6 @@ th {
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul {
margin-top: 0;
}
@ -769,47 +713,6 @@ textarea {
margin-top: 10px;
}
/*--------------------------------FOOTER-------------------------------*/
footer {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
div {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
padding: 0.8em;
flex: 1;
font-weight: bold;
color: $white-color !important;
&:hover {
color: $primary-dark-color;
}
}
}
>.version {
margin-top: 3px;
color: rgba(0, 0, 0, 0.3);
}
.fa-github {
color: $githubblack;
}
}
.ui-dialog .ui-dialog-buttonpane {
@ -852,25 +755,6 @@ footer {
}
/*--------------------------------JQuery-------------------------------*/
.ui-state-active,
.ui-widget-content .ui-state-active,
.ui-widget-header .ui-state-active,
a.ui-button:active,
.ui-button:active,
.ui-button.ui-state-active:hover {
background: $primary-color;
border-color: $primary-color;
}
.ui-corner-all,
.ui-corner-bottom,
.ui-corner-right,
.ui-corner-top,
.ui-corner-left {
border-radius: 0;
}
#club_detail {
.club_logo {
float: right;

View File

@ -10,10 +10,9 @@
border-radius: 5px;
padding: 5px 10px;
position: absolute;
white-space: nowrap;
opacity: 0;
transition: opacity 500ms ease-out;
width: max-content;
white-space: normal;
left: 0;

View File

@ -4,6 +4,12 @@
display: block;
}
.zoom-control {
margin-right: 10px;
display: flex;
justify-content: right;
}
.graph-toolbar {
margin-top: 10px;
margin-bottom: 10px;
@ -12,7 +18,7 @@
justify-content: space-around;
gap: 30px;
.toolbar-column{
.toolbar-column {
display: flex;
flex-direction: column;
gap: 20px;
@ -34,31 +40,38 @@
.depth-choice {
white-space: nowrap;
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
}
button {
background: none;
& > .fa {
&>.fa {
border-radius: 50%;
font-size: 12px;
padding: 5px;
}
&:enabled > .fa {
&:enabled>.fa {
background-color: #354a5f;
color: white;
}
&:enabled:hover > .fa {
&:enabled:hover>.fa {
color: white;
background-color: #35405f; // just a bit darker
}
&:disabled > .fa {
&:disabled>.fa {
background-color: gray;
color: white;
}
@ -74,6 +87,7 @@
@media screen and (max-width: 500px) {
flex-direction: column;
gap: 20px;
.toolbar-column {
min-width: 100%;
}
@ -87,14 +101,16 @@
padding: 10px;
box-sizing: border-box;
> form {
>form {
margin: 0;
}
}
#family-tree-link {
display: inline-block;
margin-top: 10px;
text-align: center;
@media (min-width: 450px) {
margin-right: auto;
}
@ -122,10 +138,10 @@
width: 100%;
}
> div.mini_profile_link {
>div.mini_profile_link {
position: relative;
> a {
>a {
&.mini_profile_link {
display: flex;
flex-direction: column;
@ -140,7 +156,7 @@
max-height: 65px;
}
> span {
>span {
height: 150px;
width: 100%;
@ -149,7 +165,7 @@
width: 80px;
}
> img {
>img {
width: 100%;
max-width: 100%;
max-height: 100%;
@ -163,7 +179,7 @@
}
}
> em {
>em {
box-sizing: border-box;
padding: 0 5px;
text-align: center;
@ -195,7 +211,7 @@
}
}
> a.mini_profile_link {
>a.mini_profile_link {
display: none;
}
}

View File

@ -11,16 +11,15 @@
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/footer.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
{% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
<link rel="stylesheet" href="{{ static('bundled/jquery-ui-index.css') }}">
{% endblock %}
<link rel="preload" as="style" href="{{ static('bundled/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
@ -29,11 +28,8 @@
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('bundled/vendored/jquery-ui.min.js') }}"></script>
<script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
{% endblock %}
@ -46,35 +42,28 @@
{% csrf_token %}
{% block header %}
{% if not popup %}
{% include "core/base/header.jinja" %}
{% include "core/base/header.jinja" %}
{% block info_boxes %}
<div id="info_boxes">
{% set sith = get_sith() %}
{% if sith.alert_msg %}
<div id="alert_box">
{{ sith.alert_msg|markdown }}
</div>
{% endif %}
{% if sith.info_msg %}
<div id="info_box">
{{ sith.info_msg|markdown }}
</div>
{% endif %}
</div>
{% endblock %}
{% else %}
<div id="popupheader">{{ user.get_display_name() }}</div>
{% endif %}
{% block info_boxes %}
<div id="info_boxes">
{% set sith = get_sith() %}
{% if sith.alert_msg %}
<div id="alert_box">
{{ sith.alert_msg|markdown }}
</div>
{% endif %}
{% if sith.info_msg %}
<div id="info_box">
{{ sith.info_msg|markdown }}
</div>
{% endif %}
</div>
{% endblock %}
{% endblock %}
{% block nav %}
{% if not popup %}
{% include "core/base/navbar.jinja" %}
{% endif %}
{% include "core/base/navbar.jinja" %}
{% endblock %}
<div id="page">
@ -101,33 +90,12 @@
</div>
</div>
{% if not popup %}
<footer>
{% block footer %}
<div>
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
{% endblock %}
<br>
</footer>
{% endif %}
{% block footer %}
{% include "core/base/footer.jinja" %}
{% endblock %}
{% block script %}
<script>
function showMenu() {
let navbar = document.getElementById("navbar-content");
const current = navbar.style.getPropertyValue("display");
navbar.style.setProperty("display", current === "none" ? "block" : "none");
}
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {

View File

@ -0,0 +1,16 @@
<footer class="bottom-links">
<section>
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</section>
<hr>
<section>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
</section>
</footer>

View File

@ -1,47 +1,47 @@
<nav class="navbar">
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
<div id="navbar-content" class="content" style="display: none;">
<div id="navbar-content" class="content" mobile-display="hidden">
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Events{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Events{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
</ul>
</div>
</details>
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Services{% endtrans %}</span>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}My Benefits{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Help{% endtrans %}</span>
</details>
<details name="navbar" class="menu">
<summary class="head">{% trans %}Help{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
</ul>
</div>
</details>
</div>
</nav>

View File

@ -19,9 +19,9 @@
{% macro print_file_name(file) %}
{% if file %}
{{ print_file_name(file.parent) }} >
<a href="{{ url('core:file_detail', file_id=file.id, popup=popup) }}">{{ file.get_display_name() }}</a>
<a href="{{ url('core:file_detail', file_id=file.id) }}">{{ file.get_display_name() }}</a>
{% else %}
<a href="{{ url('core:file_list', popup) }}">{% trans %}Files{% endtrans %}</a>
<a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a>
{% endif %}
{% endmacro %}
@ -33,16 +33,16 @@
<div>
{% set home = user.home %}
{% if home %}
<a href="{{ url('core:file_detail', home.id, popup) }}">{% trans %}My files{% endtrans %}</a>
<a href="{{ url('core:file_detail', home.id) }}">{% trans %}My files{% endtrans %}</a>
{% endif %}
</div>
{% if file %}
<a href="{{ url('core:file_detail', file.id, popup) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('core:file_detail', file.id) }}">{% trans %}View{% endtrans %}</a>
{% if can_edit(file, user) %}
<a href="{{ url('core:file_edit', file_id=file.id, popup=popup) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('core:file_edit', file_id=file.id) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(file, user) %}
<a href="{{ url('core:file_prop', file_id=file.id, popup=popup) }}">{% trans %}Prop{% endtrans %}</a>
<a href="{{ url('core:file_prop', file_id=file.id) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
{% endif %}
</div>

View File

@ -45,7 +45,7 @@
{% else %}
<i class="fa fa-file" aria-hidden="true"></i>
{% endif %}
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.get_display_name() }}</a></li>
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.get_display_name() }}</a></li>
{% endfor %}
</ul>
</form>
@ -59,22 +59,9 @@
<p><a href="{{ url('core:download', file_id=file.id) }}">{% trans %}Download{% endtrans %}</a></p>
{% endif %}
{% if not file.home_of and not file.home_of_club and file.parent %}
<p><a href="{{ url('core:file_delete', file_id=file.id, popup=popup) }}">{% trans %}Delete{% endtrans %}</a></p>
<p><a href="{{ url('core:file_delete', file_id=file.id) }}">{% trans %}Delete{% endtrans %}</a></p>
{% endif %}
{% if user.is_com_admin %}
<p><a href="{{ url('core:file_moderate', file_id=file.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
{% endif %}
{% endblock %}
{% block script %}
{{ super() }}
<script>
{% if popup and file.is_file %}
parent.$("#file_id").replaceWith('<div id="file_id" value="{{ file.id }}">{{ file.name }}</div>');
parent.$(".ui-dialog-buttonpane button").button("option", "disabled", false);
{% endif %}
</script>
{% endblock %}

View File

@ -12,7 +12,7 @@
{% else %}
<i class="fa fa-file" aria-hidden="true"></i>
{% endif %}
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.name }}</a></li>
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.name }}</a></li>
{% endfor %}
</ul>
{% else %}

View File

@ -26,9 +26,11 @@
{% endif %}
{% endif %}
<form method="post" action="{{ url('core:login') }}">
<form method="post" action="{{ url('core:login') }}" id="login-form">
{% if form.errors %}
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
<p class="alert alert-red">
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
</p>
{% endif %}
{% csrf_token %}

View File

@ -245,65 +245,3 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %}
{% macro tabs(tab_list, attrs = "") %}
{# Tab component
Parameters:
tab_list: list[tuple[str, str]] The list of tabs to display.
Each element of the list is a tuple which first element
is the title of the tab and the second element its content
attrs: str Additional attributes to put on the enclosing div
Example:
A basic usage would be as follow :
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
If you want to display more complex logic, you can define macros
and use those macros in parameters :
{{ tabs([("title", my_macro())]) }}
It's also possible to get and set the currently selected tab using Alpine.
Here, the title of the currently selected tab will be displayed.
Moreover, on page load, the tab will be opened on "tab 2".
<div x-data="{current_tab: 'tab 2'}">
<p x-text="current_tab"></p>
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
</div>
If you want to have translated tab titles, you can enclose the macro call
in a with block :
{% with title=_("title"), content=_("Content") %}
{{ tabs([(tab1, content)]) }}
{% endwith %}
#}
<div
class="tabs shadow"
x-data="{selected: '{{ tab_list[0][0] }}'}"
x-modelable="selected"
{{ attrs }}
>
<div class="tab-headers">
{% for title, _ in tab_list %}
<button
class="tab-header clickable"
:class="{active: selected === '{{ title }}'}"
@click="selected = '{{ title }}'"
>
{{ title }}
</button>
{% endfor %}
</div>
<div class="tab-content">
{% for title, content in tab_list %}
<section x-show="selected === '{{ title }}'">
{{ content }}
</section>
{% endfor %}
</div>
</div>
{% endmacro %}

View File

@ -1,7 +1,7 @@
{% extends "core/base.jinja" %}
{% macro monthly(objects) %}
<div>
<div class="accordion-content">
<table>
<thead>
<tr>
@ -37,22 +37,28 @@
{% if customer %}
<h3>{% trans %}User account{% endtrans %}</h3>
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
<div id="drop">
{% if buyings_month %}
<h5>{% trans %}Account purchases{% endtrans %}</h5>
{% if buyings_month %}
<details class="accordion" name="account" open>
<summary>{% trans %}Account purchases{% endtrans %}</summary>
{{ monthly(buyings_month) }}
{% endif %}
{% if refilling_month %}
<h5>{% trans %}Reloads{% endtrans %}</h5>
</details>
{% endif %}
{% if refilling_month %}
<details class="accordion" name="account">
<summary>{% trans %}Reloads{% endtrans %}</summary>
{{ monthly(refilling_month) }}
{% endif %}
{% if invoices_month %}
<h5>{% trans %}Eboutic invoices{% endtrans %}</h5>
</details>
{% endif %}
{% if invoices_month %}
<details class="accordion" name="account">
<summary>{% trans %}Eboutic invoices{% endtrans %}</summary>
{{ monthly(invoices_month) }}
{% endif %}
{% if etickets %}
<h4>{% trans %}Etickets{% endtrans %}</h4>
<div>
</details>
{% endif %}
{% if etickets %}
<details class="accordion" name="account">
<summary>{% trans %}Etickets{% endtrans %}</summary>
<div class="accordion-content">
<ul>
{% for s in etickets %}
<li>
@ -63,22 +69,9 @@
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</details>
{% endif %}
{% else %}
<p>{% trans %}User has no account{% endtrans %}</p>
{% endif %}
{% endblock %}
{% block script %}
{{ super() }}
<script>
$(function(){
$("#drop").accordion({
heightStyle: "content"
});
});
</script>
{% endblock %}

View File

@ -254,13 +254,5 @@
keys.shift();
}
});
$(function () {
$("#drop_gifts").accordion({
heightStyle: "content",
collapsible: true,
active: false
});
});
</script>
{% endblock %}

View File

@ -74,7 +74,7 @@
{%- if this_picture -%}
{% set default_picture = this_picture.get_download_url()|tojson %}
{% set delete_url = (
url('core:file_delete', file_id=this_picture.id, popup='')
url('core:file_delete', file_id=this_picture.id)
+ "?next=" + url('core:user_edit', user_id=profile.id)
)|tojson %}
{%- else -%}

View File

@ -7,7 +7,7 @@
{%- endblock -%}
{% block additional_js %}
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
<script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script>
{% endblock %}
{% block title %}
@ -15,7 +15,14 @@
{% endblock %}
{% block content %}
<div x-data="graph" :aria-busy="loading">
<div
x-data="graph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
})"
:aria-busy="loading"
>
<div class="graph-toolbar">
<div class="toolbar-column">
<div class="toolbar-input">
@ -86,17 +93,36 @@
</button>
</div>
</div>
<div class="zoom-control" x-ref="zoomControl">
<button
@click="graph.zoom(graph.zoom() + 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button
@click="graph.zoom(graph.zoom() - 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button
x-show="isZoomEnabled"
@click="isZoomEnabled = false"
>
<i class="fa-solid fa-unlock"></i>
</button>
<button
x-show="!isZoomEnabled"
@click="isZoomEnabled = true"
>
<i class="fa-solid fa-lock"></i>
</button>
</div>
<div x-ref="graph" class="graph"></div>
</div>
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});
});
</script>
{% endblock %}

View File

@ -38,6 +38,7 @@ from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment
from counter.models import Customer
from sith import settings
@ -151,24 +152,44 @@ class TestUserLogin:
def user(self) -> User:
return baker.make(User, password=make_password("plop"))
def test_login_fail(self, client, user):
@pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_fail(self, client, user, identifier_getter):
"""Should not login a user correctly."""
identifier = identifier_getter(user)
response = client.post(
reverse("core:login"),
{"username": user.username, "password": "wrong-password"},
{"username": identifier, "password": "wrong-password"},
)
assert response.status_code == 200
assert (
'<p class="alert alert-red">Votre nom d\'utilisateur '
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) in response.text
assert response.wsgi_request.user.is_anonymous
soup = BeautifulSoup(response.text, "lxml")
form = soup.find(id="login-form")
assert (
form.find(class_="alert alert-red").get_text(strip=True)
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
)
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
def test_login_success(self, client, user):
@pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_success(self, client, user, identifier_getter):
"""Should login a user correctly."""
response = client.post(
reverse("core:login"),
{"username": user.username, "password": "plop"},
{"username": identifier_getter(user), "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
@classmethod
def setUpTestData(cls):
cls.root_group = Group.objects.get(name="Root")
cls.public_group = Group.objects.get(name="Public")
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.public_user = baker.make(User)
cls.subscribers = Group.objects.get(name="Subscribers")
cls.old_subscribers = Group.objects.get(name="Old subscribers")
cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter admin")
cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = baker.make(Club)
cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public_group.id)
@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
def assert_only_in_public_group(self, user):
self.assert_in_public_group(user)
for group in (
self.root_group,
self.accounting_admin,
self.sas_admin,
self.subscribers,
self.old_subscribers,
self.club.members_group,
self.club.board_group,
):
for group in Group.objects.exclude(id=self.public_group.id):
assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name)

View File

@ -146,7 +146,7 @@ class TestUserProfilePicture:
return client.post(
reverse(
"core:file_delete",
kwargs={"file_id": user.profile_pict.pk, "popup": ""},
kwargs={"file_id": user.profile_pict.pk},
query={"next": user.get_absolute_url()},
),
)

View File

@ -335,3 +335,10 @@ class TestRedirectMe:
def test_anonymous_user(self, client: Client):
url = reverse("core:user_me_redirect")
assertRedirects(client.get(url), reverse("core:login", query={"next": url}))
@pytest.mark.parametrize("promo", [7, 22])
@pytest.mark.django_db
def test_promo_has_logo(promo):
user = baker.make(User, promo=promo)
assert user.promo_has_logo()

View File

@ -193,24 +193,24 @@ urlpatterns = [
name="user_gift_delete",
),
# File views
re_path(r"^file/(?P<popup>popup)?$", FileListView.as_view(), name="file_list"),
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
re_path(
r"^file/(?P<file_id>[0-9]+)/(?P<popup>popup)?$",
r"^file/(?P<file_id>[0-9]+)/$",
FileView.as_view(),
name="file_detail",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/edit/(?P<popup>popup)?$",
r"^file/(?P<file_id>[0-9]+)/edit/$",
FileEditView.as_view(),
name="file_edit",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/prop/(?P<popup>popup)?$",
r"^file/(?P<file_id>[0-9]+)/prop/$",
FileEditPropView.as_view(),
name="file_prop",
),
re_path(
r"^file/(?P<file_id>[0-9]+)/delete/(?P<popup>popup)?$",
r"^file/(?P<file_id>[0-9]+)/delete/$",
FileDeleteView.as_view(),
name="file_delete",
),

View File

@ -37,8 +37,6 @@ from core.views.forms import LoginForm
def forbidden(request, exception):
context = {"next": request.path, "form": LoginForm()}
if popup := request.resolver_match.kwargs.get("popup"):
context["popup"] = popup
return HttpResponseForbidden(render(request, "core/403.jinja", context=context))

View File

@ -198,9 +198,6 @@ class FileListView(ListView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
kwargs["popup"] = "popup"
return kwargs
@ -217,20 +214,7 @@ class FileEditView(CanEditMixin, UpdateView):
return modelform_factory(SithFile, fields=fields)
def get_success_url(self):
if self.kwargs.get("popup") is not None:
return reverse(
"core:file_detail", kwargs={"file_id": self.object.id, "popup": "popup"}
)
return reverse(
"core:file_detail", kwargs={"file_id": self.object.id, "popup": ""}
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
kwargs["popup"] = "popup"
return kwargs
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
class FileEditPropForm(forms.ModelForm):
@ -268,16 +252,9 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
def get_success_url(self):
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
kwargs={"file_id": self.object.id},
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = ""
if self.kwargs.get("popup") is not None:
kwargs["popup"] = "popup"
return kwargs
class FileView(CanViewMixin, DetailView, FormMixin):
"""Handle the upload of new files into a folder."""
@ -353,15 +330,12 @@ class FileView(CanViewMixin, DetailView, FormMixin):
def get_success_url(self):
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
kwargs={"file_id": self.object.id},
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = ""
kwargs["form"] = self.form
if self.kwargs.get("popup") is not None:
kwargs["popup"] = "popup"
kwargs["clipboard"] = SithFile.objects.filter(
id__in=self.request.session["clipboard"]
)
@ -380,19 +354,17 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
return self.request.GET["next"]
if self.object.parent is None:
return reverse(
"core:file_list", kwargs={"popup": self.kwargs.get("popup", "")}
"core:file_list",
)
return reverse(
"core:file_detail",
kwargs={
"file_id": self.object.parent.id,
"popup": self.kwargs.get("popup", ""),
},
)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
kwargs["next"] = self.request.GET.get("next", None)
kwargs["previous"] = self.request.GET.get("previous", None)
kwargs["current"] = self.request.path

View File

@ -39,9 +39,8 @@ from django.forms import (
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import now
from django.utils.timezone import localtime, now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -86,30 +85,6 @@ class NFCTextInput(TextInput):
return context
class SelectFile(TextInput):
def render(self, name, value, attrs=None, renderer=None):
if attrs:
attrs["class"] = "select_file"
else:
attrs = {"class": "select_file"}
output = (
'%(content)s<div name="%(name)s" class="choose_file_widget" title="%(title)s"></div>'
% {
"content": super().render(name, value, attrs, renderer),
"title": _("Choose file"),
"name": name,
}
)
output += (
'<span name="'
+ name
+ '" class="choose_file_button">'
+ gettext("Choose file")
+ "</span>"
)
return output
class SelectUser(TextInput):
def render(self, name, value, attrs=None, renderer=None):
if attrs:
@ -139,7 +114,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValueError(_("Ensure this timestamp is set in the future"))
raise ValidationError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):
@ -147,8 +122,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
return {"min": widget.format_value(localtime())}
# Forms
@ -156,29 +131,31 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs):
if "data" in kwargs:
from counter.models import Customer
data = kwargs["data"].copy()
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
try:
if account_code.match(data["username"]):
user = (
Customer.objects.filter(account_id__iexact=data["username"])
.first()
.user
)
elif "@" in data["username"]:
user = User.objects.filter(email__iexact=data["username"]).first()
else:
user = User.objects.filter(username=data["username"]).first()
data["username"] = user.username
except: # noqa E722 I don't know what error is supposed to be raised here
pass
kwargs["data"] = data
super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number")
def clean_username(self):
identifier: str = self.cleaned_data["username"]
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
if account_code.match(identifier):
qs_filter = "customer__account_id__iexact"
elif identifier.count("@") == 1:
qs_filter = "email"
else:
qs_filter = None
if qs_filter:
# if the user gave an email or an account code instead of
# a username, retrieve and return the corresponding username.
# If there is no username, return an empty string, so that
# Django will properly handle the error when failing the authentication
identifier = (
User.objects.filter(**{qs_filter: identifier})
.values_list("username", flat=True)
.first()
or ""
)
return identifier
class RegisteringForm(UserCreationForm):
error_css_class = "error"

View File

@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
return render(
request,
"app/template.jinja",
context={"fragment": fragment(request)
context={"fragment": fragment(request)}
}
# in urls.py

View File

@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
"profit",
"archived",
)
list_select_related = ("product_type",)
search_fields = ("name", "code")
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer",
"warning_mail_sent_at",
"warning_mail_error",
"dump_operation",
"dump_operation__date",
"amount",
)
list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation")
list_filter = ("warning_mail_error",)
def get_queryset(self, request):
# the `amount` property requires to know the customer and the dump_operation
return (
super()
.get_queryset(request)
.select_related("customer", "customer__user", "dump_operation")
)
@admin.register(Counter)
class CounterAdmin(admin.ModelAdmin):
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id",
"counter__name",
)
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Selling)
class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
list_select_related = ("customer", "customer__user", "counter")
search_fields = (
"customer__user__username",
"customer__user__first_name",
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
"counter__name",
)
autocomplete_fields = ("customer", "seller")
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Permanency)

View File

@ -16,11 +16,13 @@ from django.conf import settings
from django.db.models import F
from django.shortcuts import get_object_or_404
from ninja import Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType
from counter.schemas import (
CounterFilterSchema,
@ -62,6 +64,7 @@ class CounterController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@ -74,6 +77,7 @@ class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleProductSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)

View File

@ -19,7 +19,6 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
@ -374,35 +373,3 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month=None, clubs=None, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
self.clubs = clubs
for club in self.clubs:
field_name = f"club_{club.id}"
initial = (
InvoiceCall.objects.filter(club=club, month=month)
.values_list("is_validated", flat=True)
.first()
)
self.fields[field_name] = forms.BooleanField(
required=False,
initial=initial,
)
def save(self):
for club in self.clubs:
field_name = f"club_{club.id}"
is_validated = self.cleaned_data.get(field_name, False)
InvoiceCall.objects.update_or_create(
month=self.month, club=club, defaults={"is_validated": is_validated}
)
def get_club_name(self, club_id):
return f"club_{club_id}"

View File

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

View File

@ -61,7 +61,7 @@ class CustomerQuerySet(models.QuerySet):
Returns:
The number of updated rows.
Warnings:
Warning:
The execution time of this query grows really quickly.
When updating 500 customers, it may take around a second.
If you try to update all customers at once, the execution time
@ -1362,58 +1362,3 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}"
)
class MonthField(models.CharField):
description = _("Year + month field")
def __init__(self, *args, **kwargs):
kwargs["max_length"] = 7
super().__init__(*args, **kwargs)
def db_type(self, connection):
return "char(7)"
def from_db_value(self, value, expression, connection):
if value is None:
return value
try:
year, month = value.split("-")
return date(year, month, 1)
except (ValueError, TypeError):
return value
def to_python(self, value):
if isinstance(value, date):
return value
if isinstance(value, str):
try:
year, month = value.split("-")
return date(year, month, 1)
except ValueError:
pass
return value
def get_prep_value(self, value):
if isinstance(value, date):
return value.strftime("%Y-%m")
if isinstance(value, str) and len(value) == 7 and value[4] == "-":
return value
return value
def value_to_string(self, obj):
value = self.value_from_object(obj)
return self.get_prep_value(value)
class InvoiceCall(models.Model):
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
club = models.ForeignKey(Club, on_delete=models.CASCADE)
month = MonthField(verbose_name=_("invoice date"))
class Meta:
verbose_name = _("Invoice call")
verbose_name_plural = _("Invoice calls")
def __str__(self):
return f"invoice call of {self.month} made by {self.club}"

View File

@ -5,7 +5,7 @@ from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from club.schemas import ClubSchema
from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, SimpleUserSchema
from counter.models import Counter, Product, ProductType
@ -82,7 +82,7 @@ class ProductSchema(ModelSchema):
]
buying_groups: list[GroupSchema]
club: ClubSchema
club: SimpleClubSchema
product_type: SimpleProductTypeSchema | None
url: str

View File

@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
alertMessage: {
content: "",
show: false,
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
this.alertMessage.display(message, { success: false });
}
},
@ -103,13 +87,15 @@ document.addEventListener("alpine:init", () => {
this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
);
document.getElementById("selling-accordion").click();
document.getElementById("selling-accordion").setAttribute("open", "");
this.codeField.widget.focus();
},
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return;
}
this.$refs.basketForm.submit();
@ -137,14 +123,3 @@ document.addEventListener("alpine:init", () => {
},
}));
});
$(() => {
/* Accordion UI between basket and refills */
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#click-form") as any).accordion({
heightStyle: "content",
activate: () => $(".focus").focus(),
});
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
($("#products") as any).tabs();
});

View File

@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
alertMessage: {
open: false,
success: true,
content: "",
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
}
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
const success = response.ok;
const content = response.ok
? gettext("Products types reordered!")
: interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));

View File

@ -1,4 +1,4 @@
type ErrorMessage = string;
declare type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */

View File

@ -9,12 +9,14 @@
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static('bundled/counter/counter-click-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/counter/components/counter-product-select-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
{% endblock %}
{% block info_boxes %}
@ -51,185 +53,189 @@
</div>
<div id="click-form">
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
<div>
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<details class="accordion" id="selling-accordion" name="selling" open>
<summary>{% trans %}Selling{% endtrans %}</summary>
<div class="accordion-content">
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<form method="post" action=""
class="code_form" @submit.prevent="handleCode">
<form method="post" action=""
class="code_form" @submit.prevent="handleCode">
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
<option value=""></option>
<optgroup label="{% trans %}Operations{% endtrans %}">
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup>
{% for category in categories.keys() %}
<optgroup label="{{ category }}">
{% for product in categories[category] %}
<option value="{{ product.id }}">{{ product }}</option>
{% endfor %}
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
<option value=""></option>
<optgroup label="{% trans %}Operations{% endtrans %}">
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup>
{% endfor %}
</counter-product-select>
{% for category in categories.keys() %}
<optgroup label="{{ category }}">
{% for product in categories[category] %}
<option value="{{ product.id }}">{{ product }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% for error in form.non_form_errors() %}
<div class="alert alert-red">
{{ error }}
</div>
{% endfor %}
<p>{% trans %}Basket: {% endtrans %}</p>
{% for error in form.non_form_errors() %}
<div class="alert alert-red">
{{ error }}
</div>
{% endfor %}
<p>{% trans %}Basket: {% endtrans %}</p>
<form x-cloak method="post" action="" x-ref="basketForm">
<form x-cloak method="post" action="" x-ref="basketForm">
<div class="basket-error-container">
<div class="basket-error-container">
<div
x-cloak
class="alert alert-red basket-error"
x-show="alertMessage.show"
x-transition.duration.500ms
x-text="alertMessage.content"
></div>
</div>
{% csrf_token %}
<div x-ref="basketManagementForm">
{{ form.management_form }}
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)">
<li>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">
</div>
</template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
<button
class="remove-item"
@click.prevent="removeFromBasket(item.product.id)"
><i class="fa fa-trash-can delete-action"></i></button>
<input
type="hidden"
:value="item.quantity"
:id="`id_form-${index}-quantity`"
:name="`form-${index}-quantity`"
required
readonly
>
<input
type="hidden"
:value="item.product.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
required
readonly
>
</li>
</template>
</ul>
<p class="margin-bottom">
<strong>Total: </strong>
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong>
</p>
<div class="row">
<input
class="btn btn-blue"
type="submit"
@click.prevent="finish"
:disabled="getBasketSize() === 0"
value="{% trans %}Finish{% endtrans %}"
/>
<input
class="btn btn-grey"
type="submit" @click.prevent="cancel"
value="{% trans %}Cancel{% endtrans %}"
/>
</div>
</form>
</div>
</details>
<details class="accordion" name="selling">
<summary>{% trans %}Refilling{% endtrans %}</summary>
{% if object.type == "BAR" %}
{% if refilling_fragment %}
<div
x-cloak
class="alert alert-red basket-error"
x-show="alertMessage.show"
x-transition.duration.500ms
x-text="alertMessage.content"
></div>
</div>
class="accordion-content"
@htmx:after-request="onRefillingSuccess"
>
{{ refilling_fragment }}
</div>
{% else %}
<div class="accordion-content">
<p class="alert alert-yellow">
{% trans trimmed %}
As a barman, you are not able to refill any account on your own.
An admin should be connected on this counter for that.
The customer can refill by using the eboutic.
{% endtrans %}
</p>
</div>
{% endif %}
</details>
{% if student_card_fragment %}
<details class="accordion" name="selling">
<summary>{% trans %}Student card{% endtrans %}</summary>
<div class="accordion-content">
{{ student_card_fragment }}
</div>
</details>
{% endif %}
{% csrf_token %}
<div x-ref="basketManagementForm">
{{ form.management_form }}
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)">
<li>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">
</div>
</template>
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
<button
class="remove-item"
@click.prevent="removeFromBasket(item.product.id)"
><i class="fa fa-trash-can delete-action"></i></button>
<input
type="hidden"
:value="item.quantity"
:id="`id_form-${index}-quantity`"
:name="`form-${index}-quantity`"
required
readonly
>
<input
type="hidden"
:value="item.product.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
required
readonly
>
</li>
</template>
</ul>
<p class="margin-bottom">
<strong>Total: </strong>
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong>
</p>
<div class="row">
<input
class="btn btn-blue"
type="submit"
@click.prevent="finish"
:disabled="getBasketSize() === 0"
value="{% trans %}Finish{% endtrans %}"
/>
<input
class="btn btn-grey"
type="submit" @click.prevent="cancel"
value="{% trans %}Cancel{% endtrans %}"
/>
</div>
</form>
{% endif %}
</div>
{% if object.type == "BAR" %}
<h5>{% trans %}Refilling{% endtrans %}</h5>
{% if refilling_fragment %}
<div
@htmx:after-request="onRefillingSuccess"
>
{{ refilling_fragment }}
<div id="products">
{% if not products %}
<div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %}
</div>
{% else %}
<div>
<p class="alert alert-yellow">
{% trans trimmed %}
As a barman, you are not able to refill any account on your own.
An admin should be connected on this counter for that.
The customer can refill by using the eboutic.
{% endtrans %}
</p>
</div>
<ui-tab-group>
{% for category in categories.keys() -%}
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
{% if product.icon %}
src="{{ product.icon.url }}"
{% else %}
src="{{ static('core/img/na.gif') }}"
{% endif %}
/>
<span class="card-content">
<strong class="card-title">{{ product.name }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p>
</span>
</button>
{%- endfor %}
</div>
</ui-tab>
{% endfor %}
</ui-tab-group>
{% endif %}
{% if student_card_fragment %}
<h5>{% trans %}Student card{% endtrans %}</h3>
<div>
{{ student_card_fragment }}
</div>
{% endif %}
{% endif %}
</div>
</div>
<div id="products">
{% if not products %}
<div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %}
</div>
{% else %}
<ul>
{% for category in categories.keys() -%}
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
{%- endfor %}
</ul>
{% for category in categories.keys() -%}
<div id="cat_{{ category|slugify }}">
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
{% if product.icon %}
src="{{ product.icon.url }}"
{% else %}
src="{{ static('core/img/na.gif') }}"
{% endif %}
/>
<span class="card-content">
<strong class="card-title">{{ product.name }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p>
</span>
</button>
{%- endfor %}
</div>
</div>
{%- endfor %}
{% endif %}
</div>
</div>
{% endblock content %}
{% block script %}

View File

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

View File

@ -5,10 +5,6 @@
{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}
{% endblock %}
{% block jquery_css %}
{# Remove jquery_css #}
{% endblock %}
{% block content %}
<h3>{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}</h3>
<h4>

View File

@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal
import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200
@pytest.mark.django_db
class TestCounterLogout:
def test_logout_simple(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
with freeze_time():
res = client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
assertRedirects(
res,
reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
)
permanence.refresh_from_db()
assert permanence.end == now()
def test_logout_doesnt_change_old_permanences(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
old_end = now() - relativedelta(year=10)
old_permanence = baker.make(
Permanency,
counter=perm_counter,
end=old_end,
activity=now() - relativedelta(year=8),
)
with freeze_time():
client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
permanence.refresh_from_db()
assert permanence.end == now()
old_permanence.refresh_from_db()
assert old_permanence.end == old_end

View File

@ -13,10 +13,10 @@
#
#
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
end=F("activity")
)
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=now())
return redirect("counter:details", counter_id=counter_id)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,6 @@
::: api.auth
handler: python
options:
heading_level: 3
members:
- ApiKeyAuth

View File

@ -0,0 +1,8 @@
::: api.hashers
handler: python
options:
heading_level: 3
members:
- Sha256ApiKeyHasher
- get_hasher
- generate_key

View File

@ -0,0 +1,7 @@
::: api.auth
handler: python
options:
heading_level: 3
members:
- ApiKey
- ApiClient

View File

@ -0,0 +1,4 @@
::: api.permissions
handler: python
options:
heading_level: 3

View File

@ -20,13 +20,6 @@
- CanCreateMixin
- CanEditMixin
- CanViewMixin
- CanEditPropMixin
- FormerSubscriberMixin
- PermissionOrAuthorRequiredMixin
## API Permissions
::: core.auth.api_permissions
handler: python
options:
heading_level: 3

View File

@ -0,0 +1,215 @@
La connexion à l'API du site AE peut se faire par deux moyens :
- par le cookie de session du site ; si vous accédez à l'API depuis le sith
en étant connecté, cette méthode fonctionne par défaut
- par clef d'API ; si vous accédez à l'API depuis une application externe,
vous devez passer par cette méthode.
Comme la méthode par cookie de session ne devrait pas être utilisée
en dehors du cadre interne au site et qu'elle marche par défaut
dans le cadre de ce dernier, nous ne décrirons pas outre mesure la manière
de l'utiliser.
## Obtenir une clef d'API
Il n'y a, à l'heure actuelle, pas d'interface accessible sur le site
pour obtenir une clef d'API.
Si vous désirez en obtenir une, demandez directement au respo info.
!!!danger
Votre clef d'API doit rester secrète.
Ne la transmettez à personne, ne l'inscrivez pas en dur dans votre code.
Si votre clef a fuité, ou que vous soupçonnez qu'elle ait pu fuiter,
informez-en immédiatement l'équipe informatique !
## L'interface Swagger
Avant de commencer à utiliser l'API du site, vous pouvez explorer
les différentes routes qu'elle met à disposition,
avec les schémas de données attendus en requête et en réponse.
Pour cela, vous pouvez vous rendre sur
[https://ae.utbm.fr/api/docs](https://ae.utbm.fr/api/docs).
Toutes les routes, à de rares exceptions près, y sont recensées.
Vous pouvez les utiliser dans les limites
de ce à quoi vos permissions vous donnent droit
et de la méthode d'authentification.
Vous pouvez vous connecter directement sur l'interface Swagger,
en cliquant sur ce bouton, en haut à droite :
![Swagger auth (1)](../../img/api_key_authorize_1.png)
/// caption
Bouton d'autorisation sur Swagger
///
Puis rentrez votre clef d'API dans le champ prévu à cet effet,
et cliquez sur authorize :
![Swagger auth (2)](../../img/api_key_authorize_2.png)
/// caption
Saisie de la clef d'API
///
Les routes accessibles avec une clef d'API seront alors marquées par
une icône de cadenas fermé, sur la droite.
!!!warning "Authentification et permissions"
L'icône de cadenas signifie que la route accepte l'authentification
basée sur les clefs d'API, mais pas forcément que vous avez les
permissions nécessaires.
Si une route vous renvoie une erreur 403,
référez-en à l'équipe info, pour qu'elle puisse vous donner
les permissions nécessaires.
## Utiliser la clef d'API
### `X-APIKey`
Maintenant que vous avez la clef d'API,
il faut l'utiliser pour authentifier votre application
lorsqu'elle effectue des requêtes au site.
Pour cela, vous devez le fournir dans vos requêtes
à travers le header `X-APIKey`.
Par exemple :
```shell
curl "https://ae.utbm.fr/api/club/1" \
-H "X-APIKey: <votre clef d'API>"
```
Comme votre clef d'API doit rester absolument secrète,
vous ne devez en aucun cas la mettre dans votre code.
À la place, vous pouvez créer un fichier (par exemple, un `.env`)
qui contiendra votre clef et qui sera gitignoré.
```dotenv title=".env"
API_KEY="<votre clef d'API>"
```
Vous fournirez alors la clef d'API en la chargeant depuis votre environnement.
Notez que c'est une bonne pratique à double-titre,
puisque vous pouvez ainsi aisément changer votre clef d'API.
### Connexion persistante
La plupart des librairies permettant d'effectuer des requêtes
HTTP incluent une prise en charge des sessions persistantes.
Nous vous recommandons fortement d'utiliser ces fonctionnalités,
puisqu'elles permettent de rendre votre code plus simple
(vous n'aurez à renseigner votre clef d'API qu'une seule fois)
et plus efficace (réutiliser la même connexion plutôt que d'en créer
une nouvelle à chaque requête peut résulter en un gain de performance significatif ;
cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_persistent_connection))
Voici quelques exemples :
=== "Python (requests)"
Dépendances :
- `requests` (>=2.32)
- `environs` (>=14.1)
```python
import requests
from environs import Env
env = Env()
env.read_env()
with requests.Session() as session:
session.headers["X-APIKey"] = env.str("API_KEY")
response = session.get("https://ae.utbm.fr/api/club/1")
print(response.json())
```
=== "Python (aiohttp)"
Dépendances :
- `aiohttp` (>=3.11)
- `environs` (>=14.1)
```python
import aiohttp
import asyncio
from environs import Env
env = Env()
env.read_env()
async def main():
async with aiohttp.ClientSession(
base_url="https://ae.utbm.fr/api/",
headers={"X-APIKey": env.str("API_KEY")}
) as session:
async with session.get("club/1") as res:
print(await res.json())
asyncio.run(main())
```
=== "Javascript (axios)"
Dépendances :
- `axios` (>=1.9)
- `dotenv` (>=16.5)
```javascript
import { axios } from "axios";
import { config } from "dotenv";
config();
const instance = axios.create({
baseUrl: "https://ae.utbm.fr/api/",
headers: { "X-APIKey": process.env.API_KEY }
});
console.log(await instance.get("club/1").json());
```
=== "Rust (reqwest)"
Dépendances :
- `reqwest` (>= 0.12, features `json` et `gzip`)
- `tokio` (>= 1.44, feature `derive`)
- `dotenvy` (>= 0.15)
```rust
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use dotenvy::EnvLoader;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let env = EnvLoader::new().load()?;
let mut headers = HeaderMap::new();
let mut api_key = HeaderValue::from_str(env.var("API_KEY")?.as_str());
api_key.set_sensitive(true);
headers.insert("X-APIKey", api_key);
let client = Client::builder()
.default_headers(headers)
.gzip(true)
.build()?;
let resp = client
.get("https://ae.utbm.fr/api/club/1")
.send()
.await?
.json()
.await?;
println!("{resp:#?}");
Ok(())
}
```

Some files were not shown because too many files have changed in this diff Show More