diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86acb1b1..4379d91a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.11 + rev: v0.11.13 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 00000000..611bdba0 --- /dev/null +++ b/api/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin, messages +from django.db.models import QuerySet +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from api.hashers import generate_key +from api.models import ApiClient, ApiKey + + +@admin.register(ApiClient) +class ApiClientAdmin(admin.ModelAdmin): + list_display = ("name", "owner", "created_at", "updated_at") + search_fields = ( + "name", + "owner__first_name", + "owner__last_name", + "owner__nick_name", + ) + autocomplete_fields = ("owner", "groups", "client_permissions") + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ("name", "client", "created_at", "revoked") + list_filter = ("revoked",) + date_hierarchy = "created_at" + + readonly_fields = ("prefix", "hashed_key") + actions = ("revoke_keys",) + + def save_model(self, request: HttpRequest, obj: ApiKey, form, change): + if not change: + key, hashed = generate_key() + obj.prefix = key[: ApiKey.PREFIX_LENGTH] + obj.hashed_key = hashed + self.message_user( + request, + _( + "The API key for %(name)s is: %(key)s. " + "Please store it somewhere safe: " + "you will not be able to see it again." + ) + % {"name": obj.name, "key": key}, + level=messages.WARNING, + ) + return super().save_model(request, obj, form, change) + + def get_readonly_fields(self, request, obj: ApiKey | None = None): + if obj is None or obj.revoked: + return ["revoked", *self.readonly_fields] + return self.readonly_fields + + @admin.action(description=_("Revoke selected API keys")) + def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]): + queryset.update(revoked=True) diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 00000000..878e7d54 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000..787234a6 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,20 @@ +from django.http import HttpRequest +from ninja.security import APIKeyHeader + +from api.hashers import get_hasher +from api.models import ApiClient, ApiKey + + +class ApiKeyAuth(APIKeyHeader): + param_name = "X-APIKey" + + def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: + if not key or len(key) != ApiKey.KEY_LENGTH: + return None + hasher = get_hasher() + hashed_key = hasher.encode(key) + try: + key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key) + except ApiKey.DoesNotExist: + return None + return key_obj.client diff --git a/api/hashers.py b/api/hashers.py new file mode 100644 index 00000000..95c16673 --- /dev/null +++ b/api/hashers.py @@ -0,0 +1,43 @@ +import functools +import hashlib +import secrets + +from django.contrib.auth.hashers import BasePasswordHasher +from django.utils.crypto import constant_time_compare + + +class Sha512ApiKeyHasher(BasePasswordHasher): + """ + An API key hasher using the sha256 algorithm. + + This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting. + It is insecure for use in hashing passwords, but is safe for hashing + high entropy, randomly generated API keys. + """ + + algorithm = "sha512" + + def salt(self) -> str: + # No need for a salt on a high entropy key. + return "" + + def encode(self, password: str, salt: str = "") -> str: + hashed = hashlib.sha512(password.encode()).hexdigest() + return f"{self.algorithm}$${hashed}" + + def verify(self, password: str, encoded: str) -> bool: + encoded_2 = self.encode(password, "") + return constant_time_compare(encoded, encoded_2) + + +@functools.cache +def get_hasher(): + return Sha512ApiKeyHasher() + + +def generate_key() -> tuple[str, str]: + """Generate a [key, hash] couple.""" + # this will result in key with a length of 72 + key = str(secrets.token_urlsafe(54)) + hasher = get_hasher() + return key, hasher.encode(key) diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 00000000..4ebfe9d4 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2 on 2025-06-01 08:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0046_permissionrights"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApiClient", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=64, verbose_name="name")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "client_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this api client.", + related_name="clients", + to="auth.permission", + verbose_name="client permissions", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + related_name="api_clients", + to="core.group", + verbose_name="groups", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_clients", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "api client", + "verbose_name_plural": "api clients", + }, + ), + migrations.CreateModel( + name="ApiKey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", verbose_name="name")), + ( + "prefix", + models.CharField( + editable=False, max_length=5, verbose_name="prefix" + ), + ), + ( + "hashed_key", + models.CharField( + db_index=True, + editable=False, + max_length=136, + verbose_name="hashed key", + ), + ), + ("revoked", models.BooleanField(default=False, verbose_name="revoked")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "client", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to="api.apiclient", + verbose_name="api client", + ), + ), + ], + options={ + "verbose_name": "api key", + "verbose_name_plural": "api keys", + "permissions": [("revoke_apikey", "Revoke API keys")], + }, + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/models.py b/api/models.py new file mode 100644 index 00000000..36e20287 --- /dev/null +++ b/api/models.py @@ -0,0 +1,94 @@ +from typing import Iterable + +from django.contrib.auth.models import Permission +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy + +from core.models import Group, User + + +class ApiClient(models.Model): + name = models.CharField(_("name"), max_length=64) + owner = models.ForeignKey( + User, + verbose_name=_("owner"), + related_name="api_clients", + on_delete=models.CASCADE, + ) + groups = models.ManyToManyField( + Group, verbose_name=_("groups"), related_name="api_clients", blank=True + ) + client_permissions = models.ManyToManyField( + Permission, + verbose_name=_("client permissions"), + blank=True, + help_text=_("Specific permissions for this api client."), + related_name="clients", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + _perm_cache: set[str] | None = None + + class Meta: + verbose_name = _("api client") + verbose_name_plural = _("api clients") + + def __str__(self): + return self.name + + def has_perm(self, perm: str): + """Return True if the client has the specified permission.""" + + if self._perm_cache is None: + group_permissions = ( + Permission.objects.filter(group__group__in=self.groups.all()) + .values_list("content_type__app_label", "codename") + .order_by() + ) + client_permissions = self.client_permissions.values_list( + "content_type__app_label", "codename" + ).order_by() + self._perm_cache = { + f"{content_type}.{name}" + for content_type, name in (*group_permissions, *client_permissions) + } + return perm in self._perm_cache + + def has_perms(self, perm_list): + """ + Return True if the client has each of the specified permissions. If + object is passed, check if the client has all required perms for it. + """ + if not isinstance(perm_list, Iterable) or isinstance(perm_list, str): + raise ValueError("perm_list must be an iterable of permissions.") + return all(self.has_perm(perm) for perm in perm_list) + + +class ApiKey(models.Model): + PREFIX_LENGTH = 5 + KEY_LENGTH = 72 + HASHED_KEY_LENGTH = 136 + + name = models.CharField(_("name"), blank=True, default="") + prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False) + hashed_key = models.CharField( + _("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False + ) + client = models.ForeignKey( + ApiClient, + verbose_name=_("api client"), + related_name="api_keys", + on_delete=models.CASCADE, + ) + revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = _("api key") + verbose_name_plural = _("api keys") + permissions = [("revoke_apikey", "Revoke API keys")] + + def __str__(self): + return f"{self.name} ({self.prefix}***)" diff --git a/core/auth/api_permissions.py b/api/permissions.py similarity index 78% rename from core/auth/api_permissions.py rename to api/permissions.py index 6a28f13c..f371910b 100644 --- a/core/auth/api_permissions.py +++ b/api/permissions.py @@ -39,7 +39,7 @@ Example: import operator from functools import reduce -from typing import Any +from typing import Any, Callable from django.contrib.auth.models import Permission from django.http import HttpRequest @@ -67,21 +67,26 @@ class HasPerm(BasePermission): Example: ```python - # this route will require both permissions - @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] - def foo(self): ... + @api_controller("/foo") + class FooController(ControllerBase): + # this route will require both permissions + @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] + def foo(self): ... - # This route will require at least one of the perm, - # but it's not mandatory to have all of them - @route.put( - "/bar", - permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], - ) - def bar(self): ... + # This route will require at least one of the perm, + # but it's not mandatory to have all of them + @route.put( + "/bar", + permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], + ) + def bar(self): ... + ``` """ def __init__( - self, perms: str | Permission | list[str | Permission], op=operator.and_ + self, + perms: str | Permission | list[str | Permission], + op: Callable[[bool, bool], bool] = operator.and_, ): """ Args: @@ -96,7 +101,16 @@ class HasPerm(BasePermission): self._perms = perms def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: - return reduce(self._operator, (request.user.has_perm(p) for p in self._perms)) + # if the request has the `auth` property, + # it means that the user has been explicitly authenticated + # using a django-ninja authentication backend + # (whether it is SessionAuth or ApiKeyAuth). + # If not, this authentication has not been done, but the user may + # still be implicitly authenticated through AuthenticationMiddleware + user = request.auth if hasattr(request, "auth") else request.user + # `user` may either be a `core.User` or an `api.ApiClient` ; + # they are not the same model, but they both implement the `has_perm` method + return reduce(self._operator, (user.has_perm(p) for p in self._perms)) class IsRoot(BasePermission): @@ -180,4 +194,4 @@ class IsLoggedInCounter(BasePermission): return Counter.objects.filter(token=token).exists() -CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter +CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tests/test_api_key.py b/api/tests/test_api_key.py new file mode 100644 index 00000000..4fad18d3 --- /dev/null +++ b/api/tests/test_api_key.py @@ -0,0 +1,29 @@ +import pytest +from django.test import RequestFactory +from model_bakery import baker + +from api.auth import ApiKeyAuth +from api.hashers import generate_key +from api.models import ApiClient, ApiKey + + +@pytest.mark.django_db +def test_api_key_auth(): + key, hashed = generate_key() + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) == client + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")] +) +def test_api_key_auth_invalid(key, hashed): + client = baker.make(ApiClient) + baker.make(ApiKey, client=client, hashed_key=hashed) + auth = ApiKeyAuth() + + assert auth.authenticate(RequestFactory().get(""), key) is None diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 00000000..ed58c790 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,10 @@ +from ninja_extra import NinjaExtraAPI + +api = NinjaExtraAPI( + title="PICON", + description="Portail Interaction de Communication avec les Services Étudiants", + version="0.2.0", + urls_namespace="api", + csrf=True, +) +api.auto_discover_controllers() diff --git a/club/api.py b/club/api.py index 2ad0f5c8..ef46e4c7 100644 --- a/club/api.py +++ b/club/api.py @@ -1,22 +1,38 @@ from typing import Annotated from annotated_types import MinLen +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, HasPerm from club.models import Club -from club.schemas import ClubSchema -from core.auth.api_permissions import CanAccessLookup +from club.schemas import ClubSchema, SimpleClubSchema @api_controller("/club") class ClubController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ClubSchema], + response=PaginatedResponseSchema[SimpleClubSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], + url_name="search_club", ) @paginate(PageNumberPaginationExtra, page_size=50) def search_club(self, search: Annotated[str, MinLen(1)]): return Club.objects.filter(name__icontains=search).values() + + @route.get( + "/{int:club_id}", + response=ClubSchema, + auth=[SessionAuth(), ApiKeyAuth()], + permissions=[HasPerm("club.view_club")], + url_name="fetch_club", + ) + def fetch_club(self, club_id: int): + return self.get_object_or_exception( + Club.objects.prefetch_related("members", "members__user"), id=club_id + ) diff --git a/club/forms.py b/club/forms.py index 6bb09fc4..5d6a3b10 100644 --- a/club/forms.py +++ b/club/forms.py @@ -163,15 +163,16 @@ class SellingsForm(forms.Form): def __init__(self, club, *args, **kwargs): super().__init__(*args, **kwargs) - counters_qs = ( - Counter.objects.filter( - Q(club=club) - | Q(products__club=club) - | Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club)) - ) - .distinct() - .order_by(Lower("name")) + # postgres struggles really hard with a single query having three WHERE conditions, + # but deals perfectly fine with UNION of multiple queryset with their own WHERE clause, + # so we do this to get the ids, which we use to build another queryset that can be used by django. + club_sales_subquery = Selling.objects.filter(counter=OuterRef("pk"), club=club) + ids = ( + Counter.objects.filter(Q(club=club) | Q(products__club=club)) + .union(Counter.objects.filter(Exists(club_sales_subquery))) + .values_list("id", flat=True) ) + counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name")) self.fields["counters"] = forms.ModelMultipleChoiceField( counters_qs, label=_("Counter"), required=False ) diff --git a/club/schemas.py b/club/schemas.py index 7969f119..b0601af8 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -1,9 +1,10 @@ from ninja import ModelSchema -from club.models import Club +from club.models import Club, Membership +from core.schemas import SimpleUserSchema -class ClubSchema(ModelSchema): +class SimpleClubSchema(ModelSchema): class Meta: model = Club fields = ["id", "name"] @@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema): @staticmethod def resolve_url(obj: Club) -> str: return obj.get_absolute_url() + + +class ClubMemberSchema(ModelSchema): + class Meta: + model = Membership + fields = ["start_date", "end_date", "role", "description"] + + user: SimpleUserSchema + + +class ClubSchema(ModelSchema): + class Meta: + model = Club + fields = ["id", "name", "logo", "is_active", "short_description", "address"] + + members: list[ClubMemberSchema] diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py new file mode 100644 index 00000000..ade8eb4d --- /dev/null +++ b/club/tests/test_club_controller.py @@ -0,0 +1,21 @@ +import pytest +from django.test import Client +from django.urls import reverse +from model_bakery import baker +from pytest_django.asserts import assertNumQueries + +from club.models import Club, Membership +from core.baker_recipes import subscriber_user + + +@pytest.mark.django_db +def test_fetch_club(client: Client): + club = baker.make(Club) + baker.make(Membership, club=club, _quantity=10, _bulk_create=True) + user = subscriber_user.make() + client.force_login(user) + with assertNumQueries(7): + # - 4 queries for authentication + # - 3 queries for the actual data + res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id})) + assert res.status_code == 200 diff --git a/club/widgets/ajax_select.py b/club/widgets/ajax_select.py index 36ad3e9a..ddcc820f 100644 --- a/club/widgets/ajax_select.py +++ b/club/widgets/ajax_select.py @@ -1,7 +1,7 @@ from pydantic import TypeAdapter from club.models import Club -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultiple, @@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"] class AutoCompleteSelectClub(AutoCompleteSelect): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js @@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect): class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js diff --git a/com/api.py b/com/api.py index 79ff9c34..b01eef0e 100644 --- a/com/api.py +++ b/com/api.py @@ -8,10 +8,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema +from api.permissions import HasPerm from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from com.schemas import NewsDateFilterSchema, NewsDateSchema -from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file diff --git a/com/models.py b/com/models.py index c7e66515..8c7111e8 100644 --- a/com/models.py +++ b/com/models.py @@ -160,14 +160,16 @@ class News(models.Model): ) -def news_notification_callback(notif): +def news_notification_callback(notif: Notification): + # the NewsDate linked to the News + # which creation triggered this callback may not exist yet, + # so it's important to filter by "not past date" rather than by "future date" count = News.objects.filter( - dates__start_date__gt=timezone.now(), is_published=False + ~Q(dates__start_date__gt=timezone.now()), is_published=False ).count() if count: notif.viewed = False notif.param = str(count) - notif.date = timezone.now() else: notif.viewed = True @@ -191,7 +193,7 @@ class NewsDateQuerySet(models.QuerySet): class NewsDate(models.Model): """A date associated with news. - A [News][] can have multiple dates, for example if it is a recurring event. + A [News][com.models.News] can have multiple dates, for example if it is a recurring event. """ news = models.ForeignKey( diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index ad85280a..bc2ec9b4 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -7,6 +7,7 @@ import frLocale from "@fullcalendar/core/locales/fr"; import dayGridPlugin from "@fullcalendar/daygrid"; import iCalendarPlugin from "@fullcalendar/icalendar"; import listPlugin from "@fullcalendar/list"; +import { type HTMLTemplateResult, html, render } from "lit-html"; import { calendarCalendarInternal, calendarCalendarUnpublished, @@ -176,29 +177,25 @@ export class IcsCalendar extends inheritHtmlElement("div") { oldPopup.remove(); } - const makePopupInfo = (info: HTMLElement, iconClass: string) => { - const row = document.createElement("div"); - const icon = document.createElement("i"); - - row.setAttribute("class", "event-details-row"); - - icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`); - - row.appendChild(icon); - row.appendChild(info); - - return row; + const makePopupInfo = (info: HTMLTemplateResult, iconClass: string) => { + return html` +
+ + ${info} +
+ `; }; const makePopupTitle = (event: EventImpl) => { - const row = document.createElement("div"); - row.innerHTML = ` -

