Source code for organisations.views.club_menu_tabs.members

import csv
from datetime import date, timedelta
import logging
from itertools import chain
from threading import Thread

from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.db.utils import IntegrityError
from django.http import HttpResponse, HttpRequest
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone

import organisations.views.club_menu_tabs.utils
from accounts.views.admin import invite_to_join
from accounts.views.core import add_un_registered_user_with_mpc_data
from accounts.views.api import search_for_user_in_cobalt_and_mpc
from accounts.forms import UnregisteredUserForm
from accounts.models import User, UnregisteredUser, UserAdditionalInfo
from club_sessions.models import SessionEntry
from cobalt.settings import (
    ALL_SYSTEM_ACCOUNTS,
    ALL_SYSTEM_ACCOUNT_SYSTEM_NUMBERS,
    BRIDGE_CREDITS,
    GLOBAL_CURRENCY_SYMBOL,
    COBALT_HOSTNAME,
    GLOBAL_ORG,
    GLOBAL_TITLE,
)
from masterpoints.views import (
    search_mpc_users_by_name,
    user_summary,
)
from masterpoints.factories import masterpoint_query_row
from notifications.models import (
    BatchID,
    Recipient,
    UnregisteredBlockedEmail,
)
from notifications.views.core import (
    custom_sender,
    send_cobalt_email_with_template,
    create_rbac_batch_id,
    club_default_template,
    get_emails_sent_to_address,
)
from organisations.club_admin_core import (
    add_member,
    can_perform_action,
    change_membership,
    club_email_for_member,
    club_has_unregistered_members,
    is_member_allowing_auto_pay,
    get_club_members,
    get_club_member_list,
    get_club_member_list_email_match,
    get_contact_system_numbers,
    get_membership_details_for_club,
    get_member_count,
    get_member_details,
    get_members_for_renewal,
    get_member_log,
    get_member_system_numbers,
    get_outstanding_memberships_for_member,
    get_outstanding_memberships,
    get_valid_actions,
    get_valid_activities,
    log_member_change,
    make_membership_payment,
    member_details_short_description,
    member_has_future,
    perform_simple_action,
    renew_membership,
    send_renewal_notice,
    _format_renewal_notice_email,
    MEMBERSHIP_STATES_TERMINAL,
)
from organisations.decorators import check_club_menu_access
from organisations.forms import (
    ContactNameForm,
    MemberClubEmailForm,
    UserMembershipForm,
    UnregisteredUserAddForm,
    UnregisteredUserMembershipForm,
    MemberClubDetailsForm,
    MembershipExtendForm,
    MembershipChangeTypeForm,
    MembershipPaymentForm,
    MembershipRawEditForm,
    BulkRenewalFormSet,
    BulkRenewalOptionsForm,
)
from organisations.models import (
    MemberMembershipType,
    Organisation,
    MemberClubEmail,
    MemberClubOptions,
    MemberClubDetails,
    ClubLog,
    MemberClubTag,
    ClubTag,
    MembershipType,
    WelcomePack,
    OrgEmailTemplate,
    MiscPayType,
    RenewalParameters,
)
from organisations.views.general import (
    get_rbac_model_for_state,
)
from payments.models import (
    MemberTransaction,
    UserPendingPayment,
    OrgPaymentMethod,
)
from payments.views.core import (
    get_balance,
    org_balance,
    update_account,
    update_organisation,
)
from payments.views.payments_api import payment_api_batch
from rbac.core import rbac_user_has_role
from post_office.models import Email as PostOfficeEmail

from rbac.views import rbac_forbidden
from utils.utils import cobalt_currency, cobalt_paginator

logger = logging.getLogger("cobalt")


@check_club_menu_access()
def list_htmx(request: HttpRequest, club: Organisation, message: str = None):
    """build the members tab in club menu"""

    post_message = request.POST.get("message", None)
    if message and post_message:
        message = f"{message}. {post_message}"
    elif post_message:
        message = post_message

    DEFAULT_SORT = "last_desc"

    def save_sort_order(new_order):
        """Save the selected sort order"""
        additional_info = UserAdditionalInfo.objects.filter(user=request.user).last()
        if additional_info:
            if additional_info.member_sort_order != new_order:
                additional_info.member_sort_order = new_order
                additional_info.save()
        elif new_order != DEFAULT_SORT:
            additional_info = UserAdditionalInfo()
            additional_info.user = request.user
            additional_info.member_sort_order = new_order
            additional_info.save()

    # get sort options, could be POST or GET
    sort_option = request.GET.get("sort_by")
    if not sort_option:
        sort_option = request.POST.get("sort_by")
        if not sort_option:
            additional_info = UserAdditionalInfo.objects.filter(
                user=request.user
            ).last()
            if additional_info and additional_info.member_sort_order:
                sort_option = additional_info.member_sort_order
            else:
                sort_option = "last_desc"
        else:
            save_sort_order(sort_option)
    else:
        save_sort_order(sort_option)

    #  show former members
    former_members = request.POST.get("former_members") == "on"

    members = get_club_members(
        club,
        sort_option=sort_option,
        active_only=not former_members,
        exclude_deceased=not former_members,
    )

    # pagination and params
    things = cobalt_paginator(request, members)
    searchparams = f"sort_by={sort_option}&"

    total_members = len(members)

    # add balances (done after pagination to reduce load)
    for thing in things:
        thing.balance = (
            None
            if thing.user_type == "Unregistered User"
            else get_balance(thing.user_or_unreg)
        )

    # Check level of access
    member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit")

    has_errors = _check_member_errors(club)

    hx_post = reverse("organisations:club_menu_tab_members_htmx")

    return render(
        request,
        "organisations/club_menu/members/list_htmx.html",
        {
            "club": club,
            "things": things,
            "total_members": total_members,
            "message": message,
            "member_admin": member_admin,
            "has_errors": has_errors,
            "hx_post": hx_post,
            "searchparams": searchparams,
            "sort_option": sort_option,
            "former_members": former_members,
            "full_membership_mgmt": club.full_club_admin,
        },
    )


@check_club_menu_access()
def add_htmx(request, club, message=None):
    """Add sub menu"""

    if not MembershipType.objects.filter(organisation=club).exists():
        return HttpResponse(
            "<h4>Your club has no membership types defined. You cannot add a member until you fix this.</h4>"
        )

    total_members = get_member_count(club)

    # Check level of access
    member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit")

    has_errors = _check_member_errors(club)

    return render(
        request,
        "organisations/club_menu/members/add_menu_htmx.html",
        {
            "club": club,
            "total_members": total_members,
            "member_admin": member_admin,
            "has_errors": has_errors,
            "has_unregistered": club_has_unregistered_members(club),
            "message": message,
            "full_membership_mgmt": club.full_club_admin,
        },
    )


@check_club_menu_access()
def reports_htmx(request, club):
    """Reports sub menu"""

    # Check level of access
    member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit")

    has_errors = _check_member_errors(club)

    return render(
        request,
        "organisations/club_menu/members/reports_htmx.html",
        {
            "club": club,
            "member_admin": member_admin,
            "has_errors": has_errors,
            "full_membership_mgmt": club.full_club_admin,
        },
    )


