Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
aa74b999bb [UPDATE] Update cryptography requirement
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/45.0.3...46.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 08:16:38 +00:00
58 changed files with 1066 additions and 1253 deletions

View File

@@ -83,10 +83,9 @@ TODO : rewrite the pagination used in this template an Alpine one
</table> </table>
<script type="text/javascript"> <script type="text/javascript">
function formPagination(link){ function formPagination(link){
const form = document.getElementById("form") $("form").attr("action", link.href);
form.action = link.href;
link.href = "javascript:void(0)"; // block link action link.href = "javascript:void(0)"; // block link action
form.submit(); $("form").submit();
} }
</script> </script>
{{ paginate(paginated_result, paginator, "formPagination(this)") }} {{ paginate(paginated_result, paginator, "formPagination(this)") }}

View File

@@ -344,7 +344,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form() form = self.get_form()
if form.is_valid(): if form.is_valid():
if not len([v for v in form.cleaned_data.values() if v is not None]): if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none() qs = Selling.objects.filter(id=-1)
if form.cleaned_data["begin_date"]: if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"]) qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]: if form.cleaned_data["end_date"]:
@@ -362,9 +362,7 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0: if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products) qs = qs.filter(product__in=selected_products)
kwargs["result"] = qs.select_related( kwargs["result"] = qs.all().order_by("-id")
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]]) kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity")) total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]: if total_quantity["quantity__sum"]:

View File

@@ -1,49 +0,0 @@
const INTERVAL = 10;
interface Poster {
url: string; // URL of the poster
displayTime: number; // Number of seconds to display that poster
}
document.addEventListener("alpine:init", () => {
Alpine.data("slideshow", (posters: Poster[]) => ({
posters: posters,
progress: 0,
elapsed: 0,
current: 0,
previous: 0,
init() {
this.$watch("elapsed", () => {
const displayTime = this.posters[this.current].displayTime * 1000;
if (this.elapsed > displayTime) {
this.previous = this.current;
this.current = this.getNext();
this.elapsed = 0;
}
if (displayTime === 0) {
this.progress = 100;
} else {
this.progress = (100 * this.elapsed) / displayTime;
}
});
setInterval(() => {
this.elapsed += INTERVAL;
}, INTERVAL);
},
getNext() {
return (this.current + 1) % this.posters.length;
},
async toggleFullScreen(event: Event) {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
const target = event.target as HTMLElement;
await target.requestFullscreen();
},
}));
});

View File

@@ -111,7 +111,7 @@
top: 0; top: 0;
left: 0; left: 0;
z-index: 10; z-index: 10;
content: attr(hover); content: "Click to expand";
color: white; color: white;
background-color: rgba(black, 0.5); background-color: rgba(black, 0.5);
} }

View File

