Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
a09d146
BadBIC model
Jul 10, 2026
28a0551
Bad BIC model admin
Jul 10, 2026
c9d421a
Adding BIC to BadBIC list if a reversal with DNOR reason is processed
Jul 11, 2026
6512a24
Added confirmation screen template and logic in ajax view
Jul 11, 2026
d5290a8
Changed ibancalculator link to the correct link
Jul 11, 2026
7283e95
Added Bad BIC list check to registration form(s)
Jul 11, 2026
29230ce
Adding logic to remove BIC from Bad BIC list if a reversal that trigg…
Jul 15, 2026
764b0ca
Added bookkeeping field to declarationform. Selecting a committee her…
Jul 16, 2026
8506beb
Unnecessary imports
Jul 18, 2026
08042d9
Changing printing refund from CustomTransaction to CookieCornerTransa…
Jul 18, 2026
8c4b385
Moving forgive and mailing buttons to top of page and adding explanat…
Jul 22, 2026
5b2fd6b
Adding total and changing price styling
Jul 22, 2026
b363275
Overhaul handling of non-direct-debit payments in the website for act…
Kurocon Jul 22, 2026
742d7fa
Merge branch 'main' into 1283-revamp-cash
Kurocon Aug 7, 2026
06fce96
General cleanup, create ContributionTransactions on the proper date, …
Kurocon Aug 8, 2026
14813d6
Small fixes and improvements to BadBIC updates after reversal create/…
Kurocon Aug 8, 2026
0991231
Merge branch 'main' into 1283-revamp-cash
Kurocon Aug 10, 2026
1d339f3
Merge branch 'main' into 1283-revamp-cash
Kurocon Aug 10, 2026
36dd91e
Merge branch 'main' into 1300-move-unpaid-memberships-buttons-to-bott…
Kurocon Aug 10, 2026
1bbcea8
Fix conflicting migration
Kurocon Aug 10, 2026
a327a5d
Fix conflicting migration
Kurocon Aug 10, 2026
b1ae09c
build(deps): update model-bakery requirement
dependabot[bot] Aug 10, 2026
3318f80
build(deps): update pyopenssl requirement
dependabot[bot] Aug 10, 2026
c7dbdb9
build(deps): update python-gitlab requirement
dependabot[bot] Aug 10, 2026
4f08907
build(deps): update sentry-sdk requirement
dependabot[bot] Aug 10, 2026
fd805c8
Fixes after review:
Kurocon Aug 12, 2026
c59bde7
Fixes after review:
Kurocon Aug 13, 2026
57bd9b2
Add missing migration step for Transaction model options
Kurocon Aug 13, 2026
f19e27c
Do not allow creating Settlements for already settled transactions
Kurocon Aug 13, 2026
c724a48
Merge pull request #1317 from Inter-Actief/dependabot/pip/sentry-sdk-…
Kurocon Aug 14, 2026
5d6f119
Merge pull request #1316 from Inter-Actief/dependabot/pip/python-gitl…
Kurocon Aug 14, 2026
0b921c7
Merge pull request #1315 from Inter-Actief/dependabot/pip/pyopenssl-g…
Kurocon Aug 14, 2026
d063c57
Merge pull request #1314 from Inter-Actief/dependabot/pip/model-baker…
Kurocon Aug 14, 2026
a03e118
Merge pull request #1303 from Inter-Actief/1300-move-unpaid-membershi…
Kurocon Aug 14, 2026
f954ddb
Merge branch 'main' into 1283-revamp-cash
Kurocon Aug 14, 2026
6f569a4
Merge pull request #1305 from Inter-Actief/1283-revamp-cash
Kurocon Aug 14, 2026
2bdaeb5
Merge branch 'main' into 889-list-of-bad-bics
Kurocon Aug 14, 2026
87cac1e
Fix migration conflict
Kurocon Aug 14, 2026
6015d00
Merge pull request #1288 from Inter-Actief/889-list-of-bad-bics
Kurocon Aug 14, 2026
d565d17
Merge branch 'main' into 1281-declarations-for-committee-bookkeepings
Kurocon Aug 14, 2026
539564f
Override reply-to as well if bookkeeping is selected, fix migration c…
Kurocon Aug 14, 2026
35d061f
Merge pull request #1291 from Inter-Actief/1281-declarations-for-comm…
Kurocon Aug 14, 2026
478fdea
Merge branch 'main' into 1289-refund-printing-sale-not-customtransaction
Kurocon Aug 14, 2026
3a03c7e
Automatically settle refund transaction against the purchase
Kurocon Aug 14, 2026
8c075e3
Merge pull request #1292 from Inter-Actief/1289-refund-printing-sale-…
Kurocon Aug 14, 2026
c4e6337
Update django-modern-rpc to v2.1.0 and update test and documentation …
Kurocon Aug 14, 2026
0f69f91
Add declaration submitter name and date to subject to avoid grouping …
Kurocon Aug 15, 2026
8b81754
build(deps): update sentry-sdk requirement
dependabot[bot] Aug 17, 2026
5fc3005
build(deps): update httplib2 requirement
dependabot[bot] Aug 17, 2026
57d177f
build(deps): update django-debug-toolbar requirement
dependabot[bot] Aug 17, 2026
682c648
Merge pull request #1321 from Inter-Actief/dependabot/pip/sentry-sdk-…
Kurocon Aug 17, 2026
e9edaee
Merge pull request #1322 from Inter-Actief/dependabot/pip/httplib2-gt…
Kurocon Aug 17, 2026
b99751c
Merge pull request #1323 from Inter-Actief/dependabot/pip/django-debu…
Kurocon Aug 17, 2026
8085d03
build(deps): update docutils requirement
dependabot[bot] Aug 17, 2026
e7aebbf
build(deps): update django-health-check requirement
dependabot[bot] Aug 17, 2026
e5f7f2e
Merge pull request #1324 from Inter-Actief/dependabot/pip/docutils-gt…
Kurocon Aug 17, 2026
a61387c
Merge pull request #1325 from Inter-Actief/dependabot/pip/django-heal…
Kurocon Aug 17, 2026
9beb7fa
Add registration overview showing new members in the current academic…
Kurocon Aug 17, 2026
593321a
Update translation files
Kurocon Aug 17, 2026
8434959
Translated using Weblate (Dutch)
weblate Aug 17, 2026
ca94800
Merge pull request #1326 from Inter-Actief/ia-weblate
Kurocon Aug 17, 2026
2a2e95d
Fix migration issue
Kurocon Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions amelie/activities/ajax_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from typing import Optional, Dict

