feat: club link management in club edit view

This commit is contained in:
imperosol
2026-05-05 22:43:29 +02:00
parent 74a7f4ffc9
commit 2b0c36c085
5 changed files with 154 additions and 10 deletions
+42 -1
View File
@@ -28,7 +28,14 @@ from django.db.models.functions import Lower
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -39,6 +46,26 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema 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): class ClubEditForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -48,6 +75,20 @@ class ClubEditForm(forms.ModelForm):
fields = ["address", "logo", "short_description"] fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()} 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): class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"] admin_fields = ["name", "parent", "is_active"]
+97 -6
View File
@@ -1,9 +1,60 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% block title %} {% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %} {% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %} {% 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 %} {% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2> <h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
@@ -17,7 +68,7 @@
and explicitly separate them from the non-admin ones, and explicitly separate them from the non-admin ones,
with some help text. with some help text.
Non-admin users will only see the regular form fields, Non-admin users will only see the regular form fields,
so they don't need thoses explanations #} so they don't need those explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3> <h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext"> <p class="helptext">
{% trans trimmed %} {% trans trimmed %}
@@ -25,7 +76,7 @@
Only admin users can see and edit them. Only admin users can see and edit them.
{% endtrans %} {% endtrans %}
</p> </p>
<fieldset class="required margin-bottom"> <fieldset class="margin-bottom">
{% for field_name in form.admin_fields %} {% for field_name in form.admin_fields %}
{% set field = form[field_name] %} {% set field = form[field_name] %}
<div class="form-group"> <div class="form-group">
@@ -36,11 +87,13 @@
{# Remove the the admin fields from the form. {# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #} at once with a simple {{ form.as_p() }} #}
{% set _ = form.fields.pop(field_name) %} {% do form.fields.pop(field_name) %}
{% endfor %} {% endfor %}
</fieldset> </fieldset>
{% endif %}
<h3>{% trans %}Club informations{% endtrans %}</h3> <h3>{% trans %}Club informations{% endtrans %}</h3>
{% if form.admin_fields %}
<p class="helptext"> <p class="helptext">
{% trans trimmed %} {% trans trimmed %}
The following form fields are linked to the basic description of a club. The following form fields are linked to the basic description of a club.
@@ -48,7 +101,45 @@
{% endtrans %} {% endtrans %}
</p> </p>
{% endif %} {% endif %}
{{ form.as_p() }} <fieldset class="margin-bottom">
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> {{ 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> </form>
{% endblock content %} {% 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 %}
+7 -1
View File
@@ -21,7 +21,13 @@ def test_club_board_member_cannot_edit_club_properties(client: Client):
client.force_login(user) client.force_login(user)
res = client.post( res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}), reverse("club:club_edit", kwargs={"club_id": club.id}),
{"name": "new name", "is_active": False, "address": "new address"}, {
"name": "new name",
"is_active": False,
"address": "new address",
"link-TOTAL_FORMS": 0,
"link-INITIAL_FORMS": 0,
},
) )
# The request should success, # The request should success,
# but admin-only fields shouldn't be taken into account # but admin-only fields shouldn't be taken into account
+7 -1
View File
@@ -33,6 +33,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum from django.db.models import F, Q, Sum
from django.db.models.functions import Length
from django.http import Http404, StreamingHttpResponse from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
@@ -60,7 +61,7 @@ from club.forms import (
MailingForm, MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import Club, Mailing, MailingSubscription, Membership from club.models import Club, LinkType, Mailing, MailingSubscription, Membership
from com.models import Poster from com.models import Poster
from com.views import ( from com.views import (
PosterCreateBaseView, PosterCreateBaseView,
@@ -570,6 +571,11 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
return ClubAdminEditForm return ClubAdminEditForm
return ClubEditForm 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): class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin).""" """Create a club (for the Sith admin)."""
+1 -1
View File
@@ -103,7 +103,7 @@ def add_attr(field: BoundField, attr: str):
if "=" not in d: if "=" not in d:
attrs["class"] = d attrs["class"] = d
else: else:
key, val = d.split("=") key, val = d.split("=", maxsplit=1)
attrs[key] = val attrs[key] = val
return field.as_widget(attrs=attrs) return field.as_widget(attrs=attrs)