Compare commits

...

5 Commits

15 changed files with 834 additions and 18 deletions

View File

@@ -147,6 +147,7 @@ CONSTANCE_CONFIG = {
"TF_DEFAULT_SEASON_MONTH": (config("TF_DEFAULT_SEASON_MONTH", default=8, cast=int), "Default season start month", int), "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_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_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_FORMAT = "INTERNATIONAL"

View File

@@ -5,8 +5,8 @@ from .views import MemberAddView, MemberDeleteView, MemberEditView, MemberListVi
app_name = "members" app_name = "members"
urlpatterns = [ urlpatterns = [
path("", MemberListView.as_view(), name="list"), path("", MemberListView.as_view(), name="list"),
# path("add/", MemberAddView.as_view(), name="add"), 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("<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,14 +1,17 @@
from typing import Any from typing import Any
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, UpdateView, CreateView
from django_filters.views import FilterView from django_filters.views import FilterView
from rules.contrib.views import PermissionRequiredMixin from rules.contrib.views import PermissionRequiredMixin
from members.filters import MemberFilter from members.filters import MemberFilter
from members.models import Member from members.models import Member
from members.forms import MemberForm
from ..mixins import HTMXViewMixin from ..mixins import HTMXViewMixin
class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView): class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
@@ -17,19 +20,71 @@ class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
permission_denied_message = _("You do not have permission to view this page.") permission_denied_message = _("You do not have permission to view this page.")
permission_required = "members.view_member" permission_required = "members.view_member"
partial_name = "members/member_filter.html#content" partial_name = "members/member_filter.html#content"
menu_highlight = "members"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())
return HttpResponseRedirect(reverse_lazy("backend:index")) return HttpResponseRedirect(reverse_lazy("backend:index"))
def get_filterset_kwargs(self, filterset_class) -> dict[str, Any]:
kwargs = super().get_filterset_kwargs(filterset_class)
class MemberAddView: ... filter_values = {} if kwargs["data"] is None else kwargs["data"].dict()
if not filter_values:
filter_values.update({"user__is_active": "true"})
kwargs["data"] = filter_values
return kwargs
class MemberAddView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView):
model = Member
form_class = MemberForm
permission_required = "members.add_member"
permission_denied_message = _("You do not have permission to view this page.")
success_message = _("Member <strong>%(name)s</strong> has been created 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())
class MemberEditView: ... class MemberEditView: ...
class MemberDeleteView: ... class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMixin, DeleteView):
model = Member
success_message = _("Member <strong>%(name)s</strong> has been deleted successfully.")
permission_required = "members.delete_member"
permission_denied_message = _("You do not have permission to view this page.")
success_url = reverse_lazy("backend:members:list")
partial_name = "members/member_confirm_delete.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 post(self, request, *args, **kwargs):
self.object = self.get_object()
# Soft delete user
self.object.user.is_active = False
self.object.user.save()
# Do not delete the member object
messages.success(self.request, self.get_success_message({"name": self.object.user.get_full_name()}))
return HttpResponseRedirect(self.get_success_url())
class MemberLoadView: ... class MemberLoadView: ...

View File

@@ -1,3 +1,5 @@
import json
from django.http import HttpResponse from django.http import HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
@@ -17,14 +19,14 @@ class HTMXPartialMixin:
class HTMXViewMixin: class HTMXViewMixin:
""" """
A full featured HTMX integration mixin for Django CBVs. A full-featured HTMX integration mixin for Django CBVs.
Supports: Supports:
- partial rendering via django-partials - partial rendering via django-partials
- HX-Redirect - HX-Redirect
- HX-Push-URL - HX-Push-URL
- HX-Trigger events - HX-Trigger events
- HX-Refresh - HX-Refresh
- Graceful fallback to normal DJango rendering - Graceful fallback to normal Django rendering
""" """
# Name of the partial block: template.html#partial_name # Name of the partial block: template.html#partial_name
@@ -33,6 +35,9 @@ class HTMXViewMixin:
# Optional: automatically push URL on GET # Optional: automatically push URL on GET
htmx_push_url = None htmx_push_url = None
# Optional: name of the menu item to highlight
menu_highlight = None
# Optional: trigger events after rendering # Optional: trigger events after rendering
htmx_trigger = None htmx_trigger = None
htmx_trigger_after_settle = None htmx_trigger_after_settle = None
@@ -63,8 +68,20 @@ class HTMXViewMixin:
print(response.headers) print(response.headers)
# Build HX-Trigger payload
trigger_payload = {}
# 1. User-defined triggers
if self.htmx_trigger: if self.htmx_trigger:
response.headers["HX-Trigger"] = self.htmx_trigger trigger_payload.update(json.loads(self.htmx_trigger))
# 2. Auto menu highlight trigger
if self.menu_highlight:
trigger_payload["menuHighlight"] = self.menu_highlight
# Emit HX-Trigger if anything is present
if trigger_payload:
response.headers["HX-Trigger"] = json.dumps(trigger_payload)
if self.htmx_trigger_after_settle: if self.htmx_trigger_after_settle:
response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle

View File

@@ -24,5 +24,5 @@ class MemberAdmin(admin.ModelAdmin):
fieldsets = [ fieldsets = [
("GENERAL INFORMATION", {"fields": ["user", "family_members", "birthday", "license", "access_token"]}), ("GENERAL INFORMATION", {"fields": ["user", "family_members", "birthday", "license", "access_token"]}),
("CONTACT_INFORMATION", {"fields": ["phone_number", "emergency_phone_number"]}), ("CONTACT_INFORMATION", {"fields": ["phone_number", "emergency_phone_number"]}),
("METADATA", {"fields": ["created", "updated"]}), ("METADATA", {"fields": ["notes", "created", "updated"]}),
] ]

View File

@@ -8,9 +8,10 @@ class MemberFilter(django_filters.FilterSet):
user__first_name = django_filters.CharFilter(field_name="user__first_name", label=_("First name"), lookup_expr="icontains") user__first_name = django_filters.CharFilter(field_name="user__first_name", label=_("First name"), lookup_expr="icontains")
user__last_name = django_filters.CharFilter(field_name="user__last_name", label=_("Last name"), lookup_expr="icontains") user__last_name = django_filters.CharFilter(field_name="user__last_name", label=_("Last name"), lookup_expr="icontains")
license = django_filters.CharFilter(label=_("License"), lookup_expr="icontains") license = django_filters.CharFilter(label=_("License"), lookup_expr="icontains")
user__is_active = django_filters.TypedChoiceFilter( field_name='user__is_active', label=_("Active?"), initial="true", choices=( ('', 'All users'), ('true', 'Active users'), ('false', 'Inactive users'), ), coerce=lambda x: x.lower() == 'true' )
class Meta: class Meta:
model = Member model = Member
fields = ["user__first_name", "user__last_name", "license"] fields = ["user__first_name", "user__last_name", "license", "user__is_active"]

42
members/forms.py Normal file
View File

@@ -0,0 +1,42 @@
from django import forms
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)
email = forms.EmailField(label=_("Email"))
admin = forms.BooleanField(label=_("Admin?"), required=False, help_text=_("If checked will mark this user as a site admin granting them all permissions"))
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput, required=False)
password_confirmation = forms.CharField(label=_("Confirm password"), widget=forms.PasswordInput, required=False)
class Meta:
model = Member
fields = ["phone_number", "emergency_phone_number", "license", "birthday", "family_members"]
localized_fields = fields
def save(self, commit: bool = True) -> Member:
password = None
if self.cleaned_data["password"] is not None and self.cleaned_data["password"] != "" and self.cleaned_data["password"] == self.cleaned_data["password_confirmation"]:
password = self.cleaned_data["password"]
member = Member.create(first_name=self.cleaned_data["first_name"], last_name=self.cleaned_data["last_name"], email=self.cleaned_data["email"], password=password, member=self.instance)
member.phone_number = self.cleaned_data["phone_number"]
member.emergency_phone_number = self.cleaned_data["emergency_phone_number"]
member.license = self.cleaned_data["license"]
member.birthday = self.cleaned_data["birthday"]
if self.cleaned_data["admin"]:
member.user.is_superuser = True
member.user.save(update_fields=["is_superuser"])
member.save(update_fields=["phone_number", "emergency_phone_number", "license", "birthday"])
member.family_members.set(self.cleaned_data["family_members"])
return member

View File

