Compare commits

..

12 Commits

Author SHA1 Message Date
72e6388c0c Add configuration page: implement ConfigurationForm, update views, templates, and routes to enable superusers to manage club settings and toggle features using django-waffle. 2026-04-12 16:02:05 +02:00
cb3da371d1 Refactor feature flags: replace config checks with django-waffle switches, update templates and MemberLoadView to use TF_MASS_UPLOAD and TF_ENABLE_TEAMS. Add missing waffle_tags load for member_form.html. 2026-04-12 13:48:43 +02:00
8f9357f1b8 Update emergency phone icon in member_form.html to use fa-star-of-life for improved clarity 2026-04-12 13:38:36 +02:00
4e4fe62f11 Add feature flag for bulk member upload: update MemberLoadView, templates, and settings to use mass_upload flag with django-waffle. 2026-04-12 12:18:24 +02:00
b71bc2afc0 Add bulk member upload functionality: implement MemberLoadView, update routes, templates, and create MassUploadForm for CSV uploads. 2026-04-12 12:10:02 +02:00
4b819d9dd9 Fix paginator "last page" link in member_filter.html template by correcting hx-get attribute 2026-04-12 11:13:48 +02:00
7aa4a4816c Enable member editing functionality: implement MemberEditView, update routes, modify templates for dynamic filtering and superuser badges, and standardize contact info handling. 2026-04-12 11:06:28 +02:00
3d94b9b2d8 Refactor styles in styles.css: fix margin typo, improve focus/hover effects, and standardize dropdown styling with updated colors and shadows. 2026-04-12 10:37:16 +02:00
95e46fe727 Add django-waffle for feature flagging: update dependencies and middleware configuration. 2026-04-12 10:18:53 +02:00
e55c88c742 Update base.html layout: adjust styles for improved height handling, overflow behavior, and consistency in responsive designs. 2026-03-31 22:56:49 +02:00
6f07c32fb1 Adjust navbar styles: rename class to navbar-shrink, add padding to elements for better spacing. 2026-03-31 16:49:21 +02:00
646479c5be Refactor MemberEditView to include permissions, success messages, and HTMX support. Adjust member filter template layout to use flex-row. 2026-03-31 16:49:03 +02:00
16 changed files with 411 additions and 126 deletions

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"waffle",
"constance",
"tailwind",
"django_filters",
@@ -62,6 +63,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"waffle.middleware.WaffleMiddleware",
]
ROOT_URLCONF = "TeamForge.urls"
@@ -147,10 +149,12 @@ CONSTANCE_CONFIG = {
"TF_DEFAULT_SEASON_MONTH": (config("TF_DEFAULT_SEASON_MONTH", default=8, cast=int), "Default season start month", int),
"TF_DEFAULT_SEASON_DAY": (config("TF_DEFAULT_SEASON_DAY", default=1, cast=int), "Default season start day", int),
"TF_DEFAULT_SEASON_DURATION": (config("TF_DEFAULT_SEASON_DURATION", default="1y", cast=str), "Default season duration", str),
"TF_ENABLE_TEAMS": (config("TF_ENABLE_TEAMS", default=True, cast=bool), "Enable teams", bool),
}
PHONENUMBER_DEFAULT_FORMAT = "INTERNATIONAL"
PHONENUMBER_DEFAULT_REGION = config("CM_CLUB_COUNTRY_CODE", default="BE", cast=str)
TAILWIND_APP_NAME = "theme"
WAFFLE_CREATE_MISSING_FLAGS = True
WAFFLE_CREATE_MISSING_SWITCHES = True

12
backend/forms.py Normal file
View File

@@ -0,0 +1,12 @@
from django import forms
from django.utils.translation import gettext_lazy as _
class ConfigurationForm(forms.Form):
"""Form instance that holds configuration values for the application."""
club_name = forms.CharField(label=_("Club name"), max_length=250)
club_location = forms.CharField(label=_("Club location"), max_length=250, help_text=_("Changing this setting will set a new home game location and will update already existing games"))
club_logo = forms.ImageField(label=_("Club logo"), required=False)
enable_teams = forms.BooleanField(label=_("Enable teams"), required=False)
enable_activities = forms.BooleanField(label=_("Enable activities"), required=False)

