Compare commits

..

54 Commits

Author SHA1 Message Date
Sli
9be4b8f58e Add macro to refresh messages from htmx swap 2025-09-27 16:59:43 +02:00
Bartuccio Antoine
289ffe1109 Merge pull request #1190 from ae-utbm/alpine-notifications
Add alpine notifications plugin
2025-09-26 18:29:04 +02:00
thomas girod
ff5bb04af1 Merge pull request #1188 from ae-utbm/autocomplete-sas
Clear tom select text when identifying users in SAS
2025-09-26 15:48:24 +02:00
Sli
ca50e5dc81 Add alpine notifications plugin 2025-09-26 14:54:26 +02:00
Bartuccio Antoine
f015bde768 Merge pull request #1186 from ae-utbm/jquery
Remove JQuery
2025-09-26 14:36:02 +02:00
Sli
bb09fd0feb Apply review comments 2025-09-26 14:33:17 +02:00
Sli
210278440a Change notification zone position 2025-09-26 13:36:36 +02:00
Sli
e041da9cf4 Remove unnecessary complex anonymous callback on poster list 2025-09-25 22:07:29 +02:00
Sli
54c1957776 Move notifications from eboutic checkout to billing info fragment 2025-09-25 16:02:56 +02:00
Sli
30356d97f3 Use SuccessMessageMixin on trombi 2025-09-25 16:02:56 +02:00
Sli
7eaf25a64f Remove QuikNotifMixin 2025-09-25 16:02:56 +02:00
Sli
c6e86841b3 Remove jquery remeanants 2025-09-25 16:02:56 +02:00
Sli
cbe9887efb Create unified notification system 2025-09-25 16:02:55 +02:00
Noa Fouich
980952807a Merge pull request #1189 from ae-utbm/deleted_barman_user_fix
Deleted barman user fix
2025-09-25 16:01:36 +02:00
Noa Fouich
0b7c516f18 adding test 2025-09-25 15:57:21 +02:00
Noa Fouich
e186052283 Fix deleted barman on user account
# Conflicts:
#	locale/fr/LC_MESSAGES/django.po
2025-09-25 15:57:16 +02:00
imperosol
ec80b72a25 clear tom select text when identifying users in SAS 2025-09-25 07:38:44 +02:00
Bartuccio Antoine
6cd3875b2b Merge pull request #1187 from ae-utbm/fix-search
Remove `s` shortcut for search bar
2025-09-24 18:09:00 +02:00
Sli
ad8b003336 Remove s shortcut for search bar 2025-09-24 16:36:55 +02:00
Bartuccio Antoine
b4f5a866e3 Merge pull request #1185 from ae-utbm/posters
Remove jquery from posters
2025-09-23 14:59:24 +02:00
Sli
d87b069769 Apply review comments 2025-09-23 10:28:05 +02:00
thomas girod
9461b2e5d9 Merge pull request #1184 from ae-utbm/page-N+1
fix: N+1 query on PageListView
2025-09-23 09:18:24 +02:00
Sli
4701c0804b Fix slideshow transition 2025-09-22 23:06:18 +02:00
imperosol
acb6c6ce9c fix: N+1 query on PageListView 2025-09-22 18:14:14 +02:00
Sli
95e6fff98b Migrate poster view to alpine 2025-09-22 14:30:23 +02:00
thomas girod
f1a5a0781c Merge pull request #1181 from ae-utbm/fix-subscription
Fix subscription
2025-09-22 13:41:15 +02:00
imperosol
854dd2d9e7 add disclaimer for subscription purchase with AE account 2025-09-22 13:28:42 +02:00
imperosol
a7c96425c8 fix: ClubSellingView N+1 queries 2025-09-22 13:28:42 +02:00
Sli
dff23fae7f Migrate slideshow to alpine 2025-09-22 13:26:28 +02:00
thomas girod
34b0dc3302 Merge pull request #1182 from ae-utbm/fix-pagerev
fix: 500 on page properties edit
2025-09-22 13:04:22 +02:00
thomas girod
31aee01360 Merge pull request #1169 from ae-utbm/dependabot/npm_and_yarn/vite-6.3.6
Bump vite from 6.3.5 to 6.3.6
2025-09-21 16:05:03 +02:00
imperosol
ce2ef78a6d fix: 500 on page properties edit 2025-09-21 16:01:17 +02:00
Kenneth Soares
f7c5088048 Merge pull request #1177 from ae-utbm/fix_archived_products
Fix display of archived products
2025-09-19 20:09:40 +02:00
thomas girod
9bc6a447b9 Merge pull request #1179 from ae-utbm/poster-access
Make poster views available to club board members
2025-09-19 19:54:32 +02:00
imperosol
08b16d6e74 feat: make poster views available to club board members 2025-09-19 17:22:44 +02:00
thomas girod
c6baab068a Merge pull request #1164 from ae-utbm/subscription-birthday
Subscription birthday
2025-09-19 12:58:03 +02:00
Noa Fouich
262281adda Add test case 2025-09-18 14:40:20 +02:00
thomas girod
b58eca3ed0 Merge pull request #1171 from ae-utbm/club-edit-groups
fix: `Counter.edit_groups`
2025-09-16 15:20:47 +02:00
Kenneth SOARES
c7fe8961ab fixed display of archived products 2025-09-16 12:43:03 +02:00
thomas girod
18f77ef2cb Merge pull request #1176 from ae-utbm/fix-dependabot
Fix dependabot
2025-09-16 09:04:02 +02:00
imperosol
b58da0ea30 fix: dependabot.yml 2025-09-15 12:04:18 +02:00
imperosol
25cd877160 fix: Counter.edit_groups 2025-09-13 11:39:53 +02:00
dependabot[bot]
79297b7a75 Bump vite from 6.3.5 to 6.3.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 02:04:00 +00:00
imperosol
3ad40b7383 change birthdate only if user didn't have it previously 2025-09-04 11:03:02 +02:00
imperosol
3709b5c221 require birthday when creating subscriptions for users that didn't give it previously 2025-09-04 11:02:59 +02:00
imperosol
171a3f4d92 make some users not having birthday in populate_more.py 2025-09-04 11:02:48 +02:00
imperosol
84e2f1b45a fix: subscription form alignment 2025-09-04 11:02:48 +02:00
Kenneth Soares
e0702ce8be Merge pull request #1165 from ae-utbm/taiste
Commands, Galaxy, Buxfixes and other
2025-09-03 14:32:30 +02:00
thomas girod
f6683068ff Merge pull request #1147 from ae-utbm/taiste
Many fixes
2025-07-02 10:10:19 +02:00
thomas girod
81d1d1caca Merge pull request #1128 from ae-utbm/taiste
Api keys, better tabs, navbar and accordions, better notifications, fixes and dependencies updates
2025-06-17 14:08:05 +02:00
thomas girod
1cc2378476 Merge pull request #1112 from ae-utbm/taiste
Accordions, navbar and fixes
2025-06-05 19:51:13 +02:00
thomas girod
61e370cf73 Merge pull request #1107 from ae-utbm/taiste
Eboutic refactor, Celery, better tooltips, Python 3.13, bugfixes and other
2025-06-03 00:03:33 +02:00
thomas girod
6377acfffa Merge pull request #1084 from ae-utbm/taiste
Django 5.2, HTMX for billing infos form, eurocks widget consent message and new promo 24 logo
2025-04-14 12:42:19 +02:00
thomas girod
3c8933461a Merge pull request #1075 from ae-utbm/taiste
SAS and markdown pictures upload improval, google calendar removal, calendar export link, css fixes and more
2025-04-10 13:15:02 +02:00
69 changed files with 1123 additions and 862 deletions