[docs] @login_required() def club_admin_report_all_csv(request, club_id, active_only=False): """CSV of all members. We can't use the decorator as I can't get HTMX to treat this as a CSV""" # Get all ABF Numbers for members club = get_object_or_404(Organisation, pk=club_id) # Check for club level access - most common club_role = f"orgs.members.{club.id}.edit" if not rbac_user_has_role(request.user, club_role): # Check for state level access or global rbac_model_for_state = get_rbac_model_for_state(club.state) state_role = f"orgs.state.{rbac_model_for_state}.edit" if not rbac_user_has_role(request.user, state_role) and not rbac_user_has_role( request.user, "orgs.admin.edit" ): return rbac_forbidden(request, club_role) # get members club_members = get_club_members(club, active_only=active_only) # create dict of system number to membership type name membership_type_dict = {} club_members_list = [] for club_member in club_members: system_number = club_member.system_number membership_type = club_member.latest_membership.membership_type.name membership_type_dict[system_number] = membership_type club_members_list.append(system_number) # Get tags and turn into dictionary tags = MemberClubTag.objects.filter( system_number__in=club_members_list, club_tag__organisation=club ) tags_dict = {} for tag in tags: if tag.system_number not in tags_dict: tags_dict[tag.system_number] = [] tags_dict[tag.system_number].append(tag.club_tag.tag_name) response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="members.csv"' now = timezone.now() writer = csv.writer(response) writer.writerow([club.name, f"Downloaded by {request.user.full_name}", now]) # Note column order is the same as the generic csv import (up to end date) writer.writerow( [ f"{GLOBAL_ORG} Number", "First Name", "Last Name", "Email", "Membership Type", "Address 1", "Address 2", "State", "Post Code", "Preferred Phone", "Other Phone", "Date of Birth", "Club Membership Number", "Joined Date", "Left Date", "Emergency Contact", "Notes", "Membership Start Date", "Membership End Date", "Membership Status", "Paid Until Date", "Due Date", "Auto Pay Date", "Paid Date", "Fee", f"{GLOBAL_TITLE} User Type", f"{BRIDGE_CREDITS} Balance", "Tags", ] ) def format_date_or_none(a_date): return a_date.strftime("%d/%m/%Y") if a_date else "" for member in club_members: member_tags = tags_dict.get(member.system_number, "") writer.writerow( [ member.system_number, member.first_name, member.last_name, member.email, membership_type_dict.get(member.system_number, ""), member.address1, member.address2, member.state, member.postcode, member.preferred_phone, member.other_phone, member.dob, member.club_membership_number, format_date_or_none(member.joined_date), format_date_or_none(member.left_date), member.emergency_contact, member.notes, format_date_or_none(member.latest_membership.start_date), format_date_or_none(member.latest_membership.end_date), member.get_membership_status_display(), format_date_or_none(member.latest_membership.paid_until_date), format_date_or_none(member.latest_membership.due_date), format_date_or_none(member.latest_membership.auto_pay_date), format_date_or_none(member.latest_membership.paid_date), member.latest_membership.fee, member.user_type, ( "" if member.user_type == "Unregistered User" else get_balance(member.user_or_unreg) ), member_tags, ] ) return response
[docs] def club_admin_report_active_csv(request, club_id): """CSV of active members. We can't use the decorator as I can't get HTMX to treat this as a CSV""" return club_admin_report_all_csv(request, club_id, active_only=True)
# JPG deprecated - replaced
[docs] @login_required() def report_all_csv(request, club_id): """CSV of all members. We can't use the decorator as I can't get HTMX to treat this as a CSV""" # Get all ABF Numbers for members club = get_object_or_404(Organisation, pk=club_id) # Check for club level access - most common club_role = f"orgs.members.{club.id}.edit" if not rbac_user_has_role(request.user, club_role): # Check for state level access or global rbac_model_for_state = get_rbac_model_for_state(club.state) state_role = f"orgs.state.{rbac_model_for_state}.edit" if not rbac_user_has_role(request.user, state_role) and not rbac_user_has_role( request.user, "orgs.admin.edit" ): return rbac_forbidden(request, club_role) # Get club members now = timezone.now() club_members = ( MemberMembershipType.objects.filter(start_date__lte=now).filter( membership_type__organisation=club ) ).values("system_number", "membership_type__name") # create dict of system number to membership type membership_type_dict = {} club_members_list = [] for club_member in club_members: system_number = club_member["system_number"] membership_type = club_member["membership_type__name"] membership_type_dict[system_number] = membership_type club_members_list.append(system_number) # Get proper users users = User.objects.filter(system_number__in=club_members_list) # Get un reg users un_regs = UnregisteredUser.objects.filter(system_number__in=club_members_list) # Get local emails (if set) and turn into a dictionary club_emails = MemberClubEmail.objects.filter(system_number__in=club_members_list) club_emails_dict = { club_email.system_number: club_email.email for club_email in club_emails } # Get tags and turn into dictionary tags = MemberClubTag.objects.filter( system_number__in=club_members_list, club_tag__organisation=club ) tags_dict = {} for tag in tags: if tag.system_number not in tags_dict: tags_dict[tag.system_number] = [] tags_dict[tag.system_number].append(tag.club_tag.tag_name) response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="members.csv"' writer = csv.writer(response) writer.writerow([club.name, f"Downloaded by {request.user.full_name}", now]) writer.writerow( [ f"{GLOBAL_ORG} Number", "First Name", "Last Name", "Membership Type", "Email", "Email Source", f"{GLOBAL_TITLE} User Type", "Origin", "Tags", ] ) for user in users: user_tags = tags_dict.get(user.system_number, "") writer.writerow( [ user.system_number, user.first_name, user.last_name, membership_type_dict.get(user.system_number, ""), # Don't include email addresses for registered users "-", # user.email, "User", "Registered", "Self-registered", user_tags, ] ) for un_reg in un_regs: email = "-" email_source = "-" if un_reg.system_number in club_emails_dict: email = club_emails_dict[un_reg.system_number] email_source = "Unregistered User" user_tags = tags_dict.get(un_reg.system_number, "") writer.writerow( [ un_reg.system_number, un_reg.first_name, un_reg.last_name, membership_type_dict.get(un_reg.system_number, ""), email, email_source, "Unregistered", un_reg.origin, user_tags, ] ) return response
def _cancel_membership(request, club, system_number): """Common function to cancel membership""" # Memberships are coming later. For now we treat as basically binary - they start on the date they are # entered and we assume only one without checking memberships = MemberMembershipType.objects.filter( system_number=system_number ).filter(membership_type__organisation=club) # Should only be one but not enforced at database level so close any that match to be safe for membership in memberships: membership.delete() ClubLog( organisation=club, actor=request.user, action=f"Cancelled membership for {system_number}", ).save() # Delete any tags MemberClubTag.objects.filter(club_tag__organisation=club).filter( system_number=system_number ).delete() # Remove any email addresses for this club and user MemberClubEmail.objects.filter( organisation=club, system_number=system_number ).delete() @check_club_menu_access(check_members=True) def delete_un_reg_htmx(request, club): """Remove an unregistered user from club membership""" un_reg = get_object_or_404(UnregisteredUser, pk=request.POST.get("un_reg_id")) _cancel_membership(request, club, un_reg.system_number) return list_htmx(request, message=f"{un_reg.full_name} membership deleted.") @check_club_menu_access(check_members=True) def delete_member_htmx(request, club): """Remove a registered user from club membership""" member = get_object_or_404(User, pk=request.POST.get("member_id")) _cancel_membership(request, club, member.system_number) return list_htmx(request, message=f"{member.full_name} membership deleted.") # JPG deprecate def _un_reg_edit_htmx_process_form( request, un_reg, club, membership, user_form, club_email_form, club_membership_form ): """Sub process to handle form for un_reg_edit_htmx""" # Assume the worst message = "Please fix errors before proceeding" if user_form.is_valid(): new_un_reg = user_form.save() message = "Data Saved" ClubLog( organisation=club, actor=request.user, action=f"Updated details for {new_un_reg}", ).save() if club_membership_form.is_valid(): membership.home_club = club_membership_form.cleaned_data["home_club"] membership_type = MembershipType.objects.get( pk=club_membership_form.cleaned_data["membership_type"] ) membership.membership_type = membership_type membership.save() message = "Data Saved" ClubLog( organisation=club, actor=request.user, action=f"Updated details for {membership.system_number}", ).save() if club_email_form.is_valid(): club_email = club_email_form.cleaned_data["email"] # If the club email was used but is now empty, delete the record if not club_email: club_email_entry = MemberClubEmail.objects.filter( organisation=club, system_number=un_reg.system_number ) if club_email_entry: club_email_entry.delete() message = "Local email address deleted" ClubLog( organisation=club, actor=request.user, action=f"Removed club email address for {un_reg}", ).save() else: # See if we have an email for this user and club club_email_entry = MemberClubEmail.objects.filter( organisation=club, system_number=un_reg.system_number ).first() if not club_email_entry: club_email_entry = MemberClubEmail( organisation=club, system_number=un_reg.system_number ) club_email_entry.email = club_email club_email_entry.save() message = "Data Saved" ClubLog( organisation=club, actor=request.user, action=f"Updated club email address for {un_reg}", ).save() # See if we have a bounce on this user and clear it if club_email_entry.email_hard_bounce: club_email_entry.email_hard_bounce = False club_email_entry.email_hard_bounce_reason = None club_email_entry.email_hard_bounce_date = None club_email_entry.save() ClubLog( organisation=club, actor=request.user, action=f"Cleared email hard bounce for {un_reg} by editing email address", ).save() return message, un_reg, membership # JPG deprecate def _un_reg_edit_htmx_common( request, club, un_reg, message, user_form, club_email_form, club_membership_form, member_details, club_email_entry, ): """Common part of editing un registered user, used whether form was filled in or not""" member_tags = MemberClubTag.objects.prefetch_related("club_tag").filter( club_tag__organisation=club, system_number=un_reg.system_number ) used_tags = member_tags.values("club_tag__tag_name") available_tags = ClubTag.objects.filter(organisation=club).exclude( tag_name__in=used_tags ) email_address = club_email_for_member(club, un_reg.system_number) emails = get_emails_sent_to_address(email_address, club, request.user) # See if there are blocks on either email address - we don't just look for this user if club_email_entry: private_email_blocked = UnregisteredBlockedEmail.objects.filter( email=club_email_entry.email ).exists() else: private_email_blocked = False return render( request, "organisations/club_menu/members/edit_un_reg_htmx.html", { "club": club, "un_reg": un_reg, "user_form": user_form, "club_email_form": club_email_form, "club_membership_form": club_membership_form, "member_details": member_details, "member_tags": member_tags, "available_tags": available_tags, "hx_delete": reverse( "organisations:club_menu_tab_member_delete_un_reg_htmx" ), "hx_args": f"club_id:{club.id},un_reg_id:{un_reg.id}", "message": message, "email_address": email_address, "emails": emails, "private_email_blocked": private_email_blocked, }, ) # JPG deprecate ? @check_club_menu_access(check_members=True) def un_reg_edit_htmx(request, club): """Edit unregistered member details""" un_reg_id = request.POST.get("un_reg_id") un_reg = get_object_or_404(UnregisteredUser, pk=un_reg_id) # Get first membership record for this user and this club membership = MemberMembershipType.objects.filter( system_number=un_reg.system_number, membership_type__organisation=club ).first() message = "" club_email_entry = None if "save" in request.POST: # We got form data - process it user_form = UnregisteredUserForm(request.POST, instance=un_reg) club_email_form = MemberClubEmailForm(request.POST, prefix="club") club_membership_form = UnregisteredUserMembershipForm( request.POST, club=club, system_number=un_reg.system_number, prefix="member" ) message, un_reg, membership = _un_reg_edit_htmx_process_form( request, un_reg, club, membership, user_form, club_email_form, club_membership_form, ) else: # No form data so build up what we need to show user club_email_entry = MemberClubEmail.objects.filter( organisation=club, system_number=un_reg.system_number ).first() user_form = UnregisteredUserForm(instance=un_reg) club_email_form = MemberClubEmailForm(prefix="club") club_membership_form = UnregisteredUserMembershipForm( club=club, system_number=un_reg.system_number, prefix="member" ) # Set initial values for membership form # club_membership_form.initial["home_club"] = membership.home_club if membership: club_membership_form.initial[ "membership_type" ] = membership.membership_type_id # Set initial value for email if record exists if club_email_entry: club_email_form.initial["email"] = club_email_entry.email # Common parts return _un_reg_edit_htmx_common( request, club, un_reg, message, user_form, club_email_form, club_membership_form, membership, club_email_entry, ) @check_club_menu_access(check_members=True) def add_member_htmx(request, club): """Add a club member manually. This is called by the add_any page and return the list page. This shouldn't get errors so we don't return a form, we just use the message field if we do get an error and return the list view. """ message = "" form = UserMembershipForm(request.POST, club=club) if form.is_valid(): # get data from form system_number = int(form.cleaned_data["system_number"]) membership_type_id = form.cleaned_data["membership_type"] home_club = form.cleaned_data["home_club"] send_welcome_pack = form.cleaned_data.get("send_welcome_email") member = User.objects.filter(system_number=system_number).first() membership_type = MembershipType(pk=membership_type_id) if MemberMembershipType.objects.filter( system_number=member.system_number, membership_type__organisation=club, ).exists(): # shouldn't happen, but just in case message = f"{member.full_name} is already a member of this club" else: MemberMembershipType( system_number=member.system_number, membership_type=membership_type, last_modified_by=request.user, home_club=home_club, ).save() message = f"{member.full_name} added as a member" ClubLog( organisation=club, actor=request.user, action=f"Added member {member}", ).save() if send_welcome_pack: resp = _send_welcome_pack( club, member.first_name, member.email, request.user, False ) message = f"{message}. {resp}" else: print(form.errors) return list_htmx(request, message=message) def _send_welcome_pack(club, first_name, email, user, invite_to_join): """Send a welcome pack""" welcome_pack = WelcomePack.objects.filter(organisation=club).first() if not welcome_pack: return "No welcome pack found." if invite_to_join: register = reverse("accounts:register") email_body = f"""{welcome_pack.welcome_email} <br<br> <p>You are not yet a member of {GLOBAL_TITLE}. <a href="http://{COBALT_HOSTNAME}{register}">Visit us to join for free</a>.</p> """ else: email_body = welcome_pack.welcome_email context = { "name": first_name, "title": f"Welcome to {club}!", "email_body": email_body, } # Get the extra fields from the template if we have one # Use a reasonable club default if possible, otherwise a general default use_template = ( welcome_pack.template or club_default_template(club) or OrgEmailTemplate(organisation=club) ) reply_to = use_template.reply_to from_name = use_template.from_name if use_template.banner: context["img_src"] = use_template.banner.url context["footer"] = use_template.footer if use_template.box_colour: context["box_colour"] = use_template.box_colour if use_template.box_font_colour: context["box_font_colour"] = use_template.box_font_colour # sender = f"{from_name}<donotreply@myabf.com.au>" if from_name else None sender = custom_sender(from_name) # Create batch id to allow any admin for this club to view the email batch_id = create_rbac_batch_id( rbac_role=f"notifications.orgcomms.{club.id}.edit", user=user, organisation=club, batch_type=BatchID.BATCH_TYPE_COMMS, batch_size=1, description=context["title"], complete=True, ) send_cobalt_email_with_template( to_address=email, context=context, batch_id=batch_id, template="system - club", reply_to=reply_to, sender=sender, ) return "Welcome email sent." @check_club_menu_access(check_members=True) def add_any_member_htmx(request, club): """Add a club member manually""" member_form = UserMembershipForm(club=club) un_reg_form = UnregisteredUserAddForm(club=club) welcome_pack = WelcomePack.objects.filter(organisation=club).exists() return render( request, "organisations/club_menu/members/add_any_member_htmx.html", { "club": club, "member_form": member_form, "un_reg_form": un_reg_form, "welcome_pack": welcome_pack, }, )
[docs] @login_required() def add_member_search_htmx(request): """Search function for adding a member (registered, unregistered or from MPC) This is also borrowed by the edit_session_entry screen in club_sessions to change the user. They set a flag, so we can use their template instead of ours. """ first_name_search = request.POST.get("member_first_name_search") last_name_search = request.POST.get("member_last_name_search") club_id = request.POST.get("club_id") # Things from our friends at club_sessions edit_session_entry = request.POST.get("edit_session_entry") session_id = request.POST.get("session_id") session_entry_id = request.POST.get("session_entry_id") # if there is nothing to search for, don't search if not first_name_search and not last_name_search: return HttpResponse() # Note: 'user' in this context does not mean User in the Cobalt sense # In this case we are talking about players (User, Unreg or MCP) user_list, is_more = search_for_user_in_cobalt_and_mpc( first_name_search, last_name_search ) if edit_session_entry: template = "club_sessions/manage/edit_entry/member_search_results_htmx.html" else: # Now highlight players who are already club members user_list_system_numbers = [user["system_number"] for user in user_list] club = get_object_or_404(Organisation, pk=club_id) member_list = get_member_system_numbers( club, target_list=user_list_system_numbers, get_all=True, ) contact_list = get_contact_system_numbers( club, target_list=user_list_system_numbers ) for user in user_list: if user["system_number"] in member_list: user["source"] = "member" elif user["system_number"] in contact_list: user["source"] = "contact" template = "organisations/club_menu/members/member_search_results_htmx.html" return render( request, template, { "user_list": user_list, "is_more": is_more, "edit_session_entry": edit_session_entry, "club_id": club_id, "session_id": session_id, "session_entry_id": session_entry_id, }, )
# JPG deprecated @check_club_menu_access(check_members=True) def edit_member_htmx(request, club, message=""): """Edit a club member manually""" member_id = request.POST.get("member") member = get_object_or_404(User, pk=member_id) # Look for save as all requests are posts if "save" in request.POST: message = _edit_member_htmx_save(request, club, member) form = _edit_member_htmx_default(club, member) # Add on common parts hx_delete = reverse("organisations:club_menu_tab_member_delete_member_htmx") hx_vars = f"club_id:{club.id},member_id:{member.id}" # Get member tags member_tags = MemberClubTag.objects.prefetch_related("club_tag").filter( club_tag__organisation=club, system_number=member.system_number ) used_tags = member_tags.values("club_tag__tag_name") available_tags = ClubTag.objects.filter(organisation=club).exclude( tag_name__in=used_tags ) emails = get_emails_sent_to_address(member.email, club, request.user) # Get payment stuff recent_payments, misc_payment_types = _get_misc_payment_vars(member, club) # Get any outstanding debts user_pending_payments = UserPendingPayment.objects.filter( system_number=member.system_number ) # augment data for user_pending_payment in user_pending_payments: if user_pending_payment.organisation == club: user_pending_payment.can_delete = True user_pending_payment.hx_delete = reverse( "organisations:club_menu_tab_finance_cancel_user_pending_debt_htmx" ) user_pending_payment.hx_vars = f"club_id:{club.id}, user_pending_payment_id:{user_pending_payment.id}, member:{member.id}, return_member_tab:1" # Get users balance user_balance = get_balance(member) club_balance = org_balance(club) # See if this user has payments access user_has_payments_edit = rbac_user_has_role( request.user, f"club_sessions.sessions.{club.id}.edit" ) or rbac_user_has_role(request.user, f"payments.manage.{club.id}.edit") # See if user has payments view access if user_has_payments_edit: user_has_payments_view = True else: user_has_payments_view = rbac_user_has_role( request.user, f"payments.manage.{club.id}.view" ) # JPG clean up - note this only handles User members # Get augmented membership details membership_details = get_member_details(club, member.system_number) return render( request, "organisations/club_menu/members/edit_member_htmx.html", { "club": club, "form": form, "member": member, "message": message, "hx_delete": hx_delete, "hx_vars": hx_vars, "member_tags": member_tags, "available_tags": available_tags, "emails": emails, "recent_payments": recent_payments, "user_pending_payments": user_pending_payments, "misc_payment_types": misc_payment_types, "user_balance": user_balance, "club_balance": club_balance, "user_has_payments_edit": user_has_payments_edit, "user_has_payments_view": user_has_payments_view, "membership_details": membership_details, "terminal_states": MEMBERSHIP_STATES_TERMINAL, }, ) # JPG clean-up deprecated - replaced by club_admin.py activity_emails_html @check_club_menu_access(check_members=True) def get_recent_emails_htmx(request, club): """Delayed load of recent emails for the member detail view""" member_id = request.POST.get("member_id") member = get_object_or_404(User, pk=member_id) emails = get_emails_sent_to_address(member.email, club, request.user) return render( request, "organisations/club_menu/members/recent_emails.html", { "club": club, "member": member, "emails": emails, }, ) # JPG deprecated def _get_misc_payment_vars(member, club): """get variables relating to this members misc payments for this club""" # Get recent misc payments recent_payments = MemberTransaction.objects.filter( member=member, organisation=club ).order_by("-created_date")[:10] # get this orgs miscellaneous payment types misc_payment_types = MiscPayType.objects.filter(organisation=club) return recent_payments, misc_payment_types # JPG deprecated ? def _edit_member_htmx_save(request, club, member): """sub for edit_member_htmx to handle getting a real POST""" form = UserMembershipForm(request.POST, club=club) if form.is_valid(): # Get details membership_type_id = form.cleaned_data["membership_type"] membership_type = get_object_or_404(MembershipType, pk=membership_type_id) home_club = form.cleaned_data["home_club"] # Get the member membership objects member_membership = ( MemberMembershipType.objects.filter(system_number=member.system_number) .filter(membership_type__organisation=club) .first() ) # Update and save member_membership.membership_type = membership_type member_membership.home_club = home_club member_membership.save() message = f"{member.full_name} updated" ClubLog( organisation=club, actor=request.user, action=f"Edited details for member {member}", ).save() else: # Very unlikely print(form.errors) message = f"Please fix errors before proceeding: {form.errors}" return message def _edit_member_htmx_default(club, member): """sub of edit_member_htmx for when we don't get a POST.""" member_membership = ( MemberMembershipType.objects.filter(system_number=member.system_number) .filter(membership_type__organisation=club) .first() ) initial = { "member": member.id, "membership_type": member_membership.membership_type.id, "home_club": member_membership.home_club, } form = UserMembershipForm(club=club) form.initial = initial return form @check_club_menu_access(check_members=True) def add_un_reg_htmx(request, club): """Add a club unregistered user manually. This is called by the add_any page and return the list page. This shouldn't get errors so we don't return a form, we just use the message field if we do get an error and return the list view. """ message = "" # We are adding this person as a member of this club, they may or may not already be set up as unregistered users form = UnregisteredUserAddForm(request.POST, club=club) if not form.is_valid(): message = "An error occurred while trying to add a member. " for error in form.errors: message += error return list_htmx(request, message=message) # User may already be registered, the form will allow this if UnregisteredUser.objects.filter( system_number=form.cleaned_data["system_number"], ).exists(): message = "User already existed." # don't change the fields else: UnregisteredUser( system_number=form.cleaned_data["system_number"], last_updated_by=request.user, last_name=form.cleaned_data["last_name"], first_name=form.cleaned_data["first_name"], origin="Manual", added_by_club=club, ).save() ClubLog( organisation=club, actor=request.user, action=f"Added un-registered user {form.cleaned_data['first_name']} {form.cleaned_data['last_name']}", ).save() message = "User added." # Add to club if MemberMembershipType.objects.filter( system_number=form.cleaned_data["system_number"], membership_type__organisation=club, ).exists(): message += " Already a member of club." else: MemberMembershipType( system_number=form.cleaned_data["system_number"], membership_type_id=form.cleaned_data["membership_type"], home_club=form.cleaned_data["home_club"], last_modified_by=request.user, ).save() message += " Club membership added." # Add email club_email = form.cleaned_data["club_email"] if club_email and club_email != "": MemberClubEmail( organisation=club, system_number=form.cleaned_data["system_number"], email=club_email, ).save() ClubLog( organisation=club, actor=request.user, action=f"Added club specific email for {form.cleaned_data['system_number']}", ).save() message += " Club specific email added." if form.cleaned_data.get("send_welcome_email"): email_address = form.cleaned_data["mpc_email"] if email_address: resp = _send_welcome_pack( club, form.cleaned_data["first_name"], email_address, request.user, True, ) message = f"{message} {resp}" else: message += " Welcome email not sent, no email provided." # club is added to the call by the decorator return list_htmx(request, message=message) def _check_member_errors(club): """Check if there are any errors such as bounced email addresses""" members_system_numbers = MemberMembershipType.objects.filter( membership_type__organisation=club, ).values_list("system_number") # Check for bounced status. For registered users this is stored on a separate table, # for unregistered it is on the club email table users = User.objects.filter(system_number__in=members_system_numbers).filter( useradditionalinfo__email_hard_bounce=True ) un_regs_bounces = ( MemberClubEmail.objects.filter(organisation=club) .filter(email_hard_bounce=True) .values("system_number") ) un_regs = UnregisteredUser.objects.filter(system_number__in=un_regs_bounces) return list(chain(users, un_regs)) @check_club_menu_access(check_members=True) def errors_htmx(request, club): """Show errors tab (only shows if errors present)""" has_errors = _check_member_errors(club) # Check level of access member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit") return render( request, "organisations/club_menu/members/errors_htmx.html", { "has_errors": has_errors, "member_admin": member_admin, "club": club, "full_membership_mgmt": club.full_club_admin, }, ) # JPG depratce - moved to club_admin common @check_club_menu_access(check_session_or_payments=True) def add_misc_payment_htmx(request, club): """Adds a miscellaneous payment for a user. Could be the club charging them, or the club paying them""" # load data from form misc_description = request.POST.get("misc_description") member = get_object_or_404(User, pk=request.POST.get("member_id")) amount = float(request.POST.get("amount")) charge_or_pay = request.POST.get("charge_or_pay") if amount <= 0: misc_message = "Amount must be greater than zero" if charge_or_pay == "charge": misc_message = _add_misc_payment_charge( request, club, member, amount, misc_description ) else: misc_message = add_misc_payment_pay( request, club, member, amount, misc_description ) # Get relevant data recent_payments, misc_payment_types = _get_misc_payment_vars(member, club) # Get balance user_balance = get_balance(member) club_balance = org_balance(club) # User has payments edit, no need to check again user_has_payments_edit = True # return part of edit_member screen return render( request, "organisations/club_menu/members/edit_member_payments_htmx.html", { "club": club, "member": member, "misc_message": misc_message, "recent_payments": recent_payments, "misc_payment_types": misc_payment_types, "user_balance": user_balance, "club_balance": club_balance, "user_has_payments_edit": user_has_payments_edit, }, ) # JPG deprecate def _add_misc_payment_charge(request, club, member, amount, misc_description): """handle club charging user""" if payment_api_batch( member=member, amount=amount, description=f"{misc_description}", organisation=club, ): misc_message = "Payment successful" ClubLog( organisation=club, actor=request.user, action=f"Made misc payment of {GLOBAL_CURRENCY_SYMBOL}{amount:,.2f} for '{misc_description}' - {member}", ).save() else: misc_message = f"Payment FAILED for {member.full_name}. Insufficient funds." return misc_message @check_club_menu_access(check_session_or_payments=True) def get_member_balance_htmx(request, club): """Show balance for this user""" member_id = request.POST.get("member") if not member_id: return HttpResponse("No member found in request") member = get_object_or_404(User, pk=member_id) return HttpResponse(f"${get_balance(member):,.2f}")
[docs] def add_misc_payment_pay(request, club, member, amount, misc_description): """Handle club paying a member""" if org_balance(club) < amount: return "Club has insufficient funds for this transaction." # make payments update_account( member=member, amount=amount, description=misc_description, organisation=club, payment_type="Miscellaneous", ) update_organisation( member=member, amount=-amount, description=misc_description, organisation=club, payment_type="Miscellaneous", ) # log it ClubLog( organisation=club, actor=request.user, action=f"{member} - {cobalt_currency(amount)} - {misc_description}", ).save() return "Payment successful"
@check_club_menu_access(check_members=True) def bulk_invite_to_join_htmx(request, club): """Invite multiple people to join MyABF""" members = MemberMembershipType.objects.filter( membership_type__organisation=club ).values("system_number") unregistered = UnregisteredUser.objects.filter(system_number__in=members) two_weeks = timezone.now() - timezone.timedelta(weeks=2) can_invite = unregistered.filter( Q(last_registration_invite_sent__lte=two_weeks) | Q(last_registration_invite_sent=None) ) cannot_invite = unregistered.filter(last_registration_invite_sent__gt=two_weeks) if "send_invites" in request.POST: success = 0 failure = 0 for member in can_invite: club_email = club_email_for_member(club, member.system_number) if club_email and invite_to_join(member, club_email, request.user, club): success += 1 else: failure += 1 if failure > 0: return HttpResponse(f"<h3>{success} Invites sent. {failure} Failures.</h3>") else: return HttpResponse(f"<h3>{success} Invites sent</h3>") return render( request, "organisations/club_menu/members/bulk_invite_to_join_htmx.html", {"can_invite": can_invite, "cannot_invite": cannot_invite}, ) @check_club_menu_access(check_members=True) def unblock_unreg_email_address_htmx(request, club): """remove the block on an unregistered user email address""" email = request.POST.get("email") blocked = UnregisteredBlockedEmail.objects.filter(email=email).first() if blocked: blocked.delete() return HttpResponse("<h3 class='text-primary'>Block removed</h3>") else: return HttpResponse("<h3 class='text-primary'>Email address not found</h3>") @check_club_menu_access(check_members=True) def recent_sessions_for_member_htmx(request, club): """Show recent sessions for a member""" member = get_object_or_404(User, pk=request.POST.get("member_id")) sessions = ( SessionEntry.objects.filter(system_number=member.system_number) .order_by("-session__session_date") .select_related("session") ) things = cobalt_paginator(request, sessions, 2) # Add hx_post for paginator controls hx_post = reverse( "organisations:club_menu_tab_members_recent_sessions_for_member_htmx" ) hx_vars = f"club_id:{club.id}, member_id:{member.id}" hx_target = "#recent_sessions" return render( request, "organisations/club_menu/members/recent_sessions_for_member_htmx.html", { "club": club, "member": member, "things": things, "hx_post": hx_post, "hx_vars": hx_vars, "hx_target": hx_target, }, ) # ---------------------------------------------------------------------------------- # Club admin - Edit member # ---------------------------------------------------------------------------------- @check_club_menu_access(check_members=True) def club_admin_edit_member_htmx(request, club, message=None): """ Edit member for full club admin, htmx endpoint version Called with club_id and system_number in the POST (note only POST methods accepted) """ system_number = request.POST.get("system_number", None) post_message = request.POST.get("message", None) if message and post_message: message = f"{message}. {post_message}" elif post_message: message = post_message saving = request.POST.get("save", "NO") == "YES" editing = request.POST.get("edit", "NO") == "YES" show_history = request.POST.get("show_history", "NO") == "YES" if not system_number: # this should never happen, perhaps should just be an exception return _refresh_member_list( request, club, message="Error: system number required" ) member_details = get_member_details(club, system_number) valid_actions = get_valid_actions(member_details) # check whether always sharing profile data if member_details.user_type == f"{GLOBAL_TITLE} User": user = User.objects.get(pk=member_details.user_or_unreg_id) club_options = MemberClubOptions.objects.filter( club=club, user=user, ).last() always_shared = ( club_options and club_options.share_data == MemberClubOptions.SHARE_DATA_ALWAYS ) else: always_shared = False if club.full_club_admin: # get the members complete set of memberships member_history = ( MemberMembershipType.objects.filter( system_number=system_number, membership_type__organisation=club, ) .select_related("membership_type", "payment_method") .order_by("-created_at") ) # work out the index of the current membership for highlighting # in the history list current_index = None for index, mmt in enumerate(member_history): if mmt == member_details.latest_membership: current_index = index break is_past_history = member_history.count() > (current_index + 1) simplified_membership = None else: simplified_membership = member_details.latest_membership member_history = None current_index = None is_past_history = False # get the members log history log_history_full = get_member_log(club, system_number) log_history = cobalt_paginator( request, log_history_full, items_per_page=5, page_no=request.POST.get("page", 1) ) if request.POST.get("save", "NO") == "LOG": if request.POST.get("log_entry", None): # JPG to do: clean text # log it log_member_change( club, system_number, request.user, request.POST.get("log_entry"), ) message = "Comment added to the log" else: message = "Nothing added, type a comment then click Add" if member_details.user_type == f"{GLOBAL_TITLE} User": # Get any outstanding debts user_pending_payments = UserPendingPayment.objects.filter( system_number=system_number ) # get balance member_balance = get_balance(member_details.user_or_unreg) # augment data for user_pending_payment in user_pending_payments: if user_pending_payment.organisation == club: user_pending_payment.can_delete = True user_pending_payment.hx_delete = reverse( "organisations:club_menu_tab_finance_cancel_user_pending_debt_htmx" ) user_pending_payment.hx_vars = f"club_id:{club.id}, user_pending_payment_id:{user_pending_payment.id}, member:{member_details.user_or_unreg_id}, return_member_tab:1" else: user_pending_payments = None member_balance = None if saving: # Retrieve and validate the forms - NOTE: there will always be the # main form, and may also get one or two additional forms form = MemberClubDetailsForm(request.POST, instance=member_details) forms_ok = form.is_valid() forms_changed = form.has_changed() if not club.full_club_admin: smm_form = MembershipRawEditForm( request.POST, instance=simplified_membership, full_club_admin=False, club=club, registered=member_details.user_type == f"{ GLOBAL_TITLE } User", ) forms_ok = forms_ok and smm_form.is_valid() forms_changed = forms_changed or smm_form.has_changed() else: smm_form = None if member_details.user_type == "Unregistered User": name_form = ContactNameForm(request.POST) forms_ok = forms_ok and name_form.is_valid() forms_changed = forms_changed or name_form.has_changed() else: name_form = None if forms_ok: # post the changes (if any) if forms_changed: if smm_form and (smm_form.has_changed() or form.has_changed()): smm_form.save() form.save() new_status = smm_form.cleaned_data["membership_state"] # JPG Debug print( f"Saving status {new_status}, current = {member_details.membership_status}" ) if member_details.membership_status != new_status: member_details.previous_membership_status = ( member_details.membership_status ) member_details.membership_status = new_status member_details.save() elif form.has_changed(): form.save() if name_form and name_form.has_changed(): unreg = UnregisteredUser.all_objects.get( system_number=member_details.system_number ) unreg.first_name = name_form.cleaned_data["first_name"] unreg.last_name = name_form.cleaned_data["last_name"] unreg.save() log_member_change( club, system_number, request.user, "Member details changed" ) # reload to ensure that the new state is reflected corrcetly request.POST = request.POST.copy() request.POST["save"] = "NO" request.POST["edit"] = "NO" return club_admin_edit_member_htmx( request, message="Updates saved" if forms_changed else None ) else: # there is an error on a form message = "Please fix errors before proceeding" editing = True else: # initialisation form = MemberClubDetailsForm(instance=member_details) if member_details.user_type == "Unregistered User": initial_data = { "first_name": member_details.first_name, "last_name": member_details.last_name, } name_form = ContactNameForm(initial=initial_data) else: name_form = None if club.full_club_admin: smm_form = None else: smm_initial_data = { "membership_type": ( simplified_membership.membership_type.id if simplified_membership.membership_type else -1 ), "payment_method": ( simplified_membership.payment_method.id if simplified_membership.payment_method else -1 ), } smm_form = MembershipRawEditForm( full_club_admin=False, instance=simplified_membership, initial=smm_initial_data, club=club, registered=(member_details.user_type == f"{ GLOBAL_TITLE } User"), ) # which recent activities should be shown? permitted_activities = get_valid_activities(member_details) inactive_member = member_details.membership_status in MEMBERSHIP_STATES_TERMINAL member_description = member_details_short_description(member_details) # Note: member_admin is used in conditioning the member nav area. # The user has this access if they have got this far. return render( request, "organisations/club_menu/members/club_admin_edit_member_htmx.html", { "club": club, "member_details": member_details, "member_history": member_history, "member_balance": member_balance, "current_index": current_index, "is_past_history": is_past_history, "log_history": log_history, "form": form, "smm_form": smm_form, "name_form": name_form, "valid_actions": valid_actions, "inactive_member": inactive_member, "message": message, "member_admin": True, "user_pending_payments": user_pending_payments, "edit_details": editing, "member_description": member_description, "system_number": member_details.system_number, "permitted_activities": permitted_activities, "show_history": show_history, "full_membership_mgmt": club.full_club_admin, "always_shared": always_shared, }, ) # ---------------------------------------------------------------------------------- # Club admin - Edit member - Action button end points # # Simple actions are all handled through one end point which gets the action # name from the request: # club_admin_edit_member_membership_action_htmx # # More complex actions which require a view to be presented to get additional # parameters require their own end points (and url path entries) # ---------------------------------------------------------------------------------- def _refresh_edit_member(request, club, system_number, message, show_history=False): """Refreshes the edit member view from within the view ie. call as the result of an htmx end point to refresh the view """ return render( request, "organisations/club_menu/members/club_admin_edit_member_refresh_htmx.html", { "club_id": club.id, "system_number": system_number, "message": message, "show_history_str": "YES" if show_history else "NO", }, ) def _refresh_member_list(request, club, message=None): """Refreshes the member list view from within the view ie. call as the result of an htmx end point to refresh the view """ return render( request, "organisations/club_menu/members/club_admin_member_list_refresh_htmx.html", { "club_id": club.id, "message": message, }, ) def _refresh_renewal_menu(request, club, message=None): """Show the renewals menu from within the view ie. call as the result of an htmx end point to refresh the view """ return render( request, "organisations/club_menu/members/club_admin_renewal_menu_refresh_htmx.html", { "club_id": club.id, "message": message, }, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_change_htmx(request, club): """HTMX endpoint to change a member to a new membership type. Displays and processes a form to get the new type and related attributes. Redirects to reload the main edit member view on completion or fatal error. Args: request (HttpRequest): the request club_id (int): the club's Organisation id system_number (int): the member's system number Returns: HttpResponse: the response """ system_number = request.POST.get("system_number") today = timezone.now().date() member_details = get_member_details(club, system_number) permitted_action, message = can_perform_action("change", member_details) if not permitted_action: # refresh view with error return _refresh_edit_member( request, club, system_number, message if message else "Action not permitted", ) if member_details.user_type == f"{GLOBAL_TITLE} User": allowing_auto_pay = is_member_allowing_auto_pay( club=club, system_number=system_number, ) else: allowing_auto_pay = False inactive_member = member_details.membership_status in MEMBERSHIP_STATES_TERMINAL exclude_id = ( None if inactive_member else member_details.latest_membership.membership_type.id ) membership_choices, fees_and_due_dates = get_membership_details_for_club( club, exclude_id=exclude_id ) # Defaul all fees to zero when changing for key in fees_and_due_dates: fees_and_due_dates[key]["annual_fee"] = "0" if len(membership_choices) == 0: # no choices so go back to the main view return _refresh_edit_member( request, club, system_number, "Unable to change type, no alternatives available", ) if "save" in request.POST: form = MembershipChangeTypeForm( request.POST, club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), exclude_id=exclude_id, ) if form.is_valid(): membership_type = get_object_or_404( MembershipType, pk=int(form.cleaned_data["membership_type"]) ) # post logic success, message = change_membership( club, system_number, membership_type, request.user, fee=form.cleaned_data["fee"], start_date=form.cleaned_data["start_date"], end_date=form.cleaned_data["end_date"], due_date=form.cleaned_data["due_date"], payment_method_id=int(form.cleaned_data["payment_method"]), process_payment=True, ) if success: return _refresh_edit_member( request, club, system_number, message if message else "Membership type changed", ) else: initial_data = { "membership_type": membership_choices[0][0], "fee": float( fees_and_due_dates[str(membership_choices[0][0])]["annual_fee"] ), "due_date": fees_and_due_dates[str(membership_choices[0][0])]["due_date"], "end_date": fees_and_due_dates[str(membership_choices[0][0])]["end_date"], "start_date": today.strftime("%Y-%m-%d"), "payment_method": -1, "send_welcome_pack": False, } form = MembershipChangeTypeForm( initial=initial_data, club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), exclude_id=exclude_id, ) return render( request, "organisations/club_menu/members/club_admin_edit_member_change_htmx.html", { "club": club, "system_number": system_number, "form": form, "fees_and_dates": f"{fees_and_due_dates}", "message": message, "inactive_member": inactive_member, "show_auto_pay_warning": ( (member_details.user_type == f"{GLOBAL_TITLE} User") and not allowing_auto_pay ), }, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_membership_action_htmx(request, club): """Common end point for all simple membership actions The member's system number and action name are passed in the request POST. The updated is attempted and the member edit view is refreshed with the new state or an error message. """ system_number = request.POST.get("system_number", None) action_name = request.POST.get("action_name", None) if not system_number or not action_name: message = "Error - system number or action missing" else: success, message = perform_simple_action( action_name, club, system_number, requester=request.user ) if success and action_name == "delete": # member is now a contact so can't refresh the member edit view return _refresh_member_list(request, club, message="Member deleted") return _refresh_edit_member( request, club, system_number, message, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_payment_htmx(request, club): """ Get payment details Note: COB-946: if a member is not allowing auto pay, Bridge Credits should not be presented as a payment option. This is controlled via the 'registered' arguement to the form initialiser. """ system_number = request.POST.get("system_number", None) member_details = get_member_details(club, system_number) if member_details.user_type == f"{GLOBAL_TITLE} User": allowing_auto_pay = is_member_allowing_auto_pay( club=club, system_number=system_number, ) else: allowing_auto_pay = False membership_to_pay = get_outstanding_memberships_for_member( club, system_number, ).first() if not membership_to_pay: # nothing to pay, should not have been called return _refresh_edit_member( request, club, system_number, "No membership fees to pay", ) message = None if "save" in request.POST: form = MembershipPaymentForm( request.POST, club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), ) form.is_valid() payment_success, payment_message = make_membership_payment( club, membership_to_pay, int(form.cleaned_data["payment_method"]) ) if payment_success: return _refresh_edit_member( request, club, system_number, payment_message, show_history=True, ) else: message = payment_message else: form = MembershipPaymentForm( club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), ) return render( request, "organisations/club_menu/members/club_admin_edit_member_payment_htmx.html", { "club": club, "system_number": system_number, "membership": membership_to_pay, "form": form, "show_auto_pay_warning": ( (member_details.user_type == f"{GLOBAL_TITLE} User") and not allowing_auto_pay ), "message": message, }, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_extend_htmx(request, club): """ Handle the extend membership sub-view Note: COB-946: if a member is not allowing auto pay, Bridge Credits should not be presented as a payment option. This is controlled via the 'registered' arguement to the form initialiser. User can still set an auto pay date, and permissions will be checked at that time. """ system_number = request.POST.get("system_number", None) if not system_number: return _refresh_edit_member( request, club, system_number, "Error - system number not specified", ) member_details = get_member_details(club, system_number) permitted_action, message = can_perform_action("extend", member_details) if not permitted_action: # should not be here - redirect with an error message return _refresh_edit_member( request, club, system_number, message, ) if member_details.user_type == f"{GLOBAL_TITLE} User": allowing_auto_pay = is_member_allowing_auto_pay( club=club, system_number=system_number, ) else: allowing_auto_pay = False if "save" in request.POST: form = MembershipExtendForm( request.POST, club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), ) if form.is_valid(): renewal_parameters = RenewalParameters(club) renewal_parameters.update_with_extend_form( form, member_details.latest_membership.end_date + timedelta(days=1), member_details.latest_membership.membership_type, ) # get a new version with the correct annotations - a bit messy member_details = get_members_for_renewal( club, renewal_parameters, just_system_number=system_number, ) success, message = renew_membership( member_details, renewal_parameters, process_payment=renewal_parameters.payment_method is not None, requester=request.user, ) if success: return _refresh_edit_member( request, club, system_number, message if message else "Membership extended", ) else: message = "Please fix errors before proceeding" else: message = None default_due_date = club.next_renewal_date + timedelta( days=member_details.latest_membership.membership_type.grace_period_days ) initial_data = { "new_end_date": club.next_end_date.strftime("%Y-%m-%d"), "fee": ( member_details.latest_membership.membership_type.annual_fee if member_details.latest_membership.membership_type.annual_fee else 0 ), "due_date": default_due_date.strftime("%Y-%m-%d"), "auto_pay_date": default_due_date.strftime("%Y-%m-%d"), "payment_method": -1, "club_template": -1, "send_notice": True, "email_subject": "Membership Renewal", "email_content": "Thank you for your continuing membership. Please find your renewal details below.", } form = MembershipExtendForm( initial=initial_data, club=club, registered=( (member_details.user_type == f"{GLOBAL_TITLE} User") and allowing_auto_pay ), ) return render( request, "organisations/club_menu/members/club_admin_edit_member_extend_htmx.html", { "club": club, "system_number": system_number, "form": form, "show_auto_pay_warning": ( (member_details.user_type == f"{GLOBAL_TITLE} User") and not allowing_auto_pay ), "message": message, }, ) @check_club_menu_access(check_members=True) def club_admin_convert_add_contact_wrapper_htmx(request, club): """Wrapper for the convert contact form for use when converting frmom the add search """ system_number = request.POST.get("system_number") member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit") has_errors = _check_member_errors(club) return render( request, "organisations/club_menu/members/club_admin_add_convert_contact_wrapper_htmx.html", { "club": club, "system_number": system_number, "member_admin": member_admin, "has_errors": has_errors, "full_membership_mgmt": club.full_club_admin, }, ) @check_club_menu_access(check_members=True) def club_admin_add_member_htmx(request, club): """Add a member to the club Handles registered users, unregistered users and MPC imports and converting contacts (from add member search). Renders the wrapper HTML that triggers an HTMX load of the shared club_admin_edit_member_change_htmx.html System number of new member is passed in the POST """ system_number = request.POST.get("system_number") member_admin = rbac_user_has_role(request.user, f"orgs.members.{club.id}.edit") has_errors = _check_member_errors(club) return render( request, "organisations/club_menu/members/club_admin_add_member_htmx.html", { "club": club, "system_number": system_number, "member_admin": member_admin, "has_errors": has_errors, "full_membership_mgmt": club.full_club_admin, }, ) @check_club_menu_access(check_members=True) def club_admin_add_member_detail_htmx(request, club): """End point for handling the shared club_admin_edit_member_change_htmx.html when adding a new club member""" system_number = request.POST.get("system_number") user = User.objects.filter( system_number=system_number, ).last() mpc_details = None if user: user_type = "REG" else: user = UnregisteredUser.objects.filter( system_number=system_number, ).last() if user: user_type = "UNR" else: user_type = "MPC" mpc_details = get_mpc_details(club, system_number) message = None today = timezone.now().date() if user_type == "REG": allowing_auto_pay = is_member_allowing_auto_pay( club=club, user=user, ) else: allowing_auto_pay = False membership_choices, fees_and_due_dates = get_membership_details_for_club(club) if len(membership_choices) == 0: # no choices so go back to the add menu return add_htmx( request, message="You need to create membership types before adding members" ) welcome_pack = WelcomePack.objects.filter(organisation=club).exists() if "save" in request.POST: form = MembershipChangeTypeForm( request.POST, club=club, registered=((user_type == "REG") and allowing_auto_pay), ) if form.is_valid(): if ( form.cleaned_data["send_welcome_pack"] and user_type != "REG" and not form.cleaned_data["new_email"] ): message = "An email address is required to send a welcome pack" else: membership_type = get_object_or_404( MembershipType, pk=int(form.cleaned_data["membership_type"]) ) if user_type == "MPC": # need to create an unregistered user from the MCP data user_type, details = add_un_registered_user_with_mpc_data( system_number, club, request.user ) success, message = add_member( club, system_number, (user_type == "REG"), membership_type, request.user, fee=form.cleaned_data["fee"], start_date=form.cleaned_data["start_date"], end_date=form.cleaned_data["end_date"], due_date=form.cleaned_data["due_date"], payment_method_id=int(form.cleaned_data["payment_method"]), email=form.cleaned_data["new_email"], ) if success: if form.cleaned_data["send_welcome_pack"]: email = club_email_for_member(club, system_number) if email: resp = _send_welcome_pack( club, user.first_name, email, request.user, False ) if resp: message = f"{message}. {resp}" return _refresh_edit_member(request, club, system_number, message) else: message = "Please fix errors before proceeding" else: # Note: fields dependent on membership_type must be initialised as # JavaScript event handlers will handle initial show/hide configuration # but will not change values (otherwise will clobber values when displaying errors) initial_data = { "membership_type": membership_choices[0][0], "fee": float( fees_and_due_dates[str(membership_choices[0][0])]["annual_fee"] ), "due_date": fees_and_due_dates[str(membership_choices[0][0])]["due_date"], "end_date": fees_and_due_dates[str(membership_choices[0][0])]["end_date"], "start_date": today.strftime("%Y-%m-%d"), "payment_method": -1, "send_welcome_pack": welcome_pack, } if mpc_details and mpc_details["EmailAddress"]: initial_data["new_email"] = mpc_details["EmailAddress"] form = MembershipChangeTypeForm( initial=initial_data, club=club, registered=((user_type == "REG") and allowing_auto_pay), ) return render( request, "organisations/club_menu/members/club_admin_edit_member_change_htmx.html", { "club": club, "system_number": system_number, "form": form, "fees_and_dates": f"{fees_and_due_dates}", "adding": True, "message": message, "user": user, "welcome_pack": welcome_pack, "mpc_details": mpc_details, "show_auto_pay_warning": ((user_type == "REG") and not allowing_auto_pay), }, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_edit_mmt_htmx(request, club): system_number = request.POST.get("system_number") mmt = get_object_or_404(MemberMembershipType, pk=int(request.POST.get("mmt_id"))) member_details = get_member_details(club, system_number) message = None if request.POST.get("save", "NO") == "YES": form = MembershipRawEditForm( request.POST, club=club, registered=(member_details.user_type == f"{GLOBAL_TITLE} User"), ) if form.is_valid(): error = False membership_type_id = int(form.cleaned_data["membership_type"]) new_membership_type = ( MembershipType.objects.get(pk=membership_type_id) if membership_type_id >= 0 else None ) if ( new_membership_type and not new_membership_type.does_not_renew and not form.cleaned_data["end_date"] ): error = True message = ( f"Membership type {new_membership_type.name} requires an end date" ) if ( new_membership_type and new_membership_type.does_not_renew and form.cleaned_data["end_date"] ): error = True message = f"Membership type {new_membership_type.name} cannot have an end date" payment_method_id = int(form.cleaned_data["payment_method"]) new_payment_method = ( OrgPaymentMethod.objects.get(pk=payment_method_id) if payment_method_id >= 0 else None ) if ( not error and new_payment_method and new_payment_method.payment_method == "Bridge Credits" and member_details.user_type != f"{GLOBAL_TITLE} User" ): error = True message = "User must be registered to use Bridge Credits" if ( not error and form.cleaned_data["start_date"] and form.cleaned_data["end_date"] and form.cleaned_data["start_date"] > form.cleaned_data["end_date"] ): error = True message = "End date cannot be before start date" if ( not error and mmt.membership_state == MemberMembershipType.MEMBERSHIP_STATE_FUTURE and form.cleaned_data["start_date"] != (member_details.latest_membership.end_date + timedelta(days=1)) ): error = True message = "Future dated memberships must start immediately after the current membership ends" if ( not error and mmt.membership_state != MemberMembershipType.MEMBERSHIP_STATE_FUTURE and mmt.membership_state in [ MemberMembershipType.MEMBERSHIP_STATE_CURRENT, MemberMembershipType.MEMBERSHIP_STATE_DUE, ] and member_has_future(club, member_details.system_number) and form.cleaned_data["end_date"] != mmt.end_date ): error = True message = ( "Cannot change the end date when there is a future dated membership" ) if not error: mmt.membership_type = new_membership_type mmt.payment_method = new_payment_method mmt.membership_state = form.cleaned_data["membership_state"] mmt.start_date = form.cleaned_data["start_date"] mmt.end_date = ( form.cleaned_data["end_date"] if form.cleaned_data["end_date"] else None ) mmt.paid_until_date = ( form.cleaned_data["paid_until_date"] if form.cleaned_data["paid_until_date"] else None ) mmt.due_date = ( form.cleaned_data["due_date"] if form.cleaned_data["due_date"] else None ) mmt.paid_date = ( form.cleaned_data["paid_date"] if form.cleaned_data["paid_date"] else None ) mmt.auto_pay_date = ( form.cleaned_data["auto_pay_date"] if form.cleaned_data["auto_pay_date"] else None ) mmt.fee = form.cleaned_data["fee"] mmt.is_paid = form.cleaned_data["is_paid"] mmt.last_modified_by = request.user mmt.save() if mmt == member_details.latest_membership: member_details.membership_status = mmt.membership_state member_details.save() log_member_change( club, member_details.system_number, request.user, f"Membership manually edited: {mmt.description}", ) return _refresh_edit_member( request, club, system_number, "Changes saved", show_history=True ) else: message = "Please fix errors before proceeding" else: initial_data = { "membership_type": mmt.membership_type.id if mmt.membership_type else -1, "payment_method": mmt.payment_method.id if mmt.payment_method else -1, } form = MembershipRawEditForm( instance=mmt, initial=initial_data, club=club, registered=(member_details.user_type == f"{GLOBAL_TITLE} User"), ) return render( request, "organisations/club_menu/members/club_admin_edit_member_edit_mmt_htmx.html", { "club": club, "system_number": system_number, "mmt": mmt, "form": form, "message": message, }, ) @check_club_menu_access(check_members=True) def club_admin_edit_member_delete_mmt_htmx(request, club): system_number = request.POST.get("system_number") mmt = get_object_or_404(MemberMembershipType, pk=int(request.POST.get("mmt_id"))) message = "" if request.POST.get("delete", "NO") == "YES": mmt_description = mmt.description mmt.delete() log_member_change( club, system_number, request.user, f"Membership manually deleted: {mmt_description}", ) request.POST = request.POST.copy() del request.POST["delete"] return _refresh_edit_member( request, club, system_number, "Membership record deleted", show_history=True ) refund_warning = ( mmt.is_paid and mmt.payment_method and mmt.payment_method.payment_method == "Bridge Credits" ) return render( request, "organisations/club_menu/members/club_admin_edit_member_delete_mmt_htmx.html", { "club": club, "system_number": system_number, "mmt": mmt, "refund_warning": refund_warning, "message": message, }, )
[docs] def get_mpc_details(club, system_number): """Return the MCP details for the player, including an email address if the player is a home club member. Also includes home club name NOTE: This matches home clubs based on club name rather than testing Organisaton.org_id against the MPC ClubNumber. This is because the details returned by the MPC for a player includes the home club id (not number), and it is not clear how to associate the id with a number other than through the club name. """ details = user_summary(system_number) home_club_details = masterpoint_query_row(f'club/{details["HomeClubID"]}') details["HomeClubName"] = home_club_details["ClubName"] if club.name == home_club_details["ClubName"]: # is a home club member, so get the email address matches = search_mpc_users_by_name(details["GivenNames"], details["Surname"]) mpc_email = None for match in matches: if match["ABFNumber"] == system_number: if "EmailAddress" in match and match["EmailAddress"]: mpc_email = match["EmailAddress"] break details["EmailAddress"] = mpc_email else: details["EmailAddress"] = None return details
@check_club_menu_access(check_members=True) def renewals_menu_htmx(request, club): """Renewals submenu""" message = request.POST.get("message", None) return render( request, "organisations/club_menu/members/renewals_menu_htmx.html", { "club": club, "full_membership_mgmt": club.full_club_admin, "message": message, }, ) @check_club_menu_access(check_members=True) def bulk_renewals_htmx(request, club): """Initiate bulk renewals This can be called in four modes: INIT - from the menu, initialise and show the options view OPTION - allows the user to select the renewal options MEMBERS - shows a list of members who will be renewed given the options PREVIEW - shows a preview of an email, and allows the user to initiate the renewals SEND - process the renewals Note that nothing is saved to the database until the renewals are processed. This means that all modes need to pass through te formset and options forms. """ mode = request.POST.get("mode", "INIT") # Context variables that will be updated depending on mode message = None member_list = None stats = None club_email = None email_context = {} form_index = 0 system_number = 0 sending_notices = True member_details = None if mode == "INIT": # first time through, initialise the forms initial_data = [] membership_types = MembershipType.objects.filter( organisation=club, does_not_renew=False, ) default_start_date = club.next_renewal_date default_end_date = club.next_end_date for membership_type in membership_types: default_due_date = default_start_date + timedelta( days=membership_type.grace_period_days ) defaults = { "selected": False, "membership_type_id": membership_type.id, "membership_type_name": membership_type.name, "fee": membership_type.annual_fee if membership_type.annual_fee else 0, "due_date": default_due_date, "auto_pay_date": default_due_date, "start_date": default_start_date, "end_date": default_end_date, } initial_data.append(defaults) formset = BulkRenewalFormSet(initial=initial_data) default_options = { "send_notice": True, "club_template": -1, "email_subject": "Membership Renewal", "email_content": "Thank you for your continuing membership. Please find your renewal details below", } options_form = BulkRenewalOptionsForm(initial=default_options, club=club) mode = "OPTIONS" else: # all other modes must at least pass the forms through formset = BulkRenewalFormSet(request.POST) options_form = BulkRenewalOptionsForm(request.POST, club=club) form_level_errors = False if formset.is_valid() and options_form.is_valid(): for form_index, form in enumerate(formset): if form.cleaned_data["selected"]: if not form.cleaned_data["start_date"]: form_level_errors = True message = "Start date is required" break if not (formset.is_valid() and options_form.is_valid()) or form_level_errors: if not message: message = "Please fix errors before proceeding" mode = "OPTIONS" else: if mode == "SEND": # process the renewals in the background args = { "club": club, "formset": formset, "options_form": options_form, "requester": request.user, } thread = Thread(target=_process_bulk_renewals, kwargs=args) thread.setDaemon(True) thread.start() return _refresh_renewal_menu( request, club, message="Bulk renewal initiated" ) elif mode == "MEMBERS": # show the selected members full_member_list = [] stats = None for form_index, form in enumerate(formset): if form.cleaned_data["selected"]: this_renewal_parameters = RenewalParameters(club) this_renewal_parameters.update_with_line_form(form) this_renewal_parameters.update_with_options_form(options_form) members, stats = get_members_for_renewal( club, this_renewal_parameters, form_index, stats_to_date=stats, ) full_member_list += members # Note: need to pass the page number because the common routine # expects GET rather than POST member_list = cobalt_paginator( request, full_member_list, page_no=request.POST.get("page", 1), ) elif mode == "PREVIEW": # show an example email preview form_index = int(request.POST.get("form_index")) system_number = int(request.POST.get("system_number")) renewal_parameters = RenewalParameters(club) renewal_parameters.update_with_line_form(formset[form_index]) renewal_parameters.update_with_options_form(options_form) sending_notices = renewal_parameters.send_notice if renewal_parameters.send_notice: member_details = get_members_for_renewal( club, renewal_parameters, form_index, just_system_number=system_number, ) club_email, email_context = _format_renewal_notice_email( member_details, renewal_parameters, ) context = { "club": club, "mode": mode, "message": message, "formset": formset, "options_form": options_form, "member_list": member_list, "stats": stats, "club_email": club_email, "form_index": form_index, "system_number": system_number, "sending_notices": sending_notices, "member_details": member_details, } return render( request, "organisations/club_menu/members/bulk_renewals_htmx.html", {**context, **email_context}, ) @check_club_menu_access(check_members=True) def bulk_renewals_test_htmx(request, club): """Send a test message, return a string""" formset = BulkRenewalFormSet(request.POST) formset.is_valid() options_form = BulkRenewalOptionsForm(request.POST, club=club) options_form.is_valid() form_index = int(request.POST.get("form_index")) system_number = int(request.POST.get("system_number")) renewal_parameters = RenewalParameters(club) renewal_parameters.update_with_line_form(formset[form_index]) renewal_parameters.update_with_options_form(options_form) member_details = get_members_for_renewal( club, renewal_parameters, form_index, just_system_number=system_number ) success, message = send_renewal_notice( member_details, renewal_parameters, test_email_address=request.user.email, ) if success: return HttpResponse("Test message sent") else: return HttpResponse(f"Test message failed: {message}") def _process_bulk_renewals( club, formset, options_form, requester, ): """The post logic for processing a set of renewals Args: club (Organisation): the club formset (BulkRenewalFormSet): set of validated BulkRenewalLineForm forms options_form (BulkRenewalOptionsForm): the validate options form requester (User): the requesting user returns: int: processed correctly count int: error count """ ok_count = 0 error_count = 0 for form_index, form in enumerate(formset): if form.cleaned_data["selected"]: this_renewal_parameters = RenewalParameters(club) this_renewal_parameters.update_with_line_form(form) this_renewal_parameters.update_with_options_form(options_form) members, _ = get_members_for_renewal( club, this_renewal_parameters, form_index, ) # Only create batch_id if we're sending notices this_batch_id = None if this_renewal_parameters.send_notice: this_batch_id = create_rbac_batch_id( rbac_role=f"notifications.orgcomms.{club.id}.edit", organisation=club, batch_type=BatchID.BATCH_TYPE_COMMS, batch_size=len(members), description=( this_renewal_parameters.email_subject + f" ({this_renewal_parameters.membership_type.name})" ), complete=False, ) for member in members: success, _ = renew_membership( member, this_renewal_parameters, batch_id=this_batch_id, # Will be None if send_notice is False requester=requester, ) if success: ok_count += 1 else: error_count += 1 # Only mark batch complete if we created one if this_batch_id: this_batch_id.state = BatchID.BATCH_STATE_COMPLETE this_batch_id.save() return (ok_count, error_count) @check_club_menu_access(check_members=True) def view_unpaid_htmx(request, club): """Manage unpaid memberships""" sort_option = request.POST.get("sort_option", "name_desc") memberships, stats = get_outstanding_memberships(club, sort_option=sort_option) if len(memberships) == 0: # nothing to show, so go back to the menu with a message return _refresh_renewal_menu( request, club, message="No outstanding membership fees" ) # Note: need to pass the page number because the common routine # expects GET rather than POST things = cobalt_paginator( request, memberships, page_no=request.POST.get("page", 1), ) return render( request, "organisations/club_menu/members/outstanding_memberships_htmx.html", { "club": club, "things": things, "stats": stats, "sort_option": sort_option, }, )
[docs] @login_required def email_unpaid(request, club_id): """Initiate email batch to unpaid members""" club = get_object_or_404(Organisation, pk=club_id) club_role = f"orgs.org.{club.id}.view" if not rbac_user_has_role(request.user, club_role): return rbac_forbidden(request, club_role) member_role = f"orgs.members.{club.id}.edit" if not rbac_user_has_role(request.user, member_role): return rbac_forbidden(request, member_role) memberships, _ = get_outstanding_memberships(club) if len(memberships) == 0: # nobody to email, so go back to the menu with a message return _refresh_renewal_menu( request, club, message="No outstanding membership fees" ) candidates = [ ( membership.system_number, membership.first_name, membership.last_name, membership.club_email, ) for membership in memberships if membership.club_email ] # create the batch header batch_id = create_rbac_batch_id( f"notifications.orgcomms.{club.id}.edit", organisation=club, batch_type=BatchID.BATCH_TYPE_COMMS, batch_size=len(candidates), description="Outstanding memberships", complete=False, ) batch = BatchID.objects.get(batch_id=batch_id) # save the recipients for candidate in candidates: # COB-940 ALL_SYSTEM_ACCOUNTS contains ids not system numbers # so use ALL_SYSTEM_ACCOUNT_SYSTEM_NUMBERS instead if candidate[0] not in ALL_SYSTEM_ACCOUNT_SYSTEM_NUMBERS: try: recpient = Recipient() recpient.batch = batch recpient.system_number = candidate[0] recpient.first_name = candidate[1] recpient.last_name = candidate[2] recpient.email = candidate[3] recpient.save() except IntegrityError: # possible for there to be duplicates pass # go to club menu, comms tab, edit batch return redirect( "notifications:compose_email_recipients", club.id, batch.id, )