from django.conf import settings
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import DetailView, FormView

from amelie.activities.forms import ActivityManualPaymentForm
from amelie.activities.views import ActivityDetailView
from amelie.calendar.models import Participation
from amelie.members.models import Person
from amelie.tools.mixins import RequireAjaxMixin, RequireCommitteeMixin


class ActivityParticipationsView(RequireAjaxMixin, ActivityDetailView):
template_name = "includes/activity_participants_table.html"


class ActivityParticipationPaymentsView(RequireAjaxMixin, RequireCommitteeMixin, DetailView):
template_name = "includes/activity_participation_payments.html"
model = Participation
abbreviation = settings.ROOM_DUTY_ABBREVIATION

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request_person: Optional[Person] = self.request.person if hasattr(self.request, 'person') else None
context['transactions'] = self.object.activitytransaction_set.all()
context['is_roomduty'] = request_person and request_person.is_room_duty()
context['is_committee'] = request_person and self.object.event.organizer in request_person.current_committees().all()
context['status_msg'] = self.request.GET.get('msg')
return context

class ActivityParticipationCreatePaymentView(RequireAjaxMixin, RequireCommitteeMixin, FormView):
template_name = "includes/activity_participation_create_payment.html"
abbreviation = settings.ROOM_DUTY_ABBREVIATION
form_class = ActivityManualPaymentForm

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
request_person: Optional[Person] = self.request.person if hasattr(self.request, 'person') else None
context['object'] = Participation.objects.get(pk=self.kwargs['pk'])
context['transactions'] = [t for t in context['object'].activitytransaction_set.all() if not t.is_paid()]
context['transactions_total'] = sum(t.price for t in context['transactions'])
context['is_roomduty'] = request_person and request_person.is_room_duty()
context['is_committee'] = request_person and context['object'].event.organizer in request_person.current_committees().all()
return context

def get_success_url(self, query: Optional[Dict] = None):
return reverse('activities:activity_participation_payments', kwargs={
'activity_id': self.kwargs['activity_id'], 'pk': self.kwargs['pk']
}, query=query)

def form_valid(self, form):
# Get the transactions that need to be paid.
participation = Participation.objects.get(pk=self.kwargs['pk'])
payment_method = form.cleaned_data['payment_method']
settlement = participation.mark_as_paid(payment_method=payment_method, actor=self.request.person)
if settlement:
return HttpResponseRedirect(self.get_success_url(query={'msg': 'created'}))
else:
return HttpResponseRedirect(self.get_success_url(query={'msg': 'no_transactions'}))
214 changes: 175 additions & 39 deletions amelie/activities/forms.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,31 @@
from datetime import timedelta
from decimal import Decimal
from typing import OrderedDict, Optional, Tuple, List

