8 Commits

Author SHA1 Message Date
4148dde109 date validity verification
fixed template formatting
2025-06-16 20:25:58 +02:00
481c46e367 invoice call form 2025-06-16 19:20:28 +02:00
6608dac4d3 improved Club related queries
formatted migration file
2025-06-14 16:59:41 +02:00
9fe4cc5102 rename is_validated field 2025-06-14 16:57:01 +02:00
1f77f154ba MonthField for InvoiceCall 2025-06-14 15:52:39 +02:00
3dcdb6ff46 fix checkbox width 2025-06-14 15:51:58 +02:00
6e13e4fb36 added checkbox for invoice calls
formatting

separated logic for get and post

created custom month field

fixed formatting

fixed imports
2025-06-11 15:20:52 +02:00
6803294358 refactored invoice view and template 2025-06-02 17:04:46 +02:00
131 changed files with 4448 additions and 5300 deletions

View File

@ -1,24 +1,15 @@
name: "Setup project" name: "Setup project"
description: "Setup Python and Poetry" 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: runs:
using: composite using: composite
steps: steps:
- name: Install apt packages - name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3 uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with: with:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
- name: Install Redis - name: Install Redis
if: ${{ inputs.full == 'true' }}
uses: shogo82148/actions-setup-redis@v1 uses: shogo82148/actions-setup-redis@v1
with: with:
redis-version: "7.x" redis-version: "7.x"
@ -46,20 +37,15 @@ runs:
shell: bash shell: bash
- name: Install Xapian - name: Install Xapian
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py install_xapian run: uv run ./manage.py install_xapian
shell: bash 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 - name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4 uses: actions/cache/save@v4
with: with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv path: .venv
- name: Compile gettext messages - name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages run: uv run ./manage.py compilemessages
shell: bash shell: bash

View File

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

View File

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

View File

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

View File

@ -1,55 +0,0 @@
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)

View File

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

View File

@ -1,20 +0,0 @@
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

View File

@ -1,43 +0,0 @@
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

@ -1,113 +0,0 @@
# 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

@ -1,94 +0,0 @@
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

View File

@ -1,29 +0,0 @@
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

View File

@ -1,10 +0,0 @@
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,42 +1,22 @@
from typing import Annotated from typing import Annotated
from annotated_types import MinLen 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 import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from club.models import Club
from api.permissions import CanAccessLookup, HasPerm from club.schemas import ClubSchema
from club.models import Club, Membership from core.auth.api_permissions import CanAccessLookup
from club.schemas import ClubSchema, SimpleClubSchema
@api_controller("/club") @api_controller("/club")
class ClubController(ControllerBase): class ClubController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleClubSchema], response=PaginatedResponseSchema[ClubSchema],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
url_name="search_club",
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, search: Annotated[str, MinLen(1)]): def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values() 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,16 +163,15 @@ class SellingsForm(forms.Form):
def __init__(self, club, *args, **kwargs): def __init__(self, club, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# postgres struggles really hard with a single query having three WHERE conditions, counters_qs = (
# but deals perfectly fine with UNION of multiple queryset with their own WHERE clause, Counter.objects.filter(
# so we do this to get the ids, which we use to build another queryset that can be used by django. Q(club=club)
club_sales_subquery = Selling.objects.filter(counter=OuterRef("pk"), club=club) | Q(products__club=club)
ids = ( | Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
Counter.objects.filter(Q(club=club) | Q(products__club=club)) )
.union(Counter.objects.filter(Exists(club_sales_subquery))) .distinct()
.values_list("id", flat=True) .order_by(Lower("name"))
) )
counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name"))
self.fields["counters"] = forms.ModelMultipleChoiceField( self.fields["counters"] = forms.ModelMultipleChoiceField(
counters_qs, label=_("Counter"), required=False counters_qs, label=_("Counter"), required=False
) )

View File

@ -1,10 +1,9 @@
from ninja import ModelSchema from ninja import ModelSchema
from club.models import Club, Membership from club.models import Club
from core.schemas import SimpleUserSchema
class SimpleClubSchema(ModelSchema): class ClubSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name"] fields = ["id", "name"]
@ -22,19 +21,3 @@ class ClubProfileSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_url(obj: Club) -> str: def resolve_url(obj: Club) -> str:
return obj.get_absolute_url() 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,43 +0,0 @@
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

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

View File

@ -1,17 +1,16 @@
from typing import Literal from typing import Literal
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.cache import add_never_cache_headers
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm
from core.views.files import send_raw_file from core.views.files import send_raw_file
@ -19,9 +18,7 @@ from core.views.files import send_raw_file
class CalendarController(ControllerBase): class CalendarController(ControllerBase):
@route.get("/internal.ics", url_name="calendar_internal") @route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self): def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal()) return send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get( @route.get(
"/unpublished.ics", "/unpublished.ics",
@ -29,12 +26,10 @@ class CalendarController(ControllerBase):
url_name="calendar_unpublished", url_name="calendar_unpublished",
) )
def calendar_unpublished(self): def calendar_unpublished(self):
response = HttpResponse( return HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user), IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar", content_type="text/calendar",
) )
add_never_cache_headers(response)
return response
@api_controller("/news") @api_controller("/news")

View File