@@ -1,4 +1,9 @@
import secrets
import string
from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@@ -22,6 +27,7 @@ class Member(RulesModel):
phone_number = PhoneNumberField(_("phone number"), blank=True, null=True) phone_number = PhoneNumberField(_("phone number"), blank=True, null=True)
emergency_phone_number = PhoneNumberField(_("emergency phone number"), blank=True, null=True) emergency_phone_number = PhoneNumberField(_("emergency phone number"), blank=True, null=True)
notes = models.TextField(_("notes"), blank=True, null=True)
access_token = models.CharField(_("access token"), max_length=255, blank=True, null=True) access_token = models.CharField(_("access token"), max_length=255, blank=True, null=True)
@@ -44,3 +50,57 @@ class Member(RulesModel):
def __str__(self): def __str__(self):
return self.user.get_full_name() return self.user.get_full_name()
@classmethod
def create(cls, first_name: str, last_name: str, email: str, password: Optional[str] = None, member: Optional["Member"] = None) -> "Member":
"""Creates a new member based on the provided details"""
if member is not None and member.pk is not None:
member.user.first_name = first_name
member.user.last_name = last_name
member.user.email = email
member.user.username = email
if password is not None and password != "":
member.user.set_password(password)
else:
# First check to see if a user already exists in the system
user, created = get_user_model().objects.get_or_create(
username=email,
defaults={
"first_name": first_name,
"last_name": last_name,
"email": email
}
)
if not created:
user.first_name = first_name
user.last_name = last_name
user.email = email
user.username = email
if hasattr(user, "member"):
member = user.member
if password is not None and password != "":
user.set_password(password)
else:
member = cls()
initial_password = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20))
if password is None or password == "":
password = initial_password
member.notes = f"Initial password: {initial_password}"
user.set_password(password)
member.user = user
if not member.user.is_active:
member.user.is_active = True
member.user.save()
member.save()
return member

View File

@@ -9,7 +9,7 @@
{% if is_member_manager %} {% if is_member_manager %}
<li class="menu-title">Members</li> <li class="menu-title">Members</li>
<li><a href="{{ members_list }}" {% if members_list in request.path %}class="menu-active"{% endif %}><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 %} {% endif %}
<li class="menu-title mt-4">Navigation</li> <li class="menu-title mt-4">Navigation</li>

View File

@@ -0,0 +1,29 @@
{% if messages %}
<div id="messages" class="flex flex-col p-2 m-4 -mt-4 gap-y-2" {% if request.htmx %} hx-swap-oob="true"{% endif %}>
{% for message in messages %}
{% if message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}
<div role="alert" class="alert alert-success">
<i class="text-lg fa-solid fa-check"></i>
<span>{{ message|safe }}</span>
</div>
{% elif message.level == DEFAULT_MESSAGE_LEVELS.WARNING %}
<div role="alert" class="alert alert-warning">
<i class="text-lg fa-solid fa-ban"></i>
<span>{{ message|safe }}</span>
</div>
{% elif message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}
<div role="alert" class="alert alert-error">
<i class="text-lg fa-solid fa-exclamation"></i>
<span>{{ message|safe }}</span>
</div>
{% else %}
<div role="alert" class="alert alert-info">
<i class="text-lg fa-solid fa-info"></i>
<span>{{ message|safe }}</span>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div id="messages"{% if request.htmx %} hx-swap-oob="true"{% endif %}></div>
{% endif %}

View File