from betterforms.multiform import MultiForm
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType

from django.core.validators import FileExtensionValidator
from django.db.models import Sum
from django.forms import widgets, SplitDateTimeField
from django.utils import formats, timezone
from django.utils.translation import gettext_lazy as _l

from amelie.activities.models import Activity, EnrollmentoptionQuestion, \
EnrollmentoptionCheckbox, EnrollmentoptionCheckboxAnswer, \
EnrollmentoptionQuestionAnswer, EnrollmentoptionFood, \
EnrollmentoptionFoodAnswer, Restaurant, EnrollmentoptionNumericAnswer, EnrollmentoptionNumeric, ActivityLabel
from amelie.style.forms import inject_style
EnrollmentoptionFoodAnswer, Restaurant, EnrollmentoptionNumericAnswer, EnrollmentoptionNumeric, ActivityLabel, \
EnrollmentoptionAnswer
from amelie.calendar.models import Participation
from amelie.members.models import Person, Committee
from amelie.personal_tab.models import PaymentMethod
from amelie.tools.forms import MultipleFileField
from amelie.tools.widgets import DateTimeSelector, DateTimeSelectorWithToday


class PaymentForm(forms.Form):
method = forms.ChoiceField(
choices=[
('', ''),
(Participation.PaymentMethodChoices.CASH.value,
Participation.PaymentMethodChoices.CASH.label),
],
label=_l('Method of payment'))

def __init__(self, mandate=False, waiting_list=False, board=False, *args, **kwargs):
super(PaymentForm, self).__init__(*args, **kwargs)

if mandate:
choices = self.fields['method'].choices
choices.append((Participation.PaymentMethodChoices.AUTHORIZATION.value,
Participation.PaymentMethodChoices.AUTHORIZATION.label))
self.fields['method'].choices = choices

if waiting_list:
self.fields['waiting_list'] = forms.ChoiceField(choices=[
('Wait', _l("Place on bottom of waiting list")),
], label=_l("Waiting list action"))
if board:
choices = self.fields['waiting_list'].choices
choices.append(('Skip', _l("Skip waiting list and enroll immediately")))
self.fields['waiting_list'].choices = choices


class ActivityForm(forms.ModelForm):

price = forms.DecimalField(required=False, initial="0.00", min_value=0, decimal_places=2, label=_l('Price'), help_text=_l('Enrolled participants will be emailed when you change the price.'))

class Meta:
Expand Down Expand Up @@ -93,8 +70,11 @@ def clean(self):
if not self.cleaned_data.get('price', None):
self.cleaned_data['price'] = 0

if self.cleaned_data["price"] < Decimal("0.00") and self.cleaned_data["enrollment"]:
raise forms.ValidationError(_l("Website enrolment must have a positive price. Either turn off the ability to enrol or increase the cost of the activity."))

if self.cleaned_data["price"] > settings.PERSONAL_TAB_MAXIMUM_ACTIVITY_PRICE and self.cleaned_data["enrollment"]:
raise forms.ValidationError(_l("Website enrolment has a maximum of {0} euro. Either turn off the ability to enrol or decrease the cost of the activity.").format(settings.PERSONAL_TAB_MAXIMUM_ACTIVITY_PRICE))
raise forms.ValidationError(_l("Website enrollment has a maximum of {0} euro. Either turn off the ability to enrol or decrease the cost of the activity.").format(settings.PERSONAL_TAB_MAXIMUM_ACTIVITY_PRICE))

if self.cleaned_data["enrollment"]:
if not self.cleaned_data.get('enrollment_begin', None):
Expand All @@ -110,9 +90,18 @@ def clean(self):
return self.cleaned_data


class ActivityModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return "%s (%s)" % (obj.summary, formats.date_format(obj.begin))
class IndirectEnrollmentForm(forms.Form):
def __init__(self, waiting_list=False, board=False, *args, **kwargs):
super(IndirectEnrollmentForm, self).__init__(*args, **kwargs)

if waiting_list:
self.fields['waiting_list'] = forms.ChoiceField(choices=[
('wait', _l("Place on bottom of waiting list")),
], label=_l("Waiting list action"))
if board:
choices = self.fields['waiting_list'].choices
choices.append(('skip', _l("Skip waiting list and enroll immediately")))
self.fields['waiting_list'].choices = choices


class EnrollmentoptionQuestionForm(forms.ModelForm):
Expand All @@ -132,6 +121,35 @@ class Meta:
model = EnrollmentoptionNumeric
fields = ["title", "price_extra", "maximum", "maximum_per_person"]

