Merge pull request #883 from ae-utbm/htmx

Introduce htmx in sith files
This commit is contained in:
Bartuccio Antoine 2024-11-13 15:35:24 +01:00 committed by GitHub
commit dd7ed290f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 489 additions and 235 deletions

View File

@ -0,0 +1 @@
window.htmx = require("htmx.org");

View File

@ -5,7 +5,6 @@
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
@ -23,6 +22,7 @@
<script src="{{ url('javascript-catalog') }}"></script>
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
<script src="{{ static('webpack/htmx-index.js') }}" defer></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script>
<!-- Put here to always have access to those functions on django widgets -->
@ -40,145 +40,10 @@
<!-- The token is always passed here to be accessible from the dom -->
<!-- See this workaround https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true -->
{% csrf_token %}
<!-- BEGIN HEADER -->
{% block header %}
{% if not popup %}
<header class="header">
<div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
&nbsp;
</a>
<a class="header-logo-text" href="{{ url('core:index') }}">
<span>Association des Étudiants</span>
<span>de l'Université de Technologie de Belfort-Montbéliard</span>
</a>
</div>
{% if not user.is_authenticated %}
<div class="header-disconnected">
<a class="button" href="{{ url('core:login') }}">{% trans %}Login{% endtrans %}</a>
<a class="button" href="{{ url('core:register') }}">{% trans %}Register{% endtrans %}</a>
</div>
{% else %}
<div class="header-connected">
<div class="left">
<form class="search" action="{{ url('core:search') }}" method="GET" id="header_search">
<input class="header-input" type="text" placeholder="{% trans %}Search{% endtrans %}" name="query" id="search" />
<input type="submit" value="{% trans %}Search{% endtrans %}" style="display: none;" />
</form>
<ul class="bars">
{% cache 100 "counters_activity" %}
{# The sith has no periodic tasks manager
and using cron jobs would be way too overkill here.
Thus the barmen timeout is handled in the only place that
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li>
{# If the user is a barman, we redirect him directly to the barman page
else we redirect him to the activity page #}
{% if bar.has_annotated_barman %}
<a href="{{ url('counter:details', counter_id=bar.id) }}">
{% else %}
<a href="{{ url('counter:activity', counter_id=bar.id) }}">
{% endif %}
{% if bar.is_open %}
<i class="fa fa-check" style="color: #2ecc71"></i>
{% else %}
<i class="fa fa-times" style="color: #eb2f06"></i>
{% endif %}
<span>{{ bar }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="right">
<div class="user">
<div class="options">
<div class="username">
<a href="{{ url('core:user_profile', user_id=user.id) }}">{{ user.get_display_name() }}</a>
</div>
<div class="links">
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a>
</div>
</div>
<a
href="{{ url('core:user_profile', user_id=user.id) }}"
{% if user.profile_pict %}
style="background-image: url('{{ user.profile_pict.get_download_url() }}')"
{% else %}
style="background-image: url('{{ static('core/img/unknown.jpg') }}')"
{% endif %}
></a>
</div>
<div class="notification">
<a href="#" onclick="displayNotif()">
<i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %}
{% if notification_count > 0 %}
<span>
{% if notification_count < 100 %}
{{ notification_count }}
{% else %}
&nbsp;
{% endif %}
</span>
{% endif %}
</a>
<div id="header_notif">
<ul>
{% if user.notifications.filter(viewed=False).count() > 0 %}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
<li>
<a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime">
<span class="header_notif_date">
{{ n.date|localtime|date(DATE_FORMAT) }}
</span>
<span class="header_notif_time">
{{ n.date|localtime|time(DATETIME_FORMAT) }}
</span>
</div>
<div class="reason">
{{ n }}
</div>
</a>
</li>
{% endfor %}
{% else %}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{% endif %}
</ul>
<div class="options">
<a href="{{ url('core:notification_list') }}">
{% trans %}View more{% endtrans %}
</a>
<a href="{{ url('core:notification_list') }}?see_all">
{% trans %}Mark all as read{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="header-lang">
{% for language in LANGUAGES %}
<form action="{{ url('set_language') }}" method="post">
{% csrf_token %}
<input name="next" value="{{ request.path }}" type="hidden" />
<input name="language" value="{{ language[0] }}" type="hidden" />
<input type="submit" value="{% if language[0] == 'en' %}🇬🇧{% else %}🇫🇷{% endif %}" />
</form>
{% endfor %}
</div>
</header>
{% include "core/base/header.jinja" %}
{% block info_boxes %}
<div id="info_boxes">
@ -201,58 +66,10 @@
{% endif %}
{% endblock %}
<!-- END HEADER -->
{% block nav %}
{% if not popup %}
<nav class="navbar">
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
<div id="navbar-content" class="content" style="display: none;">
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
<ul class="content">
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Events{% endtrans %}</span>
<ul class="content">
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
</ul>
</div>
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
<div class="menu">
<span class="head">{% trans %}Services{% endtrans %}</span>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
<ul class="content">
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
</ul>
</div>
<div class="menu">
<span class="head">{% trans %}Help{% endtrans %}</span>
<ul class="content">
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
</ul>
</div>
</div>
</nav>
{% include "core/base/navbar.jinja" %}
{% endif %}
{% endblock %}
@ -265,19 +82,16 @@
</ul>
<div id="content">
{% if list_of_tabs %}
<div class="tool_bar">
<div class="tools">
{% for t in list_of_tabs -%}
<a href="{{ t.url }}" {%- if current_tab==t.slug %} class="selected_tab" {%- endif -%}>{{ t.name }}</a>
{%- endfor %}
</div>
</div>
{% endif %}
{% block tabs %}
{% include "core/base/tabs.jinja" %}
{% endblock %}
{% block errors%}
{% if error %}
{{ error }}
{% endif %}
{% endblock %}
{% block content %}
{% endblock %}
</div>

View File

@ -0,0 +1,136 @@
<header class="header">
<div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
&nbsp;
</a>
<a class="header-logo-text" href="{{ url('core:index') }}">
<span>Association des Étudiants</span>
<span>de l'Université de Technologie de Belfort-Montbéliard</span>
</a>
</div>
{% if not user.is_authenticated %}
<div class="header-disconnected">
<a class="button" href="{{ url('core:login') }}">{% trans %}Login{% endtrans %}</a>
<a class="button" href="{{ url('core:register') }}">{% trans %}Register{% endtrans %}</a>
</div>
{% else %}
<div class="header-connected">
<div class="left">
<form class="search" action="{{ url('core:search') }}" method="GET" id="header_search">
<input class="header-input" type="text" placeholder="{% trans %}Search{% endtrans %}" name="query" id="search" />
<input type="submit" value="{% trans %}Search{% endtrans %}" style="display: none;" />
</form>
<ul class="bars">
{% cache 100 "counters_activity" %}
{# The sith has no periodic tasks manager
and using cron jobs would be way too overkill here.
Thus the barmen timeout is handled in the only place that
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li>
{# If the user is a barman, we redirect him directly to the barman page
else we redirect him to the activity page #}
{% if bar.has_annotated_barman %}
<a href="{{ url('counter:details', counter_id=bar.id) }}">
{% else %}
<a href="{{ url('counter:activity', counter_id=bar.id) }}">
{% endif %}
{% if bar.is_open %}
<i class="fa fa-check" style="color: #2ecc71"></i>
{% else %}
<i class="fa fa-times" style="color: #eb2f06"></i>
{% endif %}
<span>{{ bar }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="right">
<div class="user">
<div class="options">
<div class="username">
<a href="{{ url('core:user_profile', user_id=user.id) }}">{{ user.get_display_name() }}</a>
</div>
<div class="links">
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a>
</div>
</div>
<a
href="{{ url('core:user_profile', user_id=user.id) }}"
{% if user.profile_pict %}
style="background-image: url('{{ user.profile_pict.get_download_url() }}')"
{% else %}
style="background-image: url('{{ static('core/img/unknown.jpg') }}')"
{% endif %}
></a>
</div>
<div class="notification">
<a href="#" onclick="displayNotif()">
<i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %}
{% if notification_count > 0 %}
<span>
{% if notification_count < 100 %}
{{ notification_count }}
{% else %}
&nbsp;
{% endif %}
</span>
{% endif %}
</a>
<div id="header_notif">
<ul>
{% if user.notifications.filter(viewed=False).count() > 0 %}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
<li>
<a href="{{ url("core:notification", notif_id=n.id) }}">
<div class="datetime">
<span class="header_notif_date">
{{ n.date|localtime|date(DATE_FORMAT) }}
</span>
<span class="header_notif_time">
{{ n.date|localtime|time(DATETIME_FORMAT) }}
</span>
</div>
<div class="reason">
{{ n }}
</div>
</a>
</li>
{% endfor %}
{% else %}
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
{% endif %}
</ul>
<div class="options">
<a href="{{ url('core:notification_list') }}">
{% trans %}View more{% endtrans %}
</a>
<a href="{{ url('core:notification_list') }}?see_all">
{% trans %}Mark all as read{% endtrans %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="header-lang">
{% for language in LANGUAGES %}
<form action="{{ url('set_language') }}" method="post">
{% csrf_token %}
<input name="next" value="{{ request.path }}" type="hidden" />
<input name="language" value="{{ language[0] }}" type="hidden" />
<input type="submit" value="{% if language[0] == 'en' %}🇬🇧{% else %}🇫🇷{% endif %}" />
</form>
{% endfor %}
</div>
</header>

View File

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

View File

@ -0,0 +1,9 @@
{% if list_of_tabs %}
<div class="tool_bar">
<div class="tools">
{% for t in list_of_tabs -%}
<a href="{{ t.url }}" {%- if current_tab==t.slug %} class="selected_tab" {%- endif -%}>{{ t.name }}</a>
{%- endfor %}
</div>
</div>
{% endif %}

View File

@ -0,0 +1,20 @@
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
<div id="fragment-content">
{% block tabs %}
{% include "core/base/tabs.jinja" %}
{% endblock %}
{% block errors %}
{% if error %}
{{ error }}
{% endif %}
{% endblock %}
{% block content %}
{% endblock %}
</div>
{% block script %}
{% endblock %}

View File

@ -1,4 +1,8 @@
{% extends "core/base.jinja" %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% block title %}
{% if file %}
@ -21,7 +25,7 @@
{% endif %}
{% endmacro %}
{% block content %}
{% block tabs %}
{{ print_file_name(file) }}
<div class="tool_bar">
@ -44,6 +48,9 @@
</div>
</div>
<hr>
{% endblock %}
{% block content %}
{% if file %}
{% block file %}

View File

@ -4,15 +4,49 @@
{% trans %}Delete confirmation{% endtrans %}
{% endblock %}
{% if is_fragment %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
<form action="" method="post">{% csrf_token %}
{% if next %}
{% set action = current + "?next=" + next %}
{% else %}
{% set action = current %}
{% endif %}
<form action="{{ action }}" method="post">
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
</form>
<form method="GET" action="javascript:history.back();">
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
<button
{% if is_fragment %}
hx-post="{{ action }}"
hx-target="#content"
hx-swap="outerHtml"
{% endif %}
>{% trans %}Confirm{% endtrans %}</button>
<button
{% if is_fragment %}
hx-get="{{ previous }}"
hx-target="#content"
hx-swap="outerHtml"
{% else %}
action="window.history.back()"
{% endif %}
>{% trans %}Cancel{% endtrans %}</button>
</form>
{% endblock %}

View File

@ -1,4 +1,16 @@
{% extends "core/base.jinja" %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block title %}
{% trans %}File moderation{% endtrans %}
@ -7,8 +19,11 @@
{% block content %}
<h3>{% trans %}File moderation{% endtrans %}</h3>
<div>
{% for f in files %}
<div style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center">
{% for f in object_list %}
<div
id="file-{{ loop.index }}"
style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center"
>
{% if f.is_folder %}
<strong>Folder</strong>
{% else %}
@ -20,9 +35,19 @@
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p>
<p><a href="{{ url('core:file_moderate', file_id=f.id) }}">{% trans %}Moderate{% endtrans %}</a> -
<a href="{{ url('core:file_delete', file_id=f.id) }}?next={{ url('core:file_moderation') }}">{% trans %}Delete{% endtrans %}</a></p>
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
hx-target="#content"
hx-swap="outerHtml"
>{% trans %}Moderate{% endtrans %}</button> -
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
<button
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
hx-target="#file-{{ loop.index }}"
hx-swap="outerHtml"
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
{{ paginate_htmx(page_obj, paginator) }}
</div>
{% endblock %}

View File

@ -166,9 +166,37 @@
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(current_page, paginator, False) }}
{% endmacro %}
{% macro paginate_htmx(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine but supporting framgents.
This must be coupled with a view that handles pagination
with the Django Paginator object and supports framgents.
The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters:
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(current_page, paginator, True) }}
{% endmacro %}
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
<nav class="pagination">
{% if current_page.has_previous() %}
<a href="?page={{ current_page.previous_page_number() }}">
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.previous_page_number() }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ current_page.previous_page_number() }}"
{%- endif -%}
>
<button>
<i class="fa fa-caret-left"></i>
</button>
@ -182,14 +210,31 @@
{% elif i == paginator.ELLIPSIS %}
<strong>{{ paginator.ELLIPSIS }}</strong>
{% else %}
<a href="?page={{ i }}">
<a
{% if use_htmx -%}
hx-get="?page={{ i }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ i }}"
{%- endif -%}
>
<button>{{ i }}</button>
</a>
{% endif %}
{% endfor %}
{% if current_page.has_next() %}
<a href="?page={{ current_page.next_page_number() }}">
<button>
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.next_page_number() }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ current_page.next_page_number() }}"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
</button>
</a>

