Introduce htmx in sith files

* Convert FileModerationView into ListView and add pagination with htmx
* Don't allow sas moderation in file moderation view
* Split up base.jinja and introduce base_fragment.jinja
* Improve FileModerationView performances and make it root only
* Add permissions tests for file modération
This commit is contained in:
Antoine Bartuccio 2024-10-13 23:26:18 +02:00
parent c7a8a1a91c
commit 3af5d96bf5
16 changed files with 429 additions and 249 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> <title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <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/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
@ -23,6 +22,7 @@
<script src="{{ url('javascript-catalog') }}"></script> <script src="{{ url('javascript-catalog') }}"></script>
<script src={{ static("webpack/core/components/include-index.ts") }}></script> <script src={{ static("webpack/core/components/include-index.ts") }}></script>
<script src="{{ static('webpack/alpine-index.js') }}" defer></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 --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('webpack/jquery-index.js') }}"></script> <script src="{{ static('webpack/jquery-index.js') }}"></script>
<!-- Put here to always have access to those functions on django widgets --> <!-- Put here to always have access to those functions on django widgets -->
@ -40,145 +40,13 @@
<!-- The token is always passed here to be accessible from the dom --> <!-- 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 --> <!-- See this workaround https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true -->
{% csrf_token %} {% csrf_token %}
<!-- BEGIN HEADER -->
{% block header %} {% block header %}
{% if not popup %} {% if not popup %}
<header class="header"> {% include "core/base/header.jinja" %}
<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 %} {% else %}
<div class="header-connected"> <div id="popupheader">{{ user.get_display_name() }}</div>
<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 %} {% 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>
{% block info_boxes %} {% block info_boxes %}
<div id="info_boxes"> <div id="info_boxes">
@ -195,64 +63,11 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% else %}
<div id="popupheader">{{ user.get_display_name() }}</div>
{% endif %}
{% endblock %} {% endblock %}
<!-- END HEADER -->
{% block nav %} {% block nav %}
{% if not popup %} {% if not popup %}
<nav class="navbar"> {% include "core/base/menu.jinja" %}
<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>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -265,19 +80,16 @@
</ul> </ul>
<div id="content"> <div id="content">
{% if list_of_tabs %} {% block tabs %}
<div class="tool_bar"> {% include "core/base/tabs.jinja" %}
<div class="tools"> {% endblock %}
{% 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 errors%}
{% if error %} {% if error %}
{{ error }} {{ error }}
{% endif %} {% endif %}
{% endblock %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </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 %} {% block title %}
{% if file %} {% if file %}
@ -21,7 +25,7 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% block content %} {% block tabs %}
{{ print_file_name(file) }} {{ print_file_name(file) }}
<div class="tool_bar"> <div class="tool_bar">
@ -44,6 +48,9 @@
</div> </div>
</div> </div>
<hr> <hr>
{% endblock %}
{% block content %}
{% if file %} {% if file %}
{% block file %} {% block file %}

View File

@ -4,15 +4,49 @@
{% trans %}Delete confirmation{% endtrans %} {% trans %}Delete confirmation{% endtrans %}
{% endblock %} {% endblock %}
{% if is_fragment %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% endif %}
{% block file %} {% block file %}
<h2>{% trans %}Delete confirmation{% endtrans %}</h2> <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> <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <button
</form> {% if is_fragment %}
<form method="GET" action="javascript:history.back();"> hx-post="{{ action }}"
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" /> 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> </form>
{% endblock %} {% 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 %} {% block title %}
{% trans %}File moderation{% endtrans %} {% trans %}File moderation{% endtrans %}
@ -7,8 +19,11 @@
{% block content %} {% block content %}
<h3>{% trans %}File moderation{% endtrans %}</h3> <h3>{% trans %}File moderation{% endtrans %}</h3>
<div> <div>
{% for f in files %} {% for f in object_list %}
<div style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center"> <div
id="file-{{ loop.index }}"
style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center"
>
{% if f.is_folder %} {% if f.is_folder %}
<strong>Folder</strong> <strong>Folder</strong>
{% else %} {% else %}
@ -20,9 +35,19 @@
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/> {% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/> {% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p> </p>
<p><a href="{{ url('core:file_moderate', file_id=f.id) }}">{% trans %}Moderate{% endtrans %}</a> - <p><button
<a href="{{ url('core:file_delete', file_id=f.id) }}?next={{ url('core:file_moderation') }}">{% trans %}Delete{% endtrans %}</a></p> 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> </div>
{% endfor %} {% endfor %}
{{ paginate_htmx(page_obj, paginator) }}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -166,9 +166,37 @@
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator 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"> <nav class="pagination">
{% if current_page.has_previous() %} {% 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> <button>
<i class="fa fa-caret-left"></i> <i class="fa fa-caret-left"></i>
</button> </button>
@ -182,14 +210,31 @@
{% elif i == paginator.ELLIPSIS %} {% elif i == paginator.ELLIPSIS %}
<strong>{{ paginator.ELLIPSIS }}</strong> <strong>{{ paginator.ELLIPSIS }}</strong>
{% else %} {% 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> <button>{{ i }}</button>
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a href="?page={{ current_page.next_page_number() }}"> <a
<button> {% 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> <i class="fa fa-caret-right"></i>
</button> </button>
</a> </a>

View File

@ -77,7 +77,7 @@
{% 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, popup='') 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 %} )|tojson %}
{%- else -%} {%- else -%}
{% set default_picture = static('core/img/unknown.jpg')|tojson %} {% set default_picture = static('core/img/unknown.jpg')|tojson %}

View File

@ -142,6 +142,30 @@ class TestFileHandling(TestCase):
assert "ls</a>" in str(response.content) 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 @pytest.mark.django_db
class TestUserProfilePicture: class TestUserProfilePicture:
"""Test interactions with user's profile picture.""" """Test interactions with user's profile picture."""

View File

@ -326,6 +326,12 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object() return super().get_object()
class AllowFragment:
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 # F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult # E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402 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.urls import reverse
from django.utils.http import http_date from django.utils.http import http_date
from django.utils.translation import gettext_lazy as _ 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.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView 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 ( from core.views import (
AllowFragment,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
@ -352,7 +353,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
return kwargs return kwargs
class FileDeleteView(CanEditPropMixin, DeleteView): class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
model = SithFile model = SithFile
pk_url_kwarg = "file_id" pk_url_kwarg = "file_id"
template_name = "core/file_delete_confirm.jinja" template_name = "core/file_delete_confirm.jinja"
@ -376,19 +377,24 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
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["popup"] = "" if self.kwargs.get("popup") is None else "popup"
if self.kwargs.get("popup") is not None: kwargs["next"] = self.request.GET.get("next", None)
kwargs["popup"] = "popup" kwargs["previous"] = self.request.GET.get("previous", None)
kwargs["current"] = self.request.path
return kwargs return kwargs
class FileModerationView(TemplateView): class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja" 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): def dispatch(self, request: HttpRequest, *args, **kwargs):
kwargs = super().get_context_data(**kwargs) user: User = request.user
kwargs["files"] = SithFile.objects.filter(is_moderated=False)[:100] if user.is_root:
return kwargs return super().dispatch(request, *args, **kwargs)
raise PermissionDenied()
class FileModerateView(CanEditPropMixin, SingleObjectMixin): class FileModerateView(CanEditPropMixin, SingleObjectMixin):

6
package-lock.json generated
View File

@ -22,6 +22,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",
@ -4455,6 +4456,11 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true "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": { "node_modules/human-signals": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "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", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",