def clean_maximum(self):
data = self.cleaned_data['maximum']
# Check if the sum of current enrollment answers does not exceed the (potentially updated) maximum
if self.instance.pk:
current_enrollment_count = self.instance.enrollmentoptionanswer_set.aggregate(
Sum('enrollmentoptionnumericanswer__answer')
).get('enrollmentoptionnumericanswer__answer__sum')
if current_enrollment_count is not None and current_enrollment_count > data:
self.add_error(
'maximum',
_l("The amount of current enrollments for this option would exceed the new maximum amount. "
"Please unenroll or edit the affected people first, or modify the maximum for this option.")
)
return data

def clean_maximum_per_person(self):
data = self.cleaned_data['maximum_per_person']
# Check if none of the current enrollment answers exceed the (potentially updated) maximum per person.
if self.instance.pk:
for answer in self.instance.enrollmentoptionanswer_set.all():
if answer.enrollmentoptionnumericanswer.answer > data:
self.add_error(
'maximum_per_person',
_l("The selected amount of (at least) one of the current enrollments for this option exceeds "
"the new maximum amount per person. Please unenroll or edit the affected people first, "
"or modify the maximum for this option.")
)
return data


class EnrollmentoptionFoodForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
Expand Down Expand Up @@ -207,10 +225,27 @@ def clean_answer(self):
max_per_person = self.enrollmentoption.maximum_per_person if self.enrollmentoption.maximum_per_person > 0 else None
if not data:
data = 0
if data < 0:
self.add_error('answer', _l("Sorry, but the amount must be a positive number!"))
if max_per_person is not None and data > max_per_person:
self.add_error('answer', _l("Sorry, but you cannot exceed the maximum amount per person!"))
if data > 0 and not self.enrollmentoption.spots_left():

# Check the number of spots left, taking into account any spots that this enrollment might have already had
# before (in case the enrollment is being edited, which is when the self.instance.pk is set)
previous_spot_count = 0
if self.instance.pk:
previous_spot_count = self.instance.answer

num_spots_left = self.enrollmentoption.count_spots_left()
if num_spots_left is not None:
# Adding the previous spots to the available spots makes sure the person can reduce their
# amount without having to completely clear and re-claim them.
num_spots_left += previous_spot_count
if data > 0 and num_spots_left == 0:
self.add_error('answer', _l("Sorry, but there are no spots left!"))
elif data > 0 and (num_spots_left is not None and num_spots_left < data):
self.add_error('answer', _l("Sorry, but there are not enough spots left!"))

return data

class Meta:
Expand All @@ -228,6 +263,110 @@ class Meta:
fields = ("dishprice",)


ENROLLMENTOPTION_FORM_TYPES = {
EnrollmentoptionQuestion: EnrollmentoptionQuestionForm,
EnrollmentoptionCheckbox: EnrollmentoptionCheckboxForm,
EnrollmentoptionFood: EnrollmentoptionFoodForm,
EnrollmentoptionNumeric: EnrollmentoptionNumericForm,
}


class ActivityEnrollmentOptionsForm(MultiForm):
ENROLLMENTOPTION_ANSWER_TYPES = {
EnrollmentoptionQuestion: EnrollmentoptionQuestionAnswer,
EnrollmentoptionCheckbox: EnrollmentoptionCheckboxAnswer,
EnrollmentoptionFood: EnrollmentoptionFoodAnswer,
EnrollmentoptionNumeric: EnrollmentoptionNumericAnswer,
}

ENROLLMENTOPTIONANSWER_FORM_TYPES = {
EnrollmentoptionQuestion: EnrollmentoptionQuestionAnswerForm,
EnrollmentoptionCheckbox: EnrollmentoptionCheckboxAnswerForm,
EnrollmentoptionFood: EnrollmentoptionFoodAnswerForm,
EnrollmentoptionNumeric: EnrollmentoptionNumericAnswerForm,
}

form_classes = OrderedDict([])

def __init__(self, activity: Activity, *args, participation: Optional[Participation] = None, add_indirect_form: bool = False, **kwargs):
# Built the forms for the enrollment options
self.form_classes = OrderedDict([])
self.activity = activity
self.participation = participation

initials = kwargs.get('initial', {}) # Reference to add the EnrollmentOptionForm initial data to.

if add_indirect_form:
self.form_classes['indirect'] = IndirectEnrollmentForm

for enrollmentoption in activity.enrollmentoption_set.all():
prefix = 'enrollmentoption_{}'.format(enrollmentoption.pk)