View File

@@ -6,7 +6,7 @@ addAssignees: author
# A list of team reviewers to be added to pull requests (GitHub team slug) # A list of team reviewers to be added to pull requests (GitHub team slug)
reviewers: reviewers:
- ae-utbm/sith-3-developers - ae-utbm/developpeurs
# Number of reviewers has no impact on GitHub teams # Number of reviewers has no impact on GitHub teams
# Set 0 to add all the reviewers (default: 0) # Set 0 to add all the reviewers (default: 0)

View File

@@ -16,7 +16,16 @@ multi-ecosystem-groups:
updates: updates:
- package-ecosystem: "uv" - package-ecosystem: "uv"
patterns: ["*"]
multi-ecosystem-group: "common" multi-ecosystem-group: "common"
- package-ecosystem: "npm" - package-ecosystem: "npm"
patterns: ["*"]
multi-ecosystem-group: "common" multi-ecosystem-group: "common"
groups:
# npm supports production and development groups, but not uv
# cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
main-deps:
dependency-type: "production"
dev-deps:
dependency-type: "development"

View File

@@ -34,12 +34,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
clubs = list(Club.objects.all()) clubs = list(Club.objects.all())
for club in clubs: for club in clubs:
club.board_group = meta_groups.get_or_create( club.board_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_BOARD_SUFFIX, name=f"{club.unix_name}-bureau", defaults={"is_meta": True}
defaults={"is_meta": True},
)[0] )[0]
club.members_group = meta_groups.get_or_create( club.members_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_MEMBER_SUFFIX, name=f"{club.unix_name}-membres", defaults={"is_meta": True}
defaults={"is_meta": True},
)[0] )[0]
club.save() club.save()
club.refresh_from_db() club.refresh_from_db()

View File