- ${event.title} -

- - ${this.formatDate(event.start)} - ${this.formatDate(event.end)} - + const row = html` +
+

+ ${event.title} +

+ + ${this.formatDate(event.start)} - ${this.formatDate(event.end)} + +
`; return makePopupInfo( row, @@ -210,9 +207,11 @@ export class IcsCalendar extends inheritHtmlElement("div") { if (event.extendedProps.location === null) { return null; } - const info = document.createElement("div"); - info.innerText = event.extendedProps.location; - + const info = html` +
+ ${event.extendedProps.location} +
+ `; return makePopupInfo(info, "fa-solid fa-location-dot"); }; @@ -220,10 +219,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { if (event.url === "") { return null; } - const url = document.createElement("a"); - url.href = event.url; - url.textContent = gettext("More info"); - + const url = html`${gettext("More info")}`; return makePopupInfo(url, "fa-solid fa-link"); }; @@ -232,64 +228,59 @@ export class IcsCalendar extends inheritHtmlElement("div") { return null; } const newsId = this.getNewsId(event); - const div = document.createElement("div"); + const buttons = [] as HTMLTemplateResult[]; + if (this.canModerate) { if (event.source.internalEventSource.ui.classNames.includes("unpublished")) { - const button = document.createElement("button"); - button.innerHTML = `${gettext("Publish")}`; - button.setAttribute("class", "btn btn-green"); - button.onclick = () => { - this.publishNews(newsId); - }; - div.appendChild(button); + const button = html` + + `; + buttons.push(button); } else { - const button = document.createElement("button"); - button.innerHTML = `${gettext("Unpublish")}`; - button.setAttribute("class", "btn btn-orange"); - button.onclick = () => { - this.unpublishNews(newsId); - }; - div.appendChild(button); + const button = html` + + `; + buttons.push(button); } } if (this.canDelete) { - const button = document.createElement("button"); - button.innerHTML = `${gettext("Delete")}`; - button.setAttribute("class", "btn btn-red"); - button.onclick = () => { - this.deleteNews(newsId); - }; - div.appendChild(button); + const button = html` + + `; + buttons.push(button); } - return makePopupInfo(div, "fa-solid fa-toolbox"); + return makePopupInfo(html`
${buttons}
`, "fa-solid fa-toolbox"); }; // Create new popup - const popup = document.createElement("div"); - const popupContainer = document.createElement("div"); - - popup.setAttribute("id", "event-details"); - popupContainer.setAttribute("class", "event-details-container"); - - popupContainer.appendChild(makePopupTitle(event.event)); + const infos = [] as HTMLTemplateResult[]; + infos.push(makePopupTitle(event.event)); const location = makePopupLocation(event.event); if (location !== null) { - popupContainer.appendChild(location); + infos.push(location); } const url = makePopupUrl(event.event); if (url !== null) { - popupContainer.appendChild(url); + infos.push(url); } const tools = makePopupTools(event.event); if (tools !== null) { - popupContainer.appendChild(tools); + infos.push(tools); } - popup.appendChild(popupContainer); + const popup = document.createElement("div"); + popup.setAttribute("id", "event-details"); + render(html`
${infos}
`, popup); // We can't just add the element relative to the one we want to appear under // Otherwise, it either gets clipped by the boundaries of the calendar or resize cells diff --git a/com/static/bundled/com/components/moderation-alert-index.ts b/com/static/bundled/com/moderation-alert-index.ts similarity index 100% rename from com/static/bundled/com/components/moderation-alert-index.ts rename to com/static/bundled/com/moderation-alert-index.ts diff --git a/com/static/bundled/com/components/upcoming-news-loader-index.ts b/com/static/bundled/com/upcoming-news-loader-index.ts similarity index 91% rename from com/static/bundled/com/components/upcoming-news-loader-index.ts rename to com/static/bundled/com/upcoming-news-loader-index.ts index ccc1e714..2bda7fba 100644 --- a/com/static/bundled/com/components/upcoming-news-loader-index.ts +++ b/com/static/bundled/com/upcoming-news-loader-index.ts @@ -8,13 +8,17 @@ interface ParsedNewsDateSchema extends Omit { - Alpine.data("upcomingNewsLoader", (startDate: Date) => ({ + Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({ startDate: startDate, currentPage: 1, pageSize: 6, hasNext: true, loading: false, newsDates: [] as NewsDateSchema[], + dateFormat: new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }), async loadMore() { this.loading = true; diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja index fc45d24e..9ab6acff 100644 --- a/com/templates/com/news_detail.jinja +++ b/com/templates/com/news_detail.jinja @@ -18,7 +18,7 @@ {% endblock %} {% block additional_js %} - + {% endblock %} {% block content %} diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index e9ff641a..92e4dd71 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -15,8 +15,8 @@ {% block additional_js %} - - + + {% endblock %} {% block content %} @@ -84,11 +84,11 @@ {{ date.news.club }}
-
@@ -103,7 +103,7 @@ {% endfor %} -
+