diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 8432ce61..b43e8653 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -21,6 +21,11 @@ You are more than welcome to contribute to the system. This guide documents how
- To create a super user for the admin interface you can run
`docker-compose run web ./manage.py createsuperuser`
+- To see the emails generated by the system (Payment confirmations etc.) run
+ `docker-compose run web ./manage.py render_emails`
+ This will create a `generated_emails` folder with the html and text version
+ of the emails.
+
## Primary Frameworks/Systems used
- [Django][django]: The base web framework used. The link is to their great
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8bc4b5eb..d840d55f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,3 +40,10 @@ jobs:
with:
name: UML_diagram.png
path: UML
+
+ - name: Create and render emails
+ run: docker-compose run web ./manage.py render_emails
+ - uses: actions/upload-artifact@v1
+ with:
+ name: rendered_emails
+ path: ./generated_emails
diff --git a/.gitignore b/.gitignore
index de0a3458..9867307b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ members/static/members/css
.sass-cache/
public_data.zip
test-screens
+generated_emails
diff --git a/forenings_medlemmer/settings.py b/forenings_medlemmer/settings.py
index 4912ead3..6a13deee 100644
--- a/forenings_medlemmer/settings.py
+++ b/forenings_medlemmer/settings.py
@@ -64,6 +64,7 @@
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env.str("SECRET_KEY")
+
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG")
if DEBUG:
@@ -183,6 +184,7 @@
# Dont keep job logs more than 7 days old
DJANGO_CRON_DELETE_LOGS_OLDER_THAN = 7
+QUICKPAY_URL = "https://api.quickpay.net/payments"
QUICKPAY_API_KEY = os.environ["QUICKPAY_API_KEY"]
QUICKPAY_PRIVATE_KEY = os.environ["QUICKPAY_PRIVATE_KEY"]
PAYMENT_ID_PREFIX = env.str("PAYMENT_ID_PREFIX")
diff --git a/members/admin/__init__.py b/members/admin/__init__.py
index b295d5db..7a896f5d 100644
--- a/members/admin/__init__.py
+++ b/members/admin/__init__.py
@@ -19,12 +19,14 @@
Union,
Volunteer,
Member,
+ Membership,
Activity,
ActivityInvite,
ActivityParticipant,
Family,
EmailItem,
Payment,
+ PayableItem,
Equipment,
EquipmentLoan,
EmailTemplate,
@@ -38,6 +40,8 @@
from .member_admin import MemberAdmin
from .payment_admin import PaymentAdmin
from .activity_admin import ActivityAdmin
+from .membership_admin import MembershipAdmin
+from .payable_item_admin import PayableItemAdmin
admin.site.site_header = "Coding Pirates Medlemsdatabase"
admin.site.index_title = "Afdelings admin"
@@ -47,8 +51,10 @@
admin.site.register(Union, UnionAdmin)
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
+admin.site.register(PayableItem, PayableItemAdmin)
admin.site.register(Person, PersonAdmin)
admin.site.register(Member, MemberAdmin)
+admin.site.register(Membership, MembershipAdmin)
admin.site.register(Payment, PaymentAdmin)
admin.site.register(Activity, ActivityAdmin)
admin.site.register(EmailTemplate)
diff --git a/members/admin/membership_admin.py b/members/admin/membership_admin.py
new file mode 100644
index 00000000..92a310f5
--- /dev/null
+++ b/members/admin/membership_admin.py
@@ -0,0 +1,16 @@
+from django.contrib import admin
+
+
+class MembershipAdmin(admin.ModelAdmin):
+ list_display = ("person", "union", "sign_up_date")
+ list_filter = ("union", "sign_up_date", "sign_up_date")
+ readonly_fields = ["person", "union", "sign_up_date"]
+ fieldsets = [
+ ("Medlemskab", {"fields": ("person", "union", "sign_up_date")}),
+ ]
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_add_permission(self, request, obj=None):
+ return False
diff --git a/members/admin/payable_item_admin.py b/members/admin/payable_item_admin.py
new file mode 100644
index 00000000..e647ca8f
--- /dev/null
+++ b/members/admin/payable_item_admin.py
@@ -0,0 +1,31 @@
+from django.contrib import admin
+
+
+class PayableItemAdmin(admin.ModelAdmin):
+ list_display = (
+ "added",
+ "person",
+ "refunded",
+ "quick_pay_id",
+ "accepted",
+ "amount_ore",
+ )
+ list_filter = ("refunded", "accepted")
+ readonly_fields = [
+ "added",
+ "person",
+ "refunded",
+ "quick_pay_id",
+ "accepted",
+ "amount_ore",
+ ]
+ fieldsets = [
+ ("Data", {"fields": ("person", "added", "amount_ore", "quick_pay_id")}),
+ ("Status", {"fields": ("refunded", "accepted",)}),
+ ]
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_add_permission(self, request, obj=None):
+ return False
diff --git a/members/admin/union_admin.py b/members/admin/union_admin.py
index 5cc80e07..245900bd 100644
--- a/members/admin/union_admin.py
+++ b/members/admin/union_admin.py
@@ -56,6 +56,13 @@ def get_queryset(self, request):
)
},
),
+ (
+ "Kontingent",
+ {
+ "fields": ("membership_price_ore",),
+ "description": "Sæt kontingent for at blive medlem af jeres forening",
+ },
+ ),
(
"Info",
{
diff --git a/members/forms/__init__.py b/members/forms/__init__.py
index 688ba0a6..1f3a04b0 100644
--- a/members/forms/__init__.py
+++ b/members/forms/__init__.py
@@ -4,12 +4,14 @@
from .admin_signup_form import adminSignupForm
from .activity_signup_form import ActivitySignupForm
from .activity_invite_decline_form import ActivivtyInviteDeclineForm
+from .membership_form import MembershipForm
__all__ = [
ActivivtyInviteDeclineForm,
ActivitySignupForm,
PersonForm,
signupForm,
+ MembershipForm,
vol_signupForm,
adminSignupForm,
]
diff --git a/members/forms/membership_form.py b/members/forms/membership_form.py
new file mode 100644
index 00000000..f16c5e5f
--- /dev/null
+++ b/members/forms/membership_form.py
@@ -0,0 +1,22 @@
+from django import forms
+from members.models import Person, Union
+from members.models import Membership
+
+
+class MembershipForm(forms.Form):
+ def __init__(self, family_members, *args, **kwargs):
+ super(MembershipForm, self).__init__(*args, **kwargs)
+ self.family_members = family_members
+ self.fields["person"].queryset = Person.objects.filter(pk__in=family_members)
+ self.fields["person"].initial = 1
+
+ # TODO exclude closed unions and union where is member
+ self.fields["union"].queryset = Union.objects.all()
+
+ person = forms.ModelChoiceField(Person.objects.none())
+ union = forms.ModelChoiceField(Union.objects.none(), label="Forening")
+
+ def clean(self):
+ Membership.can_be_member_validator(
+ self.cleaned_data["person"], self.cleaned_data["union"]
+ )
diff --git a/members/management/commands/render_emails.py b/members/management/commands/render_emails.py
new file mode 100644
index 00000000..9572bc49
--- /dev/null
+++ b/members/management/commands/render_emails.py
@@ -0,0 +1,28 @@
+import os
+
+from django.core.management.base import BaseCommand
+
+from members.tests.factories import PayableItemFactory
+from members.models import PayableItem
+
+
+email_dir = "generated_emails"
+
+
+class Command(BaseCommand):
+ help = "Renders emails with test data"
+
+ def handle(self, *args, **options):
+ if not os.path.exists(email_dir):
+ os.makedirs(email_dir)
+
+ _write_payment_confirmation()
+
+
+def _write_payment_confirmation():
+ payment = PayableItemFactory.build()
+ html, text = PayableItem._render_payment_confirmation(payment)
+ with open(f"{email_dir}/payment_confirmed_email.html", "w+") as htmlFile:
+ htmlFile.write(html)
+ with open(f"{email_dir}/payment_confirmed_email.txt", "w+") as txtFile:
+ txtFile.write(text)
diff --git a/members/management/commands/send_emails.py b/members/management/commands/send_emails.py
new file mode 100644
index 00000000..4e2a50af
--- /dev/null
+++ b/members/management/commands/send_emails.py
@@ -0,0 +1,10 @@
+from django.core.management.base import BaseCommand
+
+from members.models import PayableItem
+
+
+class Command(BaseCommand):
+ help = "Send emails in queue"
+
+ def handle(self, *args, **options):
+ PayableItem.send_all_payment_confirmations()
diff --git a/members/migrations/0023_auto_20200714_2338.py b/members/migrations/0023_auto_20200714_2338.py
new file mode 100644
index 00000000..b6476ff4
--- /dev/null
+++ b/members/migrations/0023_auto_20200714_2338.py
@@ -0,0 +1,141 @@
+# Generated by Django 3.0.8 on 2020-07-14 21:38
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import members.models.payable_item
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("members", "0022_merge_20200526_1130"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Membership",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("sign_up_date", models.DateField(verbose_name="Opskrivningsdato")),
+ (
+ "person",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="members.Person"
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Foreningsmedlemskab",
+ "verbose_name_plural": "Foreningsmedlemskaber",
+ "ordering": ["union"],
+ },
+ ),
+ migrations.AddField(
+ model_name="union",
+ name="closed",
+ field=models.DateField(blank=True, null=True, verbose_name="Lukket"),
+ ),
+ migrations.AddField(
+ model_name="union",
+ name="membership_price_ore",
+ field=models.IntegerField(
+ default=7500, verbose_name="Medlemskontingent i ører"
+ ),
+ ),
+ migrations.CreateModel(
+ name="PayableItem",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "added",
+ models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="Tilføjet"
+ ),
+ ),
+ (
+ "refunded",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="Refunderet"
+ ),
+ ),
+ ("amount_ore", models.IntegerField(verbose_name="Beløb i øre")),
+ (
+ "quick_pay_order_id",
+ models.CharField(
+ default=members.models.payable_item._set_quickpay_order_id,
+ max_length=20,
+ unique=True,
+ verbose_name="Quick Pay order ID",
+ ),
+ ),
+ (
+ "accepted",
+ models.BooleanField(default=False, verbose_name="Accepteret"),
+ ),
+ (
+ "processed",
+ models.BooleanField(default=False, verbose_name="Processed"),
+ ),
+ (
+ "confirmation_sent",
+ models.BooleanField(
+ default=False, verbose_name="Bekræftelse sendt"
+ ),
+ ),
+ ("quick_pay_id", models.IntegerField(verbose_name="Quick Pay ID")),
+ (
+ "_payment_link",
+ models.URLField(
+ blank=True, null=True, verbose_name="Betalingslink"
+ ),
+ ),
+ (
+ "membership",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="payment",
+ to="members.Membership",
+ ),
+ ),
+ (
+ "person",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="payments",
+ to="members.Person",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Betaling",
+ "verbose_name_plural": "Betalinger",
+ "ordering": ["added"],
+ },
+ ),
+ migrations.AddField(
+ model_name="membership",
+ name="union",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="members.Union"
+ ),
+ ),
+ ]
diff --git a/members/models/__init__.py b/members/models/__init__.py
index be4497c5..92e2eb87 100644
--- a/members/models/__init__.py
+++ b/members/models/__init__.py
@@ -7,7 +7,6 @@
import members.models.activityinvite
import members.models.activityparticipant
-
import members.models.department
import members.models.emailitem
import members.models.emailtemplate
@@ -41,6 +40,7 @@
from .equipmentloan import EquipmentLoan
from .family import Family
from .member import Member
+from .membership import Membership
from .notification import Notification
from .payment import Payment
from .person import Person
@@ -48,7 +48,8 @@
from .volunteer import Volunteer
from .waitinglist import WaitingList
from .zipcoderegion import ZipcodeRegion
-
+from .payable_item import PayableItem
+from .quickpaytransaction import QuickpayTransaction
from .statistics import gatherDayliStatistics
@@ -62,13 +63,16 @@
DailyStatisticsRegion,
DailyStatisticsUnion,
Department,
+ PayableItem,
EmailItem,
EmailTemplate,
Equipment,
EquipmentLoan,
Family,
gatherDayliStatistics,
+ QuickpayTransaction,
Member,
+ Membership,
Notification,
Payment,
Person,
diff --git a/members/models/membership.py b/members/models/membership.py
new file mode 100644
index 00000000..d67c151c
--- /dev/null
+++ b/members/models/membership.py
@@ -0,0 +1,39 @@
+from django.db import models
+from django.core.exceptions import ValidationError
+from datetime import date
+
+
+class Membership(models.Model):
+ class Meta:
+ verbose_name = "Foreningsmedlemskab"
+ verbose_name_plural = "Foreningsmedlemskaber"
+ ordering = ["union"]
+
+ union = models.ForeignKey("Union", on_delete=models.CASCADE)
+ person = models.ForeignKey("Person", on_delete=models.CASCADE)
+ sign_up_date = models.DateField("Opskrivningsdato")
+
+ def clean(self):
+ self.sign_up_date = (
+ date.today() if self.sign_up_date is None else self.sign_up_date
+ )
+ self.can_be_member_validator(self.person, self.union, self.sign_up_date.year)
+
+ def save(self, *args, **kwargs):
+ self.clean()
+ super(Membership, self).save(*args, **kwargs)
+
+ def __str__(self):
+ return f"Medlemskab: {self.person} : {self.union}"
+
+ @staticmethod
+ def can_be_member_validator(person, union, year=None):
+ old_memberships = [
+ membership.sign_up_date.year
+ for membership in Membership.objects.filter(person=person, union=union)
+ ]
+ year = date.today().year if year is None else year
+ if year in old_memberships:
+ raise ValidationError(
+ f"{person} er allerede medlem af foreningen {union} i år {year}"
+ )
diff --git a/members/models/payable_item.py b/members/models/payable_item.py
new file mode 100644
index 00000000..f7e14b0f
--- /dev/null
+++ b/members/models/payable_item.py
@@ -0,0 +1,207 @@
+import requests
+import json
+from django.core.mail import EmailMultiAlternatives
+
+from django.db import models
+from django.utils import timezone
+from django.core.exceptions import ValidationError
+from django.conf import settings
+from members.models import quickpaytransaction
+from django.urls import reverse
+from django.template.loader import get_template
+
+
+def _set_quickpay_order_id():
+ if settings.PAYMENT_ID_PREFIX == "pro":
+ id = (
+ PayableItem.objects.all().count()
+ + quickpaytransaction.QuickpayTransaction.objects.all().count()
+ + 1
+ )
+ return f"prod-{id}"
+ else:
+ return f"{settings.PAYMENT_ID_PREFIX}{timezone.now().timestamp()}"
+
+
+headers = {
+ "accept-version": "v10",
+ "content-type": "application/json",
+}
+auth = ("", settings.QUICKPAY_API_KEY)
+
+
+class PayableItem(models.Model):
+ class Meta:
+ verbose_name_plural = "Betalinger"
+ verbose_name = "Betaling"
+ ordering = ["added"]
+
+ person = models.ForeignKey(
+ "Person", on_delete=models.PROTECT, related_name="payments"
+ )
+ added = models.DateTimeField("Tilføjet", default=timezone.now)
+ refunded = models.DateTimeField("Refunderet", null=True, blank=True)
+ amount_ore = models.IntegerField("Beløb i øre")
+ # Payable items, at least one of them should not be null.
+ membership = models.ForeignKey(
+ "Membership",
+ on_delete=models.PROTECT,
+ related_name="payment",
+ null=True,
+ blank=True,
+ )
+ quick_pay_order_id = models.CharField(
+ "Quick Pay order ID",
+ max_length=20,
+ unique=True,
+ default=_set_quickpay_order_id,
+ )
+ accepted = models.BooleanField("Accepteret", default=False)
+ processed = models.BooleanField("Processed", default=False)
+ confirmation_sent = models.BooleanField("Bekræftelse sendt", default=False)
+ quick_pay_id = models.IntegerField("Quick Pay ID")
+ _payment_link = models.URLField("Betalingslink", null=True, blank=True)
+
+ __original_quick_pay_order_id = None
+ __original_quick_pay_id = None
+
+ def __init__(self, *args, **kwargs):
+ super(PayableItem, self).__init__(*args, **kwargs)
+ self.clean()
+ if self.quick_pay_id is None:
+ self.__original_quick_pay_order_id = self.quick_pay_order_id
+ self.quick_pay_id = self.__create_quickpay_transaction()
+ self.__original_quick_pay_id = self.quick_pay_id
+
+ def clean(self):
+ if (
+ self.__original_quick_pay_id is not None
+ and self.quick_pay_id != self.__original_quick_pay_id
+ ):
+ raise ValidationError(f"{self} tried to change quick_pay_id")
+ if (
+ self.__original_quick_pay_order_id is not None
+ and self.quick_pay_order_id != self.__original_quick_pay_order_id
+ ):
+ raise ValidationError(f"{self} tried to change quick_pay_order_id")
+ if self.membership is None:
+ raise ValidationError(f"{self} does not have any membership")
+ if self.amount_ore < 100:
+ raise ValidationError(
+ f"{self.amount_ore} is below 1kr, rember to specify price in øre"
+ )
+
+ def save(self, *args, **kwargs):
+ self.clean()
+ super(PayableItem, self).save(*args, **kwargs)
+
+ def __create_quickpay_transaction(self):
+ response = requests.post(
+ settings.QUICKPAY_URL,
+ headers=headers,
+ auth=auth,
+ data=json.dumps(
+ {
+ "order_id": self.quick_pay_order_id,
+ "currency": "dkk",
+ "text_on_statement": "Coding Pirates",
+ }
+ ),
+ )
+ if response.status_code != 201:
+ raise requests.HTTPError(
+ f"Quick pay payment failed, see:\n {response.text}"
+ )
+ return response.json()["id"]
+
+ def __str__(self):
+ return (
+ f"Betaling: {self.person} på {self.show_amount()}kr, for {self.get_item()}"
+ )
+
+ def get_link(self, base_url=None, continue_page=None):
+ if self._payment_link is not None:
+ return self._payment_link
+
+ data = {
+ "amount": self.amount_ore,
+ "language": "da",
+ "auto_capture": True,
+ }
+ if base_url is not None:
+ data["callback_url"] = f"{base_url}{reverse('quickpay_callback_new')}"
+ data["continue_url"] = (
+ "" if continue_page is None else f"{base_url}{reverse(continue_page)}"
+ )
+ response = requests.put(
+ f"{settings.QUICKPAY_URL}/{self.quick_pay_id}/link",
+ auth=auth,
+ headers=headers,
+ data=json.dumps(data),
+ )
+ if response.status_code != 200:
+ raise requests.HTTPError(f"Quick pay link failed, see:\n {response.text}")
+ self._payment_link = response.json()["url"]
+ self.save()
+ return self._payment_link
+
+ def get_status(self):
+ if self.refunded is not None:
+ return "refunded"
+ if self.accepted:
+ return "accepted"
+ response = requests.get(
+ f"{settings.QUICKPAY_URL}/{self.quick_pay_id}", auth=auth, headers=headers,
+ )
+ if response.status_code != 200:
+ raise requests.HTTPError(
+ f"Quick pay get payment info failed, see:\n {response.text}"
+ )
+ resp = response.json()
+ self.processed = resp["state"] == "processed"
+ self.accepted = resp["accepted"]
+ self.save()
+ if self.accepted:
+ return "accepted"
+ else:
+ return resp["state"]
+
+ def get_item(self):
+ if self.membership is not None:
+ return self.membership
+
+ def get_item_name(self):
+ if self.membership is not None:
+ return "Medlemsskab"
+
+ def show_amount(self):
+ return f"{(self.amount_ore / 100):,.2f}".replace(".", ",")
+
+ def send_payment_confirmation(self):
+ if self.get_status() == "accepted" and not self.confirmation_sent:
+ subject = "Betalingsbekræftelse"
+ html_content, text_content = PayableItem._render_payment_confirmation(self)
+ msg = EmailMultiAlternatives(
+ subject, text_content, settings.DEFAULT_FROM_EMAIL, [self.person.email],
+ )
+ msg.attach_alternative(html_content, "text/html")
+ self.confirmation_sent = True
+ self.save()
+ return msg.send(fail_silently=False)
+
+ @staticmethod
+ def _render_payment_confirmation(payment):
+ html_template = get_template("members/email/payment_confirm.html")
+ html = html_template.render({"payment": payment})
+
+ txt_template = get_template("members/email/payment_confirm.txt")
+ txt = txt_template.render({"payment": payment})
+
+ return html, txt
+
+ @staticmethod
+ def send_all_payment_confirmations():
+ return [
+ payment.send_payment_confirmation()
+ for payment in PayableItem.objects.filter(confirmation_sent=False)
+ ]
diff --git a/members/models/quickpaytransaction.py b/members/models/quickpaytransaction.py
index 95d70b6e..92678a30 100644
--- a/members/models/quickpaytransaction.py
+++ b/members/models/quickpaytransaction.py
@@ -4,6 +4,8 @@
from django.urls import reverse
from django.utils import timezone
+from .payable_item import _set_quickpay_order_id
+
class QuickpayTransaction(models.Model):
payment = models.ForeignKey("Payment", on_delete=models.PROTECT)
@@ -24,10 +26,7 @@ class QuickpayTransaction(models.Model):
def save(self, *args, **kwargs):
""" On creation make quickpay order_id from payment id """
if self.pk is None:
- if settings.DEBUG:
- self.order_id = f"dev{timezone.now().timestamp()}"
- else:
- self.order_id = "prod" + "%06d" % self.payment.pk
+ self.order_id = _set_quickpay_order_id()
return super(QuickpayTransaction, self).save(*args, **kwargs)
# method requests payment URL from Quickpay.
diff --git a/members/models/union.py b/members/models/union.py
index 52fc4fab..96ba04f4 100644
--- a/members/models/union.py
+++ b/members/models/union.py
@@ -58,6 +58,8 @@ class Meta:
"Sæt kryds hvis I har konto hos hovedforeningen (og ikke har egen bankkonto).",
default=True,
)
+ closed = models.DateField("Lukket", blank=True, null=True)
+ membership_price_ore = models.IntegerField("Medlemskontingent i ører", default=7500)
bank_account = models.CharField(
"Bankkonto:",
max_length=15,
diff --git a/members/static/members/js/membership_create.js b/members/static/members/js/membership_create.js
new file mode 100644
index 00000000..1a7efa55
--- /dev/null
+++ b/members/static/members/js/membership_create.js
@@ -0,0 +1,33 @@
+const base_url = window.location.href
+ .split("/")
+ .slice(0, 3)
+ .join("/");
+
+let unions = fetch(`${base_url}/graphql`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json"
+ },
+ body: '{"query":"{ unions {id membershipPriceOre }}"}'
+})
+ .then(res => res.json())
+ .then(res => {
+ unions = res.data["unions"];
+ })
+ .catch(err => {
+ console.log(err); // TODO: error handling
+ });
+
+
+document.addEventListener("DOMContentLoaded", event => {
+ // Set year
+ document.getElementById("year").innerText = new Date().getFullYear()
+
+
+ // Sets price according to selected union
+ document.getElementById("id_union").addEventListener("change", event => {
+ document.getElementById("price").innerText = unions.filter(
+ union => union.id === event.target.value
+ )[0]["membershipPriceOre"] / 100;
+ });
+});
diff --git a/members/static/members/sass/_definitions.scss b/members/static/members/sass/_definitions.scss
index bd2db737..58daea05 100644
--- a/members/static/members/sass/_definitions.scss
+++ b/members/static/members/sass/_definitions.scss
@@ -1,7 +1,7 @@
$primary-color: #00aeef;
$primary-color-muted: #00a0e0;
$accent-color: #ff9900;
-$accent-color-muted: #ed9224;
+$accent-color-muted: #e5841c;
$danger-color: #dc3545;
$danger-color-muted: #a01525;
$success-color: #28a745;
diff --git a/members/static/members/sass/forms.scss b/members/static/members/sass/forms.scss
new file mode 100644
index 00000000..d71b9f0e
--- /dev/null
+++ b/members/static/members/sass/forms.scss
@@ -0,0 +1,31 @@
+@use "_definitions";
+@use "buttons";
+
+form {
+ select {
+ width: 90%;
+ display: block;
+ margin: 0 auto;
+ }
+ label {
+ margin-top: 10px;
+ margin-right: 5px;
+ margin-left: 15px;
+ font-weight: 800;
+ }
+
+ input[type="submit"] {
+ display: block;
+ @extend .button;
+ }
+}
+
+.errorlist {
+ li {
+ @extend %rounded-rect;
+ background-color: _definitions.$danger-color;
+ font-weight: 600;
+ padding: 5px;
+ width: 90%;
+ }
+}
diff --git a/members/static/members/sass/header.scss b/members/static/members/sass/header.scss
index 4f019aa7..74c31727 100644
--- a/members/static/members/sass/header.scss
+++ b/members/static/members/sass/header.scss
@@ -39,7 +39,7 @@ nav {
}
#login-logout {
text-align: right;
- flex-grow: 2;
+ flex-grow: 2
}
.inactive {
diff --git a/members/static/members/sass/main.scss b/members/static/members/sass/main.scss
index 16912492..ef01a2e8 100644
--- a/members/static/members/sass/main.scss
+++ b/members/static/members/sass/main.scss
@@ -9,5 +9,6 @@
@use "style";
@use "table";
@use "tab";
+@use "forms";
@use "department_list";
-@import url("https://fonts.googleapis.com/css?family=Bungee|Lato|Pirata+One&display=swap");
\ No newline at end of file
+@import url("https://fonts.googleapis.com/css?family=Bungee|Lato|Pirata+One&display=swap");
diff --git a/members/templates/members/email/payment_confirm.html b/members/templates/members/email/payment_confirm.html
new file mode 100644
index 00000000..06fc55d5
--- /dev/null
+++ b/members/templates/members/email/payment_confirm.html
@@ -0,0 +1,471 @@
+
+
+
+
+
+ Coding Pirates Betalingsbekræftelse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Betalingbekræftelse
+ |
+
+
+
+ Hi {{payment.person}}
+ Du har den {{payment.added}} betalt for følgende
+ ved Coding Pirates
+ |
+
+
+
+
+
+
+ Person
+ |
+
+ Købt
+ |
+
+ Beløb
+ |
+
+
+ |
+ |
+ |
+
+
+
+ {{payment.get_item.person}}
+ |
+
+ {{payment.get_item_name}}
+ |
+
+ {{payment.show_amount}}kr
+ |
+
+
+
+
+
+
+
+
+ Med venlig hilsen
+ |
+
+
+
+ Coding Pirates
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
diff --git a/members/templates/members/email/payment_confirm.txt b/members/templates/members/email/payment_confirm.txt
new file mode 100644
index 00000000..f819b485
--- /dev/null
+++ b/members/templates/members/email/payment_confirm.txt
@@ -0,0 +1,15 @@
+Betalingbekræftelse
+
+Hi {{payment.person}}
+Du har den {{payment.added}} betalt for følgende ved Coding Pirates
+
+Person: {{payment.get_item.person}}
+Købt: {{payment.get_item_name}}
+Pris: {{payment.show_amount}}kr
+
+Se mere (members.codingpirates.dk/payments)
+
+Med venlig hilsen Coding Pirates
+
+Skriv til os hvis du har nogle spørgsmål til overstående.
+kontakt@codingpirates.dk (mailto:kontakt@codingpirates.dk)
diff --git a/members/templates/members/entry_page.html b/members/templates/members/entry_page.html
index 7cc7bf4d..f2ccfb12 100644
--- a/members/templates/members/entry_page.html
+++ b/members/templates/members/entry_page.html
@@ -2,43 +2,59 @@
{% block content %}
{% if user.is_authenticated %}
-
-
-
-
- For yderligere hjælp med at bruge denne side - se på
- "Sådan bruger du vores
- medlemssystem" eller skriv til os på
- kontakt@codingpirates.dk.
-
-
+
+ For yderligere hjælp med at bruge denne side - se på
+ "Sådan bruger du vores
+ medlemssystem" eller skriv til os på
+ kontakt@codingpirates.dk.
+
{% else %}
Tilmelding til Coding Pirates
diff --git a/members/templates/members/family_details.html b/members/templates/members/family_details.html
index 2c764e78..71c558c0 100644
--- a/members/templates/members/family_details.html
+++ b/members/templates/members/family_details.html
@@ -32,7 +32,7 @@
Personer
-
+
{% if family.person_set.count > 0 %}
diff --git a/members/templates/members/header.html b/members/templates/members/header.html
index 4f5981ea..8fec5ecb 100644
--- a/members/templates/members/header.html
+++ b/members/templates/members/header.html
@@ -8,6 +8,8 @@
Familie
Afdelinger
Arrangementer
+ Medlemskaber
+ Betalinger
Log ud
{% else %}
Tilmeld barn
diff --git a/members/templates/members/memberships.html b/members/templates/members/memberships.html
new file mode 100644
index 00000000..d8c080d0
--- /dev/null
+++ b/members/templates/members/memberships.html
@@ -0,0 +1,49 @@
+{% extends 'members/base.html' %} {% load static %}
+{% block content %}
+
+Medlemskaber
+
+ Coding Pirates består af {{ unions | length}} foreninger. Hver forening har
+ sin egen bestyrelse. For at blive medlem at en forening skal man betale
+ kontingent, hver forening sætter sin egen kontingent. Det er et krav at være
+ medlem for at være stemmeberetiget til en generalforsamling.
+ Nogle aktiviteter i Coding Pirates, f.eks klubaftner, har prisen på
+ medlemsskab indregnet.
+
+
+ På denne side kan du se dine medlemsskaber og melde dig ind i foreninger.
+
+
+
+
+{% if current_memberships %}
+
+ Mine medlemsskaber
+
+
+ Navn |
+ Forening |
+ År |
+
+
+ {% for membership in current_memberships %}
+
+ {{membership.person}} |
+ {{membership.union}} |
+ {{membership.sign_up_date.year}} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+{% endblock %}
diff --git a/members/templates/members/payments.html b/members/templates/members/payments.html
new file mode 100644
index 00000000..130a4d8c
--- /dev/null
+++ b/members/templates/members/payments.html
@@ -0,0 +1,38 @@
+{% extends 'members/base.html' %} {% load static %}
+{% block content %}
+
+Betalinger
+
+ På denne side kan du se et oversigt over dine betalinger, hvis en betaling
+ mangler er den markeret med rød.
+
+
+
+ Dato |
+ Købt |
+ Beløb |
+ Status |
+
+
+ {% for payment in payments %}
+
+ {{payment.added |date:'d-m-Y '}} |
+ {{payment.get_item}} |
+ {{payment.show_amount}} kr |
+
+ {% if payment.get_status == "accepted" %}
+ Betalt
+ {% else %}
+
+ Ikke betalt
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/members/tests/factories/__init__.py b/members/tests/factories/__init__.py
index a35602eb..7a6162e1 100644
--- a/members/tests/factories/__init__.py
+++ b/members/tests/factories/__init__.py
@@ -9,8 +9,11 @@
from .activity_participant_factory import ActivityParticipantFactory
from .payment_factory import PaymentFactory
from .volunteer_factory import VolunteerFactory
+from .membership_factory import MembershipFactory
+from .payable_item_factory import PayableItemFactory
__all__ = [
+ PayableItemFactory,
ActivityParticipantFactory,
PaymentFactory,
AddressFactory,
@@ -22,4 +25,5 @@
ActivityFactory,
MemberFactory,
VolunteerFactory,
+ MembershipFactory,
]
diff --git a/members/tests/factories/membership_factory.py b/members/tests/factories/membership_factory.py
new file mode 100644
index 00000000..7ec7b3f6
--- /dev/null
+++ b/members/tests/factories/membership_factory.py
@@ -0,0 +1,13 @@
+from factory import DjangoModelFactory, SubFactory
+from members.models import Membership
+
+from members.tests.factories.union_factory import UnionFactory
+from members.tests.factories.person_factory import PersonFactory
+
+
+class MembershipFactory(DjangoModelFactory):
+ class Meta:
+ model = Membership
+
+ union = SubFactory(UnionFactory)
+ person = SubFactory(PersonFactory)
diff --git a/members/tests/factories/payable_item_factory.py b/members/tests/factories/payable_item_factory.py
new file mode 100644
index 00000000..d8f47611
--- /dev/null
+++ b/members/tests/factories/payable_item_factory.py
@@ -0,0 +1,15 @@
+from factory import DjangoModelFactory, SubFactory
+from members.models import PayableItem
+from factory.fuzzy import FuzzyInteger
+
+from members.tests.factories.membership_factory import MembershipFactory
+from members.tests.factories.person_factory import PersonFactory
+
+
+class PayableItemFactory(DjangoModelFactory):
+ class Meta:
+ model = PayableItem
+
+ person = SubFactory(PersonFactory)
+ amount_ore = FuzzyInteger(7500, 200000)
+ membership = SubFactory(MembershipFactory)
diff --git a/members/tests/test_functional/test_admin_can_load.py b/members/tests/test_functional/test_admin_can_load.py
index 54e72f67..3c5592bd 100644
--- a/members/tests/test_functional/test_admin_can_load.py
+++ b/members/tests/test_functional/test_admin_can_load.py
@@ -1,13 +1,16 @@
-import socket
import os
-from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+import socket
+
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
-from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+
from django.contrib.auth.models import User
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from members.tests.factories import PersonFactory
+
"""
This test creates a super user and checks that the admin interface can be loaded
"""
diff --git a/members/tests/test_functional/test_create_family.py b/members/tests/test_functional/test_create_family.py
index 144efdf1..1505a34a 100644
--- a/members/tests/test_functional/test_create_family.py
+++ b/members/tests/test_functional/test_create_family.py
@@ -1,11 +1,14 @@
-import socket
import os
-from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+import socket
+
from selenium import webdriver
-from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.by import By
-from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+
"""
This test goes to the root signup page and creates a child and parent.
diff --git a/members/tests/test_functional/test_create_membership_fail.py b/members/tests/test_functional/test_create_membership_fail.py
new file mode 100644
index 00000000..d679287b
--- /dev/null
+++ b/members/tests/test_functional/test_create_membership_fail.py
@@ -0,0 +1,87 @@
+import os
+import socket
+from datetime import date
+
+from selenium import webdriver
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from members.models import Membership
+from members.tests.factories import FamilyFactory, PersonFactory, UnionFactory
+
+from .functional_helpers import get_text_contains, log_in
+
+
+"""
+This test starts with a family with a child that is a member and the parent
+wants to become a member.
+
+The parent ques to quickpay and leaves the page without entering card details.
+Once they go back to our page it is marked at not payed.
+"""
+
+
+class SignUpTest(StaticLiveServerTestCase):
+ host = socket.gethostbyname(socket.gethostname())
+
+ def setUp(self):
+ self.email = "parent@example.com"
+ self.browser = webdriver.Remote(
+ "http://selenium:4444/wd/hub", DesiredCapabilities.CHROME
+ )
+ self.family = FamilyFactory()
+ self.person = PersonFactory.create(family=self.family)
+ self.kid = PersonFactory.create(family=self.family, membertype="CH")
+ self.union = UnionFactory.create()
+ Membership.objects.create(
+ union=self.union, person=self.kid, sign_up_date=date.today()
+ )
+
+ def tearDown(self):
+ if not os.path.exists("test-screens"):
+ os.mkdir("test-screens")
+ self.browser.save_screenshot("test-screens/membership_test_final_not_payed.png")
+ self.browser.quit()
+
+ def test_entry_page(self):
+ log_in(self, self.person)
+
+ get_text_contains(self.browser, "få medlemsskab")[0].click()
+ # Check row in membership table has kid's name in it
+
+ self.browser.find_element_by_name("person").click()
+
+ # Click on option in select that has parent name
+ self.browser.find_element_by_xpath(
+ f"//*[@id='id_person']/option[text()[contains(., '{self.person}')]]"
+ ).click()
+
+ # Click on select Union
+ self.browser.find_element_by_name("union").click()
+ self.browser.find_element_by_xpath(
+ f"//*[@id='id_union']/option[text()[contains(., '{self.union}')]]"
+ ).click()
+
+ # Gå til betaling
+ self.browser.find_element_by_xpath("//input[@type='submit']").click()
+
+ # Leave quickpay and go back to entry page
+ self.browser.get(f"{self.live_server_url}")
+
+ try: # Wait to be redirected back from quickpay, worst case i 5 mins
+ WebDriverWait(self.browser, 60 * 5).until(
+ EC.title_is("Coding Pirates Medlemssystem")
+ )
+ except Exception:
+ self.fail("Was not sent back to own page")
+
+ # Go to paymetns views
+ get_text_contains(self.browser, "Se dine betalinger")[0].click()
+
+ # Klick "ikke betalt" button
+ self.browser.find_element_by_class_name("button-danger").click()
+
+ # Back at quickpay
+ self.assertIn("https://payment.quickpay.net", self.browser.current_url)
diff --git a/members/tests/test_functional/test_create_membership_success.py b/members/tests/test_functional/test_create_membership_success.py
new file mode 100644
index 00000000..03e2b232
--- /dev/null
+++ b/members/tests/test_functional/test_create_membership_success.py
@@ -0,0 +1,142 @@
+import os
+import socket
+from datetime import date
+import time
+from selenium import webdriver
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.common.keys import Keys
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from django.core import mail
+from members.models import Membership, PayableItem
+from members.tests.factories import FamilyFactory, PersonFactory, UnionFactory
+
+from .functional_helpers import get_text_contains, log_in
+
+
+"""
+This test starts with a family with a child that is a member and the parent
+wants to become a member.
+
+The parent ques to quickpay and enters a valid card, is sent to the payments
+screen. And recvies an email confirmation.
+"""
+
+
+class SignUpTest(StaticLiveServerTestCase):
+ host = socket.gethostbyname(socket.gethostname())
+
+ def setUp(self):
+ self.email = "parent@example.com"
+ self.browser = webdriver.Remote(
+ "http://selenium:4444/wd/hub", DesiredCapabilities.CHROME
+ )
+ self.family = FamilyFactory()
+ self.person = PersonFactory.create(family=self.family)
+ self.kid = PersonFactory.create(family=self.family, membertype="CH")
+ self.union = UnionFactory.create()
+ Membership.objects.create(
+ union=self.union, person=self.kid, sign_up_date=date.today()
+ )
+
+ def tearDown(self):
+ if not os.path.exists("test-screens"):
+ os.mkdir("test-screens")
+ self.browser.save_screenshot("test-screens/membership_test_final.png")
+ self.browser.quit()
+
+ def test_entry_page(self):
+ log_in(self, self.person)
+
+ get_text_contains(self.browser, "få medlemsskab")[0].click()
+ # Check row in membership table has kid's name in it
+ self.assertEqual(self.browser.find_element_by_xpath("//td").text, str(self.kid))
+ self.assertGreater(
+ len(get_text_contains(self.browser, "Mine medlemsskaber")), 0
+ )
+
+ # Click on select person
+ self.browser.find_element_by_name("person").click()
+
+ # Click on option in select that has parent name
+ self.browser.find_element_by_xpath(
+ f"//*[@id='id_person']/option[text()[contains(., '{self.person}')]]"
+ ).click()
+
+ # Click on select Union
+ self.browser.find_element_by_name("union").click()
+ self.browser.find_element_by_xpath(
+ f"//*[@id='id_union']/option[text()[contains(., '{self.union}')]]"
+ ).click()
+
+ # Gå til betaling
+ self.browser.find_element_by_xpath("//input[@type='submit']").click()
+
+ try: # Wait to be redirected to quickpay, worst case i 5 mins
+ WebDriverWait(self.browser, 60).until(
+ EC.title_is("Coding Pirates Test Account")
+ )
+ except Exception:
+ self.fail("Was not sent to quickpay")
+
+ # Enter card number
+ card_field = self.browser.find_element_by_id("cardnumber")
+ card_field.send_keys(Keys.NUMPAD1)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD6)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD0)
+ card_field.send_keys(Keys.NUMPAD2)
+ # self.browser.find_element_by_id("cardnumber").send_keys("1000 0600 0000 0002")
+ time.sleep(1)
+
+ # Enter experation month
+ self.browser.find_element_by_id("expiration-month").send_keys("11")
+
+ # Enter experation year
+ self.browser.find_element_by_id("expiration-year").send_keys("30")
+
+ # Enter CVS
+ self.browser.find_element_by_id("cvd").send_keys("208")
+ self.browser.find_element_by_id("cvd").send_keys(Keys.TAB)
+
+ self.browser.save_screenshot("test-screens/membership_test_card_details.png")
+
+ # Finish payment
+ self.browser.find_element_by_xpath("//*[@type='submit']").click()
+
+ try: # Wait to be redirected back from quickpay, worst case i 5 mins
+ WebDriverWait(self.browser, 60).until(
+ EC.title_is("Coding Pirates Medlemssystem")
+ )
+ except Exception:
+ self.fail("Was not sent back to own page")
+
+ payment_statuses = self.browser.find_elements_by_xpath(
+ '//*/td[@data-label="Status"]'
+ )
+ for status in payment_statuses:
+ self.assertEqual(status.text.strip(), "Betalt")
+
+ # Sent one confirmation email
+ self.assertEqual(PayableItem.send_all_payment_confirmations(), [1])
+
+ # Check that we can't send it again
+ self.assertEqual(PayableItem.send_all_payment_confirmations(), [])
+
+ # Sent to right person
+ self.assertEqual(mail.outbox[0].to, [self.person.email])
diff --git a/members/tests/test_model_activityparticipant.py b/members/tests/test_model_activityparticipant.py
index 338b4acb..f70bb4b3 100644
--- a/members/tests/test_model_activityparticipant.py
+++ b/members/tests/test_model_activityparticipant.py
@@ -20,6 +20,7 @@ def setUp(self):
end_date=datetime.now()
+ timedelta(days=365), # Has to be long enough to be a season
department=self.department,
+ union=self.department.union,
)
self.activity.save()
self.assertTrue(self.activity.is_season()) # If this fail increase the end_date
diff --git a/members/tests/test_model_membership.py b/members/tests/test_model_membership.py
new file mode 100644
index 00000000..3d64364e
--- /dev/null
+++ b/members/tests/test_model_membership.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+from members.models import Membership
+from .factories import UnionFactory, PersonFactory
+from datetime import date
+
+
+class TestModelMembership(TestCase):
+ def test_create_membership_same_year(self):
+ # Creates two memberships in the same year.
+ person = PersonFactory.create()
+ union = UnionFactory.create()
+ Membership.objects.create(
+ person=person, union=union, sign_up_date=date(2020, 3, 14)
+ )
+ with self.assertRaises(ValidationError):
+ Membership.objects.create(
+ person=person, union=union, sign_up_date=date(2020, 5, 14)
+ )
diff --git a/members/tests/test_model_payable_item.py b/members/tests/test_model_payable_item.py
new file mode 100644
index 00000000..5dc1c476
--- /dev/null
+++ b/members/tests/test_model_payable_item.py
@@ -0,0 +1,84 @@
+from django.core.exceptions import ValidationError
+from requests.exceptions import HTTPError
+
+from django.test import TestCase
+from members.models import PayableItem
+from members.models.payable_item import _set_quickpay_order_id
+from .factories import PersonFactory, PayableItemFactory
+
+
+class TestPayableItem(TestCase):
+ def test_set_quickpay_order_id(self):
+ # Tests that the quickpay order id is set to something uniqe in dev/test
+ # and count in prod
+ p1 = PayableItemFactory.create()
+ with self.settings(PAYMENT_ID_PREFIX="prod"):
+ id1 = _set_quickpay_order_id()
+ p2 = PayableItemFactory.create()
+ with self.settings(PAYMENT_ID_PREFIX="prod"):
+ id2 = _set_quickpay_order_id()
+
+ self.assertLess(p1.quick_pay_order_id, p2.quick_pay_order_id)
+ self.assertLess(id1, id2)
+
+ def test_get_cant_create_empty(self):
+ # Tests that either membership, activty or season is passed
+ person = PersonFactory.create()
+ with self.assertRaises(ValidationError):
+ PayableItem.objects.create(person=person, amount_ore=7500)
+
+ def test_try_to_change_quickpay_id(self):
+ # Creates a payment item and tries to change the ID which should fail
+ payment = PayableItemFactory.create()
+ payment.quick_pay_id = "something else"
+ with self.assertRaises(ValidationError):
+ payment.save()
+
+ payment = PayableItemFactory.create()
+ payment.quick_pay_order_id = "something else"
+ with self.assertRaises(ValidationError):
+ payment.save()
+
+ def test_get_payment_link(self):
+ # Creates a payment item and gets a payment link
+ payment = PayableItemFactory.create()
+ link = payment.get_link()
+ self.assertTrue("https://payment.quickpay.net/payments" in link)
+
+ def test_get_payment_status(self):
+ # Tests that we can get a payment status
+ payment = PayableItemFactory.create()
+ self.assertEqual(payment.get_status(), "initial")
+
+ def test_get_item(self):
+ # Tests that we can retrive both the paid item and the name.
+ payment = PayableItemFactory.create()
+ self.assertEqual(payment.get_item(), payment.membership)
+ self.assertEqual(payment.get_item_name(), "Medlemsskab")
+
+ def test_show_amount(self):
+ # Tests that we can retrive both the paid item and the name.
+ payment = PayableItemFactory.create(amount_ore=10000)
+ self.assertEqual(payment.show_amount(), "100,00")
+
+ def test_amount_less_than_1kr(self):
+ # Tests that we can retrive both the paid item and the name.
+ with self.assertRaises(ValidationError):
+ PayableItemFactory.create(amount_ore=10)
+
+ def test_cant_connect_to_quickpay(self):
+ with self.settings(QUICKPAY_URL="http://test.com/"):
+ with self.assertRaises(HTTPError):
+ PayableItemFactory.create()
+
+ payment = PayableItemFactory.create()
+ with self.settings(QUICKPAY_URL="http://test.com/"):
+ with self.assertRaises(HTTPError):
+ payment.get_link()
+
+ def test_to_string(self):
+ payment = PayableItemFactory()
+ payment_string = str(payment)
+ self.assertIn(str(payment.person), payment_string)
+ self.assertIn(str(payment.show_amount()), payment_string)
+ self.assertIn(str(payment.get_item()), payment_string)
diff --git a/members/urls.py b/members/urls.py
index 15f6167c..40c81774 100644
--- a/members/urls.py
+++ b/members/urls.py
@@ -8,6 +8,7 @@
EntryPage,
userCreated,
ConfirmFamily,
+ MembershipView,
QuickpayCallback,
ActivitySignup,
DepartmentSignView,
@@ -16,6 +17,8 @@
departmentView,
Activities,
AdminSignup,
+ PaymentsView,
+ QuickPayCallbackNew,
)
from django.contrib.auth import views as auth_views
from graphene_django.views import GraphQLView
@@ -70,6 +73,11 @@
url(r"volunteer$", volunteerSignup, name="volunteer_signup"),
url(r"user_created/$", userCreated, name="user_created"),
url(r"admin_signup/$", AdminSignup, name="admin_signup"),
+ url(r"memberships/$", MembershipView, name="memberships"),
+ url(r"payments/$", PaymentsView, name="payments"),
+ url(
+ r"payments/quickpaycallback$", QuickPayCallbackNew, name="quickpay_callback_new"
+ ),
url(r"family/$", FamilyDetails, name="family_detail"),
url(r"family/Person/(?P[\d]+)/$", PersonUpdate, name="person_update"),
url(r"family/Person/(?P[A-Z]{2})$", PersonCreate, name="person_add"),
diff --git a/members/views/__init__.py b/members/views/__init__.py
index 3da9b69b..b5f0ec4a 100644
--- a/members/views/__init__.py
+++ b/members/views/__init__.py
@@ -1,18 +1,44 @@
-# flake8: noqa # ignored since it is being used in the files
-from members.views.FamilyDetails import FamilyDetails
+from members.views.Activities import Activities
+from members.views.ActivitySignup import ActivitySignup
+from members.views.AdminSignup import AdminSignup
from members.views.ConfirmFamily import ConfirmFamily
+from members.views.DeclineInvitation import DeclineInvitation
+from members.views.DepartmentSignView import DepartmentSignView
+from members.views.departmentView import departmentView
+from members.views.EntryPage import EntryPage
+from members.views.FamilyDetails import FamilyDetails
+from members.views.paymentGatewayErrorView import paymentGatewayErrorView
from members.views.PersonCreate import PersonCreate
from members.views.PersonUpdate import PersonUpdate
-from members.views.WaitingListSetSubscription import WaitingListSetSubscription
-from members.views.DeclineInvitation import DeclineInvitation
-from members.views.ActivitySignup import ActivitySignup
+from members.views.QuickpayCallback import QuickpayCallback
from members.views.UpdatePersonFromForm import UpdatePersonFromForm
-from members.views.EntryPage import EntryPage
from members.views.userCreated import userCreated
from members.views.volunteerSignup import volunteerSignup
-from members.views.QuickpayCallback import QuickpayCallback
-from members.views.DepartmentSignView import DepartmentSignView
-from members.views.paymentGatewayErrorView import paymentGatewayErrorView
-from members.views.departmentView import departmentView
-from members.views.Activities import Activities
-from members.views.AdminSignup import AdminSignup
+from members.views.WaitingListSetSubscription import WaitingListSetSubscription
+
+from .memberships_view import MembershipView
+from .payments_view import PaymentsView
+from .quickpay_callback_new import QuickPayCallbackNew
+
+__all__ = [
+ Activities,
+ ActivitySignup,
+ AdminSignup,
+ ConfirmFamily,
+ DeclineInvitation,
+ DepartmentSignView,
+ departmentView,
+ EntryPage,
+ FamilyDetails,
+ MembershipView,
+ paymentGatewayErrorView,
+ PaymentsView,
+ PersonCreate,
+ PersonUpdate,
+ QuickpayCallback,
+ QuickPayCallbackNew,
+ UpdatePersonFromForm,
+ userCreated,
+ volunteerSignup,
+ WaitingListSetSubscription,
+]
diff --git a/members/views/memberships_view.py b/members/views/memberships_view.py
new file mode 100644
index 00000000..0d311419
--- /dev/null
+++ b/members/views/memberships_view.py
@@ -0,0 +1,58 @@
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.http import HttpResponseRedirect
+from django.shortcuts import render
+from members.forms import MembershipForm
+from members.models import Membership, PayableItem, Person, Union
+from members.utils.user import has_user, user_to_person
+
+
+@login_required
+@user_passes_test(has_user, "/admin_signup/")
+def MembershipView(request):
+
+ logged_in_person = user_to_person(request.user)
+ family_members = Person.objects.filter(family=logged_in_person.family)
+ unions = Union.objects.all()
+ current_memberships = Membership.objects.filter(person__in=family_members).order_by(
+ "person", "sign_up_date", "union"
+ )
+ if request.method == "GET":
+ form = MembershipForm(family_members)
+ return render(
+ request,
+ "members/memberships.html",
+ {
+ # TODO check for closed unions:
+ "unions": unions,
+ "family_members": family_members,
+ "current_memberships": current_memberships,
+ "form": form,
+ },
+ )
+ elif request.method == "POST":
+ form = MembershipForm(family_members, request.POST)
+ if form.is_valid():
+ membership = Membership.objects.create(
+ person=form.cleaned_data["person"], union=form.cleaned_data["union"]
+ )
+ payment = PayableItem.objects.create(
+ person=logged_in_person,
+ amount_ore=membership.union.membership_price_ore,
+ membership=membership,
+ )
+ base_url = "/".join(request.build_absolute_uri().split("/")[:3])
+ return HttpResponseRedirect(
+ payment.get_link(base_url=base_url, continue_page="payments")
+ )
+ else:
+ return render(
+ request,
+ "members/memberships.html",
+ {
+ # TODO check for closed unions:
+ "unions": unions,
+ "family_members": family_members,
+ "current_memberships": current_memberships,
+ "form": form,
+ },
+ )
diff --git a/members/views/payments_view.py b/members/views/payments_view.py
new file mode 100644
index 00000000..e97b59e0
--- /dev/null
+++ b/members/views/payments_view.py
@@ -0,0 +1,13 @@
+from django.contrib.auth.decorators import login_required, user_passes_test
+from django.shortcuts import render
+from members.models import PayableItem, Person
+from members.utils.user import has_user, user_to_person
+
+
+@login_required
+@user_passes_test(has_user, "/admin_signup/")
+def PaymentsView(request):
+ logged_in_person = user_to_person(request.user)
+ family_members = Person.objects.filter(family=logged_in_person.family)
+ payments = PayableItem.objects.filter(person__in=family_members).order_by("-added")
+ return render(request, "members/payments.html", {"payments": payments},)
diff --git a/members/views/quickpay_callback_new.py b/members/views/quickpay_callback_new.py
new file mode 100644
index 00000000..ec291fc7
--- /dev/null
+++ b/members/views/quickpay_callback_new.py
@@ -0,0 +1,9 @@
+from django.http import HttpResponse
+from django.views.decorators.csrf import csrf_exempt
+from members.models import PayableItem
+
+
+@csrf_exempt
+def QuickPayCallbackNew(request):
+ PayableItem.send_all_payment_confirmations()
+ return HttpResponse("OK")