View File

@@ -6,7 +6,7 @@ app_name = "members"
urlpatterns = [
path("", MemberListView.as_view(), name="list"),
path("add/", MemberAddView.as_view(), name="add"),
# path("<int:pk>/edit/", MemberEditView.as_view(), name="edit"),
path("<int:pk>/edit/", MemberEditView.as_view(), name="edit"),
path("<int:pk>/delete/", MemberDeleteView.as_view(), name="delete"),
# path("load/", MemberLoadView.as_view(), name="load"),
path("load/", MemberLoadView.as_view(), name="load"),
]

View File

@@ -1,3 +1,5 @@
import csv
import io
from typing import Any
from django.contrib import messages
@@ -5,15 +7,18 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, UpdateView, CreateView
from django.views.generic import CreateView, DeleteView, FormView, UpdateView
from django_filters.views import FilterView
from rules.contrib.views import PermissionRequiredMixin
from waffle.mixins import WaffleFlagMixin
from members.filters import MemberFilter
from members.forms import MassUploadForm, MemberForm
from members.models import Member
from members.forms import MemberForm
from ..mixins import HTMXViewMixin
class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
filterset_class = MemberFilter
paginate_by = 50
@@ -56,7 +61,30 @@ class MemberAddView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin,
return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name())
class MemberEditView: ...
class MemberEditView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView):
model = Member
form_class = MemberForm
permission_required = "members.change_member"
permission_denied_message = _("You do not have permission to view this page.")
success_message = _("Member <strong>%(name)s</strong> has been updated successfully.")
success_url = reverse_lazy("backend:members:list")
partial_name = "members/member_form.html#content"
menu_highlight = "members"
def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_success_message(self, cleaned_data):
return self.success_message % dict(cleaned_data, name=self.object.user.get_full_name())
def get_initial(self):
initial = super().get_initial()
user = self.get_object().user
initial.update({"first_name": user.first_name, "last_name": user.last_name, "email": user.email, "admin": user.is_superuser})
return initial
class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, DeleteView):
@@ -87,4 +115,35 @@ class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMix
return HttpResponseRedirect(self.get_success_url())
class MemberLoadView: ...
class MemberLoadView(PermissionRequiredMixin, HTMXViewMixin, SuccessMessageMixin, WaffleFlagMixin, FormView):
form_class = MassUploadForm
permission_required = "members.add_member"
permission_denied_message = _("You do not have permission to view this page.")
success_url = reverse_lazy("backend:members:list")
success_message = _("Members have been added successfully.")
partial_name = "members/member_load.html#content"
menu_highlight = "members"
template_name = "members/member_load.html"
waffle_flag = "TF_MASS_UPLOAD"
def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index"))
def form_valid(self, form: MassUploadForm) -> HttpResponse:
member_data = self.request.FILES["members_data"]
with io.TextIOWrapper(member_data.file) as csvfile:
reader = csv.reader(csvfile)
for row in reader:
member_information = {"first_name": row[0], "last_name": row[1], "email": row[2], "birthday": row[3], "license": row[4]}
member = Member.create(first_name=member_information["fist_name"], last_name=member_information["last_name"], email=member_information["email"])
member.license = member_information["license"]
if member_information["birthday"] is not None and member_information["birthday"] != "":
member.birthday = member_information["birthday"]
member.save(update_fields=["license", "birthday"])
return super().form_valid(form)

View File

@@ -1,9 +1,10 @@
from django.urls import include, path
from .views import index
from .views import configuration, index
app_name = "backend"
urlpatterns = [
path("", index, name="index"),
path("members/", include("backend.members.urls")),
path("configuration", configuration, name="configuration")
]

View File