@@ -0,0 +1,23 @@
$(document).ready(() => {
$("#poster_list #view").click(() => {
$("#view").removeClass("active");
});
$("#poster_list .poster .image").click((e) => {
let el = $(e.target);
if (el.hasClass("image")) {
el = el.find("img");
}
$("#poster_list #view #placeholder").html(el.clone());
$("#view").addClass("active");
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
});

View File

@@ -0,0 +1,98 @@
$(document).ready(() => {
const transitionTime = 1000;
let i = 0;
const max = $("#slideshow .slide").length;
function enterFullscreen() {
const element = document.getElementById("slideshow");
$(element).addClass("fullscreen");
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
function exitFullscreen() {
const element = document.getElementById("slideshow");
$(element).removeClass("fullscreen");
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function initProgressBar() {
$("#slideshow #progress_bar").css("transition", "none");
$("#slideshow #progress_bar").removeClass("progress");
$("#slideshow #progress_bar").addClass("init");
}
function startProgressBar(displayTime) {
$("#slideshow #progress_bar").removeClass("init");
$("#slideshow #progress_bar").addClass("progress");
$("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
}
function next() {
initProgressBar();
const slide = $($("#slideshow .slide").get(i % max));
slide.removeClass("center");
slide.addClass("left");
const nextSlide = $($("#slideshow .slide").get((i + 1) % max));
nextSlide.removeClass("right");
nextSlide.addClass("center");
const displayTime = nextSlide.attr("display_time") || 2;
$("#slideshow .bullet").removeClass("active");
const bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(() => {
const othersLeft = $("#slideshow .slide.left");
othersLeft.removeClass("left");
othersLeft.addClass("right");
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}, transitionTime);
}
const displayTime = $("#slideshow .center").attr("display_time");
initProgressBar();
setTimeout(() => {
if (max > 1) {
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}
}, 10);
$("#slideshow").click(() => {
if ($("#slideshow").hasClass("fullscreen")) {
exitFullscreen();
} else {
enterFullscreen();
}
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
exitFullscreen();
}
});
});

View File

@@ -1,4 +1,4 @@
body { body{
position: absolute; position: absolute;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@@ -7,22 +7,22 @@ body {
margin: 0; margin: 0;
} }
#slideshow { #slideshow{
position: relative; position: relative;
background-color: lightgrey; background-color: lightgrey;
height: 100%; height: 100%;
* { *{
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
&:hover { &:hover{
&::before { &::before{
position: absolute; position: absolute;
width: 100%; width: 100%;
@@ -34,7 +34,7 @@ body {
z-index: 10; z-index: 10;
content: attr(hover); content: "Click to expand";
color: white; color: white;
background-color: rgba(black, 0.5); background-color: rgba(black, 0.5);
@@ -43,7 +43,7 @@ body {
} }
&:fullscreen { &.fullscreen{
position: fixed; position: fixed;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -51,78 +51,57 @@ body {
left: 0; left: 0;
background: none; background: none;
&:before { &:before{
display: none; display:none;
} }
#slides { #slides{
height: 100vh; height: 100vh;
} }
} }
#slides { #slides{
position: relative; position: relative;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-color: grey;
.slide { .slide{
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: none; display: inline-flex;
justify-content: center; justify-content: center;
top: 0px; top: 0px;
left: 0%;
img { background-color: grey;
transition: left 1s ease-out;
img{
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
}
&.current { .slide.left{
display: inline-flex; left: -100%;
left: 0%; }
animation: scrolling-in 1s linear;
}
&.previous { .slide.center{
display: inline-flex; left: 0px;
animation: scrolling-out 1s linear; }
opacity: 0;
transition: opacity 0.1s;
transition-delay: 0.9s;
}
@keyframes scrolling-in {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
@keyframes scrolling-out {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(-100%);
}
}
.slide.right{
left: 100%;
transition: none;
} }
} }
#progress_bullets { #progress_bullets{
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;
width: 100%; width: 100%;
@@ -133,7 +112,7 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
.bullet { .bullet{
height: 10px; height: 10px;
width: 10px; width: 10px;
@@ -144,33 +123,27 @@ body {
background-color: grey; background-color: grey;
&.active { &.active{
background-color: #c99836; background-color: #c99836;
} }
} }
} }
progress { #progress_bar{
--color: #304c83;
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
height: 10px; height: 10px;
color: var(--color); background-color: #304c83;
width: 100%;
margin-bottom: 0px;
border: none;
&::-moz-progress-bar { &.init{
background: var(--color); width: 0px;
transition: none;
} }
&::-webkit-progress-value { &.progress{
background: var(--color); width: 100%;
} transition: width 10s linear;
&[value] {
background-color: transparent;
} }
} }
} }

View File

@@ -1,5 +1,11 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %} {% block title %}
{% trans %}Poster{% endtrans %} {% trans %}Poster{% endtrans %}
{% endblock %} {% endblock %}
@@ -9,7 +15,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="poster_list" x-data="{ active: null }"> <div id="poster_list">
<div id="title"> <div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3> <h3>{% trans %}Posters{% endtrans %}</h3>
@@ -32,13 +38,7 @@
{% for poster in poster_list %} {% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}"> <div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div> <div class="name">{{ poster.name }}</div>
<div <div class="image"><img src="{{ poster.file.url }}"></img></div>
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
>
<img src="{{ poster.file.url }}"></img>
</div>
<div class="dates"> <div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div> <div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div> <div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
@@ -62,14 +62,7 @@
</div> </div>
<div <div id="view"><div id="placeholder"></div></div>
id="view"
@keyup.escape.window="active = null"
@click="active = null"
:class="{active: active !== null}"
>
<div id="placeholder"><img :src="active?.src"></div>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -2,44 +2,28 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<title>{% trans %}Slideshow{% endtrans %}</title> <title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" /> <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script> <script src="{{ static('com/js/slideshow.js') }}"></script>
</head> </head>
<body x-data="slideshow([ <body>
{% for poster in posters %} <div id="slideshow">
{
url: '{{ poster.file.url }}',
displayTime: {{ poster.display_time }}
},
{% endfor %}
])">
<div
id="slideshow"
@click="toggleFullScreen"
hover="{% trans %}Click to expand{% endtrans %}"
@keyup.f.window="toggleFullScreen"
>
<div id="slides"> <div id="slides">
<template x-for="(poster, index) in posters"> {% for poster in posters %}
<div class="slide" :class="{ <div class="slide {% if loop.first %}center{% else %}right{% endif %}" display_time="{{ poster.display_time }}">
current: index === current, <img src="{{ poster.file.url }}">
previous: index !== current && index === previous,
}">
<img :src="poster.url">
</div> </div>
</template> {% endfor %}
</div> </div>
<div id="progress_bullets"> <div id="progress_bullets">
<template x-for="(poster, index) in posters"> {% for poster in posters %}
<div class="bullet" :class="{active: current === index}"></div> <div class="bullet {% if loop.first %}active{% endif %}"></div>
</template> {% endfor %}
</div> </div>
<progress :value="progress" max="100" x-show="posters.length > 1 && progress > 0"></progress> <div id="progress_bar"></div>
</div> </div>
</body> </body>

View File

@@ -31,7 +31,9 @@
<td> <td>
<a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> | <a href="{{ url('com:weekmail_article_edit', article_id=a.id) }}">{% trans %}Edit{% endtrans %}</a> |
<a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> | <a href="{{ url('com:weekmail_article_delete', article_id=a.id) }}">{% trans %}Delete{% endtrans %}</a> |
<a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a> <a href="?add_article={{ a.id }}">{% trans %}Add to weekmail{% endtrans %}</a> |
<a href="?up_article={{ a.id }}">{% trans %}Up{% endtrans %}</a> |
<a href="?down_article={{ a.id }}">{% trans %}Down{% endtrans %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -28,7 +28,6 @@ from typing import Any
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
PermissionRequiredMixin, PermissionRequiredMixin,
) )
@@ -56,7 +55,7 @@ from core.auth.mixins import (
PermissionOrClubBoardRequiredMixin, PermissionOrClubBoardRequiredMixin,
) )
from core.models import User from core.models import User
from core.views.mixins import TabedViewMixin from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@@ -334,7 +333,7 @@ class NewsFeed(Feed):
# Weekmail # Weekmail
class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView): class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, DetailView):
model = Weekmail model = Weekmail
template_name = "com/weekmail_preview.jinja" template_name = "com/weekmail_preview.jinja"
success_url = reverse_lazy("com:weekmail") success_url = reverse_lazy("com:weekmail")
@@ -346,11 +345,12 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
messages.success(self.request, _("Weekmail sent successfully"))
if request.POST["send"] == "validate": if request.POST["send"] == "validate":
try: try:
self.object.send() self.object.send()
return HttpResponseRedirect(reverse("com:weekmail")) return HttpResponseRedirect(
reverse("com:weekmail") + "?qn_weekmail_send_success"
)
except SMTPRecipientsRefused as e: except SMTPRecipientsRefused as e:
self.bad_recipients = e.recipients self.bad_recipients = e.recipients
elif request.POST["send"] == "clean": elif request.POST["send"] == "clean":
@@ -361,6 +361,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
for u in users: for u in users:
u.preferences.receive_weekmail = False u.preferences.receive_weekmail = False
u.preferences.save() u.preferences.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_object(self, queryset=None): def get_object(self, queryset=None):
@@ -374,7 +375,7 @@ class WeekmailPreviewView(ComTabsMixin, CanEditPropMixin, DetailView):
return kwargs return kwargs
class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView): class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView):
model = Weekmail model = Weekmail
template_name = "com/weekmail.jinja" template_name = "com/weekmail.jinja"
form_class = modelform_factory( form_class = modelform_factory(
@@ -414,10 +415,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, prev_art.rank = prev_art.rank, art.rank art.rank, prev_art.rank = prev_art.rank, art.rank
art.save() art.save()
prev_art.save() prev_art.save()
messages.success( self.quick_notif_list += ["qn_success"]
self.request,
_("%(title)s moved up in the Weekmail") % {"title": art.title},
)
if "down_article" in request.GET: if "down_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["down_article"], weekmail=self.object WeekmailArticle, id=request.GET["down_article"], weekmail=self.object
@@ -429,10 +427,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank, next_art.rank = next_art.rank, art.rank art.rank, next_art.rank = next_art.rank, art.rank
art.save() art.save()
next_art.save() next_art.save()
messages.success( self.quick_notif_list += ["qn_success"]
self.request,
_("%(title)s moved down in the Weekmail") % {"title": art.title},
)
if "add_article" in request.GET: if "add_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["add_article"], weekmail=None WeekmailArticle, id=request.GET["add_article"], weekmail=None
@@ -441,10 +436,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0 art.rank = self.object.articles.aggregate(Max("rank"))["rank__max"] or 0
art.rank += 1 art.rank += 1
art.save() art.save()
messages.success( self.quick_notif_list += ["qn_success"]
self.request,
_("%(title)s added to the Weekmail") % {"title": art.title},
)
if "del_article" in request.GET: if "del_article" in request.GET:
art = get_object_or_404( art = get_object_or_404(
WeekmailArticle, id=request.GET["del_article"], weekmail=self.object WeekmailArticle, id=request.GET["del_article"], weekmail=self.object
@@ -452,10 +444,7 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
art.weekmail = None art.weekmail = None
art.rank = -1 art.rank = -1
art.save() art.save()
messages.success( self.quick_notif_list += ["qn_success"]
self.request,
_("%(title)s removed from the Weekmail") % {"title": art.title},
)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -465,7 +454,9 @@ class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
return kwargs return kwargs
class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView): class WeekmailArticleEditView(
ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView
):
"""Edit an article.""" """Edit an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -477,10 +468,11 @@ class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
pk_url_kwarg = "article_id" pk_url_kwarg = "article_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
success_url = reverse_lazy("com:weekmail") success_url = reverse_lazy("com:weekmail")
quick_notif_url_arg = "qn_weekmail_article_edit"
current_tab = "weekmail" current_tab = "weekmail"
class WeekmailArticleCreateView(CreateView): class WeekmailArticleCreateView(QuickNotifMixin, CreateView):
"""Post an article.""" """Post an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -491,6 +483,7 @@ class WeekmailArticleCreateView(CreateView):
) )
template_name = "core/create.jinja" template_name = "core/create.jinja"
success_url = reverse_lazy("core:user_tools") success_url = reverse_lazy("core:user_tools")
quick_notif_url_arg = "qn_weekmail_new_article"
def get_initial(self): def get_initial(self):
if "club" not in self.request.GET: if "club" not in self.request.GET:

View File

@@ -768,7 +768,7 @@ class Command(BaseCommand):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type=subscription_type, subscription_type=subscription_type,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
) )
s.subscription_start = s.compute_start(start) s.subscription_start = s.compute_start(start)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(

View File

@@ -1197,18 +1197,6 @@ class NotLocked(LockError):
pass pass
class PageQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_anonymous:
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change # This function prevents generating migration upon settings change
def get_default_owner_group(): def get_default_owner_group():
return settings.SITH_GROUP_ROOT_ID return settings.SITH_GROUP_ROOT_ID
@@ -1278,8 +1266,6 @@ class Page(models.Model):
_("lock_timeout"), null=True, blank=True, default=None _("lock_timeout"), null=True, blank=True, default=None
) )
objects = PageQuerySet.as_manager()
class Meta: class Meta:
unique_together = ("name", "parent") unique_together = ("name", "parent")
permissions = ( permissions = (
@@ -1289,9 +1275,12 @@ class Page(models.Model):
def __str__(self): def __str__(self):
return self.get_full_name() return self.get_full_name()
def save(self, *args, force_lock: bool = False, **kwargs): def save(self, *args, **kwargs):
"""Performs some needed actions before and after saving a page in database.""" """Performs some needed actions before and after saving a page in database."""
if not force_lock and not self.is_locked(): locked = kwargs.pop("force_lock", False)
if not locked:
locked = self.is_locked()
if not locked:
raise NotLocked("The page is not locked and thus can not be saved") raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean() self.full_clean()
if not self.id: if not self.id:
@@ -1303,7 +1292,7 @@ class Page(models.Model):
# It also update all the children to maintain correct names # It also update all the children to maintain correct names
self._full_name = self.get_full_name() self._full_name = self.get_full_name()
for c in self.children.all(): for c in self.children.all():
c.save(force_lock=force_lock) c.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.unset_lock() self.unset_lock()
@@ -1419,14 +1408,14 @@ class Page(models.Model):
def need_club_redirection(self): def need_club_redirection(self):
return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE return self.is_club_page and self.name != settings.SITH_CLUB_ROOT_PAGE
def delete(self, *args, **kwargs): def delete(self):
self.unset_lock_recursive() self.unset_lock_recursive()
self.set_lock_recursive(User.objects.get(id=0)) self.set_lock_recursive(User.objects.get(id=0))
for child in self.children.all(): for child in self.children.all():
child.parent = self.parent child.parent = self.parent
child.save() child.save()
child.unset_lock_recursive() child.unset_lock_recursive()
return super().delete(*args, **kwargs) super().delete()
class PageRev(models.Model): class PageRev(models.Model):
@@ -1473,12 +1462,9 @@ class PageRev(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("core:page", kwargs={"page_name": self.page._full_name}) return reverse("core:page", kwargs={"page_name": self.page._full_name})
def can_be_edited_by(self, user: User) -> bool: def can_be_edited_by(self, user):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def get_notification_types(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

@@ -1,10 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin } from "#core:utils/notifications";
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);
Alpine.magic("notifications", alpinePlugin);
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,36 +0,0 @@
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
}
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
}
export function alpinePlugin() {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
createNotification(message, NotificationLevel.Warning),
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
};
}

