Compare commits

...

10 commits

Author SHA1 Message Date
Víðir Valberg Guðmundsson
0b2d6b9dc4 Notify via matrix when a new membership application is submitted. (#87)
Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/87
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>
2025-03-30 20:37:36 +00:00
reynir
5922c3245c Make drone-docker use BuildKit (#85)
Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/85
2025-03-26 10:06:37 +00:00
Reynir Björnsson
201da8177f Make drone-docker use BuildKit 2025-03-26 11:03:52 +01:00
Víðir Valberg Guðmundsson
328c8f59d2 Bite the bullet and remove heredoc. 2025-03-21 21:56:09 +01:00
Víðir Valberg Guðmundsson
415b47ad59 Version? 2025-03-21 21:53:22 +01:00
Víðir Valberg Guðmundsson
d7a4f8ab7c Add syntax spec to Dockerfile. 2025-03-21 21:52:08 +01:00
Víðir Valberg Guðmundsson
c80f4f3c7b Try a older version. 2025-03-21 21:49:42 +01:00
Víðir Valberg Guðmundsson
22781477f3 Pin the docker plugin for building. 2025-03-21 21:47:32 +01:00
Víðir Valberg Guðmundsson
938556cb60 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>
2025-03-08 22:10:26 +00:00
Reynir Björnsson
b3b30e05ed bootstrap_dev_data: add products & membership types (#80)
Addresses #79

Co-authored-by: Víðir Valberg Guðmundsson <valberg@orn.li>
Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/80
Reviewed-by: benjaoming <benjaoming@data.coop>
Co-authored-by: Reynir Björnsson <reynir@reynir.dk>
Co-committed-by: Reynir Björnsson <reynir@reynir.dk>
2025-03-07 19:38:24 +00:00
18 changed files with 1380 additions and 218 deletions

View file

@ -8,6 +8,7 @@ steps:
image: plugins/docker
environment:
BUILD: "${DRONE_COMMIT_SHA}"
DOCKER_BUILDKIT: "1"
settings:
repo: docker.data.coop/membersystem
registry: docker.data.coop

View file

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.12-alpine
# - Silence uv complaining about not being able to use hard links,
@ -19,25 +20,22 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml <<EOF
mkdir -p /app/src/staticfiles
apk update
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
mkdir -p /app/src/staticfiles & \
apk update & \
apk add --no-cache \
binutils \
libpq-dev \
gettext \
netcat-openbsd \
postgresql-client
postgresql-client & \
# run uv sync --no-dev if $DJANGO_ENV is production, otherwise run uv sync
if [ "$DJANGO_ENV" = "production" ]; then uv sync --frozen --no-install-project --no-dev; else uv sync --frozen --no-install-project; fi
EOF
COPY . .
ENV PATH="/venv/bin:$PATH"
RUN <<EOF
./src/manage.py compilemessages
./src/manage.py collectstatic --noinput
EOF
RUN ./src/manage.py compilemessages & \
./src/manage.py collectstatic --noinput
ENTRYPOINT ["/app/entrypoint.sh"]

View file

@ -30,6 +30,10 @@ typecheck:
test:
docker compose run --rm app pytest
coverage *ARGS:
@echo "Running tests with coverage"
docker compose run --rm app pytest --cov --cov-report term-missing:skip-covered {{ARGS}}
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
stripe_cli:
stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/

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

@ -1,6 +1,7 @@
"""Models for the membership app."""
import uuid
from typing import Any
from typing import Self
from dirtyfields import DirtyFieldsMixin
@ -9,11 +10,14 @@ from django.contrib.auth.models import UserManager
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField
from django.contrib.postgres.fields import RangeOperators
from django.contrib.sites.models import Site
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from djmoney.money import Money
from services.models import ServiceRequest
from utils.matrix import notify_admins
from utils.mixins import CreatedModifiedAbstract
@ -241,9 +245,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 +274,31 @@ 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")
def save(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
"""Create notifications when new are added."""
is_new = not self.pk
super().save(*args, **kwargs)
if is_new:
base_domain = Site.objects.get_current()
change_url = reverse("admin:membership_waitinglistentry_change", kwargs={"object_id": self.pk})
notify_admins(f"Membership application: https://{base_domain}{change_url}")

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>

View file

@ -2,10 +2,13 @@
from datetime import timedelta
from accounting.models import Product
from django.contrib.auth.models import User
from django.core.management import BaseCommand
from django.db.backends.postgresql.psycopg_any import DateRange
from django.utils import timezone
from djmoney.money import Money
from membership.models import MembershipType
from membership.models import SubscriptionPeriod
@ -17,11 +20,15 @@ class Command(BaseCommand):
superuser: User
normal_users: list[User]
products: dict[str, Product]
def handle(self, *args: str, **options: str) -> None:
"""Handle the command."""
self.create_superuser()
self.create_normal_users()
self.create_subscription_periods()
self.create_products()
self.create_membership_types()
def create_superuser(self) -> None:
"""Create superuser."""
@ -47,3 +54,30 @@ class Command(BaseCommand):
SubscriptionPeriod.objects.create(
period=DateRange(timezone.now().date() + timedelta(days=183), timezone.now().date() + timedelta(days=365))
)
def create_products(self) -> None:
"""Create products."""
self.stdout.write("Creating products")
products_data = {
"Medlemsydelse": (360, 90),
"Medlemskontingent": (150, 0),
"Nedsat medlemsydelse": (40, 10),
"Nedsat medlemskontingent": (50, 0),
}
self.products = {}
for name, (price, vat) in products_data.items():
self.products[name] = Product.objects.create(
name=name,
price=Money(price, "DKK"),
vat=Money(vat, "DKK"),
)
def create_membership_types(self) -> None:
"""Create membership types."""
self.stdout.write("Creating membership types")
ydelse = self.products["Medlemsydelse"]
kontingent = self.products["Medlemskontingent"]
nedsat_ydelse = self.products["Nedsat medlemsydelse"]
nedsat_kontingent = self.products["Nedsat medlemskontingent"]
MembershipType.objects.create(name="Normalt medlemskab").products.add(ydelse, kontingent)
MembershipType.objects.create(name="Nedsat medlemskab").products.add(nedsat_ydelse, nedsat_kontingent)

View file

@ -0,0 +1,70 @@
"""Tests for WaitingListEntry functionality."""
from unittest import mock
import pytest
from django.contrib.sites.models import Site
from django.urls import reverse
from membership.models import WaitingListEntry
class TestWaitingListEntryNotifications:
"""Tests for WaitingListEntry notification functionality."""
@pytest.mark.django_db()
def test_matrix_notification_on_create(self, membership_type):
"""Test that a Matrix notification is sent when a new WaitingListEntry is created."""
# Explicitly patch notify_admins to ensure we're testing the right function
with mock.patch("membership.models.notify_admins") as mock_notify:
# Create a new WaitingListEntry
entry = WaitingListEntry.objects.create(
name="Test User",
email="test@example.com",
membership_type=membership_type,
)
# Check that notify_admins was called
mock_notify.assert_called_once()
# Get the expected URL components
base_domain = Site.objects.get_current()
change_url = reverse("admin:membership_waitinglistentry_change", kwargs={"object_id": entry.pk})
expected_message = f"Membership application: https://{base_domain}{change_url}"
# Verify the message content
mock_notify.assert_called_with(expected_message)
@pytest.mark.django_db()
def test_no_matrix_notification_on_update(self, membership_type):
"""Test that no Matrix notification is sent when a WaitingListEntry is updated."""
# First create the entry
entry = WaitingListEntry.objects.create(
name="Test User",
email="test@example.com",
membership_type=membership_type,
)
# Now update it with a patched notify_admins
with mock.patch("membership.models.notify_admins") as mock_notify:
entry.geography = "Some Location"
entry.save()
# Verify that notify_admins was not called
mock_notify.assert_not_called()
@pytest.mark.django_db()
def test_message_format(self, membership_type):
"""Test that the message has the expected format with 'Membership application:' prefix."""
with mock.patch("membership.models.notify_admins") as mock_notify:
# Create a new WaitingListEntry
WaitingListEntry.objects.create(
name="Test User",
email="test@example.com",
membership_type=membership_type,
)
# Check the message format
args, kwargs = mock_notify.call_args
message = args[0]
assert message.startswith("Membership application: ")
assert "https://" in message