@ -160,16 +160,14 @@ class News(models.Model):
) )
def news_notification_callback(notif: Notification): def news_notification_callback(notif):
# 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( count = News.objects.filter(
~Q(dates__start_date__gt=timezone.now()), is_published=False dates__start_date__gt=timezone.now(), is_published=False
).count() ).count()
if count: if count:
notif.viewed = False notif.viewed = False
notif.param = str(count) notif.param = str(count)
notif.date = timezone.now()
else: else:
notif.viewed = True notif.viewed = True
@ -193,7 +191,7 @@ class NewsDateQuerySet(models.QuerySet):
class NewsDate(models.Model): class NewsDate(models.Model):
"""A date associated with news. """A date associated with news.
A [News][com.models.News] can have multiple dates, for example if it is a recurring event. A [News][] can have multiple dates, for example if it is a recurring event.
""" """
news = models.ForeignKey( news = models.ForeignKey(

View File

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

View File

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

View File

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

View File

@ -15,8 +15,8 @@
{% block additional_js %} {% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script> <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script> <script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/upcoming-news-loader-index.ts") }}></script> <script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -84,11 +84,11 @@
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a> <a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date"> <div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}"> <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) }} {{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> - </time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}"> <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) }} {{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time> </time>
</div> </div>
@ -103,7 +103,7 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'), '{{ get_language() }}')"> <div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
<template x-for="newsList in Object.values(groupedDates())"> <template x-for="newsList in Object.values(groupedDates())">
<div class="news_events_group"> <div class="news_events_group">
<div class="news_events_group_date"> <div class="news_events_group_date">
@ -139,11 +139,11 @@
<div class="news_date"> <div class="news_date">
<time <time
:datetime="newsDate.start_date.toISOString()" :datetime="newsDate.start_date.toISOString()"
x-text="dateFormat.format(newsDate.start_date)" x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
></time> - ></time> -
<time <time
:datetime="newsDate.end_date.toISOString()" :datetime="newsDate.end_date.toISOString()"
x-text="dateFormat.format(newsDate.end_date)" x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
></time> ></time>
</div> </div>
</div> </div>

View File

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

View File

@ -39,7 +39,7 @@ Example:
import operator import operator
from functools import reduce from functools import reduce
from typing import Any, Callable from typing import Any
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.http import HttpRequest from django.http import HttpRequest
@ -67,8 +67,6 @@ class HasPerm(BasePermission):
Example: Example:
```python ```python
@api_controller("/foo")
class FooController(ControllerBase):
# this route will require both permissions # this route will require both permissions
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
def foo(self): ... def foo(self): ...
@ -80,13 +78,10 @@ class HasPerm(BasePermission):
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
) )
def bar(self): ... def bar(self): ...
```
""" """
def __init__( def __init__(
self, self, perms: str | Permission | list[str | Permission], op=operator.and_
perms: str | Permission | list[str | Permission],
op: Callable[[bool, bool], bool] = operator.and_,
): ):
""" """
Args: Args:
@ -101,16 +96,7 @@ class HasPerm(BasePermission):
self._perms = perms self._perms = perms
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
# if the request has the `auth` property, return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
# 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): class IsRoot(BasePermission):
@ -194,4 +180,4 @@ class IsLoggedInCounter(BasePermission):
return Counter.objects.filter(token=token).exists() return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter

View File

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

View File

@ -238,13 +238,7 @@ class Command(BaseCommand):
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3) other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list( groups = list(
Group.objects.filter( Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
id__in=[
settings.SITH_GROUP_SUBSCRIBERS_ID,
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
settings.SITH_GROUP_PUBLIC_ID,
]
)
) )
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

@ -1,28 +0,0 @@
# 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

@ -1,27 +0,0 @@
# 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,6 +23,7 @@
# #
from __future__ import annotations from __future__ import annotations
import importlib
import logging import logging
import os import os
import string import string
@ -50,7 +51,6 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape
from django.utils.module_loading import import_string
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@ -341,8 +341,8 @@ class User(AbstractUser):
return reverse("core:user_profile", kwargs={"user_id": self.pk}) return reverse("core:user_profile", kwargs={"user_id": self.pk})
def promo_has_logo(self) -> bool: def promo_has_logo(self) -> bool:
return ( return Path(
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo:02d}.png" settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists() ).exists()
@cached_property @cached_property
@ -754,23 +754,6 @@ class UserBan(models.Model):
return f"Ban of user {self.user.id}" 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): class Preferences(models.Model):
user = models.OneToOneField( user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE User, related_name="_preferences", on_delete=models.CASCADE
@ -1451,10 +1434,6 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def get_notification_types():
return settings.SITH_NOTIFICATIONS
class Notification(models.Model): class Notification(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
User, related_name="notifications", on_delete=models.CASCADE User, related_name="notifications", on_delete=models.CASCADE
@ -1462,9 +1441,9 @@ class Notification(models.Model):
url = models.CharField(_("url"), max_length=255) url = models.CharField(_("url"), max_length=255)
param = models.CharField(_("param"), max_length=128, default="") param = models.CharField(_("param"), max_length=128, default="")
type = models.CharField( type = models.CharField(
_("type"), max_length=32, choices=get_notification_types, default="GENERIC" _("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
) )
date = models.DateTimeField(_("date"), auto_now=True) date = models.DateTimeField(_("date"), default=timezone.now)
viewed = models.BooleanField(_("viewed"), default=False, db_index=True) viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
def __str__(self): def __str__(self):
@ -1473,24 +1452,22 @@ class Notification(models.Model):
return self.get_type_display() return self.get_type_display()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self._state.adding and self.type in settings.SITH_PERMANENT_NOTIFICATIONS: if not self.id and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
old_notif = self.user.notifications.filter(type=self.type).last() old_notif = self.user.notifications.filter(type=self.type).last()
if old_notif: if old_notif:
old_notif.callback() old_notif.callback()
old_notif.save() old_notif.save()
return 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) super().save(*args, **kwargs)
def callback(self): def callback(self):
func_name = settings.SITH_PERMANENT_NOTIFICATIONS.get(self.type) # Get the callback defined in settings to update existing
if not func_name: # notifications
return mod_name, func_name = settings.SITH_PERMANENT_NOTIFICATIONS[self.type].rsplit(
import_string(func_name)(self) ".", 1
)
mod = importlib.import_module(mod_name)
getattr(mod, func_name)(self)
class Gift(models.Model): class Gift(models.Model):

View File

@ -1,8 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
Alpine.plugin([sort, limitedChoices]); Alpine.plugin(sort);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

@ -1,69 +0,0 @@
import type { Alpine as AlpineType } from "alpinejs";
export function limitedChoices(Alpine: AlpineType) {
/**
* Directive to limit the number of elements
* that can be selected in a group of checkboxes.
*
* When the max numbers of selectable elements is reached,
* new elements will still be inserted, but oldest ones will be deselected.
* For example, if checkboxes A, B and C have been selected and the max
* number of selections is 3, then selecting D will result in having
* B, C and D selected.
*
* # Example in template
* ```html
* <div x-data="{nbMax: 2}", x-limited-choices="nbMax">
* <button @click="nbMax += 1">Click me to increase the limit</button>
* <input type="checkbox" value="A" name="foo">
* <input type="checkbox" value="B" name="foo">
* <input type="checkbox" value="C" name="foo">
* <input type="checkbox" value="D" name="foo">
* </div>
* ```
*/
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
);
const checked = [] as HTMLInputElement[];
const manageDequeue = () => {
if (checked.length <= maxChoices) {
// There isn't too many checkboxes selected. Nothing to do
return;
}
const popped = checked.splice(0, checked.length - maxChoices);
for (const p of popped) {
p.checked = false;
}
};
for (const input of inputs) {
input.addEventListener("change", (_e) => {
if (input.checked) {
checked.push(input);
} else {
checked.splice(checked.indexOf(input), 1);
}
manageDequeue();
});
}
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed
manageDequeue();
}
});
});
},
);
}

View File

@ -1,120 +0,0 @@
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

@ -1,36 +0,0 @@
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

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

View File

@ -0,0 +1,274 @@
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

@ -1,287 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,53 +0,0 @@
@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

@ -1,89 +0,0 @@
@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,4 +1,42 @@
$(() => { $(() => {
// 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 () { $("#quick_notif li").click(function () {
$(this).hide(); $(this).hide();
}); });

View File

@ -1,7 +1,5 @@
@import "colors"; @import "colors";
$desktop-size: 500px;
nav.navbar { nav.navbar {
background-color: $primary-dark-color; background-color: $primary-dark-color;
margin: 1em; margin: 1em;
@ -9,23 +7,14 @@ nav.navbar {
border-radius: 0.6em; border-radius: 0.6em;
min-height: 40px; min-height: 40px;
@media (max-width: $desktop-size) { @media (max-width: 500px) {
position: relative; position: relative;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0; gap: 0;
margin: .2em; margin: .2em;
>.content[mobile-display="hidden"] {
display: none;
} }
>.content[mobile-display="revealed"] {
display: block;
}
}
> .expand-button { > .expand-button {
background-color: transparent; background-color: transparent;
display: none; display: none;
@ -43,18 +32,18 @@ nav.navbar {
color: white; color: white;
} }
@media (max-width: $desktop-size) { @media (max-width: 500px) {
display: flex; display: flex;
} }
} }
> .content { > .content {
@media (min-width: $desktop-size) { @media (min-width: 500px) {display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
display: flex; display: flex !important;
} }
> .menu, > .menu,
@ -67,7 +56,7 @@ nav.navbar {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
@media (max-width: $desktop-size) { @media (max-width: 500px) {
width: 100%; width: 100%;
height: auto; height: auto;
justify-content: flex-start; justify-content: flex-start;
@ -85,65 +74,70 @@ nav.navbar {
> .menu > .head, > .menu > .head,
> .link { > .link {
color: white; color: white;
} padding: 10px 20px;
box-sizing: border-box;
>.menu>summary, @media (max-width: 500px) {
>.link {
@media (max-width: $desktop-size) {
padding: 10px; padding: 10px;
} }
} }
>.link {
padding: 10px 20px;
box-sizing: border-box;
}
.link:hover, .link:hover,
.menu:hover { .menu:hover {
background-color: rgba(0, 0, 0, .2); background-color: rgba(0, 0, 0, .2);
} }
details.menu { > .menu > .head,
cursor: pointer; > .link {
user-select: none; color: white;
z-index: 10; padding: 10px 20px;
align-items: center; box-sizing: border-box;
display: inline-block;
summary { @media (max-width: 500px) {
list-style: none; padding: 10px;
}
}
.link:hover,
.menu:hover {
background-color: rgba(0, 0, 0, .2);
}
> .menu:hover > .content,
> .menu > .head:hover + .content,
> .menu > .content:hover {
display: flex; display: flex;
align-items: center;
height: 100%;
padding-left: 20px;
padding-right: 20px;
@media (min-width: $desktop-size) {
justify-content: center;
}
} }
summary::-webkit-details-marker { > .menu {
display: flex;
position: relative;
> .content {
z-index: 10;
display: none; display: none;
} position: absolute;
top: 100%;
ul.content { background-color: white;
list-style-type: none;
background: white;
margin: 0; margin: 0;
list-style-type: none;
@media (min-width: $desktop-size) { width: 130px;
box-shadow: 3px 3px 3px 0 #dfdfdf; 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;
} }
> li > a { > li > a {
display: flex; display: flex;
padding: 15px 20px; padding: 15px 20px;
@media (max-width: $desktop-size) { @media (max-width: 500px) {
padding: 10px; padding: 10px;
} }

View File

@ -111,6 +111,12 @@ body {
/*--------------------------------HEADER-------------------------------*/ /*--------------------------------HEADER-------------------------------*/
#popupheader {
width: 88%;
margin: 0 auto;
padding: 0.3em 1%;
}
#info_boxes { #info_boxes {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -346,6 +352,52 @@ body {
text-align: center; 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 { .tool_bar {
overflow: auto; overflow: auto;
padding: 4px; padding: 4px;
@ -514,6 +566,10 @@ th {
text-align: center; text-align: center;
padding: 5px 10px; padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul { >ul {
margin-top: 0; margin-top: 0;
} }
@ -713,6 +769,47 @@ textarea {
margin-top: 10px; 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 { .ui-dialog .ui-dialog-buttonpane {
@ -755,6 +852,25 @@ textarea {
} }
/*--------------------------------JQuery-------------------------------*/ /*--------------------------------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_detail {
.club_logo { .club_logo {
float: right; float: right;

View File

@ -4,12 +4,6 @@
display: block; display: block;
} }
.zoom-control {
margin-right: 10px;
display: flex;
justify-content: right;
}
.graph-toolbar { .graph-toolbar {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
@ -40,37 +34,30 @@
.depth-choice { .depth-choice {
white-space: nowrap; white-space: nowrap;
input[type="number"] { input[type="number"] {
-webkit-appearance: textfield; -webkit-appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
appearance: textfield; appearance: textfield;
&::-webkit-inner-spin-button, &::-webkit-inner-spin-button,
&::-webkit-outer-spin-button { &::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
} }
button { button {
background: none; background: none;
& > .fa { & > .fa {
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 12px;
padding: 5px; padding: 5px;
} }
&:enabled > .fa { &:enabled > .fa {
background-color: #354a5f; background-color: #354a5f;
color: white; color: white;
} }
&:enabled:hover > .fa { &:enabled:hover > .fa {
color: white; color: white;
background-color: #35405f; // just a bit darker background-color: #35405f; // just a bit darker
} }
&:disabled > .fa { &:disabled > .fa {
background-color: gray; background-color: gray;
color: white; color: white;
@ -87,7 +74,6 @@
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
.toolbar-column { .toolbar-column {
min-width: 100%; min-width: 100%;
} }
@ -105,12 +91,10 @@
margin: 0; margin: 0;
} }
} }
#family-tree-link { #family-tree-link {
display: inline-block; display: inline-block;
margin-top: 10px; margin-top: 10px;
text-align: center; text-align: center;
@media (min-width: 450px) { @media (min-width: 450px) {
margin-right: auto; margin-right: auto;
} }

View File

@ -11,15 +11,16 @@
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.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/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'"> <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> <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script> <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/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
@ -28,8 +29,11 @@
<!-- Jquery declared here to be accessible in every django widgets --> <!-- 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.min.js') }}"></script>
<script src="{{ static('bundled/vendored/jquery-ui.min.js') }}"></script>
<script src="{{ static('core/js/script.js') }}"></script> <script src="{{ static('core/js/script.js') }}"></script>
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
{% endblock %} {% endblock %}
@ -42,6 +46,7 @@
{% csrf_token %} {% csrf_token %}
{% block header %} {% block header %}
{% if not popup %}
{% include "core/base/header.jinja" %} {% include "core/base/header.jinja" %}
{% block info_boxes %} {% block info_boxes %}
@ -60,10 +65,16 @@
</div> </div>
{% endblock %} {% endblock %}
{% else %}
<div id="popupheader">{{ user.get_display_name() }}</div>
{% endif %}
{% endblock %} {% endblock %}
{% block nav %} {% block nav %}
{% if not popup %}
{% include "core/base/navbar.jinja" %} {% include "core/base/navbar.jinja" %}
{% endif %}
{% endblock %} {% endblock %}
<div id="page"> <div id="page">
@ -90,12 +101,33 @@
</div> </div>
</div> </div>
{% if not popup %}
<footer>
{% block footer %} {% block footer %}
{% include "core/base/footer.jinja" %} <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 %} {% endblock %}
<br>
</footer>
{% endif %}
{% block script %} {% block script %}
<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) => { document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form // Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) { if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {

View File

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

View File

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

View File

@ -45,7 +45,7 @@
{% else %} {% else %}
<i class="fa fa-file" aria-hidden="true"></i> <i class="fa fa-file" aria-hidden="true"></i>
{% endif %} {% endif %}
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.get_display_name() }}</a></li> <a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.get_display_name() }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</form> </form>
@ -59,9 +59,22 @@
<p><a href="{{ url('core:download', file_id=file.id) }}">{% trans %}Download{% endtrans %}</a></p> <p><a href="{{ url('core:download', file_id=file.id) }}">{% trans %}Download{% endtrans %}</a></p>
{% endif %} {% endif %}
{% if not file.home_of and not file.home_of_club and file.parent %} {% 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) }}">{% trans %}Delete{% endtrans %}</a></p> <p><a href="{{ url('core:file_delete', file_id=file.id, popup=popup) }}">{% trans %}Delete{% endtrans %}</a></p>
{% endif %} {% endif %}
{% if user.is_com_admin %} {% if user.is_com_admin %}
<p><a href="{{ url('core:file_moderate', file_id=file.id) }}">{% trans %}Moderate{% endtrans %}</a></p> <p><a href="{{ url('core:file_moderate', file_id=file.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
{% endif %} {% endif %}
{% endblock %} {% 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 %} {% else %}
<i class="fa fa-file" aria-hidden="true"></i> <i class="fa fa-file" aria-hidden="true"></i>
{% endif %} {% endif %}
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.name }}</a></li> <a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script> <script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -15,14 +15,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div <div x-data="graph" :aria-busy="loading">
x-data="graph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
})"
:aria-busy="loading"
>
<div class="graph-toolbar"> <div class="graph-toolbar">
<div class="toolbar-column"> <div class="toolbar-column">
<div class="toolbar-input"> <div class="toolbar-input">
@ -93,36 +86,17 @@
</button> </button>
</div> </div>
</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 x-ref="graph" class="graph"></div>
</div> </div>
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});
});
</script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -335,10 +335,3 @@ class TestRedirectMe:
def test_anonymous_user(self, client: Client): def test_anonymous_user(self, client: Client):
url = reverse("core:user_me_redirect") url = reverse("core:user_me_redirect")
assertRedirects(client.get(url), reverse("core:login", query={"next": url})) 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", name="user_gift_delete",
), ),
# File views # File views
re_path(r"^file/$", FileListView.as_view(), name="file_list"), re_path(r"^file/(?P<popup>popup)?$", FileListView.as_view(), name="file_list"),
re_path( re_path(
r"^file/(?P<file_id>[0-9]+)/$", r"^file/(?P<file_id>[0-9]+)/(?P<popup>popup)?$",
FileView.as_view(), FileView.as_view(),
name="file_detail", name="file_detail",
), ),
re_path( re_path(
r"^file/(?P<file_id>[0-9]+)/edit/$", r"^file/(?P<file_id>[0-9]+)/edit/(?P<popup>popup)?$",
FileEditView.as_view(), FileEditView.as_view(),
name="file_edit", name="file_edit",
), ),
re_path( re_path(
r"^file/(?P<file_id>[0-9]+)/prop/$", r"^file/(?P<file_id>[0-9]+)/prop/(?P<popup>popup)?$",
FileEditPropView.as_view(), FileEditPropView.as_view(),
name="file_prop", name="file_prop",
), ),
re_path( re_path(
r"^file/(?P<file_id>[0-9]+)/delete/$", r"^file/(?P<file_id>[0-9]+)/delete/(?P<popup>popup)?$",
FileDeleteView.as_view(), FileDeleteView.as_view(),
name="file_delete", name="file_delete",
), ),

View File

@ -37,6 +37,8 @@ from core.views.forms import LoginForm
def forbidden(request, exception): def forbidden(request, exception):
context = {"next": request.path, "form": LoginForm()} 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)) return HttpResponseForbidden(render(request, "core/403.jinja", context=context))

View File

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

View File

@ -86,6 +86,30 @@ class NFCTextInput(TextInput):
return context 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): class SelectUser(TextInput):
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
if attrs: if attrs:
@ -132,31 +156,29 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm): class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs): 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) super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number") 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): class RegisteringForm(UserCreationForm):
error_css_class = "error" error_css_class = "error"

View File

@ -41,7 +41,6 @@ class ProductAdmin(SearchModelAdmin):
"profit", "profit",
"archived", "archived",
) )
list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
@ -82,13 +81,20 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer", "customer",
"warning_mail_sent_at", "warning_mail_sent_at",
"warning_mail_error", "warning_mail_error",
"dump_operation__date", "dump_operation",
"amount", "amount",
) )
list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation") autocomplete_fields = ("customer", "dump_operation")
list_filter = ("warning_mail_error",) 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) @admin.register(Counter)
class CounterAdmin(admin.ModelAdmin): class CounterAdmin(admin.ModelAdmin):
@ -107,14 +113,11 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id", "customer__account_id",
"counter__name", "counter__name",
) )
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Selling) @admin.register(Selling)
class SellingAdmin(SearchModelAdmin): class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date") list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
list_select_related = ("customer", "customer__user", "counter")
search_fields = ( search_fields = (
"customer__user__username", "customer__user__username",
"customer__user__first_name", "customer__user__first_name",
@ -123,8 +126,6 @@ class SellingAdmin(SearchModelAdmin):
"counter__name", "counter__name",
) )
autocomplete_fields = ("customer", "seller") autocomplete_fields = ("customer", "seller")
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Permanency) @admin.register(Permanency)

View File

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

View File

@ -19,6 +19,7 @@ from counter.models import (
Counter, Counter,
Customer, Customer,
Eticket, Eticket,
InvoiceCall,
Product, Product,
Refilling, Refilling,
ReturnableProduct, ReturnableProduct,
@ -373,3 +374,35 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory( BasketForm = forms.formset_factory(
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 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

@ -0,0 +1,47 @@
# 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: Returns:
The number of updated rows. The number of updated rows.
Warning: Warnings:
The execution time of this query grows really quickly. The execution time of this query grows really quickly.
When updating 500 customers, it may take around a second. When updating 500 customers, it may take around a second.
If you try to update all customers at once, the execution time If you try to update all customers at once, the execution time
@ -1362,3 +1362,58 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} " f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}" 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 ninja import Field, FilterSchema, ModelSchema, Schema
from pydantic import model_validator from pydantic import model_validator
from club.schemas import SimpleClubSchema from club.schemas import ClubSchema
from core.schemas import GroupSchema, SimpleUserSchema from core.schemas import GroupSchema, SimpleUserSchema
from counter.models import Counter, Product, ProductType from counter.models import Counter, Product, ProductType
@ -82,7 +82,7 @@ class ProductSchema(ModelSchema):
] ]
buying_groups: list[GroupSchema] buying_groups: list[GroupSchema]
club: SimpleClubSchema club: ClubSchema
product_type: SimpleProductTypeSchema | None product_type: SimpleProductTypeSchema | None
url: str url: str

View File

@ -103,7 +103,7 @@ document.addEventListener("alpine:init", () => {
this.customerBalance += Number.parseFloat( this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value, (event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
); );
document.getElementById("selling-accordion").setAttribute("open", ""); document.getElementById("selling-accordion").click();
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
@ -137,3 +137,14 @@ 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

@ -9,14 +9,12 @@
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link> <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('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/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") }}"> <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/counter/counter-click-index.ts') }}"></script> <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/counter/components/counter-product-select-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block info_boxes %} {% block info_boxes %}
@ -53,9 +51,8 @@
</div> </div>
<div id="click-form"> <div id="click-form">
<details class="accordion" id="selling-accordion" name="selling" open> <h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
<summary>{% trans %}Selling{% endtrans %}</summary> <div>
<div class="accordion-content">
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} {% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<form method="post" action="" <form method="post" action=""
@ -166,19 +163,16 @@
</div> </div>
</form> </form>
</div> </div>
</details>
<details class="accordion" name="selling">
<summary>{% trans %}Refilling{% endtrans %}</summary>
{% if object.type == "BAR" %} {% if object.type == "BAR" %}
<h5>{% trans %}Refilling{% endtrans %}</h5>
{% if refilling_fragment %} {% if refilling_fragment %}
<div <div
class="accordion-content"
@htmx:after-request="onRefillingSuccess" @htmx:after-request="onRefillingSuccess"
> >
{{ refilling_fragment }} {{ refilling_fragment }}
</div> </div>
{% else %} {% else %}
<div class="accordion-content"> <div>
<p class="alert alert-yellow"> <p class="alert alert-yellow">
{% trans trimmed %} {% trans trimmed %}
As a barman, you are not able to refill any account on your own. As a barman, you are not able to refill any account on your own.
@ -188,14 +182,11 @@
</p> </p>
</div> </div>
{% endif %} {% endif %}
</details>
{% if student_card_fragment %} {% if student_card_fragment %}
<details class="accordion" name="selling"> <h5>{% trans %}Student card{% endtrans %}</h3>
<summary>{% trans %}Student card{% endtrans %}</summary> <div>
<div class="accordion-content">
{{ student_card_fragment }} {{ student_card_fragment }}
</div> </div>
</details>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -207,9 +198,13 @@
{% trans %}No products available on this counter for this user{% endtrans %} {% trans %}No products available on this counter for this user{% endtrans %}
</div> </div>
{% else %} {% else %}
<ui-tab-group> <ul>
{% for category in categories.keys() -%} {% for category in categories.keys() -%}
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}> <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> <h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x"> <div class="row gap-2x">
{% for product in categories[category] -%} {% for product in categories[category] -%}
@ -230,9 +225,8 @@
</button> </button>
{%- endfor %} {%- endfor %}
</div> </div>
</ui-tab> </div>
{% endfor %} {%- endfor %}
</ui-tab-group>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -17,7 +17,6 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache from django.core.cache import cache
@ -824,53 +823,3 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 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.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST @require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter.""" """End the permanency of a user in this counter."""
Permanency.objects.filter( Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
counter=counter_id, user=request.POST["user_id"], end=None end=F("activity")
).update(end=now()) )
return redirect("counter:details", counter_id=counter_id) return redirect("counter:details", counter_id=counter_id)

View File

@ -12,15 +12,17 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from django.db.models import F from django.db.models import Exists, F, OuterRef
from django.shortcuts import redirect
from django.utils import timezone from django.utils import timezone
from django.views.generic import TemplateView from django.views.generic import TemplateView
from counter.fields import CurrencyField from counter.fields import CurrencyField
from counter.models import Refilling, Selling from counter.forms import InvoiceCallForm
from counter.models import Club, InvoiceCall, Refilling, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@ -28,12 +30,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
template_name = "counter/invoices_call.jinja" template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call" 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): def get_context_data(self, **kwargs):
"""Add sums to the context.""" """Add sums to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
if "month" in self.request.GET: month_str = self.request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m") start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
except ValueError:
return redirect("counter:invoices_call")
else: else:
start_date = datetime( start_date = datetime(
year=timezone.now().year, year=timezone.now().year,
@ -46,30 +66,23 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
) )
from django.db.models import Case, Sum, When from django.db.models import Case, Sum, When
kwargs["sum_cb"] = sum( kwargs["sum_cb"] = Refilling.objects.filter(
[
r.amount
for r in Refilling.objects.filter(
payment_method="CARD", payment_method="CARD",
is_validated=True, is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
) ).aggregate(amount=Sum(F("amount"), default=0))["amount"]
]
) kwargs["sum_cb"] += Selling.objects.filter(
kwargs["sum_cb"] += sum(
[
s.quantity * s.unit_price
for s in Selling.objects.filter(
payment_method="CARD", payment_method="CARD",
is_validated=True, is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
) ).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"]
]
)
kwargs["start_date"] = start_date kwargs["start_date"] = start_date
kwargs["sums"] = (
kwargs["sums"] = list(
Selling.objects.values("club__name") Selling.objects.values("club__name")
.annotate( .annotate(
selling_sum=Sum( selling_sum=Sum(
@ -86,4 +99,56 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
.exclude(selling_sum=None) .exclude(selling_sum=None)
.order_by("-selling_sum") .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 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.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,215 +0,0 @@
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(())
}
```