@@ -42,6 +42,13 @@ from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
class ClubQuerySet(models.QuerySet):
def having_board_member(self, user: User) -> Self:
"""Filter all club in which the given user is a board member."""
active_memberships = user.memberships.board().ongoing()
return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
class Club(models.Model): class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization.""" """The Club class, made as a tree to allow nice tidy organization."""
@@ -91,6 +98,8 @@ class Club(models.Model):
Group, related_name="club_board", on_delete=models.PROTECT Group, related_name="club_board", on_delete=models.PROTECT
) )
objects = ClubQuerySet.as_manager()
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]

View File

@@ -83,9 +83,10 @@ 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){
$("form").attr("action", link.href); const form = document.getElementById("form")
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)") }}

27
club/tests/test_club.py Normal file
View File

@@ -0,0 +1,27 @@
from datetime import timedelta
import pytest
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_club_queryset_having_board_member():
clubs = baker.make(Club, _quantity=5)
user = subscriber_user.make()
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}

View File

@@ -0,0 +1,35 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from club.models import Club
from com.models import Poster
from core.baker_recipes import subscriber_user
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
def test_access(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
url = reverse(route_url, kwargs={"club_id": club.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
def test_access_specific_poster(client: Client, route_url):
club = baker.make(Club)
user = subscriber_user.make()
poster = baker.make(Poster)
url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
client.force_login(user)
assert client.get(url).status_code == 403
club.board_group.users.add(user)
assert client.get(url).status_code == 200

View File

@@ -51,13 +51,17 @@ from club.forms import (
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
PosterDeleteBaseView, PosterDeleteBaseView,
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import (
CanEditMixin,
CanViewMixin,
)
from core.models import PageRev from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
@@ -66,9 +70,12 @@ from counter.models import Selling
class ClubTabsMixin(TabedViewMixin): class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
obj = self.get_object() if not hasattr(self, "object") or not self.object:
if isinstance(obj, PageRev): self.object = self.get_object()
self.object = obj.page.club if isinstance(self.object, PageRev):
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
@@ -159,7 +166,7 @@ class ClubTabsMixin(TabedViewMixin):
"club:poster_list", kwargs={"club_id": self.object.id} "club:poster_list", kwargs={"club_id": self.object.id}
), ),
"slug": "posters", "slug": "posters",
"name": _("Posters list"), "name": _("Posters"),
}, },
] ]
) )
@@ -337,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.filter(id=-1) qs = Selling.objects.none()
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"]:
@@ -355,7 +362,9 @@ 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.all().order_by("-id") kwargs["result"] = qs.select_related(
"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"]:
@@ -686,48 +695,45 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)
class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin): class PosterListView(ClubTabsMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_queryset(self):
return super().get_queryset().filter(club=self.club.id)
def get_object(self): def get_object(self):
return self.club return self.club
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
kwargs["club"] = self.club
return kwargs
class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
"""Create communication poster.""" """Create communication poster."""
pk_url_kwarg = "club_id" current_tab = "posters"
def get_object(self):
obj = super().get_object()
if not obj:
return self.club
return obj
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_object(self, *args, **kwargs):
return self.club
class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin):
class PosterEditView(ClubTabsMixin, PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
extra_context = {"app": "club"}
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "club"
return kwargs
class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin):
"""Delete communication poster.""" """Delete communication poster."""
current_tab = "posters"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id}) return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})

View File

@@ -2,7 +2,6 @@ from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput from django.forms import CheckboxInput
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -35,20 +34,18 @@ class PosterForm(forms.ModelForm):
label=_("Start date"), label=_("Start date"),
widget=SelectDateTime, widget=SelectDateTime,
required=True, required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"), initial=timezone.now(),
) )
date_end = forms.DateTimeField( date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False label=_("End date"), widget=SelectDateTime, required=False
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, user: User, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin: if user.is_root or user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter( self.fields["club"].widget = AutoCompleteSelectClub()
id__in=self.user.clubs_with_rights else:
) self.fields["club"].queryset = Club.objects.having_board_member(user)
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm): class NewsDateForm(forms.ModelForm):
@@ -161,16 +158,9 @@ class NewsForm(forms.ModelForm):
# if the author is an admin, he/she can choose any club, # if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected # otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin: if author.is_root or author.is_com_admin:
self.fields["club"] = forms.ModelChoiceField( self.fields["club"].widget = AutoCompleteSelectClub()
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else: else:
active_memberships = author.memberships.board().ongoing() self.fields["club"].queryset = Club.objects.having_board_member(author)
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self): def is_valid(self):
return super().is_valid() and self.date_form.is_valid() return super().is_valid() and self.date_form.is_valid()

View File

@@ -412,17 +412,5 @@ class Poster(models.Model):
if self.date_end and self.date_begin > self.date_end: if self.date_end and self.date_begin > self.date_end:
raise ValidationError(_("Begin date should be before end date")) raise ValidationError(_("Begin date should be before end date"))
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_com_admin or len(user.clubs_with_rights) > 0
def can_be_moderated_by(self, user):
return user.is_com_admin
def get_display_name(self): def get_display_name(self):
return self.club.get_display_name() return self.club.get_display_name()
@property
def page(self):
return self.club.page

View File

@@ -0,0 +1,49 @@
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: "Click to expand"; content: attr(hover);
color: white; color: white;
background-color: rgba(black, 0.5); background-color: rgba(black, 0.5);
} }

View File

@@ -1,23 +0,0 @@
$(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

@@ -1,98 +0,0 @@
$(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: "Click to expand"; content: attr(hover);
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,57 +51,78 @@ 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: inline-flex; display: none;
justify-content: center; justify-content: center;
top: 0px; top: 0px;
left: 0%;
background-color: grey; img {
transition: left 1s ease-out;
img{
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
}
.slide.left{ &.current {
left: -100%; display: inline-flex;
} left: 0%;
animation: scrolling-in 1s linear;
}
.slide.center{ &.previous {
left: 0px; display: inline-flex;
} 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%;
@@ -112,7 +133,7 @@ body{
margin-bottom: 10px; margin-bottom: 10px;
.bullet{ .bullet {
height: 10px; height: 10px;
width: 10px; width: 10px;
@@ -123,27 +144,33 @@ body{
background-color: grey; background-color: grey;
&.active{ &.active {
background-color: #c99836; background-color: #c99836;
} }
} }
} }
#progress_bar{ progress {
--color: #304c83;
position: absolute; position: absolute;
bottom: 0px; bottom: 0px;
height: 10px; height: 10px;
background-color: #304c83; color: var(--color);
width: 100%;
margin-bottom: 0px;
border: none;
&.init{ &::-moz-progress-bar {
width: 0px; background: var(--color);
transition: none;
} }
&.progress{ &::-webkit-progress-value {
width: 100%; background: var(--color);
transition: width 10s linear; }
&[value] {
background-color: transparent;
} }
} }
} }

View File

@@ -1,11 +1,5 @@
{% 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 %}
@@ -15,7 +9,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="poster_list"> <div id="poster_list" x-data="{ active: null }">
<div id="title"> <div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3> <h3>{% trans %}Posters{% endtrans %}</h3>
@@ -38,7 +32,13 @@
{% 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 class="image"><img src="{{ poster.file.url }}"></img></div> <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,7 +62,14 @@
</div> </div>
<div id="view"><div id="placeholder"></div></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,28 +2,44 @@
<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 src="{{ static('bundled/vendored/jquery.min.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script> <script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head> </head>
<body> <body x-data="slideshow([
<div id="slideshow"> {% for poster in posters %}
{
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">
{% for poster in posters %} <template x-for="(poster, index) in posters">
<div class="slide {% if loop.first %}center{% else %}right{% endif %}" display_time="{{ poster.display_time }}"> <div class="slide" :class="{
<img src="{{ poster.file.url }}"> current: index === current,
previous: index !== current && index === previous,
}">
<img :src="poster.url">
</div> </div>
{% endfor %} </template>
</div> </div>
<div id="progress_bullets"> <div id="progress_bullets">
{% for poster in posters %} <template x-for="(poster, index) in posters">
<div class="bullet {% if loop.first %}active{% endif %}"></div> <div class="bullet" :class="{active: current === index}"></div>
{% endfor %} </template>
</div> </div>
<div id="progress_bar"></div> <progress :value="progress" max="100" x-show="posters.length > 1 && progress > 0"></progress>
</div> </div>
</body> </body>

View File

@@ -31,9 +31,7 @@
<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

@@ -18,17 +18,16 @@ from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import html from django.utils import html
from django.utils.timezone import localtime, now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@@ -207,31 +206,6 @@ class TestWeekmailArticle(TestCase):
assert not self.article.is_owned_by(self.sli) assert not self.article.is_owned_by(self.sli)
class TestPoster(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
cls.poster = Poster.objects.create(
name="dummy",
file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"),
club=Club.objects.first(),
date_begin=localtime(now()),
)
cls.sli = User.objects.get(username="sli")
cls.sli.memberships.all().delete()
Membership(user=cls.sli, club=Club.objects.first(), role=5).save()
cls.susbcriber = User.objects.get(username="subscriber")
cls.anonymous = AnonymousUser()
def test_poster_owner(self):
"""Test that poster are owned by com admins and board members in clubs."""
assert self.poster.is_owned_by(self.com_admin)
assert not self.poster.is_owned_by(self.anonymous)
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase): class TestNewsCreation(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@@ -28,7 +28,10 @@ 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.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib import messages
from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Max
@@ -50,9 +53,10 @@ from core.auth.mixins import (
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
PermissionOrAuthorRequiredMixin, PermissionOrAuthorRequiredMixin,
PermissionOrClubBoardRequiredMixin,
) )
from core.models import User from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.mixins import TabedViewMixin
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@@ -99,13 +103,6 @@ class ComTabsMixin(TabedViewMixin):
] ]
class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView): class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Sith model = Sith
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
@@ -337,7 +334,7 @@ class NewsFeed(Feed):
# Weekmail # Weekmail
class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, DetailView): class WeekmailPreviewView(ComTabsMixin, 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")
@@ -349,12 +346,11 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
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( return HttpResponseRedirect(reverse("com:weekmail"))
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":
@@ -365,7 +361,6 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
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):
@@ -379,7 +374,7 @@ class WeekmailPreviewView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, Detai
return kwargs return kwargs
class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView): class WeekmailEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Weekmail model = Weekmail
template_name = "com/weekmail.jinja" template_name = "com/weekmail.jinja"
form_class = modelform_factory( form_class = modelform_factory(
@@ -419,7 +414,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
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()
self.quick_notif_list += ["qn_success"] messages.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
@@ -431,7 +429,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
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()
self.quick_notif_list += ["qn_success"] messages.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
@@ -440,7 +441,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
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()
self.quick_notif_list += ["qn_success"] messages.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
@@ -448,7 +452,10 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
art.weekmail = None art.weekmail = None
art.rank = -1 art.rank = -1
art.save() art.save()
self.quick_notif_list += ["qn_success"] messages.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):
@@ -458,9 +465,7 @@ class WeekmailEditView(ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateVi
return kwargs return kwargs
class WeekmailArticleEditView( class WeekmailArticleEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
ComTabsMixin, QuickNotifMixin, CanEditPropMixin, UpdateView
):
"""Edit an article.""" """Edit an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -472,11 +477,10 @@ class WeekmailArticleEditView(
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(QuickNotifMixin, CreateView): class WeekmailArticleCreateView(CreateView):
"""Post an article.""" """Post an article."""
model = WeekmailArticle model = WeekmailArticle
@@ -487,7 +491,6 @@ class WeekmailArticleCreateView(QuickNotifMixin, 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:
@@ -558,161 +561,109 @@ class MailingModerateView(View):
raise PermissionDenied raise PermissionDenied
class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin): class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView):
current_tab = "posters"
class PosterListBaseView(PosterAdminViewMixin, ListView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_list.jinja" template_name = "com/poster_list.jinja"
permission_required = "com.view_poster"
def dispatch(self, request, *args, **kwargs): ordering = ["-date_begin"]
club_id = kwargs.pop("club_id", None)
self.club = None
if club_id:
self.club = get_object_or_404(Club, pk=club_id)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
if self.request.user.is_com_admin:
return Poster.objects.all().order_by("-date_begin")
else:
return Poster.objects.filter(club=self.club.id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
class PosterCreateBaseView(PosterAdminViewMixin, CreateView): class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "com.add_poster"
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def dispatch(self, request, *args, **kwargs):
if "club_id" in kwargs:
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"user": self.request.user}
kwargs.update({"user": self.request.user})
return kwargs def get_initial(self):
return {"club": self.club}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if not self.request.user.is_com_admin:
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if self.request.user.is_com_admin: if self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = True form.instance.is_moderated = True
return super().form_valid(form) return super().form_valid(form)
class PosterEditBaseView(PosterAdminViewMixin, UpdateView): class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters"
form_class = PosterForm form_class = PosterForm
template_name = "com/poster_edit.jinja" template_name = "com/poster_edit.jinja"
permission_required = "com.change_poster"
def get_initial(self):
return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
}
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return Poster.objects.all() return Poster.objects.all()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"user": self.request.user}
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"club": self.club}
if hasattr(self, "club"):
kwargs["club"] = self.club
return kwargs
def form_valid(self, form): def form_valid(self, form):
if self.request.user.is_com_admin: if not self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = False form.instance.is_moderated = False
return super().form_valid(form) return super().form_valid(form)
class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView): class PosterDeleteBaseView(
PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView
):
"""Edit communication poster.""" """Edit communication poster."""
pk_url_kwarg = "poster_id" pk_url_kwarg = "poster_id"
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "com.delete_poster"
def dispatch(self, request, *args, **kwargs):
if kwargs.get("club_id"):
try:
self.club = Club.objects.get(pk=kwargs["club_id"])
except Club.DoesNotExist as e:
raise PermissionDenied from e
return super().dispatch(request, *args, **kwargs)
class PosterListView(PosterListBaseView): class PosterListView(ComTabsMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters"
def get_queryset(self):
qs = super().get_queryset()
if self.request.user.has_perm("com.view_poster"):
return qs
return qs.filter(club=self.club.id)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com" kwargs["app"] = "com"
return kwargs return kwargs
class PosterCreateView(PosterCreateBaseView): class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
"""Create communication poster.""" """Create communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterEditView(PosterEditBaseView): class PosterEditView(ComTabsMixin, PosterEditBaseView):
"""Edit communication poster.""" """Edit communication poster."""
current_tab = "posters"
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
extra_context = {"app": "com"}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterDeleteView(PosterDeleteBaseView): class PosterDeleteView(PosterDeleteBaseView):
@@ -721,44 +672,39 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list") success_url = reverse_lazy("com:poster_list")
class PosterModerateListView(PosterAdminViewMixin, ListView): class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""Moderate list communication poster.""" """Moderate list communication poster."""
current_tab = "posters" current_tab = "posters"
model = Poster model = Poster
template_name = "com/poster_moderate.jinja" template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all() queryset = Poster.objects.filter(is_moderated=False).all()
permission_required = "com.moderate_poster"
def get_context_data(self, **kwargs): extra_context = {"app": "com"}
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterModerateView(PosterAdminViewMixin, View): class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster.""" """Moderate communication poster."""
current_tab = "posters"
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"]) obj = get_object_or_404(Poster, pk=kwargs["object_id"])
if obj.can_be_moderated_by(request.user): obj.is_moderated = True
obj.is_moderated = True obj.moderator = request.user
obj.moderator = request.user obj.save()
obj.save() return redirect("com:poster_moderate_list")
return redirect("com:poster_moderate_list")
raise PermissionDenied
def get_context_data(self, **kwargs):
kwargs = super(PosterModerateListView, self).get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView): class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""List communication screens.""" """List communication screens."""
current_tab = "screens" current_tab = "screens"
model = Screen model = Screen
template_name = "com/screen_list.jinja" template_name = "com/screen_list.jinja"
permission_required = "com.view_screen"
class ScreenSlideshowView(DetailView): class ScreenSlideshowView(DetailView):
@@ -769,12 +715,12 @@ class ScreenSlideshowView(DetailView):
template_name = "com/screen_slideshow.jinja" template_name = "com/screen_slideshow.jinja"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {
kwargs["posters"] = self.object.active_posters() "posters": self.object.active_posters()
return kwargs }
class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView): class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
"""Create communication screen.""" """Create communication screen."""
current_tab = "screens" current_tab = "screens"
@@ -782,9 +728,10 @@ class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
fields = ["name"] fields = ["name"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.add_screen"
class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView): class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
"""Edit communication screen.""" """Edit communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -793,9 +740,10 @@ class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
fields = ["name"] fields = ["name"]
template_name = "com/screen_edit.jinja" template_name = "com/screen_edit.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.change_screen"
class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView): class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
"""Delete communication screen.""" """Delete communication screen."""
pk_url_kwarg = "screen_id" pk_url_kwarg = "screen_id"
@@ -803,3 +751,4 @@ class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
model = Screen model = Screen
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:screen_list") success_url = reverse_lazy("com:screen_list")
permission_required = "com.delete_screen"

View File

@@ -25,6 +25,7 @@ from core.schemas import (
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
@@ -69,16 +70,22 @@ class MailingListController(ControllerBase):
return data return data
@api_controller("/user", permissions=[CanAccessLookup]) @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
"""Fetch a single user"""
return self.get_object_or_exception(User, id=user_id)
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):

View File

@@ -29,8 +29,14 @@ from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from django.views.generic.base import View from django.views.generic.base import View
from club.models import Club
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
@@ -297,3 +303,50 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
self.author_field += "_id" self.author_field += "_id"
author_id = getattr(obj, self.author_field, None) author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id return author_id == self.request.user.id
class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the board of the club.
This mixin can be used in any view that is called from a url
having a `club_id` kwarg.
Example:
In `urls.py` :
```python
urlpatterns = [
path("foo/<int:club_id>/bar/", FooView.as_view())
]
```
In `views.py` :
```python
# this view is available to users that either have the
# "foo.view_foo" permission or are in the board of the club
# which id was given in the url
class FooView(PermissionOrClubBoardRequiredMixin, View):
permission_required = "foo.view_foo"
```
"""
club_pk_url_kwarg = "club_id"
@cached_property
def club(self):
club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None)
if club_id is None:
return None
if isinstance(club_id, int) or club_id.isdigit():
return get_object_or_404(Club, pk=club_id)
raise Http404(_("No club found with id %(id)s") % {"id": club_id})
def has_permission(self):
if self.request.user.is_anonymous:
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
)

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[0][0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][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

@@ -94,7 +94,11 @@ class Command(BaseCommand):
username=self.faker.user_name(), username=self.faker.user_name(),
first_name=self.faker.first_name(), first_name=self.faker.first_name(),
last_name=self.faker.last_name(), last_name=self.faker.last_name(),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25), date_of_birth=(
None
if random.random() < 0.2
else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
),
email=self.faker.email(), email=self.faker.email(),
phone=self.faker.phone_number(), phone=self.faker.phone_number(),
address=self.faker.address(), address=self.faker.address(),

