Compare commits

..

11 Commits

Author SHA1 Message Date
imperosol ec0426a095 UI/UX tweaks 2026-05-12 11:04:23 +02:00
imperosol 7d76bf77c6 add tests 2026-05-12 11:04:23 +02:00
imperosol a672c1ebbe add translations 2026-05-12 11:04:23 +02:00
imperosol 0c2abc9d4b ask for user confirmation if its role was moved out of presidency 2026-05-12 11:04:23 +02:00
imperosol d974ae2e09 add links to club edit page 2026-05-12 11:04:23 +02:00
imperosol f285fd23d0 add help texts in the club roles edition page 2026-05-12 11:04:23 +02:00
imperosol c45aa82305 feat: page to create club roles 2026-05-12 11:04:23 +02:00
imperosol dc41379139 feat: page to edit and reorder club role 2026-05-12 11:04:23 +02:00
imperosol 667768fb66 adapt tests to new club roles framework 2026-05-12 11:04:23 +02:00
imperosol d859e4fe5a add forgotten check 2026-05-12 11:04:23 +02:00
imperosol 045ad590eb add translations 2026-05-12 11:04:23 +02:00
33 changed files with 1000 additions and 715 deletions
+1 -16
View File
@@ -16,7 +16,7 @@ from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from club.models import Club, ClubRole, Membership
@admin.register(Club)
@@ -67,18 +67,3 @@ class MembershipAdmin(admin.ModelAdmin):
"club__name",
)
autocomplete_fields = ("user",)
@admin.register(LinkType)
class LinkTypeAdmin(admin.ModelAdmin):
list_display = ("name", "url_base", "icon")
search_fields = ("name",)
@admin.register(ClubLink)
class ClubLinkAdmin(admin.ModelAdmin):
list_display = ("link_type", "club", "url")
list_select_related = ("link_type", "club")
autocomplete_fields = ("link_type", "club")
search_fields = ("link_type__name", "url")
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
+1 -1
View File
@@ -41,7 +41,7 @@ class ClubController(ControllerBase):
queryset=Membership.objects.ongoing().select_related("user", "role"),
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch, "links"), id=club_id
Club.objects.prefetch_related(prefetch), id=club_id
)
+62 -42
View File
@@ -28,14 +28,7 @@ from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -46,26 +39,6 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubLinkForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubLink
fields = ["url", "name", "link_type"]
widgets = {
"url": forms.URLInput(
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
),
"link_type": forms.HiddenInput(),
}
ClubLinkFormSet = forms.inlineformset_factory(
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
)
class ClubEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -75,20 +48,6 @@ class ClubEditForm(forms.ModelForm):
fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.link_formset = ClubLinkFormSet(
*args, instance=self.instance, prefix="link", **kwargs
)
def is_valid(self):
return super().is_valid() and self.link_formset.is_valid()
def save(self, commit=True): # noqa: FBT002
res = super().save(commit=commit)
self.link_formset.save(commit=commit)
return res
class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"]
@@ -371,3 +330,64 @@ class ClubSearchForm(forms.ModelForm):
# so we enforce it.
self.fields["club_status"].value = True
self.fields["name"].required = False
class ClubRoleForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
widgets = {
"is_presidency": forms.HiddenInput(),
"is_board": forms.HiddenInput(),
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
}
def clean(self):
cleaned_data = super().clean()
if "ORDER" in cleaned_data:
self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data
class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role.
Notes:
For UX purposes, users are not meant to fill `is_presidency`
and `is_board`, so those values are required by the form constructor
in order to initialize the instance properly.
"""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description"]
def __init__(
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
):
super().__init__(*args, **kwargs)
self.instance.club = club
self.instance.is_presidency = is_presidency
self.instance.is_board = is_board
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
ClubRoleFormSet = forms.inlineformset_factory(
Club,
ClubRole,
ClubRoleForm,
ClubRoleBaseFormSet,
can_delete=False,
can_order=True,
edit_only=True,
extra=0,
)
@@ -148,6 +148,9 @@ class Migration(migrations.Migration):
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
violation_error_message=(
"A role cannot be in the presidency while not being in the board"
),
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
-99
View File
@@ -1,99 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-27 07:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0016_clubrole_alter_membership_role")]
operations = [
migrations.CreateModel(
name="LinkType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=40, unique=True, verbose_name="name"),
),
(
"url_base",
models.URLField(
help_text=(
"L'url de base que tous les "
"liens de ce type doivent respecter "
"(par exemple `https://www.instagram.com`)"
),
unique=True,
verbose_name="url base",
),
),
(
"icon",
models.CharField(
help_text=(
"The fontawesome class to use "
"(e.g. `fa-brands fa-instagram`)"
),
max_length=40,
verbose_name="icon",
),
),
],
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
),
migrations.CreateModel(
name="ClubLink",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(blank=True, max_length=40, verbose_name="name"),
),
("url", models.URLField(verbose_name="link url")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.club",
verbose_name="club",
),
),
(
"link_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.linktype",
verbose_name="link type",
),
),
],
options={"verbose_name": "club link", "verbose_name_plural": "club links"},
),
]
+12 -84
View File
@@ -238,6 +238,15 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
return user.is_authenticated and (
user.has_perm("club.change_clubrole")
or self.members.ongoing()
.filter(user=user, role__is_presidency=True)
.exists()
)
@cached_property
def current_members(self) -> list[Membership]:
return list(
@@ -285,6 +294,9 @@ class ClubRole(OrderedModel):
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
violation_error_message=_(
"A role cannot be in the presidency while not being in the board"
),
)
]
@@ -294,21 +306,8 @@ class ClubRole(OrderedModel):
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def get_absolute_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
def clean(self):
errors = []
if self.is_presidency and not self.is_board:
errors.append(
ValidationError(
_(
"Role %(name)s was declared as a presidency role "
"without being a board role"
)
% {"name": self.name}
)
)
roles = list(self.club.roles.all())
if (
self.is_board
@@ -774,74 +773,3 @@ class MailingSubscription(models.Model):
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model):
"""A link type, in order to group links and give them icons.
Notes:
Among all club links, there is a special one, with an empty base url
and a default link icon.
It is use as a fallback item when no actual link type can be found.
Danger:
LinkType.icon is content that will be raw-rendered in the template.
It is NOT safe to allow users to give it.
The edition of this field must be reserved to trusted admins.
"""
name = models.CharField(_("name"), max_length=40, unique=True)
url_base = models.URLField(
"url base",
unique=True,
help_text=_(
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
)
icon = models.CharField(
_("icon"),
max_length=40,
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
)
class Meta:
verbose_name = _("link type")
verbose_name_plural = _("link types")
def __str__(self):
return self.name
class ClubLink(models.Model):
link_type = models.ForeignKey(
LinkType,
verbose_name=_("link type"),
on_delete=models.CASCADE,
related_name="links",
)
name = models.CharField(_("name"), max_length=40, blank=True)
url = models.URLField(_("link url"))
club = models.ForeignKey(
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("club link")
verbose_name_plural = _("club links")
def __str__(self):
return self.url
def save(self, **kwargs):
if not self.name:
self.name = self.link_type.name
return super().save(**kwargs)
def clean(self):
if not self.url.startswith(self.link_type.url_base):
raise ValidationError(
_("This link doesn't match with the url base of its type.")
)
-6
View File
@@ -2,7 +2,6 @@ from typing import Annotated
from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema
from pydantic import HttpUrl
from club.models import Club, ClubRole, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -63,11 +62,6 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
links: list[HttpUrl]
@staticmethod
def resolve_links(obj: Club):
return [link.url for link in obj.links.all()]
class UserMembershipSchema(ModelSchema):
@@ -0,0 +1,61 @@
import type { AlpineComponent } from "alpinejs";
interface RoleGroupData {
isBoard: boolean;
isPresidency: boolean;
roleId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
confirmOnSubmit: false,
/**
* Edit relevant item data after it has been moved by x-sort
*/
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
item.isBoard = conf.isBoard;
item.isPresidency = conf.isPresidency;
// if the user has moved its own role outside the presidency,
// submitting the form will require a confirmation
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
this.resetOrder();
},
/**
* Reset the value of the ORDER input of all items in the list.
* This is to be called after any reordering operation, in order to make sure
* that the order that will be saved is coherent with what is displayed.
*/
resetOrder() {
// When moving items with x-sort, the only information we truly have is
// the end position in the target group, not the previous position nor
// the position in the global list.
// To overcome this, we loop through an enumeration of all inputs
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
const inputs = document.querySelectorAll<HTMLInputElement>(
"input[name^='roles'][name$='ORDER']",
);
for (const [i, elem] of inputs.entries()) {
elem.value = (i + 1).toString();
}
},
/**
* If the user moved its role out of the presidency, ask a confirmation
* before submitting the form
*/
confirmSubmission(event: SubmitEvent) {
if (
this.confirmOnSubmit &&
!confirm(
gettext(
"You're going to remove your own role from the presidency. " +
"You may lock yourself out of this page. Do you want to continue ? ",
),
)
) {
event.preventDefault();
}
},
}));
});
-66
View File
@@ -1,66 +0,0 @@
#club-detail {
img.club-logo {
display: block;
max-height: 200px;
max-width: 200px;
}
#club-attributes {
ul {
list-style: none;
margin-left: 0;
display: flex;
flex-direction: column;
gap: .75rem;
li i {
margin-right: .5rem;
}
}
}
&:not(.has-links) {
#club-attributes {
float: right;
margin: 1em 0 1em 2em;
@media screen and (max-width: 650px) {
margin-left: 1em;
}
@media screen and (max-width: 400px) {
float: unset;
img.club-logo {
margin: auto;
}
}
}
}
&.has-links {
display: flex;
flex-direction: row-reverse;
gap: 2em;
@media screen and (max-width: 650px) {
flex-direction: column;
gap: 1em;
}
#club-attributes {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 200px;
@media screen and (max-width: 650px) {
margin-top: 1em;
flex-direction: row-reverse;
justify-content: flex-end;
h4 {
margin: 0;
}
img.club-logo {
margin-left: auto;
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
.fa-grip-vertical {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
margin-right: .5em;
}
+8 -36
View File
@@ -21,43 +21,15 @@
{% endif %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
{% endblock %}
{% block content %}
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
{% if club.logo %}
<img
class="club-logo"
src="{{ club.logo.url }}"
alt="{{ club.name }}"
width="{{ club.logo.width }}"
height="{{ club.logo.height }}"
>
{% endif %}
{% if links %}
<div id="club-links">
<h4>{% trans %}Links{% endtrans %}</h4>
<ul>
{% for link in links %}
<li>
<a href="{{ link.url }}" rel="noopener" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div id="club-page">
{% if page_revision %}
{{ page_revision|markdown }}
{% endif %}
</div>
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
<h3>{{ club.name }}</h3>
{% if page_revision %}
{{ page_revision|markdown }}
{% endif %}
</div>
{% endblock %}
-19
View File
@@ -1,13 +1,6 @@
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Liste des clubs et assos" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
@@ -69,18 +62,6 @@
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{% set links = club.links.all() %}
{% if links %}
<br>
<div class="row gap-2x">
{% for link in club.links.all() %}
<a href="{{ link.url }}">
<i class="{{ link.link_type.icon }} fa-xl"></i>
<strong>{{ link.name }}</strong>
</a>
{% endfor %}
</div>
{% endif %}
{{ club.short_description|markdown }}
</div>
</div>
+9
View File
@@ -12,6 +12,15 @@
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if club.can_roles_be_edited_by(user) %}
<a
href="{{ url("club:club_roles", club_id=object.id) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
</a>
{% endif %}
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
+172
View File
@@ -0,0 +1,172 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
{% endblock %}
{% macro display_subform(subform) %}
<div
class="row"
x-data="{
isPresidency: {{ subform.is_presidency.value()|lower }},
isBoard: {{ subform.is_board.value()|lower }},
roleId: {{ subform.id.value() }},
}"
x-sort:item="$data"
>
{# hidden fields #}
{{ subform.ORDER }}
{{ subform.id }}
{{ subform.club }}
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
{{ subform.is_board|add_attr("x-model=isBoard") }}
<i class="fa fa-grip-vertical" x-sort:handle></i>
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
<summary>
{{ subform.name.value() }}
{% if not subform.instance.is_active -%}
({% trans %}inactive{% endtrans %})
{%- endif %}
</summary>
<div class="accordion-content">
{{ subform.non_field_errors() }}
<div class="form-group">
{{ subform.name.as_field_group() }}
</div>
<div class="form-group">
{{ subform.description.as_field_group() }}
</div>
<div class="form-group">
<div>
{{ subform.is_active }}
{{ subform.is_active.label_tag() }}
</div>
<span class="helptext">
{{ subform.is_active.help_text }}
</span>
</div>
</div>
</details>
</div>
{% endmacro %}
{% block content %}
<p>
{% trans trimmed %}
Roles give rights on the club.
Higher roles grant more rights, and the members having them are displayed higher
in the club members list.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
On this page, you can edit their name and description, as well as their order.
You can also drag roles from a category to another
(e.g. a board role can be made into a presidency role).
{% endtrans %}
</p>
<form
method="post"
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
@submit="confirmSubmission"
>
{% csrf_token %}
{{ form.management_form }}
{{ form.non_form_errors() }}
<h3>{% trans %}Presidency{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
{# The style we use for markdown rendering is quite nice for what we want to display,
so we are just gonna reuse it. #}
<div class="markdown">
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
<ul>
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
<li>{% trans %}manage the club counters{% endtrans %}</li>
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
</ul>
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Board{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>
{% trans trimmed %}
Board members can do most administrative actions in the club, including :
{% endtrans %}
</p>
<ul>
<li>{% trans %}manage the club posters{% endtrans %}</li>
<li>{% trans %}create news for the club{% endtrans %}</li>
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
<li>
{% trans trimmed %}
add new members and end active memberships
for roles that are lower than their own.
{% endtrans %}
</li>
</ul>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_board.value() and not subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Members{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if not subform.is_board.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<p>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
</p>
</form>
{% endblock content %}
+13 -2
View File
@@ -5,8 +5,19 @@
<div>
<h4>{% trans %}Communication:{% endtrans %}</h4>
<ul>
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
<li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
+6 -97
View File
@@ -1,60 +1,9 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% macro link_form(form) %}
<fieldset
{# set url in x-init rather than in x-data,
in order to trigger the $watch on initial load #}
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
x-init="() => {
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
url = '{{ form.url.value() or "" }}';
}"
>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>
{{ form.url.label_tag() }}
{{ form.url.errors }}
<span>
{# we change the icon when the user change it and leave the input,
or when it is pasted from the clipboard #}
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
<i :class="linkType.icon || 'fa fa-link'"></i>
</span>
</div>
<div>{{ form.name.as_field_group() }}</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
</button>
{%- endif -%}
{{ form.link_type|add_attr(":value=linkType.id") }}
{%- for field in form.hidden_fields() -%}
{%- if field != form.link_type -%}
{{ field }}
{%- endif -%}
{%- endfor -%}
</fieldset>
{% endmacro %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
@@ -68,7 +17,7 @@
and explicitly separate them from the non-admin ones,
with some help text.
Non-admin users will only see the regular form fields,
so they don't need those explanations #}
so they don't need thoses explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
@@ -76,7 +25,7 @@
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="margin-bottom">
<fieldset class="required margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
@@ -87,13 +36,11 @@
{# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #}
{% do form.fields.pop(field_name) %}
{% set _ = form.fields.pop(field_name) %}
{% endfor %}
</fieldset>
{% endif %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
{% if form.admin_fields %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the basic description of a club.
@@ -101,45 +48,7 @@
{% endtrans %}
</p>
{% endif %}
<fieldset class="margin-bottom">
{{ form.as_p() }}
</fieldset>
<h3>{% trans %}Club links{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
{{ form.link_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.link_formset.forms -%}
{{ link_form(f) }}
{%- endfor -%}
</div>
<template x-ref="formTemplate">
{{ link_form(form.link_formset.empty_form) }}
</template>
<p>
<i>{% trans trimmed %}
Note: if the icon of one of your links doesn't exist yet,
you can ask the info team to add it.
{% endtrans %}</i>
</p>
<br>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
</button>
</div>
<hr>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock content %}
{% block script %}
<script>
const linkTypes = [
{%- for t in link_types -%}
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
{%- endfor -%}
];
</script>
{% endblock %}
+2 -2
View File
@@ -88,8 +88,8 @@ class TestFetchClub:
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(7):
with assertNumQueries(6):
# - 4 queries for authentication
# - 3 queries for the actual data
# - 2 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
+228 -8
View File
@@ -1,25 +1,48 @@
from collections.abc import Callable
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole
from club.forms import ClubRoleFormSet
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@pytest.mark.django_db
def test_order_auto():
"""Test that newly created roles are put in the right place."""
def make_club():
# unittest-style tests cannot use fixture, so we create a function
# that will be callable either by a pytest fixture or inside
# a TestCase.setUpTestData method.
club = baker.make(Club)
recipe = Recipe(ClubRole, club=club, name=seq("role "))
# bulk create initial roles (1 presidency, 1 board, 1 member)
roles = recipe.make(
recipe.make(
is_board=iter([True, True, False]),
is_presidency=iter([True, False, False]),
order=iter([1, 2, 3]),
order=iter([0, 1, 2]),
_quantity=3,
_bulk_create=True,
)
# then create the remaining roles one by one (like they will be in prod)
return club
@pytest.fixture
def club(db):
"""A club with a presidency role, a board role and a member role"""
return make_club()
@pytest.mark.django_db
def test_order_auto(club):
"""Test that newly created roles are put in the right place."""
roles = list(club.roles.all())
# create new roles one by one (like they will be in prod)
# each new role should be placed at the end of its category
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
@@ -31,3 +54,200 @@ def test_order_auto():
roles[2],
role_c,
]
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "is_allowed"),
[
(
lambda club: baker.make(
User,
user_permissions=[Permission.objects.get(codename="change_clubrole")],
),
True,
),
( # user with presidency roles can edit the club roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(is_presidency=True).first(),
)
]
),
True,
),
( # user in the board but not in the presidency cannot edit roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(
is_presidency=False, is_board=True
).first(),
)
]
),
False,
),
(lambda _: AnonymousUser(), False),
],
)
def test_can_roles_be_edited_by(
club: Club, user_factory: Callable[[Club], User], is_allowed
):
"""Test that `Club.can_roles_be_edited_by` return the right value"""
user = user_factory(club)
assert club.can_roles_be_edited_by(user) == is_allowed
@pytest.mark.django_db
@pytest.mark.parametrize(
["route", "is_presidency", "is_board"],
[
("club:new_role_president", True, True),
("club:new_role_board", False, True),
("club:new_role_member", False, False),
],
)
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
"""Test that the role creation views work."""
club = baker.make(Club)
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
user = subscriber_user.make()
baker.make(Membership, club=club, role=role, user=user, end_date=None)
url = reverse(route, kwargs={"club_id": club.id})
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"name": "foo"})
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
new_role = club.roles.last()
assert new_role.name == "foo"
assert new_role.is_presidency == is_presidency
assert new_role.is_board == is_board
class TestClubRoleUpdate(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = make_club()
cls.roles = list(cls.club.roles.all())
cls.user = subscriber_user.make()
baker.make(
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
)
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
def setUp(self):
self.payload = {
"roles-TOTAL_FORMS": 3,
"roles-INITIAL_FORMS": 3,
"roles-MIN_NUM_FORMS": 0,
"roles-MAX_NUM_FORMS": 1000,
"roles-0-ORDER": 1,
"roles-0-id": self.roles[0].id,
"roles-0-club": self.club.id,
"roles-0-is_presidency": True,
"roles-0-is_board": True,
"roles-0-name": self.roles[0].name,
"roles-0-description": self.roles[0].description,
"roles-0-is_active": True,
"roles-1-ORDER": 2,
"roles-1-id": self.roles[1].id,
"roles-1-club": self.club.id,
"roles-1-is_presidency": False,
"roles-1-is_board": True,
"roles-1-name": self.roles[1].name,
"roles-1-description": self.roles[1].description,
"roles-1-is_active": True,
"roles-2-ORDER": 3,
"roles-2-id": self.roles[2].id,
"roles-2-club": self.club.id,
"roles-2-is_presidency": False,
"roles-2-is_board": False,
"roles-2-name": self.roles[2].name,
"roles-2-description": self.roles[2].description,
"roles-2-is_active": True,
}
def test_view_ok(self):
"""Basic test to check that the view works."""
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
self.payload["roles-2-name"] = "foo"
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
self.roles[2].refresh_from_db()
assert self.roles[2].name == "foo"
def test_incoherent_order(self):
"""Test that placing a member role over a board role fails."""
self.payload["roles-0-ORDER"] = 4
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
f"Le rôle {self.roles[0].name} ne peut pas "
"être placé en-dessous d'un rôle de membre.",
f"Le rôle {self.roles[0].name} ne peut pas être placé "
"en-dessous d'un rôle qui n'est pas de la présidence.",
]
},
{},
{},
]
def test_change_order_ok(self):
"""Test that changing order the intended way works"""
self.payload["roles-1-ORDER"] = 3
self.payload["roles-1-is_board"] = False
self.payload["roles-2-ORDER"] = 2
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert list(self.club.roles.order_by("order")) == [
self.roles[0],
self.roles[2],
self.roles[1],
]
self.roles[1].refresh_from_db()
assert not self.roles[1].is_board
def test_non_board_presidency_is_forbidden(self):
"""Test that a role cannot be in the presidency without being in the board."""
self.payload["roles-0-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
]
},
{},
{},
]
def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
# When the user clicked that button, it still had the right to update roles,
# so the modification should be applied
self.roles[0].refresh_from_db()
assert self.roles[0].is_presidency is False
res = self.client.get(self.url)
assert res.status_code == 403
+1 -7
View File
@@ -21,13 +21,7 @@ def test_club_board_member_cannot_edit_club_properties(client: Client):
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
{
"name": "new name",
"is_active": False,
"address": "new address",
"link-TOTAL_FORMS": 0,
"link-INITIAL_FORMS": 0,
},
{"name": "new name", "is_active": False, "address": "new address"},
)
# The request should success,
# but admin-only fields shouldn't be taken into account
+45
View File
@@ -317,6 +317,51 @@ class TestMembership(TestClub):
self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=self.president_role).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.new_members_url,
{"user": self.subscriber.id, "role": self.president_role.id},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=self.president_role)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
user_role = self.simple_board_member.memberships.first().role
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
other_role.above(user_role)
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": other_role.id},
request_user=self.simple_board_member,
club=self.club,
)
nb_memberships = self.club.members.count()
assert not form.is_valid()
assert form.errors == {
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
assert not self.subscriber.memberships.filter(club=self.club).exists()
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm(
+2 -2
View File
@@ -21,7 +21,7 @@ def test_page_display_on_club_main_page(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page").find(class_="markdown")
detail_html = soup.find(id="club_detail").find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
@@ -34,7 +34,7 @@ def test_club_main_page_without_content(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page")
detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == []
+20
View File
@@ -35,6 +35,10 @@ from club.views import (
ClubPageEditView,
ClubPageHistView,
ClubRevView,
ClubRoleBoardCreateView,
ClubRoleMemberCreateView,
ClubRolePresidencyCreateView,
ClubRoleUpdateView,
ClubSellingCSVView,
ClubSellingView,
ClubToolsView,
@@ -71,6 +75,22 @@ urlpatterns = [
ClubOldMembersView.as_view(),
name="club_old_members",
),
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
path(
"<int:club_id>/role/new/president/",
ClubRolePresidencyCreateView.as_view(),
name="new_role_president",
),
path(
"<int:club_id>/role/new/board/",
ClubRoleBoardCreateView.as_view(),
name="new_role_board",
),
path(
"<int:club_id>/role/new/member/",
ClubRoleMemberCreateView.as_view(),
name="new_role_member",
),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
+127 -24
View File
@@ -28,12 +28,15 @@ import csv
import itertools
from typing import TYPE_CHECKING, Any
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Prefetch, Q, Sum
from django.db.models.functions import Length
from django.db.models import F, Q, Sum
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
@@ -56,19 +59,14 @@ from club.forms import (
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
ClubRoleCreateForm,
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
)
from club.models import (
Club,
ClubLink,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import (
PosterCreateBaseView,
@@ -212,22 +210,20 @@ class ClubListView(AllowFragment, FormMixin, ListView):
template_name = "club/club_list.jinja"
form_class = ClubSearchForm
queryset = Club.objects.prefetch_related(
Prefetch("links", queryset=ClubLink.objects.select_related("link_type"))
).order_by("name")
queryset = Club.objects.order_by("name")
paginate_by = 20
def get_form_kwargs(self):
res = super().get_form_kwargs()
# if request.GET is empty, the form will interpret club_status as None,
# even though we want it to be initially True,
# so we force a defaut True value.
res["data"] = {"club_status": True} | self.request.GET.dict()
if self.request.method == "GET":
res |= {"data": self.request.GET, "initial": self.request.GET}
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs.filter(is_active=True)
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
@@ -253,7 +249,6 @@ class ClubView(ClubTabsMixin, DetailView):
.values_list("content", flat=True)
.first()
)
kwargs["links"] = list(self.object.links.select_related("link_type").all())
return kwargs
@@ -425,6 +420,119 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
}
class ClubRoleUpdateView(
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
form_class = ClubRoleFormSet
model = Club
template_name = "club/club_roles.jinja"
pk_url_kwarg = "club_id"
current_tab = "members"
success_message = _("Club roles updated")
@cached_property
def club(self) -> Club:
return self.get_object()
def test_func(self):
return self.club.can_roles_be_edited_by(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"user_role": ClubRole.objects.filter(
club=self.club,
members__user=self.request.user,
members__end_date=None,
)
.values_list("id", flat=True)
.first()
}
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
This view isn't meant to be called directly, but rather subclassed for each
type of role that can exist :
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
to create a presidency role
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
to create a board role
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
to create a member role
Each subclass have to override the following variables :
- `is_presidency` and `is_board`, indicating what type of role
the view creates.
- `role_description`, which is the title of the page, indication
the user what kind of role is being created.
This way, we are making sure the correct type of role will
be created, without bothering the user with the implementation details.
"""
form_class = ClubRoleCreateForm
model = ClubRole
template_name = "core/create.jinja"
success_message = _("Role %(name)s created")
role_description = ""
is_presidency: bool
is_board: bool
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.is_authenticated and (
self.request.user.has_perm("club.add_clubrole")
or self.club.members.filter(
user=self.request.user, role__is_presidency=True
).exists()
)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"club": self.club,
"is_presidency": self.is_presidency,
"is_board": self.is_board,
}
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": self.role_description
}
def get_success_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
is_presidency = True
is_board = True
role_description = _("club role \u2013 presidency")
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = True
role_description = _("club role \u2013 board")
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = False
role_description = _("club role \u2013 member")
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club."""
@@ -581,11 +689,6 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
return ClubAdminEditForm
return ClubEditForm
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"link_types": list(LinkType.objects.order_by(Length("url_base").desc()))
}
class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin)."""
+18 -8
View File
@@ -16,13 +16,9 @@
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
@media screen and (min-width: 800px) {
max-width: 20%;
min-width: 200px;
}
}
#left_column {
@@ -50,7 +46,7 @@
}
}
@media screen and (max-width: 800px) {
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
@@ -80,10 +76,10 @@
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
#links_content {
font-size: 85%;
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
@@ -99,10 +95,24 @@
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
margin-right: .5rem;
}
}
}
+1 -49
View File
@@ -36,7 +36,7 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from club.models import Club, ClubRole, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
@@ -830,54 +830,6 @@ class Command(BaseCommand):
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
insta, fb, discord, _ = LinkType.objects.bulk_create(
[
LinkType(
name="instagram",
icon="fa-brands fa-square-instagram",
url_base="https://www.instagram.com",
),
LinkType(
name="facebook",
icon="fa-brands fa-facebook",
url_base="https://www.facebook.com",
),
LinkType(
name="discord",
icon="fa-brands fa-discord",
url_base="https://discord.gg",
),
LinkType(name="generic", icon="fa fa-link", url_base=""),
]
)
ClubLink.objects.bulk_create(
[
ClubLink(
name="insta AE",
url="https://www.instagram.com/ae_utbm/",
club=ae,
link_type=insta,
),
ClubLink(
name="insta activités AE",
url="https://www.instagram.com/activites_ae/",
club=ae,
link_type=insta,
),
ClubLink(
name="facebook AE",
url="https://www.facebook.com/ae_utbm",
club=ae,
link_type=fb,
),
ClubLink(
name="Discord",
url="https://discord.gg/QvTm3XJrHR",
club=ae,
link_type=discord,
),
]
)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
+4 -3
View File
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
opacity: 0;
@supports (max-height: calc-size(max-content, size)) {
max-height: 0px;
max-height: 0;
}
}
@@ -71,11 +71,12 @@ details.accordion>.accordion-content {
}
}
// ::details-content isn't available on firefox yet
// ::details-content is available on firefox only since september 2025
// (and wasn't available when this code was initially written)
// we use .accordion-content as a workaround
// But we need to use ::details-content for chrome because it's
// not working correctly otherwise
// it only happen in chrome, not safari or firefox
// it only happens in chrome, not safari or firefox
// Note: `selector` is not supported by scss so we comment it out to
// avoid compiling it and sending it straight to the css
// This is a trick that comes from here :
+13 -22
View File
@@ -398,28 +398,6 @@ body {
}
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
}
@media screen and (max-width: $small-devices) {
@@ -771,3 +749,16 @@ textarea {
vertical-align: middle;
}
}
/*--------------------------------JQuery-------------------------------*/
#club_detail {
.club_logo {
float: right;
img {
display: block;
max-height: 10em;
max-width: 10em;
}
}
}
+9 -2
View File
@@ -1,11 +1,18 @@
{% extends "core/base.jinja" %}
{# if the template context has the `object_name` variable,
then this one will be used in the page title,
instead of the result of `str(object)` #}
{% if not object_name %}
{% set object_name=form.instance.__class__._meta.verbose_name %}
{% endif %}
{% block title %}
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
{% trans name=object_name %}Create {{ name }}{% endtrans %}
{% endblock %}
{% block content %}
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
+1 -1
View File
@@ -103,7 +103,7 @@ def add_attr(field: BoundField, attr: str):
if "=" not in d:
attrs["class"] = d
else:
key, val = d.split("=", maxsplit=1)
key, val = d.split("=")
attrs[key] = val
return field.as_widget(attrs=attrs)
+1 -2
View File
@@ -32,9 +32,8 @@ class Migration(migrations.Migration):
(
"result",
models.OneToOneField(
help_text="The product got with the formula.",
help_text="The formula product.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
+162 -110
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 11:12+0200\n"
"POT-Creation-Date: 2026-05-12 09:52+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -270,12 +270,9 @@ msgid "club roles"
msgstr "rôles de club"
#: club/models.py
#, python-format
msgid ""
"Role %(name)s was declared as a presidency role without being a board role"
msgid "A role cannot be in the presidency while not being in the board"
msgstr ""
"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du "
"bureau."
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau"
#: club/models.py
#, python-format
@@ -287,7 +284,8 @@ msgstr ""
#, python-format
msgid "Role %(role)s cannot be placed below a non-presidency role"
msgstr ""
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle qui n'est pas "
"de la présidence."
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
@@ -364,58 +362,6 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/models.py
#, python-format
msgid "The base url that links with this type must respect (e.g. `%(url)s`)"
msgstr ""
"L'url de base que tous les liens de ce type doivent respecter (par exemple "
"`%(url)s`)"
#: club/models.py counter/models.py
msgid "icon"
msgstr "icône"
#: club/models.py
msgid "The fontawesome class to use (e.g. `fa-brands fa-instagram`)"
msgstr ""
"La classe fontawesome à utiliser (par exemple `fa-brands fa-instagram`)"
#: club/models.py
msgid "link type"
msgstr "type de lien"
#: club/models.py
msgid "link types"
msgstr "types de lien"
#: club/models.py
msgid "link url"
msgstr "url du lien"
#: club/models.py core/models.py counter/models.py
msgid "created at"
msgstr "créé le"
#: club/models.py core/models.py counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: club/models.py
msgid "club link"
msgstr "lien de club"
#: club/models.py
msgid "club links"
msgstr "liens de club"
#: club/models.py
msgid "This link doesn't match with the url base of its type."
msgstr "Ce lien ne correspond pas à l'url de base de son type."
#: club/templates/club/club_detail.jinja com/templates/com/news_list.jinja
msgid "Links"
msgstr "Liens"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
@@ -437,7 +383,7 @@ msgstr "Recherche"
msgid "New club"
msgstr "Nouveau club"
#: club/templates/club/club_list.jinja
#: club/templates/club/club_list.jinja club/templates/club/club_roles.jinja
msgid "inactive"
msgstr "inactif"
@@ -445,6 +391,10 @@ msgstr "inactif"
msgid "Club members"
msgstr "Membres du club"
#: club/templates/club/club_members.jinja
msgid "Manage roles"
msgstr "Gérer les rôles"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja
#: core/templates/core/user_clubs.jinja
@@ -483,6 +433,121 @@ msgstr "Du"
msgid "To"
msgstr "Au"
#: club/templates/club/club_roles.jinja
msgid ""
"Roles give rights on the club. Higher roles grant more rights, and the "
"members having them are displayed higher in the club members list."
msgstr ""
"Les rôles donnent des droits sur le club. Les rôles plus élevés donnent plus "
"de droit, et les membres qui les possèdent sont affichés plus haut dans la "
"liste des membres."
#: club/templates/club/club_roles.jinja
msgid ""
"On this page, you can edit their name and description, as well as their "
"order. You can also drag roles from a category to another (e.g. a board role "
"can be made into a presidency role)."
msgstr ""
"Sur cette page, vous pouvez éditer leur nom et leur description, ainsi que "
"leur ordre. Vous pouvez également déplacer des rôles d'une catégorie à "
"l'autre (par exemple, un rôle du bureau peut devenir un rôle de présidence)."
#: club/templates/club/club_roles.jinja
msgid "Presidency"
msgstr "Présidence"
#: club/templates/club/club_roles.jinja
msgid "add role"
msgstr "ajouter un rôle"
#: club/templates/club/club_roles.jinja core/templates/core/base/navbar.jinja
msgid "Help"
msgstr "Aide"
#: club/templates/club/club_roles.jinja
msgid "Users with a presidency role can :"
msgstr "Les utilisateurs avec un rôle de présidence peuvent :"
#: club/templates/club/club_roles.jinja
msgid "create new club roles and edit existing ones"
msgstr "créer de nouveaux rôles et modifier ceux qui existent"
#: club/templates/club/club_roles.jinja
msgid "manage the club counters"
msgstr "gérer les comptoirs du club"
#: club/templates/club/club_roles.jinja
msgid "add new members with any active role and end any membership"
msgstr ""
"ajouter de nouveaux membres avec n'importe quel rôle et mettre fin à "
"n'importe quelle adhésion au club."
#: club/templates/club/club_roles.jinja
msgid "They also have all the rights of the club board."
msgstr "Ils possèdent également tous les droits du bureau."
#: club/templates/club/club_roles.jinja
msgid "Board"
msgstr "Bureau"
#: club/templates/club/club_roles.jinja
msgid ""
"Board members can do most administrative actions in the club, including :"
msgstr ""
"Les membres du bureau peuvent effectuer la plupart des actions "
"administratives dans le club, incluant :"
#: club/templates/club/club_roles.jinja
msgid "manage the club posters"
msgstr "gérer les affiches du club"
#: club/templates/club/club_roles.jinja
msgid "create news for the club"
msgstr "créer des nouvelles pour le club"
#: club/templates/club/club_roles.jinja
msgid "click users on the club's counters"
msgstr "cliquer des utilisateurs sur les comptoirs du club"
#: club/templates/club/club_roles.jinja
msgid ""
"add new members and end active memberships for roles that are lower than "
"their own."
msgstr ""
"ajouter de nouveaux membres et mettre fin à des adhésions en cours, pour des "
"rôles plus bas que le leur."
#: club/templates/club/club_roles.jinja club/views.py
msgid "Members"
msgstr "Membres"
#: club/templates/club/club_roles.jinja
msgid "Simple members cannot perform administrative actions."
msgstr ""
"Les simples membres ne peuvent pas effectuer d'actions administratives."
#: club/templates/club/club_roles.jinja club/templates/club/edit_club.jinja
#: club/templates/club/pagerev_edit.jinja com/templates/com/news_edit.jinja
#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
#: com/templates/com/weekmail.jinja core/templates/core/create.jinja
#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
#: core/templates/core/fragment/user_visibility.jinja
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
#: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Save"
msgstr "Sauver"
#: club/templates/club/club_sellings.jinja
msgid "Previous"
msgstr "Précédent"
@@ -643,10 +708,6 @@ msgstr "Comptoirs : "
msgid "Edit %(name)s"
msgstr "Éditer %(name)s"
#: club/templates/club/edit_club.jinja
msgid "Remove link"
msgstr "Retirer le lien"
#: club/templates/club/edit_club.jinja
msgid "Club properties"
msgstr "Propriétés du club"
@@ -671,44 +732,6 @@ msgstr ""
"Les champs de formulaire suivants sont liées à la description basique d'un "
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
#: club/templates/club/edit_club.jinja
msgid "Club links"
msgstr "Liens du club"
#: club/templates/club/edit_club.jinja
msgid ""
"Note: if the icon of one of your links doesn't exist yet, you can ask the "
"info team to add it."
msgstr ""
"Note : si l'icône d'un de vos liens n'existe pas encore, vous pouvez demander "
"au pôle info de l'ajouter."
#: club/templates/club/edit_club.jinja
msgid "Add link"
msgstr "Ajouter un lien"
#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja
#: core/templates/core/file_edit.jinja
#: core/templates/core/fragment/user_visibility.jinja
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
#: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Save"
msgstr "Sauver"
#: club/templates/club/fragments/add_member.jinja
msgid "Add a new member"
msgstr "Ajouter un nouveau membre"
@@ -789,10 +812,6 @@ msgstr "Éditer la page"
msgid "Infos"
msgstr "Infos"
#: club/views.py
msgid "Members"
msgstr "Membres"
#: club/views.py
msgid "Old members"
msgstr "Anciens membres"
@@ -842,6 +861,27 @@ msgstr "Vous êtes maintenant membre de ce club."
msgid "%(user)s has been added to club."
msgstr "%(user)s a été ajouté au club."
#: club/views.py
msgid "Club roles updated"
msgstr "Rôles de club mis à jour"
#: club/views.py
#, python-format
msgid "Role %(name)s created"
msgstr "Rôle %(name)s créé"
#: club/views.py
msgid "club role presidency"
msgstr "rôle de club présidence"
#: club/views.py
msgid "club role board"
msgstr "rôle de club bureau"
#: club/views.py
msgid "club role member"
msgstr "rôle de club membre"
#: club/views.py
msgid "Benefit"
msgstr "Bénéfice"
@@ -1211,6 +1251,10 @@ msgstr ""
msgid "All coming events"
msgstr "Tous les événements à venir"
#: com/templates/com/news_list.jinja
msgid "Links"
msgstr "Liens"
#: com/templates/com/news_list.jinja
msgid "Our services"
msgstr "Nos services"
@@ -1716,6 +1760,10 @@ msgstr "Visiteur"
msgid "ban type"
msgstr "type de ban"
#: core/models.py counter/models.py
msgid "created at"
msgstr "créé le"
#: core/models.py
msgid "expires at"
msgstr "expire le"
@@ -1805,6 +1853,10 @@ msgstr "taille"
msgid "date"
msgstr "date"
#: core/models.py counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py
msgid "asked for removal"
msgstr "retrait demandé"
@@ -2057,10 +2109,6 @@ msgstr "Partenaires"
msgid "Subscriber benefits"
msgstr "Les avantages cotisants"
#: core/templates/core/base/navbar.jinja
msgid "Help"
msgstr "Aide"
#: core/templates/core/base/navbar.jinja
msgid "FAQ"
msgstr "FAQ"
@@ -3279,6 +3327,10 @@ msgstr "prix d'achat"
msgid "Initial cost of purchasing the product"
msgstr "Coût initial d'achat du produit"
#: counter/models.py
msgid "icon"
msgstr "icône"
#: counter/models.py
msgid "limit age"
msgstr "âge limite"
+11 -2
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-26 15:45+0100\n"
"POT-Creation-Date: 2026-04-17 22:42+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -17,6 +17,14 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: club/static/bundled/club/role-list-index.ts
msgid ""
"You're going to remove your own role from the presidency. You may lock "
"yourself out of this page. Do you want to continue ? "
msgstr ""
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez "
"de perdre l'accès à cette page. Voulez-vous continuer ?"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "More info"
msgstr "Plus d'informations"
@@ -271,4 +279,5 @@ msgstr "Il n'a pas été possible de supprimer l'image"
msgid ""
"Wrong timetable format. Make sure you copied if from your student folder."
msgstr ""
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis "
"votre dossier étudiant."
-5
View File
@@ -88,11 +88,6 @@ X_FRAME_OPTIONS = "SAMEORIGIN"
ALLOWED_HOSTS = ["*"]
# RemovedInDjango60Warning: It's a transitional setting helpful in early
# adoption of "https" as the new default value of forms.URLField.assume_scheme.
# Remove this after upgrading to Django 6.x
FORMS_URLFIELD_ASSUME_HTTPS = True
# Application definition
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"