View File

@@ -321,6 +321,7 @@ $hovered-red-text-color: #ff4d4d;
>#header_notif { >#header_notif {
box-sizing: border-box; box-sizing: border-box;
display: none;
position: absolute; position: absolute;
margin: 0; margin: 0;
background-color: whitesmoke; background-color: whitesmoke;

View File

@@ -0,0 +1,38 @@
$(() => {
$("#quick_notif li").click(function () {
$(this).hide();
});
});
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function createQuickNotif(msg) {
const el = document.createElement("li");
el.textContent = msg;
el.addEventListener("click", () => el.parentNode.removeChild(el));
document.getElementById("quick_notif").appendChild(el);
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function deleteQuickNotifs() {
const el = document.getElementById("quick_notif");
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function displayNotif() {
$("#header_notif").toggle().parent().toggleClass("white");
}
// You can't get the csrf token from the template in a widget
// We get it from a cookie as a workaround, see this link
// https://docs.djangoproject.com/en/2.0/ref/csrf/#ajax
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True
// So, the true workaround is to get the token from the dom
// https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true
// biome-ignore lint/style/useNamingConvention: can't find it used anywhere but I will not play with the devil
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val();
}

View File

@@ -270,6 +270,17 @@ body {
} }
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#quick_notif {
width: 100%;
margin: 0 auto;
list-style-type: none;
background: $second-color;
li {
padding: 10px;
}
}
#content { #content {
padding: 1em 1%; padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px; box-shadow: $shadow-color 0 5px 10px;

View File

@@ -32,6 +32,10 @@
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script> <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script> <script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.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 %}
@@ -70,15 +74,17 @@
<div id="page"> <div id="page">
<ul id="quick_notif">
{% for n in quick_notifs %}
<li>{{ n }}</li>
{% endfor %}
</ul>
<div id="content"> <div id="content">
{%- block tabs -%} {%- block tabs -%}
{% include "core/base/tabs.jinja" %} {% include "core/base/tabs.jinja" %}
{%- endblock -%} {%- endblock -%}
{% block notifications %}
{% include "core/base/notifications.jinja" %}
{% endblock %}
{%- block errors -%} {%- block errors -%}
{% if error %} {% if error %}
{{ error }} {{ error }}
@@ -95,6 +101,16 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script>
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
return;
}
document.getElementById("search").focus();
e.preventDefault(); // Don't type the character in the focused search input
})
</script>
{% endblock %} {% endblock %}
</body> </body>
</html> </html>

View File

@@ -74,9 +74,9 @@
{% endif %} {% endif %}
></a> ></a>
</div> </div>
<div class="notification" x-data="{display: false}" :class="{white: display}"> <div class="notification">
<a href="#" @click.prevent="display = !display"> <a href="#" onclick="displayNotif()">
<i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></i> <i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %} {% set notification_count = user.notifications.filter(viewed=False).count() %}
{% if notification_count > 0 %} {% if notification_count > 0 %}
@@ -89,7 +89,7 @@
</span> </span>
{% endif %} {% endif %}
</a> </a>
<div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false"> <div id="header_notif">
<ul> <ul>
{% if user.notifications.filter(viewed=False).count() > 0 %} {% if user.notifications.filter(viewed=False).count() > 0 %}
{% for n in user.notifications.filter(viewed=False).order_by('-date') %} {% for n in user.notifications.filter(viewed=False).order_by('-date') %}

View File

@@ -1,24 +0,0 @@
<div id="quick-notifications"
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="message in messages">
<div x-data="{show: true}" class="alert" :class="`alert-${message.tag}`" x-show="show" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="show = false">
<i class="fa fa-close"></i>
</span>
</div>
</template>
</div>

View File

@@ -15,7 +15,6 @@
{{ select_all_checkbox("add_users") }} {{ select_all_checkbox("add_users") }}
<hr> <hr>
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors() }}
<label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label> <label for="{{ form.users_removed.id_for_label }}">{{ form.users_removed.label }} :</label>
{{ form.users_removed.errors }} {{ form.users_removed.errors }}
{% for user in form.users_removed %} {% for user in form.users_removed %}

View File

@@ -30,11 +30,7 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }} - {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td> </td>
<td>{{ purchase.counter }}</td> <td>{{ purchase.counter }}</td>
{% if not purchase.seller %} <td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
<td>{% trans %}Deleted user{% endtrans %}</td>
{% else %}
<td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td>
{% endif %}
<td>{{ purchase.label }}</td> <td>{{ purchase.label }}</td>
<td>{{ purchase.quantity }}</td> <td>{{ purchase.quantity }}</td>
<td>{{ purchase.quantity * purchase.unit_price }} €</td> <td>{{ purchase.quantity * purchase.unit_price }} €</td>

View File

@@ -1,58 +0,0 @@
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.baker_recipes import board_user, subscriber_user
from core.models import AnonymousUser, Page, User
from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db
def test_edit_page(client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
revision = page.revisions.last()
assert revision.content == "Hello World"
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[],
]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
for page, groups in zip(pages, view_groups, strict=True):
page.view_groups.set(groups)
viewable = Page.objects.viewable_by(AnonymousUser()).values_list("id", flat=True)
assert set(viewable) == {pages[0].id, pages[1].id}
subscriber = subscriber_user.make()
viewable = Page.objects.viewable_by(subscriber).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages[0:4]}
root_user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_page")]
)
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages}

View File

@@ -20,8 +20,7 @@ from core.baker_recipes import (
) )
from core.models import Group, User from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.models import Counter, Refilling, Selling
from counter.models import Counter, Customer, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -130,31 +129,6 @@ def test_user_account_not_found(client: Client):
assert res.status_code == 404 assert res.status_code == 404
@pytest.mark.django_db
def test_is_deleted_barman_shown_as_deleted(client: Client):
customer = baker.make(Customer)
date = now()
sale_recipe.make(
seller=iter([None, baker.make(User)]),
customer=customer,
date=date,
_quantity=2,
_bulk_create=True,
)
client.force_login(customer.user)
res = client.get(
reverse(
"core:user_account_detail",
kwargs={
"user_id": customer.user.id,
"year": date.year,
"month": date.month,
},
)
)
assert res.status_code == 200
class TestFilterInactive(TestCase): class TestFilterInactive(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -2,6 +2,7 @@ import copy
import inspect import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -40,6 +41,36 @@ class TabedViewMixin(View):
return kwargs return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment: class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx""" """Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""

View File

@@ -43,25 +43,23 @@ class CanEditPagePropMixin(CanEditPropMixin):
return res return res
class PageListView(ListView): class PageListView(CanViewMixin, ListView):
model = Page model = Page
template_name = "core/page_list.jinja" template_name = "core/page_list.jinja"
queryset = (
def get_queryset(self): Page.objects.annotate(
return ( display_name=Coalesce(
Page.objects.viewable_by(self.request.user) Subquery(
.annotate( PageRev.objects.filter(page=OuterRef("id"))
display_name=Coalesce( .order_by("-date")
Subquery( .values("title")[:1]
PageRev.objects.filter(page=OuterRef("id")) ),
.order_by("-date") F("name"),
.values("title")[:1]
),
F("name"),
)
) )
.select_related("parent")
) )
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):
@@ -186,7 +184,7 @@ class PageEditViewBase(CanEditMixin, UpdateView):
) )
template_name = "core/pagerev_edit.jinja" template_name = "core/pagerev_edit.jinja"
def get_object(self, *args, **kwargs): def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"]) self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision() return self._get_revision()

View File

@@ -65,7 +65,7 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import TabedViewMixin, UseFragmentsMixin from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice from eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
@@ -564,7 +564,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "groups" current_tab = "groups"
class UserToolsView(LoginRequiredMixin, UserTabsMixin, TemplateView): class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, TemplateView):
"""Displays the logged user's tools.""" """Displays the logged user's tools."""
template_name = "core/user_tools.jinja" template_name = "core/user_tools.jinja"

View File

@@ -4,6 +4,7 @@
heading_level: 3 heading_level: 3
members: members:
- TabedViewMixin - TabedViewMixin
- QuickNotifMixin
- AllowFragment - AllowFragment
- FragmentMixin - FragmentMixin
- UseFragmentsMixin - UseFragmentsMixin

View File