View File

@@ -1197,6 +1197,18 @@ 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
@@ -1266,6 +1278,8 @@ 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 = (
@@ -1275,12 +1289,9 @@ class Page(models.Model):
def __str__(self): def __str__(self):
return self.get_full_name() return self.get_full_name()
def save(self, *args, **kwargs): def save(self, *args, force_lock: bool = False, **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."""
locked = kwargs.pop("force_lock", False) if not force_lock and not self.is_locked():
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:
@@ -1292,7 +1303,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() c.save(force_lock=force_lock)
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.unset_lock() self.unset_lock()
@@ -1408,14 +1419,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): def delete(self, *args, **kwargs):
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()
super().delete() return super().delete(*args, **kwargs)
class PageRev(models.Model): class PageRev(models.Model):
@@ -1462,9 +1473,12 @@ 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): def can_be_edited_by(self, user: User) -> bool:
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

@@ -34,6 +34,22 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"] fields = ["id", "nick_name", "first_name", "last_name"]
class UserSchema(ModelSchema):
class Meta:
model = User
fields = [
"id",
"nick_name",
"first_name",
"last_name",
"date_of_birth",
"email",
"role",
"quote",
"promo",
]
class UserProfileSchema(ModelSchema): class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile""" """The necessary information to show a user profile"""

View File

@@ -1,7 +1,9 @@
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); Alpine.plugin(sort);
Alpine.magic("notifications", alpinePlugin);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

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

@@ -1,38 +0,0 @@
$(() => {
$("#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,17 +270,6 @@ 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,10 +32,6 @@
<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 %}
@@ -74,17 +70,15 @@
<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 }}
@@ -101,16 +95,6 @@
{% 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"> <div class="notification" x-data="{display: false}" :class="{white: display}">
<a href="#" onclick="displayNotif()"> <a href="#" @click.prevent="display = !display">
<i class="fa-regular fa-bell"></i> <i :class="`fa-${display ? 'solid': 'regular'} fa-bell`" x-transition></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"> <div id="header_notif" x-show="display" x-cloak x-transition @click.outside="display = false">
<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

@@ -0,0 +1,24 @@
<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, index) in messages">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i>
</span>
</div>
</template>
</div>

View File

@@ -15,6 +15,7 @@
{{ 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

@@ -245,3 +245,26 @@
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button> <button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %} {% endmacro %}
{% macro update_notifications(messages, clear) %}
{# Update notification area from new messages sent by django backend
This is useful when performing fragment swaps to keep messages up to date
Without this, the fragment would need to take control of the notification area and
this would be an issue when having more than one fragment
Parameters:
messages: messages from django.contrib
clear : optional boolean that controls if notifications should be cleared first. True is the default
#}
{% set clear = clear|default(true) %}
{% if messages %}
<div x-init="() => {
{% if clear %}
$notifications.clear()
{% endif %}
{% for message in messages %}
$notifications.{{ message.tags }}('{{ message }}')
{% endfor %}
}"></div>
{% endif %}
{% endmacro %}

View File

@@ -30,7 +30,11 @@
- {{ purchase.date|localtime|time(DATETIME_FORMAT) }} - {{ purchase.date|localtime|time(DATETIME_FORMAT) }}
</td> </td>
<td>{{ purchase.counter }}</td> <td>{{ purchase.counter }}</td>
<td><a href="{{ purchase.seller.get_absolute_url() }}">{{ purchase.seller.get_display_name() }}</a></td> {% if not purchase.seller %}
<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>

58
core/tests/test_page.py Normal file
View File

@@ -0,0 +1,58 @@
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,7 +20,8 @@ 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.models import Counter, Refilling, Selling from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -129,6 +130,31 @@ 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,7 +2,6 @@ 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
@@ -41,36 +40,6 @@ 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,23 +43,25 @@ class CanEditPagePropMixin(CanEditPropMixin):
return res return res
class PageListView(CanViewMixin, ListView): class PageListView(ListView):
model = Page model = Page
template_name = "core/page_list.jinja" template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate( def get_queryset(self):
display_name=Coalesce( return (
Subquery( Page.objects.viewable_by(self.request.user)
PageRev.objects.filter(page=OuterRef("id")) .annotate(
.order_by("-date") display_name=Coalesce(
.values("title")[:1] Subquery(
), PageRev.objects.filter(page=OuterRef("id"))
F("name"), .order_by("-date")
.values("title")[:1]
),
F("name"),
)
) )
.select_related("parent")
) )
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):
@@ -184,7 +186,7 @@ class PageEditViewBase(CanEditMixin, UpdateView):
) )
template_name = "core/pagerev_edit.jinja" template_name = "core/pagerev_edit.jinja"
def get_object(self): def get_object(self, *args, **kwargs):
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 QuickNotifMixin, TabedViewMixin, UseFragmentsMixin from core.views.mixins import 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, QuickNotifMixin, UserTabsMixin, TemplateView): class UserToolsView(LoginRequiredMixin, 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

@@ -535,13 +535,6 @@ class Counter(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def __getattribute__(self, name: str):
if name == "edit_groups":
return Group.objects.filter(
name=self.club.unix_name + settings.SITH_BOARD_SUFFIX
).all()
return object.__getattribute__(self, name)
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
if self.type == "EBOUTIC": if self.type == "EBOUTIC":
return reverse("eboutic:main") return reverse("eboutic:main")
@@ -690,8 +683,10 @@ class Counter(models.Model):
Prices will be annotated Prices will be annotated
""" """
products = self.products.select_related("product_type").prefetch_related( products = (
"buying_groups" self.products.filter(archived=False)
.select_related("product_type")
.prefetch_related("buying_groups")
) )
# Only include age appropriate products # Only include age appropriate products

View File

@@ -583,6 +583,16 @@ class TestCounterClick(TestFullClickBase):
- self.beer.selling_price - self.beer.selling_price
) )
def test_no_fetch_archived_product(self):
counter = baker.make(Counter)
customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter])
unarchived_products = product_recipe.make(
archived=False, counters=[counter], _quantity=3
)
customer_products = counter.get_products_for(customer)
assert unarchived_products == customer_products
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod

View File

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

View File

@@ -1,3 +1,5 @@
{% from 'core/macros.jinja' import update_notifications %}
<div id=billing-infos-fragment> <div id=billing-infos-fragment>
<div <div
class="collapse" class="collapse"
@@ -29,14 +31,6 @@
> >
</form> </form>
</div> </div>
<br> <br>
{{ update_notifications(messages) }}
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
</div> </div>

View File

@@ -1,5 +1,9 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block notifications %}
{# Notifications are moved under the billing form #}
{% endblock %}
{% block title %} {% block title %}
{% trans %}Basket state{% endtrans %} {% trans %}Basket state{% endtrans %}
{% endblock %} {% endblock %}
@@ -56,6 +60,7 @@
<div @htmx:after-request="fill"> <div @htmx:after-request="fill">
{{ billing_infos_form }} {{ billing_infos_form }}
</div> </div>
{% include "core/base/notifications.jinja" %}
<form <form
method="post" method="post"
action="{{ settings.SITH_EBOUTIC_ET_URL }}" action="{{ settings.SITH_EBOUTIC_ET_URL }}"

View File

@@ -22,14 +22,6 @@
{% 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,14 +4,6 @@
<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

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n" "POT-Creation-Date: 2025-09-25 15:33+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"
@@ -514,8 +514,8 @@ msgstr "Éditer le Trombi"
msgid "New Trombi" msgid "New Trombi"
msgstr "Nouveau Trombi" msgstr "Nouveau Trombi"
#: club/templates/club/club_tools.jinja com/templates/com/poster_list.jinja #: club/templates/club/club_tools.jinja club/views.py
#: core/templates/core/user_tools.jinja #: com/templates/com/poster_list.jinja core/templates/core/user_tools.jinja
msgid "Posters" msgid "Posters"
msgstr "Affiches" msgstr "Affiches"
@@ -675,10 +675,6 @@ msgstr "Vente"
msgid "Mailing list" msgid "Mailing list"
msgstr "Listes de diffusion" msgstr "Listes de diffusion"
#: club/views.py com/views.py
msgid "Posters list"
msgstr "Liste d'affiches"
#: com/forms.py #: com/forms.py
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"
@@ -1107,6 +1103,10 @@ 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 +1164,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 "Up"
msgstr "Monter"
#: com/templates/com/weekmail.jinja
msgid "Down"
msgstr "Descendre"
#: com/templates/com/weekmail.jinja #: com/templates/com/weekmail.jinja
msgid "Articles included the next weekmail" msgid "Articles included the next weekmail"
msgstr "Article inclus dans le prochain Weekmail" msgstr "Article inclus dans le prochain Weekmail"
@@ -1180,6 +1172,14 @@ msgstr "Article inclus dans le prochain Weekmail"
msgid "Delete from weekmail" msgid "Delete from weekmail"
msgstr "Supprimer du Weekmail" msgstr "Supprimer du Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Up"
msgstr "Monter"
#: com/templates/com/weekmail.jinja
msgid "Down"
msgstr "Descendre"
#: 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
@@ -1249,6 +1249,10 @@ msgstr "Message d'info"
msgid "Alert message" msgid "Alert message"
msgstr "Message d'alerte" msgstr "Message d'alerte"
#: com/views.py
msgid "Posters list"
msgstr "Liste d'affiches"
#: com/views.py #: com/views.py
msgid "Screens list" msgid "Screens list"
msgstr "Liste d'écrans" msgstr "Liste d'écrans"
@@ -1257,6 +1261,10 @@ 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"
@@ -1265,6 +1273,26 @@ 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."
@@ -1272,6 +1300,11 @@ msgstr ""
"Vous devez êtres un membre du bureau du club sélectionné pour poster dans le " "Vous devez êtres un membre du bureau du club sélectionné pour poster dans le "
"Weekmail." "Weekmail."
#: core/auth/mixins.py
#, python-format
msgid "No club found with id %(id)s"
msgstr "Pas de club avec l'id %(id)s trouvé"
#: core/models.py #: core/models.py
msgid "Is manually manageable" msgid "Is manually manageable"
msgstr "Est gérable manuellement" msgstr "Est gérable manuellement"
@@ -1713,8 +1746,8 @@ msgid ""
"AE UTBM is a voluntary organisation run by UTBM students. It organises " "AE UTBM is a voluntary organisation run by UTBM students. It organises "
"student life at UTBM and manages its student facilities." "student life at UTBM and manages its student facilities."
msgstr "" msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de " "L'AE UTBM est une association bénévole gérée par les étudiants de l'UTBM. "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie." "Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -2157,10 +2190,6 @@ msgstr ""
msgid "Page history" msgid "Page history"
msgstr "Historique de la page" msgstr "Historique de la page"
#: core/templates/core/page_list.jinja
msgid "There is no page in this website."
msgstr "Il n'y a pas de page sur ce site web."
#: core/templates/core/page_prop.jinja #: core/templates/core/page_prop.jinja
msgid "Page properties" msgid "Page properties"
msgstr "Propriétés de la page" msgstr "Propriétés de la page"
@@ -2339,6 +2368,10 @@ 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
@@ -4539,22 +4572,6 @@ 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é"
@@ -4818,8 +4835,8 @@ msgid "N/A"
msgstr "N/A" msgstr "N/A"
#: sith/settings.py #: sith/settings.py
msgid "Transfert" msgid "AE account"
msgstr "Virement" msgstr "Compte AE"
#: sith/settings.py #: sith/settings.py
msgid "Belfort" msgid "Belfort"
@@ -5107,26 +5124,6 @@ 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"
@@ -5135,6 +5132,10 @@ msgstr "Tee-shirt AE"
msgid "A user with that email address already exists" msgid "A user with that email address already exists"
msgstr "Un utilisateur avec cette adresse email existe déjà" msgstr "Un utilisateur avec cette adresse email existe déjà"
#: subscription/forms.py
msgid "This user didn't fill its birthdate yet."
msgstr "Cet utilisateur n'a pas encore renseigné sa date de naissance"
#: subscription/models.py #: subscription/models.py
msgid "Bad subscription type" msgid "Bad subscription type"
msgstr "Mauvais type de cotisation" msgstr "Mauvais type de cotisation"
@@ -5163,6 +5164,14 @@ 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"
@@ -5174,7 +5183,7 @@ msgid ""
"%(user)s received its new %(type)s subscription. It will be active until " "%(user)s received its new %(type)s subscription. It will be active until "
"%(end)s included." "%(end)s included."
msgstr "" msgstr ""
"%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sert active jusqu'au " "%(user)s a reçu sa nouvelle cotisaton %(type)s. Elle sera active jusqu'au "
"%(end)s inclu." "%(end)s inclu."
#: subscription/templates/subscription/fragments/creation_success.jinja #: subscription/templates/subscription/fragments/creation_success.jinja
@@ -5426,10 +5435,38 @@ 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é"
@@ -5470,6 +5507,10 @@ 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)"
@@ -5482,6 +5523,14 @@ 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 "
@@ -5497,4 +5546,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"

33
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"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",
@@ -47,10 +46,9 @@
"@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.2.6", "vite": "^6.3.6",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.1.2"
} }
@@ -2889,16 +2887,6 @@
"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",
@@ -2919,13 +2907,6 @@
"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",
@@ -4384,12 +4365,6 @@
"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",
@@ -5737,9 +5712,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

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