View File

@ -77,7 +77,7 @@
{% set default_picture = this_picture.get_download_url()|tojson %}
{% set delete_url = (
url('core:file_delete', file_id=this_picture.id, popup='')
+"?next=" + profile.get_absolute_url()
+ "?next=" + url('core:user_edit', user_id=profile.id)
)|tojson %}
{%- else -%}
{% set default_picture = static('core/img/unknown.jpg')|tojson %}

View File

@ -21,9 +21,11 @@ import pytest
from django.core import mail
from django.core.cache import cache
from django.core.mail import EmailMessage
from django.test import Client, TestCase
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
@ -32,6 +34,7 @@ from club.models import Membership
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment
from sith import settings
@ -538,3 +541,18 @@ class TestDateUtils(TestCase):
# forward time to the middle of the next semester
frozen_time.move_to(mid_autumn)
assert get_start_of_semester() == autumn_2023
def test_allow_fragment_mixin():
class TestAllowFragmentView(AllowFragment, ContextMixin, View):
def get(self, *args, **kwargs):
context = self.get_context_data(**kwargs)
return context["is_fragment"]
request = RequestFactory().get("/test")
base_headers = request.headers
assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": False, **base_headers}
assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers}
assert TestAllowFragmentView.as_view()(request)

View File