@@ -31,5 +31,12 @@
</div> </div>
<br> <br>
{% include "core/base/notifications.jinja" %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
</div> </div>

View File

@@ -1,9 +1,5 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block notifications %}
{# Notifications are moved inside the billing info fragment #}
{% endblock %}
{% block title %} {% block title %}
{% trans %}Basket state{% endtrans %} {% trans %}Basket state{% endtrans %}
{% endblock %} {% endblock %}

View File

@@ -22,6 +22,14 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div id="eboutic" x-data="basket({{ last_purchase_time }})"> <div id="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>

View File

@@ -4,6 +4,14 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<div> <div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% if success %} {% if success %}
{% trans %}Payment successful{% endtrans %} {% trans %}Payment successful{% endtrans %}
{% else %} {% else %}

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,37 +115,36 @@ 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"] = {
"vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote,
} }
results["blank vote"] = {
"vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote,
}
return results return results
@property @property
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

@@ -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,
},
}

View File

@@ -1,34 +1,183 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from cryptography.utils import cached_property from django import forms
from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from election.forms import ( from core.views.forms import SelectDateTime
CandidateForm, from core.views.widgets.ajax_select import (
ElectionForm, AutoCompleteSelect,
ElectionListForm, AutoCompleteSelectMultipleGroup,
RoleForm, AutoCompleteSelectUser,
VoteForm,
) )
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING: if TYPE_CHECKING:
from core.models import User from core.models import User
# Custom form field
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 much candidates."), code="invalid"
)
# Forms
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
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, **kwargs):
election_id = kwargs.pop("election_id", None)
can_edit = kwargs.pop("can_edit", False)
super().__init__(*args, **kwargs)
if election_id:
self.fields["role"].queryset = Role.objects.filter(
election__id=election_id
).all()
self.fields["election_list"].queryset = ElectionList.objects.filter(
election__id=election_id
).all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election, user, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.has_voted(user):
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
)
# Display elections # Display elections
@@ -36,21 +185,25 @@ class ElectionsListView(CanViewMixin, ListView):
"""A list of all non archived elections visible.""" """A list of all non archived elections visible."""
model = Election model = Election
queryset = model.objects.filter(archived=False)
ordering = ["-id"] ordering = ["-id"]
paginate_by = 10 paginate_by = 10
template_name = "election/election_list.jinja" template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False).all()
class ElectionListArchivedView(CanViewMixin, ListView): class ElectionListArchivedView(CanViewMixin, ListView):
"""A list of all archived elections visible.""" """A list of all archived elections visible."""
model = Election model = Election
queryset = model.objects.filter(archived=True)
ordering = ["-id"] ordering = ["-id"]
paginate_by = 10 paginate_by = 10
template_name = "election/election_list.jinja" template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=True).all()
class ElectionDetailView(CanViewMixin, DetailView): class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability.""" """Details an election responsability by responsability."""
@@ -59,67 +212,46 @@ class ElectionDetailView(CanViewMixin, DetailView):
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
pk_url_kwarg = "election_id" pk_url_kwarg = "election_id"
@staticmethod
def _reorder_votes(action: str, role: int):
role = Role.objects.filter(id=role).first()
if not role:
return
if action == "up":
role.up()
elif action == "down":
role.down()
elif action == "bottom":
role.bottom()
elif action == "top":
role.top()
def get(self, request, *arg, **kwargs): def get(self, request, *arg, **kwargs):
response = super().get(request, *arg, **kwargs)
election: Election = self.get_object() election: Election = self.get_object()
if election.is_vote_editable and request.user.can_edit(election): if request.user.can_edit(election) and election.is_vote_editable:
action = request.GET.get("action", None) action = request.GET.get("action", None)
role = request.GET.get("role", None) role = request.GET.get("role", None)
if action and role and role.isdigit(): if action and role and Role.objects.filter(id=role).exists():
self._reorder_votes(action, int(role)) if action == "up":
return super().get(request, *arg, **kwargs) Role.objects.get(id=role).up()
elif action == "down":
Role.objects.get(id=role).down()
elif action == "bottom":
Role.objects.get(id=role).bottom()
elif action == "top":
Role.objects.get(id=role).top()
return redirect(
reverse("election:detail", kwargs={"election_id": election.id})
)
return response
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add additionnal data to the template.""" """Add additionnal data to the template."""
user: User = self.request.user kwargs = super().get_context_data(**kwargs)
return super().get_context_data(**kwargs) | { kwargs["election_form"] = VoteForm(self.object, self.request.user)
"election_form": VoteForm(self.object, user), kwargs["election_results"] = self.object.results
"show_vote_buttons": self.object.can_vote(user), return kwargs
"user_has_voted": self.object.has_voted(user),
"election_results": (
self.object.results if self.object.is_vote_finished else None
),
"election_lists": list(self.object.election_lists.all()),
"election_roles": list(self.object.roles.order_by("order")),
}
# Form view # Form view
class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView): class VoteFormView(CanCreateMixin, FormView):
"""Alows users to vote.""" """Alows users to vote."""
form_class = VoteForm form_class = VoteForm
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
@cached_property def dispatch(self, request, *arg, **kwargs):
def election(self): self.election = get_object_or_404(Election, pk=kwargs["election_id"])
return get_object_or_404(Election, pk=self.kwargs["election_id"]) return super().dispatch(request, *arg, **kwargs)
def test_func(self):
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def vote(self, election_data): def vote(self, election_data):
with transaction.atomic(): with transaction.atomic():
@@ -139,16 +271,20 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
self.election.voters.add(self.request.user) self.election.voters.add(self.request.user)
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | { kwargs = super().get_form_kwargs()
"election": self.election, kwargs["election"] = self.election
"user": self.request.user, kwargs["user"] = self.request.user
} return kwargs
def form_valid(self, form): def form_valid(self, form):
"""Verify that the user is part in a vote group.""" """Verify that the user is part in a vote group."""
data = form.clean() data = form.clean()
self.vote(data) res = super(FormView, self).form_valid(form)
return super().form_valid(form) for grp_id in self.election.vote_groups.values_list("pk", flat=True):
if self.request.user.is_in_group(pk=grp_id):
self.vote(data)
return res
return res
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -174,22 +310,26 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"]) self.election = get_object_or_404(Election, pk=kwargs["election_id"])
self.can_edit = self.request.user.can_edit(self.election)
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_initial(self): def get_initial(self):
return {"user": self.request.user.id} init = {}
self.can_edit = self.request.user.can_edit(self.election)
init["user"] = self.request.user.id
return init
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | { kwargs = super().get_form_kwargs()
"election": self.election, kwargs["election_id"] = self.election.id
"can_edit": self.can_edit, kwargs["can_edit"] = self.can_edit
} return kwargs
def form_valid(self, form: CandidateForm): def form_valid(self, form):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = self.election obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):
@@ -197,7 +337,9 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
raise PermissionDenied raise PermissionDenied
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election} kwargs = super().get_context_data(**kwargs)
kwargs["election"] = self.election
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -213,79 +355,80 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
return reverse("election:detail", kwargs={"election_id": self.object.id}) return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class RoleCreateView(CanCreateMixin, CreateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
@cached_property def dispatch(self, request, *arg, **kwargs):
def election(self): self.election = get_object_or_404(Election, pk=kwargs["election_id"])
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
return False raise PermissionDenied
if self.request.user.has_perm("election.add_role"): return super().dispatch(request, *arg, **kwargs)
return True
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} init = {}
init["election"] = self.election
return init
def form_valid(self, form):
"""Verify that the user can edit properly."""
obj: Role = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id} kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse( return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election_id} "election:detail", kwargs={"election_id": self.object.election.id}
) )
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class ElectionListCreateView(CanCreateMixin, CreateView):
model = ElectionList model = ElectionList
form_class = ElectionListForm form_class = ElectionListForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
@cached_property def dispatch(self, request, *arg, **kwargs):
def election(self): self.election = get_object_or_404(Election, pk=kwargs["election_id"])
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
return False raise PermissionDenied
if self.request.user.has_perm("election.add_electionlist"): return super().dispatch(request, *arg, **kwargs)
return True
groups = set(
self.election.candidature_groups.values("id")
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} init = {}
init["election"] = self.election
return init
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id} kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def form_valid(self, form):
"""Verify that the user can vote on this election."""
obj: ElectionList = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.candidature_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse( return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election_id} "election:detail", kwargs={"election_id": self.object.election.id}
) )
@@ -314,23 +457,45 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView): class CandidatureUpdateView(CanEditMixin, UpdateView):
model = Candidature model = Candidature
form_class = CandidateForm form_class = CandidateForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "candidature_id" pk_url_kwarg = "candidature_id"
def get_form(self, *args, **kwargs): def dispatch(self, request, *arg, **kwargs):
form = super().get_form(*args, **kwargs) self.object = self.get_object()
form.fields.pop("role", None) if not self.object.role.election.is_vote_editable:
return form raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def remove_fields(self):
self.form.fields.pop("role", None)
def get(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
return self.render_to_response(self.get_context_data(form=self.form))
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
if (
request.user.is_authenticated
and request.user.can_edit(self.object)
and self.form.is_valid()
):
return super().form_valid(self.form)
return self.form_invalid(self.form)
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.object.role.election} kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.object.role.election.id
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse( return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.role.election_id} "election:detail", kwargs={"election_id": self.object.role.election.id}
) )
@@ -381,12 +546,18 @@ class RoleUpdateView(CanEditMixin, UpdateView):
# Delete Views # Delete Views
class ElectionDeleteView(PermissionRequiredMixin, DeleteView): class ElectionDeleteView(DeleteView):
model = Election model = Election
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "election_id" pk_url_kwarg = "election_id"
permission_required = "election.delete_election"
success_url = reverse_lazy("election:list") def dispatch(self, request, *args, **kwargs):
if request.user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse_lazy("election:list")
class CandidatureDeleteView(CanEditMixin, DeleteView): class CandidatureDeleteView(CanEditMixin, DeleteView):
@@ -402,7 +573,7 @@ class CandidatureDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class RoleDeleteView(CanEditMixin, DeleteView): class RoleDeleteView(CanEditMixin, DeleteView):
@@ -418,7 +589,7 @@ class RoleDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionListDeleteView(CanEditMixin, DeleteView): class ElectionListDeleteView(CanEditMixin, DeleteView):
@@ -434,4 +605,4 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-25 15:33+0200\n" "POT-Creation-Date: 2025-09-19 17:22+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,7 +141,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py election/forms.py #: club/forms.py com/forms.py counter/forms.py election/views.py
#: subscription/forms.py #: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@@ -679,7 +679,7 @@ msgstr "Listes de diffusion"
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/forms.py subscription/forms.py #: com/forms.py election/views.py subscription/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -1103,10 +1103,6 @@ msgstr "Modération"
msgid "No posters" msgid "No posters"
msgstr "Aucune affiche" msgstr "Aucune affiche"
#: com/templates/com/poster_list.jinja com/templates/com/screen_slideshow.jinja
msgid "Click to expand"
msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_moderate.jinja #: com/templates/com/poster_moderate.jinja
msgid "Posters - moderation" msgid "Posters - moderation"
msgstr "Affiches - modération" msgstr "Affiches - modération"
@@ -1164,14 +1160,6 @@ msgstr "Contenu"
msgid "Add to weekmail" msgid "Add to weekmail"
msgstr "Ajouter au Weekmail" msgstr "Ajouter au Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail.jinja #: com/templates/com/weekmail.jinja
msgid "Up" msgid "Up"
msgstr "Monter" msgstr "Monter"
@@ -1180,6 +1168,14 @@ msgstr "Monter"
msgid "Down" msgid "Down"
msgstr "Descendre" msgstr "Descendre"
#: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
@@ -1261,10 +1257,6 @@ msgstr "Liste d'écrans"
msgid "All incoming events" msgid "All incoming events"
msgstr "Tous les événements à venir" msgstr "Tous les événements à venir"
#: com/views.py
msgid "Weekmail sent successfully"
msgstr "Weekmail envoyé avec succès"
#: com/views.py #: com/views.py
msgid "Delete and save to regenerate" msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer" msgstr "Supprimer et sauver pour régénérer"
@@ -1273,26 +1265,6 @@ msgstr "Supprimer et sauver pour régénérer"
msgid "Weekmail of the " msgid "Weekmail of the "
msgstr "Weekmail du " msgstr "Weekmail du "
#: com/views.py
#, python-format
msgid "%(title)s moved up in the Weekmail"
msgstr "%(title)s monté dans le Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s moved down in the Weekmail"
msgstr "%(title)s descendu dans le Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s added to the Weekmail"
msgstr "%(title)s ajouté dans Weekmail"
#: com/views.py
#, python-format
msgid "%(title)s removed from the Weekmail"
msgstr "%(title)s retiré du Weekmail"
#: com/views.py #: com/views.py
msgid "" msgid ""
"You must be a board member of the selected club to post in the Weekmail." "You must be a board member of the selected club to post in the Weekmail."
@@ -2368,10 +2340,6 @@ msgstr "Etickets"
msgid "User has no account" msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte" msgstr "L'utilisateur n'a pas de compte"
#: core/templates/core/user_account_detail.jinja
msgid "Deleted user"
msgstr "Utilisateur supprimé"
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja #: counter/templates/counter/refilling_list.jinja
@@ -3930,30 +3898,6 @@ msgstr ""
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: election/forms.py
msgid "You have selected too many candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/forms.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/forms.py election/templates/election/election_detail.jinja
msgid "Blank vote"
msgstr "Vote blanc"
#: election/forms.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/forms.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/forms.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: election/models.py #: election/models.py
msgid "start candidature" msgid "start candidature"
msgstr "début des candidatures" msgstr "début des candidatures"
@@ -3978,10 +3922,6 @@ msgstr "groupe de vote"
msgid "candidature groups" msgid "candidature groups"
msgstr "groupe de candidature" msgstr "groupe de candidature"
#: election/models.py
msgid "voters"
msgstr "électeurs"
#: election/models.py #: election/models.py
msgid "election" msgid "election"
msgstr "élection" msgstr "élection"
@@ -4037,10 +3977,17 @@ msgstr "Vous avez déjà soumis votre vote."
msgid "You have voted in this election." msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection." msgstr "Vous avez déjà voté pour cette élection."
#: election/templates/election/election_detail.jinja election/views.py
msgid "Blank vote"
msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#, python-format msgid "You may choose up to"
msgid "You may choose up to %(nb_choices)s people." msgstr "Vous pouvez choisir jusqu'à"
msgstr "Vous pouvez choisir jusqu'à %(nb_choices)s personnes."
#: election/templates/election/election_detail.jinja
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Choose blank vote" msgid "Choose blank vote"
@@ -4082,6 +4029,26 @@ msgstr "au"
msgid "Polls open from" msgid "Polls open from"
msgstr "Votes ouverts du" msgstr "Votes ouverts du"
#: election/views.py
msgid "You have selected too much candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/views.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/views.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/views.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/views.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: forum/models.py #: forum/models.py
msgid "is a category" msgid "is a category"
msgstr "est une catégorie" msgstr "est une catégorie"
@@ -4573,6 +4540,22 @@ msgstr "Signaler ce commentaire"
msgid "Edit UE" msgid "Edit UE"
msgstr "Éditer l'UE" msgstr "Éditer l'UE"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Import from UTBM"
msgstr "Importer depuis l'UTBM"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Unknown UE code"
msgstr "Code d'UE inconnu"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Successful autocomplete"
msgstr "Autocomplétion réussite"
#: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "An error occurred: "
msgstr "Une erreur est survenue : "
#: rootplace/forms.py #: rootplace/forms.py
msgid "User that will be kept" msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé" msgstr "Utilisateur qui sera conservé"
@@ -4836,8 +4819,8 @@ msgid "N/A"
msgstr "N/A" msgstr "N/A"
#: sith/settings.py #: sith/settings.py
msgid "AE account" msgid "Transfert"
msgstr "Compte AE" msgstr "Virement"
#: sith/settings.py #: sith/settings.py
msgid "Belfort" msgid "Belfort"
@@ -5125,6 +5108,26 @@ msgstr "Vous avez acheté %s"
msgid "You have a notification" msgid "You have a notification"
msgstr "Vous avez une notification" msgstr "Vous avez une notification"
#: sith/settings.py
msgid "Success!"
msgstr "Succès !"
#: sith/settings.py
msgid "Fail!"
msgstr "Échec !"
#: sith/settings.py
msgid "You successfully posted an article in the Weekmail"
msgstr "Article posté avec succès dans le Weekmail"
#: sith/settings.py
msgid "You successfully edited an article in the Weekmail"
msgstr "Article édité avec succès dans le Weekmail"
#: sith/settings.py
msgid "You successfully sent the Weekmail"
msgstr "Weekmail envoyé avec succès"
#: sith/settings.py #: sith/settings.py
msgid "AE tee-shirt" msgid "AE tee-shirt"
msgstr "Tee-shirt AE" msgstr "Tee-shirt AE"
@@ -5165,14 +5168,6 @@ msgstr "lieu"
msgid "You can not subscribe many time for the same period" msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période" msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
#: subscription/templates/subscription/forms/create_existing_user.jinja
msgid ""
"If the subscription is done using the AE account, you must also click it on "
"the AE counter."
msgstr ""
"Si la cotisation est faite en utilisant le compte AE, vous devez également "
"la cliquer sur le comptoir AE."
#: subscription/templates/subscription/fragments/creation_success.jinja #: subscription/templates/subscription/fragments/creation_success.jinja
#, python-format #, python-format
msgid "Subscription created for %(user)s" msgid "Subscription created for %(user)s"
@@ -5436,38 +5431,10 @@ msgstr "Mes photos"
msgid "Admin tools" msgid "Admin tools"
msgstr "Admin Trombi" msgstr "Admin Trombi"
#: trombi/views.py
msgid "Trombi modified"
msgstr "Trombi modifié"
#: trombi/views.py
msgid "User added to the trombi"
msgstr "Utilisateur ajouté au trombi"
#: trombi/views.py
msgid "User couldn't be added to the trombi"
msgstr "L'utilisateur n'a pas pu être ajouté au trombi"
#: trombi/views.py
msgid "User removed from the trombi"
msgstr "Utilisateur retiré du trombi"
#: trombi/views.py #: trombi/views.py
msgid "Explain why you rejected the comment" msgid "Explain why you rejected the comment"
msgstr "Expliquez pourquoi vous refusez le commentaire" msgstr "Expliquez pourquoi vous refusez le commentaire"
#: trombi/views.py
msgid "Comment accepted"
msgstr "Commentaire accepté"
#: trombi/views.py
msgid "Comment rejected"
msgstr "Commentaire rejeté"
#: trombi/views.py
msgid "Comment removed"
msgstr "Commentaire retiré"
#: trombi/views.py #: trombi/views.py
msgid "Rejected comment" msgid "Rejected comment"
msgstr "Commentaire rejeté" msgstr "Commentaire rejeté"
@@ -5508,10 +5475,6 @@ msgstr ""
"pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option " "pouvez vous inscrire qu'à un seul Trombi, donc ne jouez pas avec cet option "
"ou vous encourerez la colère des admins!" "ou vous encourerez la colère des admins!"
#: trombi/views.py
msgid "User modified"
msgstr "Utilisateur modifié"
#: trombi/views.py #: trombi/views.py
msgid "Personal email (not UTBM)" msgid "Personal email (not UTBM)"
msgstr "Email personnel (pas UTBM)" msgstr "Email personnel (pas UTBM)"
@@ -5524,14 +5487,6 @@ msgstr "Téléphone"
msgid "Native town" msgid "Native town"
msgstr "Ville d'origine" msgstr "Ville d'origine"
#: trombi/views.py
msgid "User removed from trombi"
msgstr "Utilisateur retiré du trombi"
#: trombi/views.py
msgid "Comment added"
msgstr "Commentaire ajouté"
#: trombi/views.py #: trombi/views.py
msgid "" msgid ""
"You can not yet write comment, you must wait for the subscription deadline " "You can not yet write comment, you must wait for the subscription deadline "
@@ -5547,4 +5502,4 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#: trombi/views.py #: trombi/views.py
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"