@@ -1,6 +1,59 @@
from pathlib import Path
from constance import config
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from waffle.models import Switch
from backend.forms import ConfigurationForm
# Create your views here.
def index(request):
return render(request, "backend/index.html")
@login_required
@user_passes_test(lambda u: u.is_superuser)
def configuration(request: HttpRequest) -> HttpResponse:
switches = {
"enable_teams": Switch.objects.get_or_create(name="TF_TEAMS", defaults={"active": False})[0],
"enable_activities": Switch.objects.get_or_create(name="TF_ACTIVITIES", defaults={"active": False})[0],
}
initial_data = {
"club_name": config.TF_CLUB_NAME,
"club_location": config.TF_CLUB_HOME,
"club_logo": config.TF_CLUB_LOGO,
"enable_teams": switches["enable_teams"].active,
"enable_activities": switches["enable_activities"].active,
}
form = ConfigurationForm(initial=initial_data)
if request.method == "POST":
form = ConfigurationForm(request.POST, request.FILES)
if form.is_valid():
config.TF_CLUB_NAME = form.cleaned_data["club_name"]
config.TF_CLUB_HOME = form.cleaned_data["club_location"]
if form.cleaned_data["club_logo"] is not None:
default_storage.save(str(Path(settings.STATIC_ROOT) / form.cleaned_data["club_logo"].name), form.cleaned_data["club_logo"])
config.TF_CLUB_LOGO = form.cleaned_data["club_logo"].name
for switch_key in switches.keys():
if switches[switch_key].active != form.cleaned_data[switch_key]:
switches[switch_key].active = form.cleaned_data[switch_key]
Switch.objects.bulk_update(switches.values(), ["active"])
cache.clear()
messages.success(request=request, message=_("Settings have been saved successfully"))
return render(request, "backend/configuration.html", {"form": form})

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from .models import Member
class MemberForm(forms.ModelForm):
first_name = forms.CharField(label=_("First name"), max_length=250)
last_name = forms.CharField(label=_("Last name"), max_length=250)
@@ -40,3 +41,5 @@ class MemberForm(forms.ModelForm):
return member
class MassUploadForm(forms.Form):
csv_file = forms.FileField()

View File