View File

@@ -21,11 +21,6 @@
{{ 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 %}
@@ -36,48 +31,3 @@
<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

@@ -74,7 +74,7 @@ dev = [
tests = [ tests = [
"freezegun<2.0.0,>=1.5.1", "freezegun<2.0.0,>=1.5.1",
"pytest<9.0.0,>=8.3.5", "pytest<9.0.0,>=8.3.5",
"pytest-cov>=6.0.0,<8.0.0", "pytest-cov<7.0.0,>=6.0.0",
"pytest-django<5.0.0,>=4.10.0", "pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4", "model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.13.3,<5", "beautifulsoup4>=4.13.3,<5",

View File

@@ -309,6 +309,7 @@ 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

@@ -405,9 +405,6 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4) SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60 SITH_SAS_IMAGES_PER_PAGE = 60
SITH_BOARD_SUFFIX = "-bureau"
SITH_MEMBER_SUFFIX = "-membres"
SITH_PROFILE_DEPARTMENTS = [ SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")), ("TC", _("TC")),
("IMSI", _("IMSI")), ("IMSI", _("IMSI")),
@@ -424,18 +421,11 @@ 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")),
("EBOUTIC", _("Eboutic")), ("AE_ACCOUNT", _("AE account")),
("OTHER", _("Other")), ("OTHER", _("Other")),
] ]
@@ -444,6 +434,7 @@ 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")]
@@ -694,14 +685,6 @@ 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,6 +2,7 @@ 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 _
@@ -23,13 +24,28 @@ class SelectionDateForm(forms.Form):
class SubscriptionForm(forms.ModelForm): class SubscriptionForm(forms.ModelForm):
def __init__(self, *args, **kwargs): allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
initial = kwargs.pop("initial", {})
class Meta:
model = Subscription
fields = ["subscription_type", "payment_method", "location"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, initial=None, **kwargs):
initial = initial or {}
if "subscription_type" not in initial: if "subscription_type" not in initial:
initial["subscription_type"] = "deux-semestres" initial["subscription_type"] = "deux-semestres"
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:
@@ -61,7 +77,8 @@ class SubscriptionNewUserForm(SubscriptionForm):
assert user.is_subscribed assert user.is_subscribed
""" """
template_name = "subscription/forms/create_new_user.html" allowed_payment_methods = ["CARD", "CASH"]
template_name = "subscription/forms/create_new_user.jinja"
__user_fields = forms.fields_for_model( __user_fields = forms.fields_for_model(
User, User,
@@ -73,10 +90,6 @@ 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",
@@ -130,9 +143,57 @@ 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.html" template_name = "subscription/forms/create_existing_user.jinja"
required_css_class = "required"
class Meta: birthdate = forms.fields_for_model(
model = Subscription User,
fields = ["member", "subscription_type", "payment_method", "location"] ["date_of_birth"],
widgets = {"member": AutoCompleteSelectUser} widgets={"date_of_birth": SelectDate(attrs={"hidden": True})},
help_texts={"date_of_birth": _("This user didn't fill its birthdate yet.")},
)["date_of_birth"]
class Meta(SubscriptionForm.Meta):
fields = ["member", *SubscriptionForm.Meta.fields]
widgets = SubscriptionForm.Meta.widgets | {"member": AutoCompleteSelectUser}
field_order = [
"member",
"birthdate",
"subscription_type",
"payment_method",
"location",
]
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
self.fields["birthdate"].required = True
if not initial:
return
member: str | None = initial.get("member")
if member and member.isdigit():
member: User | None = User.objects.filter(id=int(member)).first()
else:
member = None
if member and member.date_of_birth:
# if there is an initial member with a birthdate,
# there is no need to ask this to the user
self.fields["birthdate"].initial = member.date_of_birth
elif member:
# if there is an initial member without a birthdate,
# then the field must be displayed
self.fields["birthdate"].widget.attrs.update({"hidden": False})
# if there is no initial member, it means that it will be
# dynamically selected using the AutoCompleteSelectUser widget.
# JS will take care of un-hiding the field if necessary
def save(self, *args, **kwargs):
if self.errors:
return super().save(*args, **kwargs)
if (
self.cleaned_data["birthdate"] is not None
and self.instance.member.date_of_birth is None
):
self.instance.member.date_of_birth = self.cleaned_data["birthdate"]
self.instance.member.save()
return super().save(*args, **kwargs)

View File

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

@@ -1,3 +1,5 @@
import { userFetchUser } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("existing_user_subscription_form", () => ({ Alpine.data("existing_user_subscription_form", () => ({
loading: false, loading: false,
@@ -12,13 +14,24 @@ document.addEventListener("alpine:init", () => {
}, },
async loadProfile(userId: number) { async loadProfile(userId: number) {
const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
if (!Number.isInteger(userId)) { if (!Number.isInteger(userId)) {
this.profileFragment = ""; this.profileFragment = "";
birthdayInput.hidden = true;
return; return;
} }
this.loading = true; this.loading = true;
const response = await fetch(`/user/${userId}/mini/`); const [miniProfile, userInfos] = await Promise.all([
this.profileFragment = await response.text(); fetch(`/user/${userId}/mini/`),
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
birthdayInput.value = userInfos.data.date_of_birth;
birthdayInput.hidden = userInfos.data.date_of_birth !== null;
this.loading = false; this.loading = false;
}, },
})); }));

View File

@@ -1,4 +1,14 @@
#subscription-form form { #subscription-form form {
margin-top: 0;
.form-content {
margin-top: 0;
}
fieldset p:first-of-type, & > p:first-of-type {
margin-top: 0;
}
.form-content.existing-user { .form-content.existing-user {
max-height: 100%; max-height: 100%;
display: flex; display: flex;
@@ -13,6 +23,11 @@
* then display the user profile right in the middle of the remaining space. */ * then display the user profile right in the middle of the remaining space. */
fieldset { fieldset {
flex: 0 1 auto; flex: 0 1 auto;
p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group
display: none;
}
} }
#subscription-form-user-mini-profile { #subscription-form-user-mini-profile {

View File

@@ -1,14 +0,0 @@
{% 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

@@ -0,0 +1,28 @@
{% 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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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[0], payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
) )
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

@@ -1,6 +1,6 @@
"""Tests focused on testing subscription creation""" """Tests focused on testing subscription creation"""
from datetime import timedelta from datetime import date, timedelta
from typing import Callable from typing import Callable
import pytest import pytest
@@ -31,20 +31,44 @@ def test_form_existing_user_valid(
): ):
"""Test `SubscriptionExistingUserForm`""" """Test `SubscriptionExistingUserForm`"""
user = user_factory() user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14)
user.save()
data = { data = {
"member": user, "member": user,
"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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
assert form.is_valid() assert form.is_valid()
form.save() form.save()
user.refresh_from_db() user.refresh_from_db()
assert user.is_subscribed assert user.is_subscribed
@pytest.mark.django_db
def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None)
data = {
"member": user,
"subscription_type": "deux-semestres",
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
}
form = SubscriptionExistingUserForm(data)
assert not form.is_valid()
data |= {"birthdate": date(year=1967, month=3, day=14)}
form = SubscriptionExistingUserForm(data)
assert form.is_valid()
form.save()
user.refresh_from_db()
assert user.is_subscribed
assert user.date_of_birth == date(year=1967, month=3, day=14)
@pytest.mark.django_db @pytest.mark.django_db
def test_form_existing_user_invalid(settings: SettingsWrapper): def test_form_existing_user_invalid(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe.""" """Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
@@ -57,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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
} }
form = SubscriptionExistingUserForm(data) form = SubscriptionExistingUserForm(data)
@@ -75,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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
} }
form = SubscriptionNewUserForm(data) form = SubscriptionNewUserForm(data)
assert form.is_valid() assert form.is_valid()
@@ -106,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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
} }
form = SubscriptionNewUserForm(data) form = SubscriptionNewUserForm(data)
assert form.is_valid() assert form.is_valid()
@@ -132,6 +156,14 @@ def test_page_access(
assert res.status_code == status_code assert res.status_code == status_code
@pytest.mark.django_db
def test_page_access_with_get_data(client: Client):
user = old_subscriber_user.make()
client.force_login(baker.make(User, is_superuser=True))
res = client.get(reverse("subscription:subscription", query={"member": user.id}))
assert res.status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper): def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login( client.force_login(
@@ -140,14 +172,15 @@ def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
user_permissions=Permission.objects.filter(codename="add_subscription"), user_permissions=Permission.objects.filter(codename="add_subscription"),
) )
) )
user = old_subscriber_user.make() user = old_subscriber_user.make(date_of_birth=date(year=1967, month=3, day=14))
response = client.post( response = client.post(
reverse("subscription:fragment-existing-user"), reverse("subscription:fragment-existing-user"),
{ {
"member": user.id, "member": user.id,
"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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
}, },
) )
user.refresh_from_db() user.refresh_from_db()
@@ -179,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[0][0], "payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1][0],
}, },
) )
user = User.objects.get(email="jdoe@utbm.fr") user = User.objects.get(email="jdoe@utbm.fr")