25
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
@@ -46,6 +47,7 @@
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4", "@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4", "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.3.6",
@@ -2887,6 +2889,16 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jquery": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.33.tgz",
"integrity": "sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/js-cookie": { "node_modules/@types/js-cookie": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -2907,6 +2919,13 @@
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/sizzle": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz",
"integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/tern": { "node_modules/@types/tern": {
"version": "0.23.9", "version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -4365,6 +4384,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jquery": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"license": "MIT"
},
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",

View File

@@ -32,6 +32,7 @@
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4", "@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4", "@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.6", "vite": "^6.3.6",
@@ -60,6 +61,7 @@
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.0", "lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",

View File

@@ -13,15 +13,16 @@
{% block content %} {% block content %}
<div class="pedagogy"> <div class="pedagogy">
<div id="uv_detail"> <div id="uv_detail">
<button onclick='(function(){ <p id="return_noscript"><a href="{{ url('pedagogy:guide') }}">{% trans %}Back{% endtrans %}</a></p>
// If comes from the guide page, go back with history <button id="return_js" onclick='(function(){
if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){ // If comes from the guide page, go back with history
window.history.back(); if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
return; window.history.back();
} return;
// Simply goes to the guide page }
window.location.href = `{{ url("pedagogy:guide") }}`; // Simply goes to the guide page
})()' hidden>{% trans %}Back{% endtrans %}</button> window.location.href = `{{ url("pedagogy:guide") }}`;
})()' hidden>{% trans %}Back{% endtrans %}</button>
<h1>{{ object.code }} - {{ object.title }}</h1> <h1>{{ object.code }} - {{ object.title }}</h1>
<br> <br>
@@ -216,4 +217,9 @@
</div> </div>
</div> </div>
<script type="text/javascript">
$("#return_noscript").hide();
$("#return_js").show();
</script>
{% endblock %} {% endblock %}