@ -142,6 +142,30 @@ class TestFileHandling(TestCase):
assert "ls</a>" in str(response.content)
@pytest.mark.django_db
class TestFileModerationView:
"""Test access to file moderation view"""
@pytest.mark.parametrize(
("user_factory", "status_code"),
[
(lambda: None, 403), # Anonymous user
(lambda: baker.make(User, is_superuser=True), 200),
(lambda: baker.make(User), 403),
(lambda: subscriber_user.make(), 403),
(lambda: old_subscriber_user.make(), 403),
(lambda: board_user.make(), 403),
],
)
def test_view_access(
self, client: Client, user_factory: Callable[[], User | None], status_code: int
):
user = user_factory()
if user: # if None, then it's an anonymous user
client.force_login(user_factory())
assert client.get(reverse("core:file_moderation")).status_code == status_code
@pytest.mark.django_db
class TestUserProfilePicture:
"""Test interactions with user's profile picture."""

View File

@ -326,6 +326,14 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object()
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)
# F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402

View File

@ -27,12 +27,13 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import http_date
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
from core.models import Notification, RealGroup, SithFile
from core.models import Notification, RealGroup, SithFile, User
from core.views import (
AllowFragment,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
@ -352,7 +353,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
return kwargs
class FileDeleteView(CanEditPropMixin, DeleteView):
class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
model = SithFile
pk_url_kwarg = "file_id"
template_name = "core/file_delete_confirm.jinja"
@ -376,19 +377,24 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
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"
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
kwargs["next"] = self.request.GET.get("next", None)
kwargs["previous"] = self.request.GET.get("previous", None)
kwargs["current"] = self.request.path
return kwargs
class FileModerationView(TemplateView):
class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
paginate_by = 100
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["files"] = SithFile.objects.filter(is_moderated=False)[:100]
return kwargs
def dispatch(self, request: HttpRequest, *args, **kwargs):
user: User = request.user
if user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied()
class FileModerateView(CanEditPropMixin, SingleObjectMixin):

View File

@ -200,6 +200,19 @@ Grâce à son architecture, il est extrêmement
bien adapté pour un usage dans un site multipage.
C'est une technologie simple et puissante qui se veut comme le jQuery du web moderne.
### Htmx
[Site officiel](https://htmx.org/)
En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx.
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
ajouter directement dans les templates.
Son principe est de remplacer certains éléments du html par un fragment de
HTML renvoyé par le serveur backend. Cela se marie très bien avec le
fonctionnement de django et en particulier de ses formulaires afin d'éviter
de doubler le travail pour la vérification des données.
### Sass
[Site officiel](https://sass-lang.com/)

View File

@ -1 +0,0 @@
::: stock.models

View File

@ -1 +0,0 @@
::: stock.views

View File

@ -0,0 +1,40 @@
Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend.
Le truc, c'est que tout est optimisé pour utiliser `base.jinja` qui est assez gros.
Dans beaucoup de scénario, on veut pouvoir renvoyer soit la vue complète, soit
juste le fragment. En particulier quand on utilise l'attribut `hx-history` de htmx.
Pour remédier à cela, il existe le mixin [AllowFragment][core.views.AllowFragment].
Une fois ajouté à une vue Django, il ajoute le boolean `is_fragment` dans les
templates jinja. Sa valeur est `True` uniquement si HTMX envoie la requête.
Il est ensuite très simple de faire un if/else pour hériter de
`core/base_fragment.jinja` au lieu de `core/base.jinja` dans cette situation.
Exemple d'utilisation d'une vue avec fragment:
```python
from django.views.generic import TemplateView
from core.views import AllowFragment
class FragmentView(AllowFragment, TemplateView):
template_name = "my_template.jinja"
```
Exemple de template (`my_template.jinja`)
```jinja
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% block title %}
{% trans %}My view with a fragment{% endtrans %}
{% endblock %}
{% block content %}
<h3>{% trans %}This will be a fragment when is_fragment is True{% endtrans %}
{% endblock %}
```

View File

@ -66,6 +66,7 @@ nav:
- Structure du projet: tutorial/structure.md
- Gestion des permissions: tutorial/perms.md
- Gestion des groupes: tutorial/groups.md
- Créer des fragments: tutorial/fragments.md
- Etransactions: tutorial/etransaction.md
- How-to:
- L'ORM de Django: howto/querysets.md

6
package-lock.json generated
View File

@ -22,6 +22,7 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.18.0",
"glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0",
@ -4455,6 +4456,11 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/htmx.org": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.3.tgz",
"integrity": "sha512-AeoJUAjkCVVajbfKX+3sVQBTCt8Ct4lif1T+z/tptTXo8+8yyq3QIMQQe/IT+R8ssfrO1I0DeX4CAronzCL6oA=="
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",

View File

@ -54,6 +54,7 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.18.0",
"glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0",