answer_type: type[EnrollmentoptionAnswer] = ActivityEnrollmentOptionsForm.ENROLLMENTOPTION_ANSWER_TYPES[enrollmentoption.content_type.model_class()]
content_type = ContentType.objects.get_for_model(answer_type)

# Try to resolve the instance to an actual object if the person is enrolled for the activity
instance: EnrollmentoptionAnswer = answer_type(enrollmentoption=enrollmentoption, content_type=content_type)
if participation is not None:
try:
instance: EnrollmentoptionAnswer = answer_type.objects.get(
enrollment=participation,
enrollmentoption=enrollmentoption,
content_type=content_type
)
except EnrollmentoptionAnswer.DoesNotExist:
pass

form_type: type[EnrollmentoptionAnswerForm] = ActivityEnrollmentOptionsForm.ENROLLMENTOPTIONANSWER_FORM_TYPES[enrollmentoption.content_type.model_class()]

# Make sure that if someone adjusts their enrollment, and they have a spot with a limited capacity,
# they can still keep their spot.
checked = False
if instance.pk:
if form_type == EnrollmentoptionCheckboxAnswerForm:
instance: EnrollmentoptionCheckboxAnswer
checked = instance.answer
elif form_type == EnrollmentoptionNumericAnswerForm:
instance: EnrollmentoptionNumericAnswer
checked = instance.answer > 0

# Add the enrollment option form to the MultiForm
self.form_classes[prefix] = form_type
initials[prefix] = {
'enrollmentoption': enrollmentoption,
'checked': checked,
'prefix': prefix,
'instance': instance
}
super().__init__(*args, **kwargs)

def get_form_args_kwargs(self, key, args, kwargs):
args, fkwargs = super().get_form_args_kwargs(key=key, args=args, kwargs=kwargs)
# Extra per-form kwargs are stored in the initial values for the forms, which also includes a nested 'initial' key for the actual initial values.
fkwargs['initial'] = None
fkwargs.update(**self.initials.get(key, {}))
return args, fkwargs

def get_costs(self) -> Tuple[Decimal, Decimal, bool]:
if not self.is_valid():
raise ValueError("Cannot calculate costs. The forms are not valid.")
answers = [f.save(commit=False) for f in self.forms.values() if isinstance(f, EnrollmentoptionAnswerForm)]
prices_extra: List[Decimal] = [enrollment_option_answer.get_price_extra() for enrollment_option_answer in answers]
return self.activity.price, Decimal(sum(prices_extra)), len(prices_extra) > 0


class ActivityManualPaymentForm(forms.Form):
payment_method = forms.ModelChoiceField(
queryset=PaymentMethod.objects.filter(visible_activities=True),
label=_l('Payment method')
)


class ActivityModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return "%s (%s)" % (obj.summary, formats.date_format(obj.begin))


class PhotoCheckboxSelectMultiple(widgets.CheckboxSelectMultiple):
option_template_name = 'photo_upload_checkbox_option.html'

Expand Down Expand Up @@ -286,6 +425,3 @@ class EventDeskActivityMatchForm(forms.Form):
def __init__(self, activities, *args, **kwargs):
super(EventDeskActivityMatchForm, self).__init__(*args, **kwargs)
self.fields['activity'].queryset = activities


inject_style(ActivityForm, PaymentForm, PhotoUploadForm, EventDeskActivityMatchForm)
15 changes: 7 additions & 8 deletions amelie/activities/graphql.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import graphene
from django_filters import FilterSet, BooleanFilter
from django.utils.translation import gettext_lazy as _
from django.db.models import Q, Count
from docutils.nodes import description
from django.db.models import Count
from graphene_django import DjangoObjectType

from amelie.activities.models import Activity, ActivityLabel
Expand Down Expand Up @@ -107,27 +106,27 @@ def resolve_calendar_url(self: Activity, info):
return self.get_calendar_url()

def resolve_enrollment_open(self: Activity, info):
return self.enrollment_open()
return self.enrollment_open

def resolve_enrollment_closed(self: Activity, info):
return self.enrollment_closed()
return self.enrollment_closed

def resolve_can_edit(self: Activity, info):
if is_logged_in(info):
return self.can_edit(info.context.user.person)
return False

def resolve_enrollment_full(self: Activity, info):
return self.enrollment_full()
return self.enrollment_full

def resolve_enrollment_almost_full(self: Activity, info):
return self.enrollment_almost_full()
return self.enrollment_almost_full

def resolve_has_enrollment_option(self: Activity, info):
return self.has_enrollmentoptions()
return self.has_enrollmentoptions

def resolve_has_costs(self: Activity, info):
return self.has_costs()
return self.has_costs


@check_authorization
Expand Down
Loading
Loading