View File

@@ -21,6 +21,11 @@
{{ field.errors }} {{ field.errors }}
<label for="{{ field.name }}">{{ field.label }}</label> <label for="{{ field.name }}">{{ field.label }}</label>
{{ field }} {{ field }}
{% if field.name == 'code' %}
<button type="button" id="autofill">{% trans %}Import from UTBM{% endtrans %}</button>
{% endif %}
</p> </p>
{% endif %} {% endif %}
@@ -31,3 +36,48 @@
<p><input type="submit" value="{% trans %}Update{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Update{% endtrans %}" /></p>
</form> </form>
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
const autofillBtn = document.getElementById('autofill')
const codeInput = document.querySelector('input[name="code"]')
autofillBtn.addEventListener('click', () => {
const url = `/api/uv/${codeInput.value}`;
deleteQuickNotifs()
$.ajax({
dataType: "json",
url: url,
success: function(data, _, xhr) {
if (xhr.status !== 200) {
createQuickNotif("{% trans %}Unknown UE code{% endtrans %}")
return
}
Object.entries(data)
.filter(([_, val]) => !!val) // skip entries with null or undefined value
.map(([key, val]) => { // convert keys to DOM elements
return [document.querySelector('[name="' + key + '"]'), val];
})
.filter(([elem, _]) => !!elem) // skip non-existing DOM elements
.forEach(([elem, val]) => { // write the value in the form field
if (elem.tagName === 'TEXTAREA') {
// MD editor text input
elem.parentNode.querySelector('.CodeMirror').CodeMirror.setValue(val);
} else {
elem.value = val;
}
});
createQuickNotif('{% trans %}Successful autocomplete{% endtrans %}')
},
error: function(_, _, statusMessage) {
createQuickNotif('{% trans %}An error occurred: {% endtrans %}' + statusMessage)
},
})
})
})
</script>
{% endblock %}

View File

