diff --git a/TeamForge/settings.py b/TeamForge/settings.py index cabef62..0b55ca0 100644 --- a/TeamForge/settings.py +++ b/TeamForge/settings.py @@ -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_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" diff --git a/backend/members/urls.py b/backend/members/urls.py index be8770d..d53996c 100644 --- a/backend/members/urls.py +++ b/backend/members/urls.py @@ -5,7 +5,7 @@ from .views import MemberAddView, MemberDeleteView, MemberEditView, MemberListVi app_name = "members" urlpatterns = [ path("", MemberListView.as_view(), name="list"), - # path("add/", MemberAddView.as_view(), name="add"), + path("add/", MemberAddView.as_view(), name="add"), # path("/edit/", MemberEditView.as_view(), name="edit"), path("/delete/", MemberDeleteView.as_view(), name="delete"), # path("load/", MemberLoadView.as_view(), name="load"), diff --git a/backend/members/views.py b/backend/members/views.py index 608e69c..c518e08 100644 --- a/backend/members/views.py +++ b/backend/members/views.py @@ -5,12 +5,13 @@ 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 +from django.views.generic import DeleteView, UpdateView, CreateView from django_filters.views import FilterView from rules.contrib.views import PermissionRequiredMixin from members.filters import MemberFilter from members.models import Member +from members.forms import MemberForm from ..mixins import HTMXViewMixin class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView): @@ -37,7 +38,22 @@ class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView): return kwargs -class MemberAddView: ... +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 %(name)s 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: ... @@ -49,6 +65,7 @@ class MemberDeleteView(HTMXViewMixin, PermissionRequiredMixin, SuccessMessageMix 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: diff --git a/members/admin.py b/members/admin.py index af69ec6..ad6f7c0 100644 --- a/members/admin.py +++ b/members/admin.py @@ -24,5 +24,5 @@ class MemberAdmin(admin.ModelAdmin): fieldsets = [ ("GENERAL INFORMATION", {"fields": ["user", "family_members", "birthday", "license", "access_token"]}), ("CONTACT_INFORMATION", {"fields": ["phone_number", "emergency_phone_number"]}), - ("METADATA", {"fields": ["created", "updated"]}), + ("METADATA", {"fields": ["notes", "created", "updated"]}), ] diff --git a/members/forms.py b/members/forms.py new file mode 100644 index 0000000..ff6a270 --- /dev/null +++ b/members/forms.py @@ -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 + + \ No newline at end of file diff --git a/members/models.py b/members/models.py index 3ae0762..cfab2c6 100644 --- a/members/models.py +++ b/members/models.py @@ -1,4 +1,9 @@ +import secrets +import string +from typing import Optional + from django.conf import settings +from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField @@ -22,6 +27,7 @@ class Member(RulesModel): phone_number = PhoneNumberField(_("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) @@ -44,3 +50,57 @@ class Member(RulesModel): def __str__(self): 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 \ No newline at end of file diff --git a/templates/backend/partials/messages.html b/templates/backend/partials/messages.html new file mode 100644 index 0000000..295c0ec --- /dev/null +++ b/templates/backend/partials/messages.html @@ -0,0 +1,29 @@ +{% if messages %} +
+ {% for message in messages %} + {% if message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} + + {% elif message.level == DEFAULT_MESSAGE_LEVELS.WARNING %} + + {% elif message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} + + {% else %} + + {% endif %} + {% endfor %} +
+{% else %} +
+{% endif %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 7194e49..e992961 100644 --- a/templates/base.html +++ b/templates/base.html @@ -101,11 +101,15 @@
-
- {% block content %} -

Welcome!

-

This is your main content area.

- {% endblock %} +
+ {% include "backend/partials/messages.html" %} + +
+ {% block content %} +

Welcome!

+

This is your main content area.

+ {% endblock %} +
diff --git a/templates/members/member_confirm_delete.html b/templates/members/member_confirm_delete.html index 4eb4d1d..2222f04 100644 --- a/templates/members/member_confirm_delete.html +++ b/templates/members/member_confirm_delete.html @@ -15,11 +15,11 @@ {% endblocktranslate %} -
+ {% csrf_token %}
- {% translate "Cancel" %} + {% translate "Cancel" %}
diff --git a/templates/members/member_filter.html b/templates/members/member_filter.html index 3d49c13..503715b 100644 --- a/templates/members/member_filter.html +++ b/templates/members/member_filter.html @@ -7,6 +7,10 @@ {% block content %} {% partialdef content inline %} + {% if request.htmx %} + {% include "backend/partials/messages.html" %} + {% endif %} +

{% translate "Members" %}

@@ -59,7 +63,7 @@ {% translate "Load members from file" %} - + {% translate "Add member" %}
@@ -129,7 +133,7 @@ {% translate "Details" %} - + {% translate "Delete" %} diff --git a/templates/members/member_form.html b/templates/members/member_form.html new file mode 100644 index 0000000..8f6de77 --- /dev/null +++ b/templates/members/member_form.html @@ -0,0 +1,157 @@ +{% extends "backend/base.html" %} + +{% load i18n %} +{% load form_field %} +{% load avatar %} + +{% block content %} + {% partialdef content inline %} +

{% translate "Members" %}

+ +
+
+ + + +
+ +
+ {% if member %} +
{{ member.user.get_full_name }}
+ + {% if member.user.is_superuser %} +
+
+ +
+
+ {% endif %} + {% else %} +
{% translate "Create new member" %}
+ {% endif %} +
+ +
+ {% if member %} + + + + {% else %} +   + {% endif %} +
+
+ + {% if member %} +
+
+ {% if member.phone_number %} + + + {{ member.phone }} + + {% endif %} + + {% if member.emergency_phone_number %} + + + {{ member.emergency_phone_number }} + + {% endif %} +
+ + {% if config.TF_ENABLE_TEAMS %} + + {% translate "View team memberships" %} + + {% endif %} + + + {% else %} + + {% endif %} + + {% if form.errors %} +
+ + +
+
{% translate "Error" %}
+
{% translate "Please correct the errors below before saving again." %}
+
+
+ {% endif %} + +
+ {% csrf_token %} + +

{% translate "Personal information" %}

+
+ {% form_field form.first_name %} + {% form_field form.last_name %} + {% form_field form.birthday %} +
+ +

{% translate "Contact information" %}

+
+ {% form_field form.email %} + {% form_field form.phone_number %} + {% form_field form.emergency_phone_number %} +
+ +

{% translate "Family information" %}

+
+ {% form_field form.family_members %} +
+ +

{% translate "Club information" %}

+
+ {% form_field form.license %} + {% form_field form.admin show_as_toggle=True %} +
+ +

{% translate "Password" %}

+
{% 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.

If both fields are empty the current password will not be changed.{% endblocktranslate %}
+
+ {% form_field form.password %} + {% form_field form.password_confirmation %} +
+ + +
+ + + {% endpartialdef content %} +{% endblock content %} \ No newline at end of file diff --git a/theme/static_src/src/styles.css b/theme/static_src/src/styles.css index f0696be..b623fd1 100644 --- a/theme/static_src/src/styles.css +++ b/theme/static_src/src/styles.css @@ -69,6 +69,12 @@ } } + h2.page-subtitle { + @apply text-xl font-bold; + @apply mt-8; + @apply border-b border-base-content; + } + div.action_bar { @apply flex flex-col lg:flex-row; @apply items-center; @@ -98,4 +104,409 @@ @apply min-w-fit lg:w-fit; } } +} + +.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 } \ No newline at end of file