An application form! (#83)

Co-authored-by: bbb <benjamin@overtag.dk>
Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/83
Reviewed-by: benjaoming <benjaoming@data.coop>
Co-authored-by: Víðir Valberg Guðmundsson <valberg@orn.li>
Co-committed-by: Víðir Valberg Guðmundsson <valberg@orn.li>
This commit is contained in:
Víðir Valberg Guðmundsson 2025-03-08 22:10:26 +00:00 committed by valberg
parent b3b30e05ed
commit 938556cb60
13 changed files with 1249 additions and 209 deletions

View file

@ -44,7 +44,7 @@ dev-dependencies = [
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE="tests.settings"
DJANGO_SETTINGS_MODULE="project.settings"
addopts = "--reuse-db"
norecursedirs = "build dist docs .eggs/* *.egg-info htmlcov .git"
python_files = "test*.py"

View file

@ -14,6 +14,9 @@ from django.db import transaction
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import path
from django.urls.resolvers import URLPattern
from django.utils.text import slugify
from .emails import InviteEmail
@ -150,9 +153,43 @@ class MemberAdmin(UserAdmin):
class WaitingListEntryAdmin(admin.ModelAdmin):
"""Admin for WaitingList model."""
list_display = ("email", "member")
list_display = ("email", "name", "geography", "created", "wants_introduction", "member")
list_filter = ("wants_introduction", "created", "geography")
search_fields = ("email", "name", "comment", "geography")
readonly_fields = ("created", "modified")
actions = ("create_member",)
def get_urls(self) -> list[URLPattern]:
"""Add custom URLs to the admin."""
urls = super().get_urls()
custom_urls = [
path(
"statistics/",
self.admin_site.admin_view(self.application_statistics_view),
name="membership_waitinglistentry_statistics",
),
]
return custom_urls + urls
def application_statistics_view(self, request: HttpRequest) -> HttpResponse:
"""View to display application statistics."""
applications_by_month = WaitingListEntry.get_applications_by_month()
context = {
**self.admin_site.each_context(request),
"title": "Application Statistics",
"applications_by_month": applications_by_month,
"opts": self.model._meta, # noqa: SLF001
}
return render(request, "admin/membership/waitinglistentry/statistics.html", context)
def changelist_view(self, request: HttpRequest, extra_context: dict | None = None) -> HttpResponse:
"""Add a link to the statistics view."""
extra_context = extra_context or {}
extra_context["show_statistics_link"] = True
return super().changelist_view(request, extra_context)
@admin.action(description="Create member account for entries")
def create_member(self, request: HttpRequest, queryset: QuerySet[WaitingListEntry]) -> None:
"""Create a user account for this entry.

View file

@ -1,10 +1,23 @@
"""Form for the membership app."""
from __future__ import annotations
from typing import TYPE_CHECKING
from allauth.account.adapter import get_adapter as get_allauth_adapter
from allauth.account.forms import SetPasswordForm
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import MembershipType
from .models import WaitingListEntry
if TYPE_CHECKING:
from typing import Any
from typing import ClassVar
from django_stubs_ext import StrOrPromise
class InviteForm(SetPasswordForm):
"""Create a new password for a user account that is created through an invite."""
@ -39,3 +52,63 @@ class InviteForm(SetPasswordForm):
self.user.is_active = True
self.user.save()
super().save()
class MemberApplicationForm(forms.ModelForm):
"""Form for applying for membership."""
accepted_aup = forms.BooleanField(
label=_("I have read and accepted the Acceptable Usage Policy."),
required=False,
)
has_read_bylaws = forms.BooleanField(
label=_("I have read the bylaws."),
required=False,
)
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
super().__init__(*args, **kwargs)
# To evaluate the queryset before the form is rendered, we need to set the choices here.
# Otherwise, django-zen-queries will complain about queries in the template.
self.fields["membership_type"].choices = MembershipType.objects.filter(active=True).values_list("id", "name")
class Meta:
model = WaitingListEntry
fields: ClassVar[list[str]] = [
"name",
"email",
"username",
"geography",
"membership_type",
"comment",
"wants_introduction",
"accepted_aup",
"has_read_bylaws",
]
labels: ClassVar[dict[str, StrOrPromise]] = {
"username": _("Your preferred username"),
"comment": _("Tell us about yourself"),
"geography": _("Location"),
"membership_type": _("Membership Type"),
"wants_introduction": _("I would like an introduction to the association."),
"accepted_aup": _("I have read and accepted the Acceptable Usage Policy."),
"has_read_bylaws": _("I have read the bylaws."),
}
help_texts: ClassVar[dict[str, StrOrPromise]] = {
"username": _("The username you would like to use."),
"membership_type": _("Please select the membership type you are applying for."),
"comment": _("Please provide a brief description about yourself and why you want to join."),
"geography": _("Where are you located? This helps us organize local events."),
}
widgets: ClassVar[dict[str, forms.Widget]] = {
"comment": forms.Textarea(attrs={"rows": 4}),
}
def clean(self) -> dict[str, str]:
"""Check that the user has read the bylaws."""
cleaned_data = super().clean()
if not cleaned_data.get("has_read_bylaws"):
self.add_error("has_read_bylaws", _("You must read the bylaws before applying."))
if not cleaned_data.get("accepted_aup"):
self.add_error("accepted_aup", _("You must accept the AUP before applying."))
return cleaned_data

View file

@ -0,0 +1,47 @@
# Generated by Django 5.1.4 on 2025-03-08 22:04
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("membership", "0014_alter_membership_options"),
]
operations = [
migrations.AddField(
model_name="waitinglistentry",
name="membership_type",
field=models.ForeignKey(
default=1,
help_text="The membership type the person wants to apply for.",
limit_choices_to=models.Q(("active", True)),
on_delete=django.db.models.deletion.PROTECT,
to="membership.membershiptype",
verbose_name="membership type",
),
preserve_default=False,
),
migrations.AddField(
model_name="waitinglistentry",
name="name",
field=models.CharField(default="", max_length=255, verbose_name="navn"),
preserve_default=False,
),
migrations.AddField(
model_name="waitinglistentry",
name="username",
field=models.CharField(blank=True, default="", max_length=150, verbose_name="brugernavn"),
),
migrations.AddField(
model_name="waitinglistentry",
name="wants_introduction",
field=models.BooleanField(
default=False,
help_text="Whether the person wants an introduction to the association.",
verbose_name="wants introduction",
),
),
]

View file

@ -241,9 +241,23 @@ class MembershipType(CreatedModifiedAbstract):
class WaitingListEntry(CreatedModifiedAbstract):
"""People who for some reason could want to be added to a waiting list and invited to join later."""
name = models.CharField(verbose_name=_("name"), max_length=255)
email = models.EmailField()
username = models.CharField(verbose_name=_("username"), max_length=150, blank=True, default="")
geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
comment = models.TextField(blank=True)
wants_introduction = models.BooleanField(
verbose_name=_("wants introduction"),
default=False,
help_text=_("Whether the person wants an introduction to the association."),
)
membership_type = models.ForeignKey(
MembershipType,
verbose_name=_("membership type"),
help_text=_("The membership type the person wants to apply for."),
on_delete=models.PROTECT,
limit_choices_to=models.Q(active=True),
)
member = models.ForeignKey(
Member,
null=True,
@ -256,6 +270,20 @@ class WaitingListEntry(CreatedModifiedAbstract):
def __str__(self) -> str:
return self.email
@classmethod
def get_applications_by_month(cls) -> dict[str, int]:
"""Return a dictionary with the number of applications by month."""
from django.db.models import Count
from django.db.models.functions import TruncMonth
return dict(
cls.objects.annotate(month=TruncMonth("created"))
.values("month")
.annotate(count=Count("id"))
.order_by("-month")
.values_list("month", "count")
)
class Meta:
verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries")

View file

@ -0,0 +1,13 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}
{% block object-tools-items %}
{% if show_statistics_link %}
<li>
<a href="{% url 'admin:membership_waitinglistentry_statistics' %}" class="viewlink">
{% trans "Application Statistics" %}
</a>
</li>
{% endif %}
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% block content %}
<div id="content-main">
<div class="module">
<h2>{% trans "Applications by Month" %}</h2>
{% if applications_by_month %}
<table>
<thead>
<tr>
<th>{% trans "Month" %}</th>
<th>{% trans "Number of Applications" %}</th>
</tr>
</thead>
<tbody>
{% for month, count in applications_by_month.items %}
<tr>
<td>{{ month|date:"F Y" }}</td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>{% trans "No applications found." %}</p>
{% endif %}
</div>
<div class="submit-row">
<a href="{% url 'admin:membership_waitinglistentry_changelist' %}" class="button">{% trans "Back to list" %}</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,135 @@
{% extends "account/pre_login_base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Apply for Membership" %}
{% endblock %}
{% block non_login_content %}
<div id="applicationbox">
<div class="application-form">
<h2>{% trans "Apply for Membership" %}</h2>
<p>
{% blocktrans trimmed %}
Fill out the form below to apply for membership of data.coop.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
All applicants are required to have read and understood the
<a href="https://git.data.coop/data.coop/dokumenter/src/branch/main/Acceptable%20Usage%20Policy.md" target="_blank">Acceptable Usage Policy</a> and the
<a href="https://data.coop/rights/" target="_blank">bylaws of the association</a>.
{% endblocktrans %}
</p>
<form method="POST">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.name.id_for_label }}">{% trans "Name" %}</label>
{{ form.name }}
{% if form.name.errors %}
<div class="error">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.email.id_for_label }}">{% trans "Email" %}</label>
{{ form.email }}
{% if form.email.errors %}
<div class="error">{{ form.email.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.username.id_for_label }}">{{ form.username.label }}</label>
{{ form.username }}
{% if form.username.errors %}
<div class="error">{{ form.username.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.geography.id_for_label }}">{{ form.geography.label }}</label>
{{ form.geography }}
<small>{{ form.geography.help_text }}</small>
{% if form.geography.errors %}
<div class="error">{{ form.geography.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.membership_type.id_for_label }}">{{ form.membership_type.label }}</label>
<div>
{{ form.membership_type }}
</div>
<small>{{ form.membership_type.help_text }}</small>
{% if form.membership_type.errors %}
<div class="error">{{ form.membership_type.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }}</label>
{{ form.comment }}
<small>{{ form.comment.help_text }}</small>
{% if form.comment.errors %}
<div class="error">{{ form.comment.errors }}</div>
{% endif %}
</div>
<div class="form-group checkbox">
<label for="{{ form.wants_introduction.id_for_label }}">
{{ form.wants_introduction }}
{{ form.wants_introduction.label }}
</label>
{% if form.wants_introduction.errors %}
<div class="error">{{ form.wants_introduction.errors }}</div>
{% endif %}
</div>
<div class="form-group checkbox">
<label for="{{ form.accepted_aup.id_for_label }}">
{{ form.accepted_aup }}
{{ form.accepted_aup.label }}
</label>
{% if form.accepted_aup.errors %}
<div class="error">{{ form.accepted_aup.errors }}</div>
{% endif %}
</div>
<div class="form-group checkbox">
<label for="{{ form.has_read_bylaws.id_for_label }}">
{{ form.has_read_bylaws }}
{{ form.has_read_bylaws.label }}
</label>
{% if form.has_read_bylaws.errors %}
<div class="error">{{ form.has_read_bylaws.errors }}</div>
{% endif %}
</div>
<div>
<button type="submit">{% trans "Submit Application" %}</button>
</div>
</form>
<div class="login-link">
<a href="{% url 'account_login' %}">{% trans "Already a member? Log in" %}</a>
</div>
</div>
<div class="application-info">
<img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo">
<div class="about">
<h2>{% trans "About data.coop" %}</h2>
<p>{% trans "We are a cooperative association dedicated to helping members manage data ethically and securely." %}</p>
<p>
{% blocktrans trimmed %}
Read more about becoming a member on
<a href="https://data.coop/en/membership/" target="_blank">our main website</a>.
{% endblocktrans %}
</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -17,6 +17,7 @@ from utils.view_utils import RowAction
from utils.view_utils import render
from .forms import InviteForm
from .forms import MemberApplicationForm
from .models import Membership
from .permissions import ADMINISTRATE_MEMBERS
from .selectors import get_member
@ -168,3 +169,29 @@ def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse
template_name="membership/invite.html",
context=context,
)
@member_view(
paths="apply/",
name="apply",
login_required=False,
)
def apply_for_membership(request: HttpRequest) -> HttpResponse:
"""View for applying for membership."""
if request.method == "POST":
form = MemberApplicationForm(request.POST)
if form.is_valid():
form.save()
messages.success(
request,
_("Thank you for your application! We will review it and get back to you soon."),
)
return redirect("index")
else:
form = MemberApplicationForm()
return render(
request,
"membership/apply.html",
{"form": form},
)

File diff suppressed because it is too large Load diff

View file

@ -391,6 +391,9 @@ article table tbody td input[type="radio"] {
form>div {
margin: 0 0 var(--double-space);
}
form>div.checkbox {
margin: 0 0 var(--space);
}
form>div>label {
display: block;
@ -441,10 +444,47 @@ form div.buttonHolder button {
}
#login {
height: 100%;
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 1em 0 1em 0;
}
#login-messages {
width: 800px;
max-width: 100%;
margin-bottom: 20px;
}
#login-messages .message {
padding: 15px;
border-radius: var(--space);
background-color: var(--light-dust);
margin-bottom: 10px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
#login-messages .message.success {
background-color: #d4edda;
color: #155724;
}
#login-messages .message.error {
background-color: #f8d7da;
color: #721c24;
}
#login-messages .message.warning {
background-color: #fff3cd;
color: #856404;
}
#login-messages .message.info {
background-color: #d1ecf1;
color: #0c5460;
}
#loginbox {
@ -508,6 +548,65 @@ form div.buttonHolder button {
padding: 0 var(--double-space);
}
/* Application box styling - matches loginbox */
#applicationbox {
border-radius: var(--space);
border: 6px solid white;
width: 800px;
height: auto;
display: flex;
flex-flow: row;
max-width: 100%;
}
#applicationbox>div {
padding: var(--double-space);
flex: 1;
overflow-y: visible;
}
#applicationbox>div.application-form {
background: var(--light-dust);
display: flex;
flex-flow: column;
justify-content: flex-start;
}
#applicationbox>div.application-info {
background: var(--water);
}
#applicationbox>div:first-child {
border-radius: var(--half-space) 0 0 var(--half-space);
}
#applicationbox>div:last-child {
border-radius: 0 var(--half-space) var(--half-space) 0;
}
#applicationbox>div:last-child>* {
flex: 1;
}
#applicationbox div.about {
margin-top: var(--double-space);
}
#applicationbox h2 {
margin: var(--double-space) 0 var(--space);
}
#applicationbox p {
margin: 0 0 var(--space);
}
#applicationbox button {
width: 100%;
}
#applicationbox img {
padding: 0 var(--double-space);
}
footer {
margin: var(--space) var(--outer-space);
padding: var(--space);
@ -578,3 +677,19 @@ span.time_remaining {
.pagination .page-item.disabled .page-link {
cursor: default;
}
@media (max-width: 800px) {
#applicationbox {
flex-flow: column;
width: 100%;
height: auto;
}
#applicationbox>div:first-child {
border-radius: var(--half-space) var(--half-space) 0 0;
}
#applicationbox>div:last-child {
border-radius: 0 0 var(--half-space) var(--half-space);
}
}

View file

@ -54,7 +54,8 @@
<img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo">
<div class="new_here">
<h2>{% trans "Are you new here?" %}</h2>
<a class="button" href="https://data.coop/membership/">{% trans "Become a member" %}</a>
<a class="button" href="{% url 'member:apply' %}">{% trans "Apply for membership" %}</a>
{% comment %} <a class="button" href="https://data.coop/membership/">{% trans "Become a member" %}</a> {% endcomment %}
{% comment %} <a class="button" href="{% url "account_signup" %}">{% trans "Become a member" %}</a> {% endcomment %}
</div>
</div>

View file

@ -12,6 +12,15 @@
</head>
<body>
<main id="login">
{% if messages %}
<div id="login-messages">
{% for message in messages %}
<div class="message {% if message.tags %}{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% block non_login_content %}
{% endblock %}
</main>