@@ -25,7 +25,7 @@ dependencies = [
"Pillow<12.0.0,>=11.1.0", "Pillow<12.0.0,>=11.1.0",
"mistune<4.0.0,>=3.1.3", "mistune<4.0.0,>=3.1.3",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=45.0.3,<46.0.0", "cryptography>=45.0.3,<47.0.0",
"django-phonenumber-field<9.0.0,>=8.1.0", "django-phonenumber-field<9.0.0,>=8.1.0",
"phonenumbers>=9.0.2,<10.0.0", "phonenumbers>=9.0.2,<10.0.0",
"reportlab<5.0.0,>=4.3.1", "reportlab<5.0.0,>=4.3.1",

View File

@@ -309,7 +309,6 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
// Clear selection and cache of retrieved user so they can be filtered again // Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false); widget.clear(false);
widget.clearOptions(); widget.clearOptions();
widget.setTextboxValue("");
}, },
/** /**

View File

@@ -421,11 +421,18 @@ SITH_PROFILE_DEPARTMENTS = [
("NA", _("N/A")), ("NA", _("N/A")),
] ]
SITH_ACCOUNTING_PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("TRANSFERT", _("Transfert")),
("CARD", _("Credit card")),
]
SITH_SUBSCRIPTION_PAYMENT_METHOD = [ SITH_SUBSCRIPTION_PAYMENT_METHOD = [
("CHECK", _("Check")), ("CHECK", _("Check")),
("CARD", _("Credit card")), ("CARD", _("Credit card")),
("CASH", _("Cash")), ("CASH", _("Cash")),
("AE_ACCOUNT", _("AE account")), ("EBOUTIC", _("Eboutic")),
("OTHER", _("Other")), ("OTHER", _("Other")),
] ]
@@ -434,7 +441,6 @@ SITH_SUBSCRIPTION_LOCATIONS = [
("SEVENANS", _("Sevenans")), ("SEVENANS", _("Sevenans")),
("MONTBELIARD", _("Montbéliard")), ("MONTBELIARD", _("Montbéliard")),
("EBOUTIC", _("Eboutic")), ("EBOUTIC", _("Eboutic")),
("OTHER", _("Other")),
] ]
SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")] SITH_COUNTER_BARS = [(1, "MDE"), (2, "Foyer"), (35, "La Gommette")]
@@ -685,6 +691,14 @@ SITH_PERMANENT_NOTIFICATIONS = {
"SAS_MODERATION": "sas.models.sas_notification_callback", "SAS_MODERATION": "sas.models.sas_notification_callback",
} }
SITH_QUICK_NOTIF = {
"qn_success": _("Success!"),
"qn_fail": _("Fail!"),
"qn_weekmail_new_article": _("You successfully posted an article in the Weekmail"),
"qn_weekmail_article_edit": _("You successfully edited an article in the Weekmail"),
"qn_weekmail_send_success": _("You successfully sent the Weekmail"),
}
# Mailing related settings # Mailing related settings
SITH_MAILING_DOMAIN = "utbm.fr" SITH_MAILING_DOMAIN = "utbm.fr"

View File

@@ -2,7 +2,6 @@ import secrets
from typing import Any from typing import Any
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -24,13 +23,6 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm): class SubscriptionForm(forms.ModelForm):
allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
initial = initial or {} initial = initial or {}
if "subscription_type" not in initial: if "subscription_type" not in initial:
@@ -38,14 +30,6 @@ class SubscriptionForm(forms.ModelForm):
if "payment_method" not in initial: if "payment_method" not in initial:
initial["payment_method"] = "CARD" initial["payment_method"] = "CARD"
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
self.fields["payment_method"].choices = [
m
for m in settings.SITH_SUBSCRIPTION_PAYMENT_METHOD
if m[0] in self.allowed_payment_methods
]
self.fields["location"].choices = [
m for m in settings.SITH_SUBSCRIPTION_LOCATIONS if m[0] != "EBOUTIC"
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.errors: if self.errors:
@@ -77,8 +61,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
assert user.is_subscribed assert user.is_subscribed
""" """
allowed_payment_methods = ["CARD", "CASH"] template_name = "subscription/forms/create_new_user.html"
template_name = "subscription/forms/create_new_user.jinja"
__user_fields = forms.fields_for_model( __user_fields = forms.fields_for_model(
User, User,
@@ -90,6 +73,10 @@ class SubscriptionNewUserForm(SubscriptionForm):
email = __user_fields["email"] email = __user_fields["email"]
date_of_birth = __user_fields["date_of_birth"] date_of_birth = __user_fields["date_of_birth"]
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
field_order = [ field_order = [
"first_name", "first_name",
"last_name", "last_name",
@@ -143,7 +130,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
class SubscriptionExistingUserForm(SubscriptionForm): class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user.""" """Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.jinja" template_name = "subscription/forms/create_existing_user.html"
required_css_class = "required" required_css_class = "required"
birthdate = forms.fields_for_model( birthdate = forms.fields_for_model(
@@ -153,9 +140,10 @@ class SubscriptionExistingUserForm(SubscriptionForm):
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")}, help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"] )["date_of_birth"]
class Meta(SubscriptionForm.Meta): class Meta:
fields = ["member", *SubscriptionForm.Meta.fields] model = Subscription
widgets = SubscriptionForm.Meta.widgets | {"member": AutoCompleteSelectUser} fields = ["member", "subscription_type", "payment_method", "location"]
widgets = {"member": AutoCompleteSelectUser}
field_order = [ field_order = [
"member", "member",

View File

@@ -1,56 +0,0 @@
# Generated by Django 5.2.3 on 2025-09-08 05:38
from django.db import migrations, models
from django.db.migrations.state import StateApps
def rename_enums(apps: StateApps, schema_editor):
Subscription = apps.get_model("subscription", "Subscription")
Subscription.objects.filter(subscription_type="EBOUTIC").update(
subscription_type="AE_ACCOUNT"
)
def rename_enums_reverse(apps: StateApps, schema_editor):
Subscription = apps.get_model("subscription", "Subscription")
Subscription.objects.filter(subscription_type="AE_ACCOUNT").update(
subscription_type="EBOUTIC"
)
class Migration(migrations.Migration):
dependencies = [("subscription", "0014_auto_20201207_2323")]
operations = [
migrations.AlterField(
model_name="subscription",
name="location",
field=models.CharField(
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sevenans"),
("MONTBELIARD", "Montbéliard"),
("EBOUTIC", "Eboutic"),
("OTHER", "Other"),
],
max_length=20,
verbose_name="location",
),
),
migrations.AlterField(
model_name="subscription",
name="payment_method",
field=models.CharField(
choices=[
("CHECK", "Check"),
("CARD", "Credit card"),
("CASH", "Cash"),
("AE_ACCOUNT", "AE account"),
("OTHER", "Other"),
],
max_length=255,
verbose_name="payment method",
),
),
migrations.RunPython(rename_enums, reverse_code=rename_enums_reverse),
]

View File

@@ -0,0 +1,14 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ form.as_p }}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>

View File

@@ -1,28 +0,0 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ errors }}
{% for field, errors in fields %}
<p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
</p>
{% if field.name == "payment_method" %}
<i>
{% blocktranslate %}If the subscription is done using the AE account, you must also click it on the AE counter.{% endblocktranslate %}
</i>
{% endif %}
{% endfor %}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>

View File

@@ -90,7 +90,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2017, 8, 29) s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.166, start=s.subscription_start) s.subscription_end = s.compute_end(duration=0.166, start=s.subscription_start)
@@ -101,7 +101,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2017, 8, 29) s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.333, start=s.subscription_start) s.subscription_end = s.compute_end(duration=0.333, start=s.subscription_start)
@@ -112,7 +112,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2017, 8, 29) s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(
@@ -126,7 +126,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2017, 8, 29) s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.5, start=s.subscription_start) s.subscription_end = s.compute_end(duration=0.5, start=s.subscription_start)
@@ -137,7 +137,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2017, 8, 29) s.subscription_start = date(2017, 8, 29)
s.subscription_end = s.compute_end(duration=0.67, start=s.subscription_start) s.subscription_end = s.compute_end(duration=0.67, start=s.subscription_start)
@@ -148,7 +148,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=self.user, member=self.user,
subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3], subscription_type=list(settings.SITH_SUBSCRIPTIONS.keys())[3],
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2018, 9, 1) s.subscription_start = date(2018, 9, 1)
s.subscription_end = s.compute_end(duration=0.23, start=s.subscription_start) s.subscription_end = s.compute_end(duration=0.23, start=s.subscription_start)
@@ -160,7 +160,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type="deux-semestres", subscription_type="deux-semestres",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2015, 8, 29) s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(
@@ -181,7 +181,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type="deux-mois-essai", subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2015, 8, 29) s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(
@@ -202,7 +202,7 @@ class TestSubscriptionIntegration(TestCase):
s = Subscription( s = Subscription(
member=user, member=user,
subscription_type="deux-mois-essai", subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0],
) )
s.subscription_start = date(2015, 8, 29) s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end( s.subscription_end = s.compute_end(

View File

@@ -38,7 +38,7 @@ def test_form_existing_user_valid(
"birthdate": user.date_of_birth, "birthdate": user.date_of_birth,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
assert form.is_valid() assert form.is_valid()
@@ -55,7 +55,7 @@ def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"member": user, "member": user,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
assert not form.is_valid() assert not form.is_valid()
@@ -81,7 +81,7 @@ def test_form_existing_user_invalid(settings: SettingsWrapper):
"member": user, "member": user,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
@@ -99,7 +99,7 @@ def test_form_new_user(settings: SettingsWrapper):
"date_of_birth": localdate() - relativedelta(years=18), "date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionNewUserForm(data) form = SubscriptionNewUserForm(data)
assert form.is_valid() assert form.is_valid()
@@ -130,7 +130,7 @@ def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_ty
"date_of_birth": localdate() - relativedelta(years=18), "date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": subscription_type, "subscription_type": subscription_type,
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
} }
form = SubscriptionNewUserForm(data) form = SubscriptionNewUserForm(data)
assert form.is_valid() assert form.is_valid()
@@ -180,7 +180,7 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
"birthdate": user.date_of_birth, "birthdate": user.date_of_birth,
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}, },
) )
user.refresh_from_db() user.refresh_from_db()
@@ -212,7 +212,7 @@ def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
"date_of_birth": localdate() - relativedelta(years=18), "date_of_birth": localdate() - relativedelta(years=18),
"subscription_type": "deux-semestres", "subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0], "location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
}, },
) )
user = User.objects.get(email="jdoe@utbm.fr") user = User.objects.get(email="jdoe@utbm.fr")

View File

@@ -26,9 +26,7 @@ from datetime import date
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import IntegrityError from django.db import IntegrityError
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@@ -48,7 +46,7 @@ from core.auth.mixins import (
) )
from core.models import User from core.models import User
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.mixins import TabedViewMixin from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser
@@ -136,15 +134,15 @@ class TrombiCreateView(CanCreateMixin, CreateView):
return self.form_invalid(form) return self.form_invalid(form)
class TrombiEditView( class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView):
CanEditPropMixin, TrombiTabsMixin, SuccessMessageMixin, UpdateView
):
model = Trombi model = Trombi
form_class = TrombiForm form_class = TrombiForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
current_tab = "admin_tools" current_tab = "admin_tools"
success_message = _("Trombi modified")
def get_success_url(self):
return super().get_success_url() + "?qn_success"
class AddUserForm(forms.Form): class AddUserForm(forms.Form):
@@ -157,7 +155,7 @@ class AddUserForm(forms.Form):
) )
class TrombiDetailView(CanEditMixin, TrombiTabsMixin, DetailView): class TrombiDetailView(CanEditMixin, QuickNotifMixin, TrombiTabsMixin, DetailView):
model = Trombi model = Trombi
template_name = "trombi/detail.jinja" template_name = "trombi/detail.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
@@ -169,9 +167,9 @@ class TrombiDetailView(CanEditMixin, TrombiTabsMixin, DetailView):
if form.is_valid(): if form.is_valid():
try: try:
TrombiUser(user=form.cleaned_data["user"], trombi=self.object).save() TrombiUser(user=form.cleaned_data["user"], trombi=self.object).save()
messages.success(self.request, _("User added to the trombi")) self.quick_notif_list.append("qn_success")
except IntegrityError: # We don't care about duplicate keys except IntegrityError: # We don't care about duplicate keys
messages.error(self.request, _("User couldn't be added to the trombi")) self.quick_notif_list.append("qn_fail")
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -187,20 +185,22 @@ class TrombiExportView(CanEditMixin, TrombiTabsMixin, DetailView):
current_tab = "admin_tools" current_tab = "admin_tools"
class TrombiDeleteUserView( class TrombiDeleteUserView(CanEditPropMixin, TrombiTabsMixin, DeleteView):
CanEditPropMixin, TrombiTabsMixin, SuccessMessageMixin, DeleteView
):
model = TrombiUser model = TrombiUser
pk_url_kwarg = "user_id" pk_url_kwarg = "user_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
current_tab = "admin_tools" current_tab = "admin_tools"
success_message = _("User removed from the trombi")
def get_success_url(self): def get_success_url(self):
return reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id}) return (
reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id})
+ "?qn_success"
)
class TrombiModerateCommentsView(CanEditPropMixin, TrombiTabsMixin, DetailView): class TrombiModerateCommentsView(
CanEditPropMixin, QuickNotifMixin, TrombiTabsMixin, DetailView
):
model = Trombi model = Trombi
template_name = "trombi/comment_moderation.jinja" template_name = "trombi/comment_moderation.jinja"
pk_url_kwarg = "trombi_id" pk_url_kwarg = "trombi_id"
@@ -235,18 +235,16 @@ class TrombiModerateCommentView(DetailView):
if request.POST["action"] == "accept": if request.POST["action"] == "accept":
self.object.is_moderated = True self.object.is_moderated = True
self.object.save() self.object.save()
messages.success(self.request, _("Comment accepted"))
return redirect( return redirect(
reverse( reverse(
"trombi:moderate_comments", "trombi:moderate_comments",
kwargs={"trombi_id": self.object.author.trombi.id}, kwargs={"trombi_id": self.object.author.trombi.id},
) )
+ "?qn_success"
) )
elif request.POST["action"] == "reject": elif request.POST["action"] == "reject":
messages.success(self.request, _("Comment rejected"))
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
elif request.POST["action"] == "delete" and "reason" in request.POST: elif request.POST["action"] == "delete" and "reason" in request.POST:
messages.success(self.request, _("Comment removed"))
self.object.author.user.email_user( self.object.author.user.email_user(
subject="[%s] %s" % (settings.SITH_NAME, _("Rejected comment")), subject="[%s] %s" % (settings.SITH_NAME, _("Rejected comment")),
message=_( message=_(
@@ -267,6 +265,7 @@ class TrombiModerateCommentView(DetailView):
"trombi:moderate_comments", "trombi:moderate_comments",
kwargs={"trombi_id": self.object.author.trombi.id}, kwargs={"trombi_id": self.object.author.trombi.id},
) )
+ "?qn_success"
) )
raise Http404 raise Http404
@@ -300,7 +299,9 @@ class UserTrombiForm(forms.Form):
) )
class UserTrombiToolsView(LoginRequiredMixin, TrombiTabsMixin, TemplateView): class UserTrombiToolsView(
LoginRequiredMixin, QuickNotifMixin, TrombiTabsMixin, TemplateView
):
"""Display a user's trombi tools.""" """Display a user's trombi tools."""
template_name = "trombi/user_tools.jinja" template_name = "trombi/user_tools.jinja"
@@ -317,6 +318,7 @@ class UserTrombiToolsView(LoginRequiredMixin, TrombiTabsMixin, TemplateView):
user=request.user, trombi=self.form.cleaned_data["trombi"] user=request.user, trombi=self.form.cleaned_data["trombi"]
) )
trombi_user.save() trombi_user.save()
self.quick_notif_list += ["qn_success"]
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -333,24 +335,21 @@ class UserTrombiToolsView(LoginRequiredMixin, TrombiTabsMixin, TemplateView):
return kwargs return kwargs
class UserTrombiEditPicturesView( class UserTrombiEditPicturesView(TrombiTabsMixin, UserIsInATrombiMixin, UpdateView):
TrombiTabsMixin, UserIsInATrombiMixin, SuccessMessageMixin, UpdateView
):
model = TrombiUser model = TrombiUser
fields = ["profile_pict", "scrub_pict"] fields = ["profile_pict", "scrub_pict"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "pictures" current_tab = "pictures"
success_message = _("User modified")
def get_object(self): def get_object(self):
return self.request.user.trombi_user return self.request.user.trombi_user
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") return reverse("trombi:user_tools") + "?qn_success"
class UserTrombiEditProfileView( class UserTrombiEditProfileView(
TrombiTabsMixin, UserIsInATrombiMixin, SuccessMessageMixin, UpdateView QuickNotifMixin, TrombiTabsMixin, UserIsInATrombiMixin, UpdateView
): ):
model = User model = User
form_class = modelform_factory( form_class = modelform_factory(
@@ -371,20 +370,16 @@ class UserTrombiEditProfileView(
) )
template_name = "trombi/edit_profile.jinja" template_name = "trombi/edit_profile.jinja"
current_tab = "profile" current_tab = "profile"
success_message = _("User modified")
def get_object(self): def get_object(self):
return self.request.user return self.request.user
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") return reverse("trombi:user_tools") + "?qn_success"
class UserTrombiResetClubMembershipsView( class UserTrombiResetClubMembershipsView(UserIsInATrombiMixin, RedirectView):
UserIsInATrombiMixin, SuccessMessageMixin, RedirectView
):
permanent = False permanent = False
success_message = _("User modified")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
user = self.request.user.trombi_user user = self.request.user.trombi_user
@@ -392,18 +387,18 @@ class UserTrombiResetClubMembershipsView(
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):
return reverse("trombi:profile") return reverse("trombi:profile") + "?qn_success"
class UserTrombiDeleteMembershipView( class UserTrombiDeleteMembershipView(TrombiTabsMixin, CanEditMixin, DeleteView):
TrombiTabsMixin, CanEditMixin, SuccessMessageMixin, DeleteView
):
model = TrombiClubMembership model = TrombiClubMembership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("trombi:profile") success_url = reverse_lazy("trombi:profile")
current_tab = "profile" current_tab = "profile"
success_message = _("User removed from trombi")
def get_success_url(self):
return super().get_success_url() + "?qn_success"
# Used by admins when someone does not have every club in his list # Used by admins when someone does not have every club in his list
@@ -433,18 +428,15 @@ class UserTrombiAddMembershipView(TrombiTabsMixin, CreateView):
) )
class UserTrombiEditMembershipView( class UserTrombiEditMembershipView(CanEditMixin, TrombiTabsMixin, UpdateView):
CanEditMixin, TrombiTabsMixin, SuccessMessageMixin, UpdateView
):
model = TrombiClubMembership model = TrombiClubMembership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
fields = ["role", "start", "end"] fields = ["role", "start", "end"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "profile" current_tab = "profile"
success_message = _("User modified")
def get_success_url(self): def get_success_url(self):
return super().get_success_url() return super().get_success_url() + "?qn_success"
class UserTrombiProfileView(TrombiTabsMixin, DetailView): class UserTrombiProfileView(TrombiTabsMixin, DetailView):
@@ -469,13 +461,12 @@ class UserTrombiProfileView(TrombiTabsMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
class TrombiCommentFormView(LoginRequiredMixin, SuccessMessageMixin, View): class TrombiCommentFormView(LoginRequiredMixin, View):
"""Create/edit a trombi comment.""" """Create/edit a trombi comment."""
model = TrombiComment model = TrombiComment
fields = ["content"] fields = ["content"]
template_name = "trombi/comment.jinja" template_name = "trombi/comment.jinja"
success_message = _("Comment added")
def get_form_class(self): def get_form_class(self):
self.trombi = self.request.user.trombi_user.trombi self.trombi = self.request.user.trombi_user.trombi
@@ -505,7 +496,7 @@ class TrombiCommentFormView(LoginRequiredMixin, SuccessMessageMixin, View):
) )
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") return reverse("trombi:user_tools") + "?qn_success"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@@ -11,7 +11,7 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["alpinejs"], "types": ["jquery", "alpinejs"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"], "#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"], "#openapi:*": ["./staticfiles/generated/openapi/client/*"],

View File

@@ -4,6 +4,7 @@ import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import { type AliasOptions, type UserConfig, defineConfig } from "vite"; import { type AliasOptions, type UserConfig, defineConfig } from "vite";
import type { Rollup } from "vite"; import type { Rollup } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import tsconfig from "./tsconfig.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
@@ -86,6 +87,17 @@ export default defineConfig((config: UserConfig) => {
Alpine: "alpinejs", Alpine: "alpinejs",
htmx: "htmx.org", htmx: "htmx.org",
}), }),
viteStaticCopy({
targets: [
{
src: resolve(nodeModules, "jquery/dist/jquery.min.js"),
dest: vendored,
},
],
}),
], ],
optimizeDeps: {
include: ["jquery"],
},
} satisfies UserConfig; } satisfies UserConfig;
}); });