Compare commits
10 commits
1adaf5e341
...
0b2d6b9dc4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0b2d6b9dc4 | ||
![]() |
5922c3245c | ||
![]() |
201da8177f | ||
![]() |
328c8f59d2 | ||
![]() |
415b47ad59 | ||
![]() |
d7a4f8ab7c | ||
![]() |
c80f4f3c7b | ||
![]() |
22781477f3 | ||
![]() |
938556cb60 | ||
![]() |
b3b30e05ed |
18 changed files with 1380 additions and 218 deletions
|
@ -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
|
||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -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"]
|
||||
|
||||
|
|
4
Justfile
4
Justfile
|
@ -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/
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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}")
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
135
src/membership/templates/membership/apply.html
Normal file
135
src/membership/templates/membership/apply.html
Normal 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 %}
|
|
@ -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
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
70
tests/test_waitinglistentry.py
Normal file
70
tests/test_waitinglistentry.py
Normal 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
|
Loading…
Add table
Reference in a new issue