@@ -101,11 +101,15 @@
<!-- MAIN CONTENT--> <!-- MAIN CONTENT-->
<div class="drawer-content flex w-full"> <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" id="content"> <main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full">
{% include "backend/partials/messages.html" %}
<div id="content">
{% block content %} {% block content %}
<h1 class="text-3xl font-bold">Welcome!</h1> <h1 class="text-3xl font-bold">Welcome!</h1>
<p>This is your main content area.</p> <p>This is your main content area.</p>
{% endblock %} {% endblock %}
</div>
</main> </main>
</div> </div>
</div> </div>
@@ -116,6 +120,13 @@
</footer> </footer>
<script> <script>
document.body.addEventListener("menuHighlight", (event) => {
const active = event.detail.value;
document.querySelectorAll(".menu-item").forEach(el => {
el.classList.toggle("menu-active", el.dataset.menu === active);
});
});
{% comment %}// Shrinking navbar on scroll {% comment %}// Shrinking navbar on scroll
const navbar = document.getElementById("mainNavbar"); const navbar = document.getElementById("mainNavbar");
let lastScroll = 0; window.addEventListener("scroll", () => { let lastScroll = 0; window.addEventListener("scroll", () => {

View File

@@ -0,0 +1,27 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% load pagination %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Members" %}</h1>
<div>
{% blocktranslate with name=object.user.get_full_name %}
Are you sure you want to delete member <span class="font-bold">{{ name }}</span>?
{% endblocktranslate %}
</div>
<form method="post" hx-post="{% url "backend:members:delete" object.pk %}" hx-target="#content">
{% csrf_token %}
<div class="flex flex-row gap-2 mt-8">
<a href="{% url "backend:members:list" %}" class="btn btn-neutral btn-outline grow lg:grow-0" hx-get="{% url "backend:members:list" %}" hx-target="#content">{% translate "Cancel" %}</a>
<button type="submit" class="btn btn-error grow lg:grow-0"><i class="fa-solid fa-trash"></i>{% translate "Delete" %}</button>
</div>
</form>
{% endpartialdef content %}
{% endblock content %}

View File

@@ -7,7 +7,11 @@
{% block content %} {% block content %}
{% partialdef content inline %} {% partialdef content inline %}
<h1 class="page-title">Members</h1> {% if request.htmx %}
{% include "backend/partials/messages.html" %}
{% endif %}
<h1 class="page-title">{% translate "Members" %}</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border"> <div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<input type="checkbox" /> <input type="checkbox" />
@@ -59,7 +63,7 @@
<i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %} <i class="fa-solid fa-file-upload"></i>{% translate "Load members from file" %}
</a> </a>
<a class="btn btn-neutral btn-outline btn-sm grow" href=""> <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" %} <i class="fa-solid fa-plus"></i>{% translate "Add member" %}
</a> </a>
</div> </div>
@@ -129,7 +133,7 @@
<i class="fa-solid fa-eye"></i>{% translate "Details" %} <i class="fa-solid fa-eye"></i>{% translate "Details" %}
</a> </a>
<a class="btn btn-outline btn-error btn-sm" href=""> <a class="btn btn-outline btn-error btn-sm" 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>{% translate "Delete" %} <i class="fa-solid fa-trash"></i>{% translate "Delete" %}
</a> </a>
</div> </div>

View File

@@ -0,0 +1,157 @@
{% extends "backend/base.html" %}
{% load i18n %}
{% load form_field %}
{% load avatar %}
{% block content %}
{% partialdef content inline %}
<h1 class="page-title">{% translate "Members" %}</h1>
<div class="flex flex-row gap-2 items-center lg:hidden">
<div class="text-3xl min-w-12">
<a href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-angle-left"></i>
</a>
</div>
<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 %}
</div>
<div class="flex min-w-12 justify-end">
{% if member %}
<a class="btn btn-error btn-outline" href="{% url "backend:members:delete" member.pk %}">
<i class="fa-solid fa-trash"></i>
</a>
{% else %}
&nbsp;
{% endif %}
</div>
</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 }}
</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>
{{ member.emergency_phone_number }}
</a>
{% endif %}
</div>
{% if config.TF_ENABLE_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 %}
<div class="flex 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>
{% if member.user.is_superuser %}
<div class="badge badge-accent"><i class="fa-solid fa-user-shield"></i>{% translate "Admin" %}</div>
{% 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>
{% 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>
{% endif %}
{% if config.TF_ENABLE_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 %}
<a href="{% url "backend:members:members_delete" member.id %}" class="btn btn-error btn-outline">
<i class="fa-solid fa-trash"></i>{% translate "Delete" %}
</a>
</div>
</div>
{% else %}
<h2 class="page-subtitle border-b-0! hidden lg:flex">{% translate "Create new member" %}</h2>
{% endif %}
{% 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 "Personal information" %}</h2>
<div class="grid grid-cols-1 gap-4 mt-2 lg:grid-cols-2">
{% 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">
{% 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">
{% 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">
{% 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">
{% form_field form.password %}
{% form_field form.password_confirmation %}
</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

@@ -57,6 +57,7 @@
} }
@source inline("input-{xs,sm,md,lg,xl}"); @source inline("input-{xs,sm,md,lg,xl}");
@source inline("select-{xs,sm,md,lg,xl}");
@layer components { @layer components {
h1.page-title { h1.page-title {
@@ -68,6 +69,12 @@
} }
} }
h2.page-subtitle {
@apply text-xl font-bold;
@apply mt-8;
@apply border-b border-base-content;
}
div.action_bar { div.action_bar {
@apply flex flex-col lg:flex-row; @apply flex flex-col lg:flex-row;
@apply items-center; @apply items-center;
@@ -98,3 +105,408 @@
} }
} }
} }
.choices {
position: relative;
overflow: hidden;
margin-bottom: 0px;
font-size: 16px
}
.choices:focus {
outline: 0
}
.choices:last-child {
margin-bottom: 0
}
.choices.is-open {
overflow: visible
}
.choices.is-disabled .choices__inner,
.choices.is-disabled .choices__input {
background-color: #eaeaea;
cursor: not-allowed;
-webkit-user-select: none;
user-select: none
}
.choices.is-disabled .choices__item {
cursor: not-allowed
}
.choices [hidden] {
display: none !important
}
.choices[data-type*=select-one] {
cursor: pointer
}
.choices[data-type*=select-one] .choices__inner {
padding-bottom: 7.5px
}
.choices[data-type*=select-one] .choices__input {
display: block;
width: 100%;
padding: 10px;
border-bottom: 1px solid #ddd;
background-color: #fff;
margin: 0
}
.choices[data-type*=select-one] .choices__button {
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);
padding: 0;
background-size: 8px;
position: absolute;
top: 50%;
right: 0;
margin-top: -10px;
margin-right: 25px;
height: 20px;
width: 20px;
border-radius: 10em;
opacity: .25
}
.choices[data-type*=select-one] .choices__button:focus,
.choices[data-type*=select-one] .choices__button:hover {
opacity: 1
}
.choices[data-type*=select-one] .choices__button:focus {
box-shadow: 0 0 0 2px #005f75
}
.choices[data-type*=select-one] .choices__item[data-placeholder] .choices__button {
display: none
}
.choices[data-type*=select-one]::after {
content: "";
height: 0;
width: 0;
border-style: solid;
border-color: #333 transparent transparent;
border-width: 5px;
position: absolute;
right: 11.5px;
top: 50%;
margin-top: -2.5px;
pointer-events: none
}
.choices[data-type*=select-one].is-open::after {
border-color: transparent transparent #333;
margin-top: -7.5px
}
.choices[data-type*=select-one][dir=rtl]::after {
left: 11.5px;
right: auto
}
.choices[data-type*=select-one][dir=rtl] .choices__button {
right: auto;
left: 0;
margin-left: 25px;
margin-right: 0
}
.choices[data-type*=select-multiple] .choices__inner,
.choices[data-type*=text] .choices__inner {
cursor: text
}
.choices[data-type*=select-multiple] .choices__button,
.choices[data-type*=text] .choices__button {
position: relative;
display: inline-block;
margin: 0-4px 0 2px;
padding-left: 16px;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);
background-size: 8px;
width: 8px;
line-height: 1;
opacity: .75;
border-radius: 0
}
.choices[data-type*=select-multiple] .choices__button:focus,
.choices[data-type*=select-multiple] .choices__button:hover,
.choices[data-type*=text] .choices__button:focus,
.choices[data-type*=text] .choices__button:hover {
opacity: 1
}
.choices__inner {
display: inline-block;
vertical-align: top;
width: 100%;
background-color: var(--color-base-100);
padding: 7.5px 7.5px 3.75px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, #0000);
border-radius: var(--radius-field);
font-size: 14px;
min-height: 44px;
overflow: hidden
}
.is-focused .choices__inner,
.is-open .choices__inner {
border-color: var(--color-base-content);
isolation: isolate;
border-radius: var(--radius-field);
}
.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);
}
.is-flipped.is-open .choices__inner {
border-radius: var(--radius-field);
}
.choices__list {
margin: 0;
padding-left: 0;
list-style: none
}
.choices__list--single {
display: inline-block;
padding: 4px 16px 4px 4px;
width: 100%
}
[dir=rtl] .choices__list--single {
padding-right: 4px;
padding-left: 16px
}
.choices__list--single .choices__item {
width: 100%
}
.choices__list--multiple {
display: inline
}
.choices__list--multiple .choices__item {
display: inline-block;
vertical-align: middle;
border-radius: var(--radius-selector);
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
margin-right: 3.75px;
margin-bottom: 3.75px;
background-color: var(--color-primary);
border: 1px solid var(--color-primary);
color: #fff;
word-break: break-all;
box-sizing: border-box
}
.choices__list--multiple .choices__item[data-deletable] {
padding-right: 5px
}
[dir=rtl] .choices__list--multiple .choices__item {
margin-right: 0;
margin-left: 3.75px
}
.choices__list--multiple .choices__item.is-highlighted {
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary)
}
.is-disabled .choices__list--multiple .choices__item {
background-color: #aaa;
border: 1px solid #919191
}
.choices__list--dropdown,
.choices__list[aria-expanded] {
display: none;
z-index: 1;
position: absolute;
width: 100%;
background-color: var(--color-base-100);
border: 1px solid #ddd;
top: 100%;
margin-top: 3px;
border-radius: var(--radius-field);
overflow: hidden;
word-break: break-all
}
.is-active.choices__list--dropdown,
.is-active.choices__list[aria-expanded] {
display: block
}
.is-open .choices__list--dropdown,
.is-open .choices__list[aria-expanded] {
border-color: #b7b7b7
}
.is-flipped .choices__list--dropdown,
.is-flipped .choices__list[aria-expanded] {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: -1px;
border-radius: .25rem .25rem 0 0
}
.choices__list--dropdown .choices__list,
.choices__list[aria-expanded] .choices__list {
position: relative;
max-height: 300px;
overflow: auto;
-webkit-overflow-scrolling: touch;
will-change: scroll-position
}
.choices__list--dropdown .choices__item,
.choices__list[aria-expanded] .choices__item {
position: relative;
padding: 10px;
font-size: 14px
}
[dir=rtl] .choices__list--dropdown .choices__item,
[dir=rtl] .choices__list[aria-expanded] .choices__item {
text-align: right
}
@media (min-width:640px) {
.choices__list--dropdown .choices__item--selectable[data-select-text],
.choices__list[aria-expanded] .choices__item--selectable[data-select-text] {
padding-right: 100px
}
.choices__list--dropdown .choices__item--selectable[data-select-text]::after,
.choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after {
content: attr(data-select-text);
font-size: 12px;
opacity: 0;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%)
}
[dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text],
[dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text] {
text-align: right;
padding-left: 100px;
padding-right: 10px
}
[dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text]::after,
[dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after {
right: auto;
left: 10px
}
}
.choices__list--dropdown .choices__item--selectable.is-highlighted,
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
background-color: var(--color-base-100)
}
.choices__list--dropdown .choices__item--selectable.is-highlighted::after,
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted::after {
opacity: .5
}
.choices__item {
cursor: default
}
.choices__item--selectable {
cursor: pointer
}
.choices__item--disabled {
cursor: not-allowed;
-webkit-user-select: none;
user-select: none;
opacity: .5
}
.choices__heading {
font-weight: 600;
font-size: 12px;
padding: 10px;
border-bottom: 1px solid #f7f7f7;
color: gray
}
.choices__button {
text-indent: -9999px;
appearance: none;
border: 0;
background-color: transparent;
background-repeat: no-repeat;
background-position: center;
cursor: pointer
}
.choices__button:focus,
.choices__input:focus {
outline: 0
}
.choices__input {
display: inline-block;
vertical-align: baseline;
background-color: var(--color-base-100);
font-size: 14px;
margin-bottom: 5px;
border: 0;
border-radius: 0;
max-width: 100%;
padding: 4px 0 4px 2px
}
.choices__input::-webkit-search-cancel-button,
.choices__input::-webkit-search-decoration,
.choices__input::-webkit-search-results-button,
.choices__input::-webkit-search-results-decoration {
display: none
}
.choices__input::-ms-clear,
.choices__input::-ms-reveal {
display: none;
width: 0;
height: 0
}
[dir=rtl] .choices__input {
padding-right: 2px;
padding-left: 0
}
.choices__placeholder {
opacity: .5
}