@@ -12,6 +12,7 @@ dependencies = [
"django-htmx>=1.27.0",
"django-phonenumber-field[phonenumbers]>=8.4.0",
"django-tailwind[cookiecutter,honcho]>=4.4.2",
"django-waffle>=5.0.0",
"pillow>=12.1.0",
"psycopg2-binary>=2.9.11",
"python-decouple>=3.8",

View File

@@ -4,17 +4,18 @@
{% block sidebar %}
{% url "backend:members:list" as members_list %}
{% url "backend:configuration" as configuration %}
{% has_perm "members.member_manager" request.user as is_member_manager %}
{% if is_member_manager %}
<li class="menu-title">Members</li>
<li><a href="{{ members_list }}" class="menu-item {% if members_list in request.path %}menu-active{% endif %}" data-menu="members" hx-get="{% url "backend:members:list" %}" hx-target="#content"><i class="fa-solid fa-users"></i> Members</a></li>
<li><a href="{{ members_list }}" class="menu-item {% if members_list in request.path %}menu-active{% endif %}" data-menu="members" hx-get="{% url "backend:members:list" %}" hx-target="#content"><i class="fa-solid fa-users"></i> Members</a>
</li>
{% endif %}
<li class="menu-title mt-4">Navigation</li>
<li><a href="#"><i class="fa-solid fa-house"></i> Dashboard</a></li>
<li><a href="#"><i class="fa-solid fa-calendar"></i> Calendar</a></li>
<li><a href="#"><i class="fa-solid fa-users"></i> Members</a></li>
<li><a href="#"><i class="fa-solid fa-gear"></i> Settings</a></li>
{% if request.user.is_superuser %}
<li class="menu-title mt-4">Configuration</li>
<li><a href="{% url "backend:configuration" %}" class="menu-item {% if configuration in request.path %}menu-active{% endif %}" data-menu="configuration"><i class="fa-solid fa-screwdriver-wrench"></i> Settings</a></li>
{% endif %}
{% endblock sidebar %}

View File

@@ -0,0 +1,76 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% load waffle_tags %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Configuration" %}</h1>
{% blocktranslate %}
<article class="prose text-justify max-w-none">
<p>Use the below options to configure your TeamForge instance. Options marked as required will need to be set in order for TeamForge to function properly.</p>
<p>You can also use this form to enable or disable certain pieces of functionality for your users.</p>
</article>
{% endblocktranslate %}
{% if form.errors %}
<div class="flex flex-row items-center gap-2 p-2 m-4 rounded-lg bg-error">
<i class="mr-2 text-3xl fa-solid fa-exclamation-triangle text-error-content"></i>
<div class="flex flex-col">
<div class="mb-1 font-semibold text-error-content">{% translate "Error" %}</div>
<div class="text-sm text-error-content">{% translate "Please correct the errors below before saving again." %}</div>
</div>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<h2 class="page-subtitle">{% translate "General configuration" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.club_name %}
{% form_field form.club_location %}
{% form_field form.club_logo %}
</div>
<h2 class="page-subtitle">{% translate "Module configuration" %}</h2>
<div class="grid grid-cols-1 gap-1 mt-2 gap-x-4 lg:grid-cols-3 xl:grid-cols-5">
<div class="flex flex-row gap-2">
<span class="font-semibold">{% translate "Teams" %}</span>
{% form_field form.enable_teams show_label=False show_as_toggle=True %}
{% switch "TF_TEAMS" %}
<div class="badge badge-success"><i class="fa-solid fa-check"></i>{% translate "Enabled" %}</div>
{% else %}
<div class="badge badge-error"><i class="fa-solid fa-times"></i>{% translate "Disabled" %}</div>
{% endswitch %}
</div>
<div></div>
<div class="flex flex-row gap-2">
<span class="font-semibold">{% translate "Activities" %}</span>
{% form_field form.enable_activities show_label=False show_as_toggle=True %}
{% switch "TF_ACTIVITIES" %}
<div class="badge badge-success"><i class="fa-solid fa-check"></i>{% translate "Enabled" %}</div>
{% else %}
<div class="badge badge-error"><i class="fa-solid fa-times"></i>{% translate "Disabled" %}</div>
{% endswitch %}
</div>
</div>
<button class="w-full mt-8 btn btn-neutral" type="submit">
<i class="fa-solid fa-floppy-disk"></i>{% translate "Save" %}
</button>
</form>
<script type="text/javascript">
new Choices(document.querySelector("#id_family_members"));
</script>
{% endpartialdef content %}
{% endblock content %}

View File

@@ -39,9 +39,9 @@
</style>
</head>
<body class="flex flex-col h-screen" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<body class="flex flex-col h-dvh overflow-hidden" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<!-- NAVBAR -->
<header id="mainNavbar" class="navbar-normal navbar bg-base-100 sticky top-0 z-50 shadow">
<header id="mainNavbar" class="navbar-shrink navbar bg-base-100 sticky top-0 z-50 shadow">
<div class="flex-none lg:hidden">
<!-- Mobile sidebar toggle -->
<label for="sidebar-toggle" class="btn btn-square btn-ghost">
@@ -49,12 +49,12 @@
</label>
</div>
<div class="flex-1 flex items-center gap-3">
<div class="flex-1 flex items-center gap-3 pl-2">
<img src="{% static config.TF_CLUB_LOGO %}" class="hidden lg:inline h-10 {% if config.TF_CLUB_LOGO != "teamforge/logo.png" %}mask mask-circle{% endif %}" alt="{{ config.TF_CLUB_NAME }} logo">
<span class="text-xl font-bold font-jersey">{{ config.TF_CLUB_NAME }}</span>
</div>
<div class="flex flex-row items-center gap-1 lg:gap-4">
<div class="flex flex-row items-center gap-1 lg:gap-4 pr-2">
<!-- Notifications -->
<button class="btn btn-ghost btn-circle">
<i class="fa-solid fa-bell text-xl"></i>
@@ -84,7 +84,7 @@
</div>
</header>
<div class="drawer lg:drawer-open flex-1">
<div class="drawer lg:drawer-open flex-1 min-h-0">
<!-- Hidden checkbox for mobile sidebar -->
<input type="checkbox" id="sidebar-toggle" class="drawer-toggle">
@@ -92,7 +92,7 @@
<aside class="drawer-side z-60 lg:z-auto min-w-fit h-full lg:h-fit">
<label for="sidebar-toggle" class="drawer-overlay"></label>
<div class="w-64 bg-base-100 border-r p-4 h-full lg:h-fit lg:border lg:border-base-300 lg:m-4 lg:mr-2 lg:rounded-xl lg:min-h-full">
<div class="w-64 bg-base-100 border-r p-4 h-full lg:h-fit lg:border lg:border-base-300 lg:m-4 lg:mr-2 lg:rounded-xl">
<ul class="menu w-full">
{% block sidebar %}{% endblock sidebar %}
</ul>
@@ -100,8 +100,8 @@
</aside>
<!-- MAIN CONTENT-->
<div class="drawer-content flex w-full">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full">
<div class="drawer-content flex w-full overflow-y-auto">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full h-fit">
{% include "backend/partials/messages.html" %}
<div id="content">
@@ -115,7 +115,7 @@
</div>
<!-- FOOTER -->
<footer class="footer footer-center p-4 bg-neutral text-neutral-content mt-auto">
<footer class="footer footer-center p-4 bg-neutral text-neutral-content">
<p>&copy; {% now "Y" %} TeamForge — All rights reserved.</p>
</footer>

View File

@@ -4,6 +4,7 @@
{% load form_field %}
{% load avatar %}
{% load pagination %}
{% load waffle_tags %}
{% block content %}
{% partialdef content inline %}
@@ -14,7 +15,7 @@
<h1 class="page-title">{% translate "Members" %}</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<input type="checkbox" />
<input type="checkbox"/>
<div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div>
<div class="collapse-content">
<form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content">
@@ -59,9 +60,11 @@
</div>
<div class="add">
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="">
{% flag "TF_MASS_UPLOAD" %}
<a class="btn btn-accent btn-sm grow hidden lg:flex" href="{% url "backend:members:load" %}" hx-get="{% url "backend:members:load" %}" hx-target="#content">
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
</a>
{% endflag %}
<a class="btn btn-neutral btn-outline btn-sm grow" href="{% url "backend:members:add" %}" hx-get="{% url "backend:members:add" %}" hx-target="#content">
<i class="fa-solid fa-plus"></i>{% translate "Add member" %}
@@ -89,7 +92,7 @@
{% for member in object_list %}
<tr class="hover:bg-base-300">
<td>
<a href="">
<a href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<div class="flex flex-row items-center gap-3">
<div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name %}
@@ -105,7 +108,7 @@
{% endif %}
{% if not member.user.is_active %}
<div class="badge badge-neutral badge-sm">{% translate "Inactive"%}</div>
<div class="badge badge-neutral badge-sm">{% translate "Inactive" %}</div>
{% endif %}
</div>
</a>
@@ -113,7 +116,7 @@
<td>{{ member.birthday|date:"d M Y"|default:"-" }}</td>
<td>{{ member.license|default:"-" }}</td>
<td>
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-1">
{% if member.phone_number %}
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-xs">
<i class="fa-solid fa-phone"></i>{{ member.phone_number }}
@@ -129,7 +132,7 @@
</td>
<td>
<div class="flex flex-row gap-2">
<a class="btn btn-outline btn-sm" href="">
<a class="btn btn-outline btn-sm" href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<i class="fa-solid fa-eye"></i>{% translate "Details" %}
</a>
@@ -151,13 +154,23 @@
{% else %}
<div class="flex flex-col gap-1">
{% for member in object_list %}
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href="">
<a class="border border-base-300 rounded-lg p-2 flex flex-row gap-2 items-center" href="{% url "backend:members:edit" member.pk %}" hx-get="{% url "backend:members:edit" member.pk %}" hx-target="#content">
<div>
{% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %}
</div>
<div class="grow">
<div class="font-semibold text-sm">{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}</div>
<div class="font-semibold text-sm">
{{ member.user.get_full_name }}
{% if member.license %}
#{{ member.license }}
{% endif %}
{% if member.user.is_superuser %}
<div class="badge badge-xs badge-accent">
<i class="fa-solid fa-user-shield"></i>
</div>
{% endif %}
</div>
<div class="opacity-50 text-xs">{{ member.birthday|date:"d M Y"|default:"" }}</div>
</div>
@@ -190,7 +203,7 @@
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "next" %}</span><span
class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.num_pages %}" hx-target="#content"><span
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-target="#content"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %}
</div>