View File

@@ -26,7 +26,9 @@ 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
@@ -46,7 +48,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 QuickNotifMixin, TabedViewMixin from core.views.mixins import 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
@@ -134,15 +136,15 @@ class TrombiCreateView(CanCreateMixin, CreateView):
return self.form_invalid(form) return self.form_invalid(form)
class TrombiEditView(CanEditPropMixin, TrombiTabsMixin, UpdateView): class TrombiEditView(
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):
@@ -155,7 +157,7 @@ class AddUserForm(forms.Form):
) )
class TrombiDetailView(CanEditMixin, QuickNotifMixin, TrombiTabsMixin, DetailView): class TrombiDetailView(CanEditMixin, 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"
@@ -167,9 +169,9 @@ class TrombiDetailView(CanEditMixin, QuickNotifMixin, TrombiTabsMixin, DetailVie
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()
self.quick_notif_list.append("qn_success") messages.success(self.request, _("User added to the trombi"))
except IntegrityError: # We don't care about duplicate keys except IntegrityError: # We don't care about duplicate keys
self.quick_notif_list.append("qn_fail") messages.error(self.request, _("User couldn't be added to the trombi"))
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -185,22 +187,20 @@ class TrombiExportView(CanEditMixin, TrombiTabsMixin, DetailView):
current_tab = "admin_tools" current_tab = "admin_tools"
class TrombiDeleteUserView(CanEditPropMixin, TrombiTabsMixin, DeleteView): class TrombiDeleteUserView(
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 ( return reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id})
reverse("trombi:detail", kwargs={"trombi_id": self.object.trombi.id})
+ "?qn_success"
)
class TrombiModerateCommentsView( class TrombiModerateCommentsView(CanEditPropMixin, TrombiTabsMixin, DetailView):
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,16 +235,18 @@ 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=_(
@@ -265,7 +267,6 @@ 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
@@ -299,9 +300,7 @@ class UserTrombiForm(forms.Form):
) )
class UserTrombiToolsView( class UserTrombiToolsView(LoginRequiredMixin, TrombiTabsMixin, TemplateView):
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"
@@ -318,7 +317,6 @@ class UserTrombiToolsView(
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):
@@ -335,21 +333,24 @@ class UserTrombiToolsView(
return kwargs return kwargs
class UserTrombiEditPicturesView(TrombiTabsMixin, UserIsInATrombiMixin, UpdateView): class UserTrombiEditPicturesView(
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") + "?qn_success" return reverse("trombi:user_tools")
class UserTrombiEditProfileView( class UserTrombiEditProfileView(
QuickNotifMixin, TrombiTabsMixin, UserIsInATrombiMixin, UpdateView TrombiTabsMixin, UserIsInATrombiMixin, SuccessMessageMixin, UpdateView
): ):
model = User model = User
form_class = modelform_factory( form_class = modelform_factory(
@@ -370,16 +371,20 @@ 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") + "?qn_success" return reverse("trombi:user_tools")
class UserTrombiResetClubMembershipsView(UserIsInATrombiMixin, RedirectView): class UserTrombiResetClubMembershipsView(
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
@@ -387,18 +392,18 @@ class UserTrombiResetClubMembershipsView(UserIsInATrombiMixin, RedirectView):
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") + "?qn_success" return reverse("trombi:profile")
class UserTrombiDeleteMembershipView(TrombiTabsMixin, CanEditMixin, DeleteView): class UserTrombiDeleteMembershipView(
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
@@ -428,15 +433,18 @@ class UserTrombiAddMembershipView(TrombiTabsMixin, CreateView):
) )
class UserTrombiEditMembershipView(CanEditMixin, TrombiTabsMixin, UpdateView): class UserTrombiEditMembershipView(
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() + "?qn_success" return super().get_success_url()
class UserTrombiProfileView(TrombiTabsMixin, DetailView): class UserTrombiProfileView(TrombiTabsMixin, DetailView):
@@ -461,12 +469,13 @@ class UserTrombiProfileView(TrombiTabsMixin, DetailView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
class TrombiCommentFormView(LoginRequiredMixin, View): class TrombiCommentFormView(LoginRequiredMixin, SuccessMessageMixin, 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
@@ -496,7 +505,7 @@ class TrombiCommentFormView(LoginRequiredMixin, View):
) )
def get_success_url(self): def get_success_url(self):
return reverse("trombi:user_tools") + "?qn_success" return reverse("trombi:user_tools")
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": ["jquery", "alpinejs"], "types": ["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,7 +4,6 @@ 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");
@@ -87,17 +86,6 @@ 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;
}); });