View File

@ -1,175 +0,0 @@
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
Ce sont des librairies relativement simples et qui présentent
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
de données à la fois simples et expressifs.
## Dossiers et fichiers
L'API possède une application (`api`)
à la racine du projet, contenant des utilitaires
et de la configuration partagée par toutes les autres applications.
C'est la pièce centrale de notre API, mais ce n'est pas là que
vous trouverez les routes de l'API.
Les routes en elles-mêmes sont contenues dans les autres applications,
de manière thématiques :
les routes liées aux clubs sont dans `club`, les routes liées
aux photos dans `sas` et ainsi de suite.
Les fichiers liés à l'API dans chaque application sont
`schemas.py` et `api.py`.
`schemas.py` contient les schémas de validation de données
et `api.py` contient les contrôleurs de l'API.
## Schéma de données
Le cœur de django-ninja étant sa validation de données grâce à Pydantic,
le développement de l'API commence par l'écriture de ses schémas de données.
Pour en comprendre le fonctionnement, veuillez consulter
[la doc de django-ninja](https://django-ninja.dev/guides/response/).
Il est également important de consulter
[la doc de pydantic](https://docs.pydantic.dev/latest/).
Notre surcouche par-dessus les schémas de django-ninja est relativement mince.
Elle ne comprend que [UploadedImage][core.schemas.UploadedImage], qui hérite de
[`UploadedFile`](https://django-ninja.dev/guides/input/file-params/?h=upl)
pour le restreindre uniquement aux images.
## Authentification et permissions
### Authentification
Notre API offre deux moyens d'authentification :
- par cookie de session (la méthode par défaut de django)
- par clef d'API
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
utilisez l'attribut `auth` et les classes `SessionAuth` et
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
!!!example
```python
@api_controller("/foo")
class FooController(ControllerBase):
# Cette route sera accessible uniquement avec l'authentification
# par cookie de session
@route.get("", auth=[SessionAuth()])
def fetch_foo(self, club_id: int): ...
# Et celle-ci sera accessible peut importe la méthode d'authentification
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def fetch_bar(self, club_id: int): ...
```
### Permissions
Si l'utilisateur est connecté, ça ne veut pas dire pour autant qu'il a accès à tout.
Une fois qu'il est authentifié, il faut donc vérifier ses permissions.
Pour cela, nous utilisons une surcouche
par-dessus `django-ninja`, le système de permissions de django
et notre propre système.
Cette dernière est documentée [ici](../perms.md).
### Limites des clefs d'API
#### Incompatibilité avec certaines permissions
Le système des clefs d'API est apparu très tard dans l'histoire du site
(en P25, 10 ans après le début du développement).
Il s'agit ni plus ni moins qu'un système d'authentification parallèle fait maison,
devant interagir avec un système de permissions ayant connu lui-même
une histoire assez chaotique.
Assez logiquement, on ne peut pas tout faire :
il n'est pas possible que toutes les routes acceptent
l'authentification par clef d'API.
Cette impossibilité provient majoritairement d'une incompatibilité
entre cette méthode d'authentification et le système de permissions
(qui n'a pas été prévu pour l'implémentation d'un client d'API).
Les principaux points de friction sont :
- `CanView` et `CanEdit`, qui se basent `User.can_view` et `User.can_edit`,
qui peuvent eux-mêmes se baser sur les méthodes `can_be_viewed_by`
et `can_be_edited_by` des différents modèles.
Or, ces dernières testent spécifiquement la relation entre l'objet et un `User`.
Ce comportement est possiblement changeable, mais au prix d'un certain travail
et au risque de transformer encore plus notre système de permissions
en usine à gaz.
- `IsSubscriber` et `OldSubscriber`, qui vérifient qu'un utilisateur est ou
a été cotisant.
Or, une clef d'API est liée à un client d'API, pas à un utilisateur.
Par définition, un client d'API ne peut pas être cotisant.
- `IsLoggedInCounter`, qui utilise encore un autre système
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
#### Incompatibilité avec les tokens csrf
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un des multiples facteurs d'attaque sur le web.
Heureusement, Django vient encore une fois à notre aide,
avec des mécanismes intégrés pour s'en protéger.
Ceux-ci incluent notamment un système de
[token CSRF](https://docs.djangoproject.com/fr/stable/ref/csrf/)
à fournir dans les requêtes POST/PUT/PATCH.
Ceux-ci sont bien adaptés au cycle requêtes/réponses
typique de l'expérience utilisateur sur un navigateur,
où les requêtes POST sont toujours effectuées après une requête
GET au cours de laquelle on a pu récupérer un token csrf.
Cependant, le flux des requêtes sur une API est bien différent ;
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
par un client externe n'aient pas de token CSRF et se retrouvent
donc bloquées.
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
par un client externe ne marche pas.
## Créer un client et une clef d'API
Le site n'a actuellement pas d'interface permettant à ses utilisateurs
de créer une application et des clefs d'API.
C'est volontaire : tant que le système ne sera pas suffisamment mature,
toute attribution de clef d'API doit passer par le pôle info.
Cette opération se fait au travers de l'interface admin.
Pour commencer, créez un client d'API, en renseignant son nom,
son propriétaire (l'utilisateur qui vous a demandé de le créer)
et les groupes qui lui sont attribués.
Ces groupes sont les mêmes que ceux qui sont attribués aux utilisateurs,
ce qui permet de réutiliser une partie du système d'authentification.
!!!warning
N'attribuez pas les groupes "anciens cotisants" et "cotisants"
aux clients d'API.
Un client d'API géré comme un cotisant, ça n'a aucun sens.
Evitez également de donner à des clients d'API des droits
autres que ceux de lecture sur le site.
Et surtout, n'attribuez jamais le group Root à un client d'API.
Une fois le client d'API créé, créez-lui une clef d'API.
Renseignez uniquement son nom et le client d'API auquel elle est lié.
La valeur de cette clef d'API est automatiquement générée
et affichée en haut de la page une fois la création complétée.
Notez bien la valeur de la clef d'API et transmettez-la à la personne
qui en a besoin.
Dites-lui bien de garder cette clef en lieu sûr !
Si la clef est perdue, il n'y a pas moyen de la récupérer,
vous devrez en recréer une.

View File

@ -606,4 +606,4 @@ vous ne devriez pas être perdu, étant donné
que le système de permissions de l'API utilise que le système de permissions de l'API utilise
des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`... des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`...
Vous pouvez trouver des exemples d'utilisation de ce système Vous pouvez trouver des exemples d'utilisation de ce système
dans [cette partie](../reference/api/perms.md). dans [cette partie](../reference/core/api_permissions.md).

View File

@ -24,66 +24,64 @@ sith/
├── .github/ ├── .github/
│ ├── actions/ (1) │ ├── actions/ (1)
│ └── workflows/ (2) │ └── workflows/ (2)
├── api/ (3) ├── club/ (3)
│ └── ... │ └── ...
├── antispam/ (4) ├── com/ (4)
│ └── ... │ └── ...
├── club/ (5) ├── core/ (5)
│ └── ... │ └── ...
├── com/ (6) ├── counter/ (6)
│ └── ... │ └── ...
├── core/ (7) ├── docs/ (7)
│ └── ... │ └── ...
├── counter/ (8) ├── eboutic/ (8)
│ └── ... │ └── ...
├── docs/ (9) ├── election/ (9)
│ └── ... │ └── ...
├── eboutic/ (10) ├── forum/ (10)
│ └── ... │ └── ...
├── election/ (11) ├── galaxy/ (11)
│ └── ... │ └── ...
├── forum/ (12) ├── launderette/ (12)
│ └── ... │ └── ...
├── galaxy/ (13) ├── locale/ (13)
│ └── ... │ └── ...
├── locale/ (14) ├── matmat/ (14)
│ └── ... │ └── ...
├── matmat/ (15) ├── pedagogy/ (15)
│ └── ... │ └── ...
├── pedagogy/ (16) ├── rootplace/ (16)
│ └── ... │ └── ...
├── rootplace/ (17) ├── sas/ (17)
│ └── ... │ └── ...
├── sas/ (18) ├── sith/ (18)
│ └── ... │ └── ...
├── sith/ (19) ├── subscription/ (19)
│ └── ... │ └── ...
├── subscription/ (20) ├── trombi/ (20)
│ └── ... │ └── ...
├── trombi/ (21) ├── antispam/ (21)
│ └── ... │ └── ...
├── antispam/ (22) ├── staticfiles/ (22)
│ └── ... │ └── ...
├── staticfiles/ (23) ├── processes/ (23)
│ └── ...
├── processes/ (24)
│ └── ... │ └── ...
├── .coveragerc (25) ├── .coveragerc (24)
├── .envrc (26) ├── .envrc (25)
├── .gitattributes ├── .gitattributes
├── .gitignore ├── .gitignore
├── .mailmap ├── .mailmap
├── .env (27) ├── .env (26)
├── .env.example (28) ├── .env.example (27)
├── manage.py (29) ├── manage.py (28)
├── mkdocs.yml (30) ├── mkdocs.yml (29)
├── uv.lock ├── uv.lock
├── pyproject.toml (31) ├── pyproject.toml (30)
├── .venv/ (32) ├── .venv/ (31)
├── .python-version (33) ├── .python-version (32)
├── Procfile.static (34) ├── Procfile.static (33)
├── Procfile.service (35) ├── Procfile.service (34)
└── README.md └── README.md
``` ```
</div> </div>
@ -96,55 +94,54 @@ sith/
des workflows Github. des workflows Github.
Par exemple, le workflow `docs.yml` compile Par exemple, le workflow `docs.yml` compile
et publie la documentation à chaque push sur la branche `master`. et publie la documentation à chaque push sur la branche `master`.
3. Application avec la configuration de l'API 3. Application de gestion des clubs et de leurs membres.
4. Application contenant des utilitaires pour bloquer le spam et les bots 4. Application contenant les fonctionnalités
5. Application de gestion des clubs et de leurs membres.
6. Application contenant les fonctionnalités
destinées aux responsables communication de l'AE. destinées aux responsables communication de l'AE.
7. Application contenant la modélisation centrale du site. 5. Application contenant la modélisation centrale du site.
On en reparle plus loin sur cette page. On en reparle plus loin sur cette page.
8. Application de gestion des comptoirs, des permanences 6. Application de gestion des comptoirs, des permanences
sur ces comptoirs et des transactions qui y sont effectuées. sur ces comptoirs et des transactions qui y sont effectuées.
9. Dossier contenant la documentation. 7. Dossier contenant la documentation.
10. Application de gestion de la boutique en ligne. 8. Application de gestion de la boutique en ligne.
11. Application de gestion des élections. 9. Application de gestion des élections.
12. Application de gestion du forum 10. Application de gestion du forum
13. Application de gestion de la galaxie ; la galaxie 11. Application de gestion de la galaxie ; la galaxie
est un graphe des niveaux de proximité entre les différents est un graphe des niveaux de proximité entre les différents
étudiants. étudiants.
14. Dossier contenant les fichiers de traduction. 12. Gestion des machines à laver de l'AE
15. Fonctionnalités de recherche d'utilisateurs. 13. Dossier contenant les fichiers de traduction.
16. Le guide des UEs du site, sur lequel les utilisateurs 14. Fonctionnalités de recherche d'utilisateurs.
15. Le guide des UEs du site, sur lequel les utilisateurs
peuvent également laisser leurs avis. peuvent également laisser leurs avis.
17. Fonctionnalités utiles aux utilisateurs root. 16. Fonctionnalités utiles aux utilisateurs root.
18. Le SAS, où l'on trouve toutes les photos de l'AE. 17. Le SAS, où l'on trouve toutes les photos de l'AE.
19. Application principale du projet, contenant sa configuration. 18. Application principale du projet, contenant sa configuration.
20. Gestion des cotisations des utilisateurs du site. 19. Gestion des cotisations des utilisateurs du site.
21. Outil pour faciliter la fabrication des trombinoscopes de promo. 20. Outil pour faciliter la fabrication des trombinoscopes de promo.
22. Fonctionnalités pour gérer le spam. 21. Fonctionnalités pour gérer le spam.
23. Gestion des statics du site. Override le système de statics de Django. 22. Gestion des statics du site. Override le système de statics de Django.
Ajoute l'intégration du scss et du bundler js Ajoute l'intégration du scss et du bundler js
de manière transparente pour l'utilisateur. de manière transparente pour l'utilisateur.
24. Module de gestion des services externes. 23. Module de gestion des services externes.
Offre une API simple pour utiliser les fichiers `Procfile.*`. Offre une API simple pour utiliser les fichiers `Procfile.*`.
25. Fichier de configuration de coverage. 24. Fichier de configuration de coverage.
26. Fichier de configuration de direnv. 25. Fichier de configuration de direnv.
27. Contient les variables d'environnement, qui sont susceptibles 26. Contient les variables d'environnement, qui sont susceptibles
de varier d'une machine à l'autre. de varier d'une machine à l'autre.
28. Contient des valeurs par défaut pour le `.env` 27. Contient des valeurs par défaut pour le `.env`
pouvant convenir à un environnment de développement local pouvant convenir à un environnment de développement local
29. Fichier généré automatiquement par Django. C'est lui 28. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py <nom de la commande>` avec la syntaxe `python ./manage.py <nom de la commande>`
30. Le fichier de configuration de la documentation, 29. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières. avec ses plugins et sa table des matières.
31. Le fichier où sont déclarés les dépendances et la configuration 30. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles. de certaines d'entre elles.
32. Dossier d'environnement virtuel généré par uv 31. Dossier d'environnement virtuel généré par uv
33. Fichier qui contrôle quelle version de python utiliser pour le projet 32. Fichier qui contrôle quelle version de python utiliser pour le projet
34. Fichier qui contrôle les commandes à lancer pour gérer la compilation 33. Fichier qui contrôle les commandes à lancer pour gérer la compilation
automatique des static et autres services nécessaires à la command runserver. automatique des static et autres services nécessaires à la command runserver.
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement 34. Fichier qui contrôle les services tiers nécessaires au fonctionnement
du Sith tel que redis. du Sith tel que redis.
## L'application principale ## L'application principale

View File

@ -1,7 +1,7 @@
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
from api.permissions import CanView from core.auth.api_permissions import CanView
from counter.models import BillingInfo from counter.models import BillingInfo
from eboutic.models import Basket from eboutic.models import Basket

View File

@ -4,6 +4,10 @@
{% trans %}Basket state{% endtrans %} {% trans %}Basket state{% endtrans %}
{% endblock %} {% endblock %}
{% block jquery_css %}
{# Remove jquery css #}
{% endblock %}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script> <script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,10 @@
{% trans %}Eboutic{% endtrans %} {% trans %}Eboutic{% endtrans %}
{% endblock %} {% endblock %}
{% block jquery_css %}
{# Remove jquery css #}
{% endblock %}
{% block additional_js %} {% block additional_js %}
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the
user basket without having to reload the page #} user basket without having to reload the page #}

View File

@ -1,155 +0,0 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
super().__init__(queryset, **kwargs)
def clean(self, value):
qs = super().clean(value)
self.validate(qs)
return qs
def validate(self, qs):
if qs.count() > self.max_choice:
raise forms.ValidationError(
_("You have selected too many candidates."), code="invalid"
)
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
required_css_class = "required"
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, election: Election, can_edit: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["role"].queryset = election.roles.select_related("election")
self.fields["election_list"].queryset = election.election_lists.all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election: Election, user: User, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.can_vote(user):
return
for role in election.roles.all():
cand = role.candidatures
if role.max_choice > 1:
self.fields[role.title] = LimitedCheckboxField(
cand, role.max_choice, required=False
)
else:
self.fields[role.title] = forms.ModelChoiceField(
cand,
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
)
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
class Meta:
model = Role
fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
)
class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm):
class Meta:
model = Election
fields = [
"title",
"description",
"archived",
"start_candidature",
"end_candidature",
"start_date",
"end_date",
"edit_groups",
"view_groups",
"vote_groups",
"candidature_groups",
]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
}
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
start_candidature = forms.DateTimeField(
label=_("Start candidature"), widget=SelectDateTime, required=True
)
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
)

View File

@ -1,30 +0,0 @@
# Generated by Django 4.2.20 on 2025-03-14 18:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("election", "0004_auto_20191006_0049"),
]
operations = [
migrations.AlterField(
model_name="candidature",
name="program",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="candidature",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="candidates",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

@ -1,7 +1,5 @@
from django.db import models from django.db import models
from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
@ -24,18 +22,21 @@ class Election(models.Model):
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
blank=True, blank=True,
) )
view_groups = models.ManyToManyField( view_groups = models.ManyToManyField(
Group, Group,
related_name="viewable_elections", related_name="viewable_elections",
verbose_name=_("view groups"), verbose_name=_("view groups"),
blank=True, blank=True,
) )
vote_groups = models.ManyToManyField( vote_groups = models.ManyToManyField(
Group, Group,
related_name="votable_elections", related_name="votable_elections",
verbose_name=_("vote groups"), verbose_name=_("vote groups"),
blank=True, blank=True,
) )
candidature_groups = models.ManyToManyField( candidature_groups = models.ManyToManyField(
Group, Group,
related_name="candidate_elections", related_name="candidate_elections",
@ -44,7 +45,7 @@ class Election(models.Model):
) )
voters = models.ManyToManyField( voters = models.ManyToManyField(
User, verbose_name=_("voters"), related_name="voted_elections" User, verbose_name=("voters"), related_name="voted_elections"
) )
archived = models.BooleanField(_("archived"), default=False) archived = models.BooleanField(_("archived"), default=False)
@ -54,20 +55,20 @@ class Election(models.Model):
@property @property
def is_vote_active(self): def is_vote_active(self):
now = timezone.now() now = timezone.now()
return self.start_date <= now <= self.end_date return bool(now <= self.end_date and now >= self.start_date)
@property @property
def is_vote_finished(self): def is_vote_finished(self):
return timezone.now() > self.end_date return bool(timezone.now() > self.end_date)
@property @property
def is_candidature_active(self): def is_candidature_active(self):
now = timezone.now() now = timezone.now()
return self.start_candidature <= now <= self.end_candidature return bool(now <= self.end_candidature and now >= self.start_candidature)
@property @property
def is_vote_editable(self): def is_vote_editable(self):
return timezone.now() <= self.end_candidature return bool(timezone.now() <= self.end_candidature)
def can_candidate(self, user): def can_candidate(self, user):
for group_id in self.candidature_groups.values_list("pk", flat=True): for group_id in self.candidature_groups.values_list("pk", flat=True):
@ -86,7 +87,7 @@ class Election(models.Model):
def has_voted(self, user): def has_voted(self, user):
return self.voters.filter(id=user.id).exists() return self.voters.filter(id=user.id).exists()
@cached_property @property
def results(self): def results(self):
results = {} results = {}
total_vote = self.voters.count() total_vote = self.voters.count()
@ -94,6 +95,12 @@ class Election(models.Model):
results[role.title] = role.results(total_vote) results[role.title] = role.results(total_vote)
return results return results
def delete(self, *args, **kwargs):
self.election_lists.all().delete()
super().delete(*args, **kwargs)
# Permissions
class Role(OrderedModel): class Role(OrderedModel):
"""This class allows to create a new role avaliable for a candidature.""" """This class allows to create a new role avaliable for a candidature."""
@ -108,27 +115,23 @@ class Role(OrderedModel):
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), null=True, blank=True)
max_choice = models.IntegerField(_("max choice"), default=1) max_choice = models.IntegerField(_("max choice"), default=1)
def __str__(self): def results(self, total_vote):
return f"{self.title} - {self.election.title}" results = {}
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0:
candidates = self.candidatures.values_list("user__username")
return {
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
}
total_vote *= self.max_choice total_vote *= self.max_choice
results = {"total vote": total_vote}
non_blank = 0 non_blank = 0
candidatures = self.candidatures.annotate(nb_votes=Count("votes")).values( for candidature in self.candidatures.all():
"nb_votes", "user__username" cand_results = {}
) cand_results["vote"] = self.votes.filter(candidature=candidature).count()
for candidature in candidatures: if total_vote == 0:
non_blank += candidature["nb_votes"] cand_results["percent"] = 0
results[candidature["user__username"]] = { else:
"vote": candidature["nb_votes"], cand_results["percent"] = cand_results["vote"] * 100 / total_vote
"percent": candidature["nb_votes"] * 100 / total_vote, non_blank += cand_results["vote"]
} results[candidature.user.username] = cand_results
results["total vote"] = total_vote
if total_vote == 0:
results["blank vote"] = {"vote": 0, "percent": 0}
else:
results["blank vote"] = { results["blank vote"] = {
"vote": total_vote - non_blank, "vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote, "percent": (total_vote - non_blank) * 100 / total_vote,
@ -139,6 +142,9 @@ class Role(OrderedModel):
def edit_groups(self): def edit_groups(self):
return self.election.edit_groups return self.election.edit_groups
def __str__(self):
return ("%s : %s") % (self.election.title, self.title)
class ElectionList(models.Model): class ElectionList(models.Model):
"""To allow per list vote.""" """To allow per list vote."""
@ -157,6 +163,11 @@ class ElectionList(models.Model):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.can_edit(self.election) return user.can_edit(self.election)
def delete(self, *args, **kwargs):
for candidature in self.candidatures.all():
candidature.delete()
super().delete(*args, **kwargs)
class Candidature(models.Model): class Candidature(models.Model):
"""This class is a component of responsability.""" """This class is a component of responsability."""
@ -171,9 +182,10 @@ class Candidature(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="candidates", related_name="candidates",
blank=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
program = models.TextField(_("description"), default="", blank=True) program = models.TextField(_("description"), null=True, blank=True)
election_list = models.ForeignKey( election_list = models.ForeignKey(
ElectionList, ElectionList,
related_name="candidatures", related_name="candidatures",
@ -184,10 +196,13 @@ class Candidature(models.Model):
def __str__(self): def __str__(self):
return f"{self.role.title} : {self.user.username}" return f"{self.role.title} : {self.user.username}"
def delete(self):
for vote in self.votes.all():
vote.delete()
super().delete()
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return ( return (user == self.user) or user.can_edit(self.role.election)
(user == self.user) or user.can_edit(self.role.election)
) and self.role.election.is_vote_editable
class Vote(models.Model): class Vote(models.Model):

View File

@ -93,14 +93,13 @@ $min_col_width: 100px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin: 0; margin: 0;
gap: 20px; row-gap: 10px;
padding: $padding; padding: $padding;
width: 100%; width: 100%;
>.role_text { >.role_text {
display: flex; display: flex;
width: 100%;
flex-direction: column; flex-direction: column;
>h4 { >h4 {
@ -108,6 +107,7 @@ $min_col_width: 100px;
} }
.role_description { .role_description {
flex-grow: 1;
margin-top: .5em; margin-top: .5em;
text-wrap: auto; text-wrap: auto;
text-align: left; text-align: left;

View File

@ -31,7 +31,7 @@
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time> <time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time> {% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
</p> </p>
{%- if user_has_voted %} {%- if election.has_voted(user) %}
<p class="election__elector-infos"> <p class="election__elector-infos">
{%- if election.is_vote_active %} {%- if election.is_vote_active %}
<span>{% trans %}You already have submitted your vote.{% endtrans %}</span> <span>{% trans %}You already have submitted your vote.{% endtrans %}</span>
@ -45,11 +45,12 @@
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form"> <form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
{% csrf_token %} {% csrf_token %}
<table class="election_table"> <table class="election_table">
{%- set election_lists = election.election_lists.all() -%}
<thead class="lists"> <thead class="lists">
<tr> <tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th> <th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{%- for election_list in election_lists %} {%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%"> <th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">
<span>{{ election_list.title }}</span> <span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%} {% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a> <a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
@ -58,26 +59,18 @@
{%- endfor %} {%- endfor %}
</tr> </tr>
</thead> </thead>
{%- for role in election_roles %} {%- set role_list = election.roles.order_by('order').all() %}
{%- for role in role_list %}
{%- set count = [0] %}
{%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %} {%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
<tbody data-max-choice="{{role.max_choice}}" class="role{{ ' role_error' if role.title in election_form.errors else '' }}{{ ' role__multiple-choices' if role.max_choice > 1 else ''}}">
<tbody
{% if role.max_choice > 1 -%}
x-data x-limited-choices="{{ role.max_choice }}"
{%- endif %}
class="role {% if role.title in election_form.errors %}role_error{% endif %}"
>
<tr> <tr>
<td class="role_title"> <td class="role_title">
<div class="role_text"> <div class="role_text">
<h4>{{ role.title }}</h4> <h4>{{ role.title }}</h4>
<p class="role_description" show-more="300">{{ role.description }}</p> <p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and show_vote_buttons %} {%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %}
<strong> <strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong>
{% trans trimmed nb_choices=role.max_choice %}
You may choose up to {{ nb_choices }} people.
{% endtrans %}
</strong>
{%- endif %} {%- endif %}
{%- if election_form.errors[role.title] is defined %} {%- if election_form.errors[role.title] is defined %}
@ -88,40 +81,36 @@
</div> </div>
{% if user.can_edit(role) and election.is_vote_editable -%} {% if user.can_edit(role) and election.is_vote_editable -%}
<div class="role_buttons"> <div class="role_buttons">
<a href="{{ url('election:update_role', role_id=role.id) }}"> <a href="{{url('election:update_role', role_id=role.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<i class="fa-regular fa-pen-to-square edit-action"></i> <a href="{{url('election:delete_role', role_id=role.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
</a> {%- if role == role_list.last() %}
<a href="{{ url('election:delete_role', role_id=role.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{%- if loop.last -%}
<button disabled><i class="fa fa-arrow-down"></i></button> <button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button> <button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%} {%- else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
{%- endif -%} {%- endif %}
{%- if loop.first -%} {% if role == role_list.first() %}
<button disabled><i class="fa fa-caret-up"></i></button> <button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button> <button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%} {% else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
{%- endif -%} {% endif %}
</div> </div>
{%- endif -%} {%- endif -%}
</td> </td>
</tr> </tr>
<tr class="role_candidates"> <tr class="role_candidates">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"> <td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
{%- if role.max_choice == 1 and show_vote_buttons %} {%- if role.max_choice == 1 and election.can_vote(user) %}
<div class="radio-btn"> <div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %} <input id="id_{{ role.title }}_{{ count[0] }}" type="radio" name="{{ role.title }}" value {{ '' if role_data in election_form else 'checked' }} {{ 'disabled' if election.has_voted(user) else '' }}>
<input id="{{ input_id }}" type="radio" name="{{ role.title }}"> <label for="id_{{ role.title }}_{{ count[0] }}">
<label for="{{ input_id }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span> <span>{% trans %}Choose blank vote{% endtrans %}</span>
</label> </label>
</div> </div>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %} {%- endif %}
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %} {%- set results = election_results[role.title]['blank vote'] %}
@ -131,14 +120,13 @@
{%- endif %} {%- endif %}
</td> </td>
{%- for election_list in election_lists %} {%- for election_list in election_lists %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"> <td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
<ul class="candidates"> <ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %} {%- for candidature in election_list.candidatures.filter(role=role) %}
<li class="candidate"> <li class="candidate">
{%- if show_vote_buttons %} {%- if election.can_vote(user) %}
{% set input_id = "candidature_" + candidature.id|string %} <input id="id_{{ role.title }}_{{ count[0] }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if election.has_voted(user) else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}"> <label for="id_{{ role.title }}_{{ count[0] }}">
<label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_subscriber_viewable %} {%- if user.is_subscriber_viewable %}
@ -152,7 +140,7 @@
<h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5> <h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5>
{%- if not election.is_vote_finished %} {%- if not election.is_vote_finished %}
<q class="candidate_program" show-more="200"> <q class="candidate_program" show-more="200">
{{ candidature.program|markdown }} {{ candidature.program|markdown or '' }}
</q> </q>
{%- endif %} {%- endif %}
</figcaption> </figcaption>
@ -165,8 +153,9 @@
{%- endif -%} {%- endif -%}
{%- endif -%} {%- endif -%}
</figure> </figure>
{%- if show_vote_buttons %} {%- if election.can_vote(user) %}
</label> </label>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %} {%- endif %}
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title][candidature.user.username] %} {%- set results = election_results[role.title][candidature.user.username] %}
@ -202,9 +191,36 @@
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a> <a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %} {%- endif %}
</section> </section>
{%- if show_vote_buttons %} {%- if not election.has_voted(user) and election.can_vote(user) %}
<section class="buttons"> <section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button> <button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section> </section>
{%- endif %} {%- endif %}
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.querySelectorAll('.role__multiple-choices').forEach(setupRestrictions);
function setupRestrictions(role) {
var selectedChoices = [];
role.querySelectorAll('input').forEach(setupRestriction);
function setupRestriction(choice) {
if (choice.checked)
selectedChoices.push(choice);
choice.addEventListener('change', onChange);
function onChange() {
if (choice.checked)
selectedChoices.push(choice);
else
selectedChoices.splice(selectedChoices.indexOf(choice), 1);
while (selectedChoices.length > role.dataset.maxChoice)
selectedChoices.shift().checked = false;
}
}
}
</script>
{% endblock %}

View File

@ -1,15 +1,9 @@
from datetime import timedelta
import pytest
from django.conf import settings from django.conf import settings
from django.test import Client, TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Election
class TestElection(TestCase): class TestElection(TestCase):
@ -18,7 +12,8 @@ class TestElection(TestCase):
cls.election = Election.objects.first() cls.election = Election.objects.first()
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.public = baker.make(User) cls.subscriber = User.objects.get(username="subscriber")
cls.public = User.objects.get(username="public")
class TestElectionDetail(TestElection): class TestElectionDetail(TestElection):
@ -41,7 +36,7 @@ class TestElectionDetail(TestElection):
class TestElectionUpdateView(TestElection): class TestElectionUpdateView(TestElection):
def test_permission_denied(self): def test_permission_denied(self):
self.client.force_login(subscriber_user.make()) self.client.force_login(self.subscriber)
response = self.client.get( response = self.client.get(
reverse("election:update", args=str(self.election.id)) reverse("election:update", args=str(self.election.id))
) )
@ -50,68 +45,3 @@ class TestElectionUpdateView(TestElection):
reverse("election:update", args=str(self.election.id)) reverse("election:update", args=str(self.election.id))
) )
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.django_db
def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))
groups = [
Group.objects.get(pk=settings.SITH_GROUP_SUBSCRIBERS_ID),
baker.make(Group),
]
election.candidature_groups.add(groups[0])
election.edit_groups.add(groups[1])
url = reverse("election:create_list", kwargs={"election_id": election.id})
for user in subscriber_user.make(), baker.make(User, groups=[groups[1]]):
client.force_login(user)
assert client.get(url).status_code == 200
# the post is a 200 instead of a 302, because we don't give form data,
# but we don't care as we only test permissions here
assert client.post(url).status_code == 200
client.force_login(baker.make(User))
assert client.get(url).status_code == 403
assert client.post(url).status_code == 403
@pytest.mark.django_db
def test_election_results():
election = baker.make(
Election, voters=baker.make(User, _quantity=50, _bulk_create=True)
)
lists = baker.make(ElectionList, election=election, _quantity=2, _bulk_create=True)
roles = baker.make(
Role, election=election, max_choice=iter([1, 2]), _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
cand = [
baker.make(Candidature, role=roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=roles[1], user=users[3], election_list=lists[1]),
]
votes = [
baker.make(Vote, role=roles[0], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[0], _quantity=25, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=35, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=10, _bulk_create=True),
]
cand[0].votes.set(votes[0])
cand[1].votes.set(votes[1])
cand[2].votes.set([*votes[2], *votes[4]])
cand[3].votes.set([*votes[3], *votes[4]])
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 40.0, "vote": 20},
cand[1].user.username: {"percent": 50.0, "vote": 25},
"blank vote": {"percent": 10.0, "vote": 5},
"total vote": 50,
},
roles[1].title: {
cand[2].user.username: {"percent": 30.0, "vote": 30},
cand[3].user.username: {"percent": 45.0, "vote": 45},
"blank vote": {"percent": 25.0, "vote": 25},
"total vote": 100,
},
}

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