View File

@@ -3,6 +3,7 @@
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% load waffle_tags %}
{% block content %}
{% partialdef content inline %}
@@ -18,14 +19,6 @@
<div class="flex flex-row gap-2 grow justify-center items-center">
{% if member %}
<div class="font-bold text-xl">{{ member.user.get_full_name }}</div>
{% if member.user.is_superuser %}
<div class="tooltip" data-tip="{% translate "This user is a site admin" %}">
<div class="badge badge-sm badge-accent">
<i class="fa-solid fa-user-shield"></i>
</div>
</div>
{% endif %}
{% else %}
<div class="font-bold text-xl">{% translate "Create new member" %}</div>
{% endif %}
@@ -33,7 +26,7 @@
<div class="flex min-w-12 justify-end">
{% if member %}
<a class="btn btn-error btn-outline" href="{% url "backend:members:delete" member.pk %}">
<a class="btn btn-error btn-outline" href="{% url "backend:members:delete" member.pk %}" hx-get="{% url "backend:members:delete" member.pk %}" hx-target="#content">
<i class="fa-solid fa-trash"></i>
</a>
{% else %}
@@ -43,30 +36,29 @@
</div>
{% if member %}
<div class="mt-4 lg:hidden flex flex-row gap-2">
<div class="mt-4 lg:hidden flex flex-row gap-2">
{% if member.phone_number %}
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-info btn-outline btn-sm grow">
<i class="fa-solid fa-phone"></i>
{{ member.phone }}
{{ member.phone_number }}
</a>
{% endif %}
{% if member.emergency_phone_number %}
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-error btn-outline btn-sm grow">
<i class="fa-solid fa-file-medical"></i>
<i class="fa-solid fa-star-of-life"></i>
{{ member.emergency_phone_number }}
</a>
{% endif %}
</div>
{% if config.TF_ENABLE_TEAMS %}
{% switch "TF_TEAMS" %}
<a href="?member__user__first_name={{ member.user.first_name }}&member__user__last_name={{ member.user.last_name }}" class="btn btn-sm w-full mt-2 btn-outline btn-neutral lg:hidden">
<i class="fa-solid fa-ticket"></i>{% translate "View team memberships" %}
</a>
{% endif %}
{% endswitch %}
<div class="flex flex-row items-center mt-8 gap-x-3 hidden lg:flex">
<div class="hidden flex-row items-center mt-8 gap-x-3 hidden lg:flex">
{% avatar first_name=member.user.first_name last_name=member.user.last_name %}
<h2 class="page-subtitle border-b-0! mt-0!">{{ member.user.get_full_name }}</h2>
@@ -76,21 +68,21 @@
{% endif %}
<div class="justify-end hidden gap-2 lg:flex lg:flex-row grow">
{% if member.phone %}
<a href="{{ member.phone.as_rfc3966 }}" class="btn btn-outline btn-info"><i class="fa-solid fa-phone"></i>{{ member.phone }}</a>
{% if member.phone_number %}
<a href="{{ member.phone_number.as_rfc3966 }}" class="btn btn-outline btn-info"><i class="fa-solid fa-phone"></i>{{ member.phone_number }}</a>
{% endif %}
{% if member.emergency_phone %}
<a href="{{ member.emergency_phone.as_rfc3966 }}" class="btn btn-outline btn-error"><i class="fa-solid fa-file-medical"></i>{{ member.emergency_phone }}</a>
{% if member.emergency_phone_number %}
<a href="{{ member.emergency_phone_number.as_rfc3966 }}" class="btn btn-outline btn-error"><i class="fa-solid fa-star-of-life"></i>{{ member.emergency_phone_number }}</a>
{% endif %}
{% if config.TF_ENABLE_TEAMS %}
{% switch "TF_TEAMS" %}
<a href="?member__user__first_name={{ member.user.first_name }}&member__user__last_name={{ member.user.last_name }}" class="btn btn-outline btn-neutral">
<i class="fa-solid fa-ticket"></i>{% translate "Team Memberships" %}
</a>
{% endif %}
{% endswitch %}
<a href="{% url "backend:members:members_delete" member.id %}" class="btn btn-error btn-outline">
<a href="{% url "backend:members:delete" member.pk %}" class="btn btn-error btn-outline" hx-get="{% url "backend:members:delete" member.pk %}" hx-target="#content">
<i class="fa-solid fa-trash"></i>{% translate "Delete" %}
</a>
</div>
@@ -110,37 +102,38 @@
</div>
{% endif %}
<form method="post">
<form method="post" hx-post>
{% csrf_token %}
<h2 class="page-subtitle">{% translate "Personal information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.first_name %}
{% form_field form.last_name %}
{% form_field form.birthday %}
</div>
<h2 class="page-subtitle">{% translate "Contact information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.email %}
{% form_field form.phone_number %}
{% form_field form.emergency_phone_number %}
</div>
<h2 class="page-subtitle">{% translate "Family information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.family_members %}
</div>
<h2 class="page-subtitle">{% translate "Club information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.license %}
{% form_field form.admin show_as_toggle=True %}
</div>
<h2 class="page-subtitle">{% translate "Password" %}</h2>
<div class="mt-2 text-sm text-justify">{% blocktranslate %}Setting the password here will overwrite the current password for this member, after changing the member will be prompted to set a new password at the next login.<br /><br />If both fields are empty the current password will not be changed.{% endblocktranslate %}</div>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
<div class="mt-2 text-sm text-justify">{% blocktranslate %}Setting the password here will overwrite the current password for this member, after changing the member will be prompted to set a new password at the next login.<br/><br/>If both
fields are empty the current password will not be changed.{% endblocktranslate %}</div>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2 xl:grid-cols-3">
{% form_field form.password %}
{% form_field form.password_confirmation %}
</div>

View File

@@ -0,0 +1,56 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Members" %}</h1>
<h2 class="page-subtitle border-b-0! hidden lg:flex">{% translate "Bulk load new member information" %}</h2>
<div class="alert alert-info mt-2 text-sm text-justify">
<i class="text-lg fa-solid fa-info"></i>
<span>
{% blocktranslate %}
Data should be formatted as a .csv file with the following information in the different columns:
<ul class="my-2 list-disc list-inside">
<li>First name</li>
<li>Last name</li>
<li>Email</li>
<li>Birthday (YYYY-MM-DD)</li>
<li>License number</li>
<!-- <li>Team (short name)</li>
<li>Role (abbreviation)</li>
<li>Number</li>
<li>Position (C or A, depending on captain or assistant captain, leave empty if neither)</li> -->
</ul>
{% endblocktranslate %}
</span>
</div>
{% if form.errors %}
<div class="flex flex-row items-center gap-2 p-2 m-4 rounded-lg bg-error">
<i class="mr-2 text-3xl fa-solid fa-exclamation-triangle text-error-content"></i>
<div class="flex flex-col">
<div class="mb-1 font-semibold text-error-content">{% translate "Error" %}</div>
<div class="text-sm text-error-content">{% translate "Please correct the errors below before saving again." %}</div>
</div>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mt-4">
{% form_field form.csv_file %}
</div>
<button class="w-full mt-8 btn btn-neutral" type="submit">
<i class="fa-solid fa-floppy-disk"></i>{% translate "Save" %}
</button>
</form>
{% endpartialdef content %}
{% endblock content %}

View File

@@ -226,7 +226,7 @@
.choices[data-type*=text] .choices__button {
position: relative;
display: inline-block;
margin: 0-4px 0 2px;
margin: 0 -4px 0 2px;
padding-left: 16px;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);
background-size: 8px;
@@ -253,26 +253,16 @@
border-radius: var(--radius-field);
font-size: 14px;
min-height: 44px;
overflow: hidden
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.is-focused .choices__inner,
.is-open .choices__inner {
border-color: var(--color-base-content);
isolation: isolate;
border-radius: var(--radius-field);
box-shadow: 0 0 0 2px var(--color-base-100), 0 0 0 4px var(--color-base-content);
}
.is-focused {
border: 2px solid var(--color-base-content);
border-radius: calc(var(--radius-field) * 2);
margin: -2px;
padding: 2px; /* adjust offset */
}
.is-open .choices__inner {
border-radius: var(--radius-field);
}
@@ -281,6 +271,13 @@
border-radius: var(--radius-field);
}
.is-focused {
border: 0;
margin: 0;
padding: 0;
outline: 0;
}
.choices__list {
margin: 0;
padding-left: 0;
@@ -344,16 +341,16 @@
.choices__list--dropdown,
.choices__list[aria-expanded] {
display: none;
z-index: 1;
z-index: 10;
position: absolute;
width: 100%;
background-color: var(--color-base-100);
border: 1px solid #ddd;
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, #0000);
top: 100%;
margin-top: 3px;
margin-top: 0.25rem;
border-radius: var(--radius-field);
overflow: hidden;
word-break: break-all
word-break: break-all;
box-shadow: 0 10px 25px color-mix(in oklab, var(--color-base-content) 12%, transparent)
}
.is-active.choices__list--dropdown,
@@ -363,7 +360,7 @@
.is-open .choices__list--dropdown,
.is-open .choices__list[aria-expanded] {
border-color: #b7b7b7
border-color: color-mix(in oklab, var(--color-base-content) 20%, #0000)
}
.is-flipped .choices__list--dropdown,
@@ -371,8 +368,8 @@
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: -1px;
border-radius: .25rem .25rem 0 0
margin-bottom: 0.25rem;
border-radius: var(--radius-field)
}
.choices__list--dropdown .choices__list,
@@ -387,8 +384,9 @@
.choices__list--dropdown .choices__item,
.choices__list[aria-expanded] .choices__item {
position: relative;
padding: 10px;
font-size: 14px
padding: 10px 12px;
font-size: 14px;
color: var(--color-base-content)
}
[dir=rtl] .choices__list--dropdown .choices__item,
@@ -396,7 +394,7 @@
text-align: right
}
@media (min-width:640px) {
@media (min-width: 640px) {
.choices__list--dropdown .choices__item--selectable[data-select-text],
.choices__list[aria-expanded] .choices__item--selectable[data-select-text] {
@@ -430,7 +428,7 @@
.choices__list--dropdown .choices__item--selectable.is-highlighted,
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
background-color: var(--color-base-100)
background-color: color-mix(in oklab, var(--color-primary) 10%, var(--color-base-100))
}
.choices__list--dropdown .choices__item--selectable.is-highlighted::after,
@@ -473,7 +471,8 @@
.choices__button:focus,
.choices__input:focus {
outline: 0
outline: 0;
box-shadow: none;
}
.choices__input {

14
uv.lock generated
View File

@@ -318,6 +318,18 @@ honcho = [
{ name = "honcho" },
]
[[package]]
name = "django-waffle"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/e1/6f533da0d4ac89f427dfd9410e39bfc14ae3a23335ecd549d76be4b2a834/django_waffle-5.0.0.tar.gz", hash = "sha256:62f9d00eedf68dafb82657beab56e601bddedc1ea1ccfef91d83df8658708509", size = 37761, upload-time = "2025-06-12T07:38:54.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/d2/6f0d664bd35a3fdd0403655c7c32ec290704923f11541ef356b180cd8fbf/django_waffle-5.0.0-py3-none-any.whl", hash = "sha256:3312851d9d926b76b9e90712355781700a383b82b5bf2b61e1f1be97532c0f3d", size = 48137, upload-time = "2025-06-12T07:38:53.698Z" },
]
[[package]]
name = "honcho"
version = "2.0.0"
@@ -701,6 +713,7 @@ dependencies = [
{ name = "django-htmx" },
{ name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "django-tailwind", extra = ["cookiecutter", "honcho"] },
{ name = "django-waffle" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "python-decouple" },
@@ -723,6 +736,7 @@ requires-dist = [
{ name = "django-htmx", specifier = ">=1.27.0" },
{ name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "django-tailwind", extras = ["cookiecutter", "honcho"], specifier = ">=4.4.2" },
{ name = "django-waffle", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=12.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "python-decouple", specifier = ">=3.8" },