diff --git a/amelie/activities/ajax_views.py b/amelie/activities/ajax_views.py new file mode 100644 index 00000000..753165d7 --- /dev/null +++ b/amelie/activities/ajax_views.py @@ -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'})) diff --git a/amelie/activities/forms.py b/amelie/activities/forms.py index 55f01a1e..65e8e4f1 100644 --- a/amelie/activities/forms.py +++ b/amelie/activities/forms.py @@ -1,9 +1,14 @@ 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 @@ -11,44 +16,16 @@ 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: @@ -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): @@ -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): @@ -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): @@ -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: @@ -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' @@ -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) diff --git a/amelie/activities/graphql.py b/amelie/activities/graphql.py index fd701006..6c0f47c3 100644 --- a/amelie/activities/graphql.py +++ b/amelie/activities/graphql.py @@ -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 @@ -107,10 +106,10 @@ 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): @@ -118,16 +117,16 @@ def resolve_can_edit(self: Activity, info): 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 diff --git a/amelie/activities/mail.py b/amelie/activities/mail.py index 1869b1f8..53d7bf27 100644 --- a/amelie/activities/mail.py +++ b/amelie/activities/mail.py @@ -1,6 +1,5 @@ from django.utils import translation -from amelie.calendar.models import Participation from amelie.iamailer import MailTask, Recipient from amelie.tools.const import TaskPriority from amelie.members.models import Preference @@ -9,7 +8,7 @@ from django.utils.translation import gettext_lazy as _l -def activity_send_cashrefundmail(cash_participants, activity, request): +def activity_send_cashrefundmail(cash_participants, activity): """ Notify the treasurer that cash paying participants from a cancelled activity should get their money back. """ @@ -97,7 +96,7 @@ def activity_send_cancellationmail(participations, activity, from_waiting_list=F task.add_recipient(PersonRecipient(participation.person, context={ 'activity': activity, 'participation_costs': participation.calculate_costs()[0], - 'paymentmethod': participation.get_payment_method_display() + 'has_mandate': participation.person.has_mandate_activities() })) task.send() @@ -105,7 +104,7 @@ def activity_send_price_change_mail(old_activity, new_activity): """ Email all enrolled people to notify them of a price change """ - participations = old_activity.participation_set.filter(payment_method=Participation.PaymentMethodChoices.AUTHORIZATION) + participations = old_activity.participation_set.all() template_name = "activities/activity_price_change.mail" can_unenroll = new_activity.can_unenroll task = MailTask(template_name=template_name, priority=TaskPriority.HIGH) @@ -125,7 +124,7 @@ def activity_send_enrollment_option_price_change_mail(old_enrollment_option, new """ Email all enrolled people to notify them of a price change """ - participations = new_enrollment_option.activity.participation_set.filter(payment_method=Participation.PaymentMethodChoices.AUTHORIZATION) + participations = new_enrollment_option.activity.participation_set.all() template_name = "activities/activity_price_change.mail" can_unenroll = new_enrollment_option.activity.can_unenroll task = MailTask(template_name=template_name, priority=TaskPriority.HIGH) @@ -140,6 +139,8 @@ def activity_send_enrollment_option_price_change_mail(old_enrollment_option, new 'on_waiting_list': participation.waiting_list })) task.send() + + def activity_send_on_waiting_listmail(participation): """ Send a confirmation of enrollment on the waiting list for an activity. diff --git a/amelie/activities/management/commands/check_eventdesk.py b/amelie/activities/management/commands/check_eventdesk.py index 6f56a493..153d0806 100644 --- a/amelie/activities/management/commands/check_eventdesk.py +++ b/amelie/activities/management/commands/check_eventdesk.py @@ -12,7 +12,7 @@ from django.utils.timezone import make_aware from amelie.activities.models import EventDeskRegistrationMessage, Activity -from amelie.activities.utils import setlocale +from amelie.tools.locale import setlocale from amelie.claudia.google import GoogleSuiteAPI EVENT_REGISTERED_REGEX_NL = re.compile("Uw aanmelding voor bijeenkomst '(?P.*)'") diff --git a/amelie/activities/migrations/0014_alter_activity_price.py b/amelie/activities/migrations/0014_alter_activity_price.py new file mode 100644 index 00000000..2776f709 --- /dev/null +++ b/amelie/activities/migrations/0014_alter_activity_price.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.12 on 2026-08-12 13:36 + +import django.core.validators +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activities', '0013_activity_enrollment_private'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='price', + field=models.DecimalField(decimal_places=2, default='0.00', help_text='Enrolled participants will be emailed when you change the price.', max_digits=8, validators=[django.core.validators.MinValueValidator(Decimal('0.00'))], verbose_name='price'), + ), + ] diff --git a/amelie/activities/models.py b/amelie/activities/models.py index f61a6327..2add8f4b 100644 --- a/amelie/activities/models.py +++ b/amelie/activities/models.py @@ -1,21 +1,28 @@ from datetime import datetime +from decimal import Decimal +from typing import Optional, Tuple, List, Type from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator -from django.db.models import Sum +from django.db.models import Sum, QuerySet +from django.dispatch import receiver from django.urls import reverse -from django.db import models -from django.db.models.signals import post_save, pre_save -from django.utils import timezone +from django.db import models, transaction +from django.db.models.signals import post_save, pre_save, pre_delete +from django.utils import timezone, translation from django.utils.timezone import make_aware +from django.utils.translation import gettext as _ from django.utils.translation import get_language, gettext_lazy as _l from oauth2_provider.models import Application -from amelie.activities.utils import setlocale, update_waiting_list +from amelie.activities.mail import activity_send_enrollmentmail, activity_send_cancellationmail +from amelie.tools.locale import setlocale from amelie.files.models import Attachment from amelie.calendar.managers import EventManager from amelie.calendar.models import Event, Participation +from amelie.members.models import Person +from amelie.personal_tab import transactions from amelie.tools.discord import send_discord, send_discord_presave from amelie.tools.managers import SubclassManager @@ -64,7 +71,10 @@ class Activity(Event): photos = models.ManyToManyField(Attachment, blank=True, related_name='foto_set') components = models.ManyToManyField('self', blank=True) - price = models.DecimalField(default="0.00", max_digits=8, decimal_places=2, verbose_name=_l('price'), help_text=_l("Enrolled participants will be emailed when you change the price.")) + price = models.DecimalField( + default="0.00", max_digits=8, decimal_places=2, validators=[MinValueValidator(Decimal('0.00'))], + verbose_name=_l('price'), help_text=_l("Enrolled participants will be emailed when you change the price.") + ) can_unenroll = models.BooleanField(default=True, verbose_name=_l('can unenroll')) image_icon = models.ImageField(upload_to='activities/icon/', max_length=255, null=True, blank=True, verbose_name=_l('icon'), help_text=_l('Image of 175 by 275 pixels.')) @@ -85,26 +95,6 @@ class Meta: def __str__(self): return self.summary - @property - def activity_type(self): - return "regular" - - @property - def confirmed_participants(self): - return self.participants.filter(participation__waiting_list=False) - - @property - def confirmed_participations(self): - return self.participation_set.filter(waiting_list=False) - - @property - def waiting_participants(self): - return self.participants.filter(participation__waiting_list=True) - - @property - def waiting_participations(self): - return self.participation_set.filter(waiting_list=True) - def clean(self): super(Activity, self).clean() @@ -115,84 +105,79 @@ def clean(self): if self.enrollment_end is None: raise ValidationError(_l('There is no enrollment end date entered.')) - def get_absolute_url(self): - return reverse('activities:activity', args=[self.id]) - - def get_photo_url_random(self): - return reverse('activities:random_photo', args=[self.id]) - - def get_photo_url(self): - return reverse('activities:gallery', args=[self.id]) + ## + # Properties and simple getter functions + ## - def get_calendar_url(self): - return reverse('activities:ics', args=[self.id]) + @property + def activity_type(self) -> str: + return "regular" - def random_photo(self, only_public=True): - photos = self.photos.filter_public(only_public) + @property + def confirmed_participants(self) -> QuerySet['Person']: + return self.participants.filter(participation__waiting_list=False) - # Select a random photo - photo = photos.order_by('?')[:1] + @property + def confirmed_participations(self) -> QuerySet['Participation']: + return self.participation_set.filter(waiting_list=False) - # Done - return photo[0] if len(photo) > 0 else None + @property + def waiting_participants(self) -> QuerySet['Person']: + return self.participants.filter(participation__waiting_list=True) - def random_public_photo(self): - return self.random_photo(only_public=True) + @property + def waiting_participations(self) -> QuerySet['Participation']: + return self.participation_set.filter(waiting_list=True) - def enrollment_open(self): + @property + def enrollment_open(self) -> bool: """Shows whether the enrollment is open at this time""" return self.enrollment_begin < timezone.now() < self.enrollment_end - enrollment_open.boolean = True - - def enrollment_closed(self): + @property + def enrollment_closed(self) -> bool: """ - If True then the enrollment deadline is over thus you are too late to enroll. + If True, then the enrollment deadline is over, thus you are too late to enroll. """ - return timezone.now() > self.enrollment_end - def can_edit(self, person): - """ - - """ - - return person.is_board() or (self.organizer in person.current_committees()) or person.user.is_superuser - - def places_available(self): + @property + def places_available(self) -> Optional[int]: """ Calculates the number of available places. If there is no maximum, then the result of this function is None. """ - return max(0, self.maximum - self.participants.filter(participation__waiting_list=False).count()) if self.maximum else None - def enrollment_full(self): + @property + def enrollment_full(self) -> bool: """ True if there are no more places available to enroll and if there is a maximum. """ - - places_available = self.places_available() + places_available = self.places_available return places_available is not None and places_available == 0 - def has_waiting_participants(self): + @property + def has_waiting_participants(self) -> bool: """ True if there is a waiting list that is not empty. """ return self.waiting_participants.count() > 0 - def enrollment_almost_full(self): + @property + def enrollment_almost_full(self) -> bool: """ True if there are less than ten places available and if there is a maximum. """ - - places_available = self.places_available() + places_available = self.places_available return places_available is not None and places_available <= 10 - def has_enrollmentoptions(self): + @property + def has_enrollmentoptions(self) -> bool: return self.enrollmentoption_set.exists() - def has_costs(self): + @property + def has_costs(self) -> bool: """ An activity has costs if there are enrollment costs, or if there are enrollment options, optional or not, that have costs. In this case the @@ -201,18 +186,208 @@ def has_costs(self): It does not matter if enrollment costs or enrollment options result in a discount. """ - # Trivial check - if self.price != 0: + if self.price != Decimal("0.00"): return True # Check every option return any([enrollmentoption.has_extra_costs() for enrollmentoption in self.enrollmentoption_set.all()]) - def latest_event_registration_message(self): + @property + def latest_event_registration_message(self) -> Optional['EventDeskRegistrationMessage']: return self.eventdeskregistrationmessage_set.all().order_by('-message_date').first() + def get_absolute_url(self) -> str: + return reverse('activities:activity', args=[self.id]) + + def get_photo_url_random(self) -> str: + return reverse('activities:random_photo', args=[self.id]) + + def get_photo_url(self) -> str: + return reverse('activities:gallery', args=[self.id]) + + def get_calendar_url(self) -> str: + return reverse('activities:ics', args=[self.id]) + + def random_photo(self, only_public=True) -> Optional['Attachment']: + photos: QuerySet['Attachment'] = self.photos.filter_public(only_public) + + # Select a random photo + photo = photos.order_by('?')[:1] + + # Done + return photo[0] if len(photo) > 0 else None + + def random_public_photo(self) -> Optional['Attachment']: + return self.random_photo(only_public=True) + + def can_edit(self, person) -> bool: + return person.is_board() or (self.organizer in person.current_committees()) or person.user.is_superuser + + ## + # Enrollment helper functions + ## + def check_enrollment_allowed(self, person: Person, indirect: bool = False, + ignore_start_enrollment: bool = False, + authentication_application: Optional[Application] = None) -> Tuple[bool, Optional[str]]: + """Checks whether a person can enroll for this activity + + :param person: The person who wants to enroll for an activity, standard is request.person + :param indirect: Whether the enrollment is done by the person him-/herself, or done for him/her + :param ignore_start_enrollment: Ignore the enrollment period of the activity + :param authentication_application: The oauth application being used to process this enrollment + :returns: Whether the person can enroll for the given activity + """ + if not self.enrollment: + return False, _l('Enrollment for {activity} is not possible.').format(activity=self) + + if not person.is_member(): + return False, _l( + 'Enrollment for {activity} is not possible. You do not have an active membership at Inter-Actief' + ).format(activity=self) + + if self.oauth_application and (authentication_application is None or self.oauth_application != authentication_application): + return False, _l( + 'Enrollment for {activity} is only possible on the website of this activity.' + ).format(activity=self) + + if not self.enrollment_open: + if not ignore_start_enrollment: + return False, _l( + "The enrollment period for {activity} hasn't started yet or has already passed." + ).format(activity=self) + + if person in self.participants.all(): + if indirect: + return False, _l('{person} is already enrolled for {activity}.').format( + person=person, + activity=self + ) + else: + return False, _l('You are already enrolled for {activity}.').format(activity=self) + + return True, None + + def check_unenrollment_allowed(self, person: Person, indirect: bool = False, ignore_can_unenroll: bool = False, + ignore_unenrollment_period: bool = False, + authentication_application: Optional[Application] = None) -> Tuple[bool, Optional[str]]: + """Checks whether a person can unenroll for an activity + + :param person: The person who wants to unenroll for an activity, standard is request.person + :param indirect: Whether the unenrollment is done by the person him-/herself or for him/her + :param ignore_can_unenroll: Ignore the unenrollment prohibition of the activity + :param ignore_unenrollment_period: Ignore the unenrollment period of the activity + :param authentication_application: The oauth application being used to process this unenrollment + :returns: Whether the person can unenroll for the given activity + + :side_effect: Puts an elaborate description of the error in django messages + """ + if not self.enrollment: + return False, _l('Unenrollment for {activity} is not possible.').format(activity=self) + + if self.oauth_application and (authentication_application is None or self.oauth_application != authentication_application): + return False, _l( + 'Unenrollment for {activity} is only possible on the website of this activity.' + ).format(activity=self) + + try: + # The select_for_update makes sure to acquire a write lock + Participation.objects.select_for_update().get(person=person, event=self) + except Participation.DoesNotExist: + if indirect: + return False, _l('{person} is not enrolled for {activity}.').format( + person=person, + activity=self + ) + else: + return False, _l('You are not enrolled for {activity}.').format(activity=self) + + if not self.can_unenroll and not ignore_can_unenroll: + return False, _l('Unenrollment for {activity} is not possible.').format(activity=self) + + if not self.enrollment_open and not ignore_unenrollment_period: + return False, _l('The unenrollment term for {activity} has already passed.').format(activity=self) + + return True, None + + @transaction.atomic + def update_waiting_list(self) -> List[Participation]: + """ + Checks if any participants on the waiting list for this activity can be enrolled into the activity. + If there is room, the participants will be definitively enrolled. + + The list of newly enrolled participants is returned. + + :return: List of newly enrolled participants + :rtype: List[Participation] + """ + new_participants: List[Participation] = [] + while not self.enrollment_full and self.has_waiting_participants and not self.waiting_list_locked: + new_participation = Participation.objects.get( + person=self.waiting_participants.order_by('participation__added_on').first(), + event=self + ) + activity_send_enrollmentmail(new_participation, from_waiting_list=True) + new_participation.waiting_list = False + new_participation.save() + transactions.add_participation(new_participation, added_by=new_participation.added_by) + new_participants.append(new_participation) + return new_participants + + @transaction.atomic + def unenroll_all_participants(self, actor: Optional[Person] = None): + """ + Neatly unenroll all participants from this activity, undoing any transactions that + may have been added to the personal tabs of the participants. + :param actor: A person that is registered as the 'added by' actor for the cancellation transaction. + :type actor: Person + """ + # Undo transactions and remove participations + for participation in self.participation_set.all(): + # If necessary, compensate the person for the enrollment costs. + with translation.override(participation.person.preferred_language): + transactions.remove_participation(participation, added_by=actor, reason=_("Activity was cancelled")) + # Remove the participation + participation.delete() + + @transaction.atomic + def cancel_activity(self, actor: Optional[Person] = None): + """ + Cancel this activity. + Events may only be canceled if they have not yet started. + This will also remove all enrollments (and closes the option to enroll) and undoes the corresponding transactions. + :param actor: A person that is registered as the 'added by' actor for the cancellation transaction. + :type actor: Person + """ + # Can only cancel if the activity hasn't started yet. + if self.begin < timezone.now(): + raise ValueError(_("Activity has already started, it cannot be canceled any more.")) + + # Send cancellation e-mails + # First to regular participants (filtered by waiting_list=False) + activity_send_cancellationmail(self.participation_set.filter(waiting_list=False), self) + # Then to waiting list participants, if present + if self.waiting_participations.exists(): + activity_send_cancellationmail(self.waiting_participations.all(), self, from_waiting_list=True) + + # Undo transactions and remove participations + self.unenroll_all_participants(actor=actor) + + # Mark the activity as canceled and close enrollments. + self.cancelled = True + self.enrollment = False + self.save() + + def uncancel_activity(self): + """ + Uncancel an activity. + The cancellation status will be removed, but enrollments will not be re-opened + (because we can't know if it was open before). + """ + self.cancelled = False + self.save() + class Enrollmentoption(models.Model): activity = models.ForeignKey(Activity, on_delete=models.CASCADE) @@ -237,6 +412,10 @@ def save(self, *args, **kwargs): def has_extra_costs(self): return False + def get_min_max_extra_costs(self) -> Tuple[Decimal, Decimal]: + """Gets the minimum possible costs and maximum possible costs for this option.""" + return Decimal('0.00'), Decimal('0.00') + def as_leaf_class(self, enforce_content_type=None): if enforce_content_type is not None: content_type = enforce_content_type @@ -284,6 +463,12 @@ class EnrollmentoptionCheckbox(Enrollmentoption): def has_extra_costs(self): return bool(self.price_extra) + def get_min_max_extra_costs(self) -> Tuple[Decimal, Decimal]: + if self.has_extra_costs(): + return min(self.price_extra, Decimal('0.00')), max(self.price_extra, Decimal('0.00')) + else: + return super().get_min_max_extra_costs() + def has_limit(self): return self.maximum != 0 @@ -311,6 +496,15 @@ class EnrollmentoptionNumeric(Enrollmentoption): def has_extra_costs(self): return bool(self.price_extra) + def get_min_max_extra_costs(self) -> Tuple[Decimal, Decimal]: + if self.has_extra_costs(): + # 9999 is a highball estimate that should not be reached in normal use, because 'infinite' is hard to work with. + max_choice_amount = min(self.maximum_per_person or 9999, self.maximum or 9999, 9999) + largest_extra_price = self.price_extra * max_choice_amount + return min(largest_extra_price, Decimal('0.00')), max(largest_extra_price, Decimal('0.00')) + else: + return super().get_min_max_extra_costs() + def has_limit(self): return self.maximum != 0 @@ -335,6 +529,13 @@ class EnrollmentoptionSelectbox(Enrollmentoption): def has_extra_costs(self): return any([selectboxoption.price_extra for selectboxoption in self.selectboxoption_set.all()]) + def get_min_max_extra_costs(self) -> Tuple[Decimal, Decimal]: + if self.has_extra_costs(): + possible_prices = [selectboxoption.price_extra for selectboxoption in self.selectboxoption_set.all()] + return min(min(possible_prices), Decimal('0.00')), max(max(possible_prices), Decimal('0.00')) + else: + return super().get_min_max_extra_costs() + class SelectboxOption(models.Model): """ @@ -395,12 +596,11 @@ def as_leaf_class(self, enforce_content_type=None): return new_model - def get_price_extra(self): + def get_price_extra(self) -> Decimal: """ Calculate extra costs based on data """ - - return 0 + return Decimal("0") class EnrollmentoptionQuestionAnswer(EnrollmentoptionAnswer): @@ -430,7 +630,7 @@ def display_answer(self): def is_empty(self): return EnrollmentoptionCheckboxAnswer.objects.filter(id=self.id).filter(answer=False).exists() - def get_price_extra(self): + def get_price_extra(self) -> Decimal: if self.answer: return self.enrollmentoption.enrollmentoptioncheckbox.price_extra else: @@ -448,7 +648,7 @@ def display_answer(self): def is_empty(self): return EnrollmentoptionNumericAnswer.objects.filter(id=self.id).filter(answer=0).exists() - def get_price_extra(self): + def get_price_extra(self) -> Decimal: return self.enrollmentoption.enrollmentoptionnumeric.price_extra * self.answer @@ -463,7 +663,7 @@ def display_answer(self): def is_empty(self): return EnrollmentoptionSelectboxAnswer.objects.filter(id=self.id).filter(answer__isnull=True).exists() - def get_price_extra(self): + def get_price_extra(self) -> Decimal: return self.answer.price_extra @@ -530,28 +730,10 @@ def display_answer(self): def is_empty(self): return EnrollmentoptionFoodAnswer.objects.filter(id=self.id).filter(dishprice__isnull=True).exists() - def get_price_extra(self): + def get_price_extra(self) -> Decimal: return self.dishprice.price if self.dishprice else super(EnrollmentoptionFoodAnswer, self).get_price_extra() -def createdishprice(sender, **kwargs): - if kwargs['created']: - instance = kwargs['instance'] - for dish in instance.restaurant.dish_set.all(): - dishprice = DishPrice(dish=dish, price=dish.price, question=instance) - dishprice.save() - - -post_save.connect(createdishprice, sender=EnrollmentoptionFood) -# IRC notifications disabled because the bot is broken -- albertskja 2023-03-28 -# post_save.connect(send_irc, sender=Activity) -post_save.connect(send_discord, sender=Activity) -post_save.connect(update_waiting_list, sender=Activity) - -# Pre-save hook for activity to send_discord to be able to check if pictures were added -pre_save.connect(send_discord_presave, sender=Activity) - - class EventDeskRegistrationMessage(models.Model): class EventRegistrationStates(models.TextChoices): NEW = 'NEW', _l("Registered") @@ -617,4 +799,36 @@ def ratio_ok(self): else: return self.match_ratio > 0.6 -post_save.connect(update_waiting_list, Activity) + +## +# Event hooks for Activities module +## +def createdishprice(sender, **kwargs): + if kwargs['created']: + instance = kwargs['instance'] + for dish in instance.restaurant.dish_set.all(): + dishprice = DishPrice(dish=dish, price=dish.price, question=instance) + dishprice.save() + + +def updatewaitinglist(sender, **kwargs): + instance: Activity = kwargs['instance'] + instance.update_waiting_list() + + +post_save.connect(createdishprice, sender=EnrollmentoptionFood) +post_save.connect(updatewaitinglist, sender=Activity) +post_save.connect(send_discord, sender=Activity) + +# Pre-save hook for activity to send_discord to be able to check if pictures were added +pre_save.connect(send_discord_presave, sender=Activity) + +@receiver(pre_delete, sender=Activity) +def pre_delete_cleanup_activity_participations(sender: Type[Activity] , instance: Activity, **kwargs): + """ + Pre-delete hook as a final catch to make sure to unenroll everyone properly from an activity before it is deleted. + This makes sure everyone gets a cancellation e-mail even if it is forgotten in the deleting party. + However, ideally, this method (Activity.unenroll_all_participants) is called earlier with the actor argument set, + so that it is recorded who deleted the activity and unenrolled the participants. + """ + instance.unenroll_all_participants() diff --git a/amelie/activities/templates/activities/activity_cancelled.mail b/amelie/activities/templates/activities/activity_cancelled.mail index 497d178d..31219a49 100644 --- a/amelie/activities/templates/activities/activity_cancelled.mail +++ b/amelie/activities/templates/activities/activity_cancelled.mail @@ -1,11 +1,13 @@ {% extends "iamailer/email_basic.mail" %} {% load i18n htmlify only %} -{% block subject %}{% blocktrans %}Cancellation of {% endblocktrans %} {{ activity.summary }} {% endblock %} -{% block content %}{% htmlify %}{% blocktrans %}Dear{% endblocktrans %} {{ recipient.first_name }}, -{% blocktrans with summary=activity.summary begindate=activity.begin|date:"l j F Y" begintime=activity.begin|date:"G:i" %} -You receive this email because you were enrolled for {{ summary }}. This activity has been cancelled. +{% block subject %}{% blocktrans with activity=activity.summary %}Cancellation of {{ activity }}{% endblocktrans %}{% endblock %} +{% block content %}{% htmlify %}{% blocktrans with name=recipient.first_name %}Dear {{ name }},{% endblocktrans %} +{% blocktrans with activity=activity.summary %} +You receive this email because you were enrolled for {{ activity }}. This activity has been cancelled. {% endblocktrans %}{% endhtmlify %} -{% if participation_costs > 0 %}{% htmlify %}{% blocktrans %}The total costs were {{ participation_costs }} euro. Your payment method was {{paymentmethod}}. These costs will be refunded on your personal tab in the case of a mandate or you can collect your cash payment at the association room in the case of a cash payment.{% endblocktrans %}{% endhtmlify %} -{% endif %}{% blocktrans %} +{% if participation_costs > 0 %}{% htmlify %}{% blocktrans with costs=participation_costs %}The total costs were {{ costs }} euro.{% endblocktrans %}{% endhtmlify %} +{% if has_mandate %}{% htmlify %}{% blocktrans %}These costs will be refunded on your personal tab.{% endblocktrans %}{% endhtmlify %} +{% else %}{% htmlify %}{% blocktrans %}If you already paid for this activity, please come by the association room or contact the treasurer to re-collect your payment. If you had not paid yet you do not need to do anything.{% endblocktrans %}{% endhtmlify %} +{% endif %}{% endif %}{% blocktrans %} Kind regards,{% endblocktrans %} {% onlyhtml %}
{% endonlyhtml %}Inter-{% onlyhtml %}{% endonlyhtml %}Actief{% onlyhtml %}{% endonlyhtml %}{% endblock %} diff --git a/amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail b/amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail index 8e57192d..8713aff6 100644 --- a/amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail +++ b/amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail @@ -2,7 +2,7 @@ {% load i18n htmlify only %} {% block subject %}{% blocktrans %}Cancellation of {% endblocktrans %} {{ activity.summary }} {% endblock %} {% block content %}{% htmlify %}{% blocktrans %}Dear{% endblocktrans %} {{ recipient.first_name }}, -{% blocktrans with summary=activity.summary begindate=activity.begin|date:"l j F Y" begintime=activity.begin|date:"G:i" %} +{% blocktrans with summary=activity.summary %} You receive this email because you were on the waiting list for {{ summary }}. This activity has been cancelled. {% endblocktrans %}{% endhtmlify %} {% blocktrans %} Kind regards,{% endblocktrans %} diff --git a/amelie/activities/templates/activities/activity_enrolled.mail b/amelie/activities/templates/activities/activity_enrolled.mail index 0afec305..a880f262 100644 --- a/amelie/activities/templates/activities/activity_enrolled.mail +++ b/amelie/activities/templates/activities/activity_enrolled.mail @@ -6,7 +6,8 @@ {% blocktrans with summary=activity.summary begindate=activity.begin|date:"l j F Y" begintime=activity.begin|date:"G:i" %}You receive this email because you have been enrolled for {{ summary }}. This activity starts at {{ begindate }} hour on {{ begintime }}.{% endblocktrans %}{% endhtmlify %} -{% with costs=participation.calculate_costs|first paymentmethod=participation.get_payment_method_display %}{% if costs > 0 %}{% htmlify %}{% blocktrans %}The total costs are {{ costs }} euro. Payment method: {{ paymentmethod }}.{% endblocktrans %}{% endhtmlify %} +{% with costs=participation.calculate_costs|first has_mandate=participation.person.has_mandate_activities %}{% if costs > 0 %}{% htmlify %}{% blocktrans %}The total costs are {{ costs }} euro.{% endblocktrans %}{% endhtmlify %} +{% if has_mandate %}{% htmlify %}{% blocktrans %}These costs will be debited from your bank account via your direct debit mandate.{% endblocktrans %}{% endhtmlify %}{% else %}{% htmlify %}{% blocktrans %}These costs will need to be paid manually by you, either at the activity or at the board.{% endblocktrans %}{% endhtmlify %}{% endif %} {% endif %}{% endwith %}{% if participation.enrollmentoptionanswer_set.all %}{% htmlify %}{% blocktrans %}For this activity, the following enrollment options exist:{% endblocktrans %}{% endhtmlify %} diff --git a/amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail b/amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail index 0d2c162c..f480d5b3 100644 --- a/amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail +++ b/amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail @@ -6,7 +6,8 @@ {% blocktrans with summary=activity.summary begindate=activity.begin|date:"l j F Y" begintime=activity.begin|date:"G:i" %}You receive this email because you have been enrolled for {{ summary }} and are no longer on the waiting list. This activity starts at {{ begindate }} hour on {{ begintime }}.{% endblocktrans %}{% endhtmlify %} -{% with costs=participation.calculate_costs|first paymentmethod=participation.get_payment_method_display %}{% if costs > 0 %}{% htmlify %}{% blocktrans %}The total costs are {{ costs }} euro. Payment method: {{ paymentmethod }}.{% endblocktrans %}{% endhtmlify %} +{% with costs=participation.calculate_costs|first has_mandate=participation.person.has_mandate_activities %}{% if costs > 0 %}{% htmlify %}{% blocktrans %}The total costs are {{ costs }} euro.{% endblocktrans %}{% endhtmlify %} +{% if has_mandate %}{% htmlify %}{% blocktrans %}These costs will be debited from your bank account via your direct debit mandate.{% endblocktrans %}{% endhtmlify %}{% else %}{% htmlify %}{% blocktrans %}These costs will need to be paid manually by you, either at the activity or at the board.{% endblocktrans %}{% endhtmlify %}{% endif %} {% endif %}{% endwith %}{% if participation.enrollmentoptionanswer_set.all %}{% htmlify %}{% blocktrans %}For this activity, the following enrollment options exist:{% endblocktrans %}{% endhtmlify %} diff --git a/amelie/activities/templates/activity.html b/amelie/activities/templates/activity.html index dcf760f0..13ee29b2 100644 --- a/amelie/activities/templates/activity.html +++ b/amelie/activities/templates/activity.html @@ -181,7 +181,7 @@

{% trans 'Export enrollments' %}

{% trans 'Enrollment' %}

- {% include "activity_enrollment_overview.html" %} + {% include "includes/activity_enrollment_overview.html" %}
diff --git a/amelie/activities/templates/activity_confirm_cancellation.html b/amelie/activities/templates/activity_confirm_cancellation.html index 31833960..4cd1b498 100644 --- a/amelie/activities/templates/activity_confirm_cancellation.html +++ b/amelie/activities/templates/activity_confirm_cancellation.html @@ -1,25 +1,33 @@ {% extends "basis.html" %} {% load i18n %} -{% block titel %}{% trans 'Confirmation cancel' %} {{ obj }}{% endblock titel %} +{% block titel %}{% if object.cancelled %}{% trans 'Confirmation undo cancel' %}{% else %}{% trans 'Confirmation cancel' %}{% endif %} {{ object }}{% endblock titel %} {% block content %}
-

{% trans 'Confirmation cancel' %} {{ obj }}

+

{% if object.cancelled %}{% trans 'Confirmation undo cancel' %}{% else %}{% trans 'Confirmation cancel' %}{% endif %} {{ object }}

-

- {% blocktrans with obj=obj %} - Are you sure you want to cancel {{ obj }}? - {% endblocktrans %} -

- -

{% trans 'All enrollments will be cancelled, the transactions will be undone, and all the participants will receive an email.' %}

+ {% if object.cancelled %} +

+ {% blocktrans with obj=object %} + Are you sure you want to undo the cancelation of {{ obj }}? + {% endblocktrans %} +

+

{% trans 'The event will be available again, but enrollments will stay closed and need to be manually re-opened.' %}

+ {% else %} +

+ {% blocktrans with obj=object %} + Are you sure you want to cancel {{ obj }}? + {% endblocktrans %} +

+

{% trans 'All enrollments will be cancelled, the transactions will be undone, and all the participants will receive an email.' %}

+ {% endif %}
{% csrf_token %}

- + {% trans 'No' %}

diff --git a/amelie/activities/templates/activity_confirm_delete.html b/amelie/activities/templates/activity_confirm_delete.html index 18f3f8a7..64571cea 100644 --- a/amelie/activities/templates/activity_confirm_delete.html +++ b/amelie/activities/templates/activity_confirm_delete.html @@ -1,14 +1,14 @@ {% extends "basis.html" %} {% load i18n %} -{% block titel %}{% trans 'Confirmation delete' %} {{ obj }}{% endblock titel %} +{% block titel %}{% trans 'Confirmation delete' %} {{ object }}{% endblock titel %} {% block content %}
-

{% trans 'Confirmation delete' %} {{ obj }}

+

{% trans 'Confirmation delete' %} {{ object }}

- {% blocktrans with obj=obj %} + {% blocktrans with obj=object %} Are you sure you want to remove {{ obj }}? {% endblocktrans %}

@@ -19,7 +19,7 @@

{% trans 'Confirmation delete' %} {{ obj }}

{% csrf_token %}

- + {% trans 'No' %}

diff --git a/amelie/activities/templates/activity_confirm_unenrollment.html b/amelie/activities/templates/activity_confirm_unenrollment.html index 3f9a178d..7c02219a 100644 --- a/amelie/activities/templates/activity_confirm_unenrollment.html +++ b/amelie/activities/templates/activity_confirm_unenrollment.html @@ -8,16 +8,22 @@

{% trans 'Confirmation unenrollment' %} {{ obj }}

- {% blocktrans with obj=obj person=participation.person %} - Are you sure you want to unenroll {{ person }} for {{ obj }}? - {% endblocktrans %} + {% if indirect %} + {% blocktrans with obj=activity person=person %} + Are you sure you want to unenroll {{ person }} for {{ obj }}? + {% endblocktrans %} + {% else %} + {% blocktrans with obj=activity %} + Are you sure you want to unenroll for {{ obj }}? + {% endblocktrans %} + {% endif %}

{% csrf_token %}

- + {% trans 'No' %}

diff --git a/amelie/activities/templates/activity_enrollment_form.html b/amelie/activities/templates/activity_enrollment_form.html index ca41b301..ca936def 100644 --- a/amelie/activities/templates/activity_enrollment_form.html +++ b/amelie/activities/templates/activity_enrollment_form.html @@ -2,7 +2,27 @@ {% load i18n %} {% block titel %} - {% trans 'Enroll for' %} {{ activity.summary }} + {% if update %} + {% if indirect %} + {% blocktrans with person=person summary=activity.summary %} + Update enrollment of {{ person }} for {{ summary }} + {% endblocktrans %} + {% else %} + {% blocktrans with summary=activity.summary %} + Update enrollment for {{ summary }} + {% endblocktrans %} + {% endif %} + {% else %} + {% if indirect %} + {% blocktrans with person=person summary=activity.summary %} + Enroll {{ person }} for {{ summary }} + {% endblocktrans %} + {% else %} + {% blocktrans with summary=activity.summary %} + Enroll for {{ summary }} + {% endblocktrans %} + {% endif %} + {% endif %} {% endblock titel %} {% block content %} @@ -10,15 +30,42 @@ {% csrf_token %}

- {% if indirect %} - {{ person }} {% trans 'enroll for' %} {{ activity.summary }} - {% else %} - {% trans 'Enroll for' %} {{ activity.summary }} - {% endif %} + {% if update %} + {% if indirect %} + {% blocktrans with person=person summary=activity.summary %} + Update enrollment of {{ person }} for {{ summary }} + {% endblocktrans %} + {% else %} + {% blocktrans with summary=activity.summary %} + Update enrollment for {{ summary }} + {% endblocktrans %} + {% endif %} + {% else %} + {% if indirect %} + {% blocktrans with person=person summary=activity.summary %} + Enroll {{ person }} for {{ summary }} + {% endblocktrans %} + {% else %} + {% blocktrans with summary=activity.summary %} + Enroll for {{ summary }} + {% endblocktrans %} + {% endif %} + {% endif %}

+ {% if update %} + {% if indirect %} + {% blocktrans with name=person.first_name %} + With this form, you will update the enrollment of {{ name }} for the activity. + {% endblocktrans %} + {% else %} + {% blocktrans %} + Click "Update" below this form to save the changes to your enrollment. + {% endblocktrans %} + {% endif %} + {% else %} {% if indirect %} {% blocktrans with name=person.first_name %} With this form, you will enroll {{ name }} for the activity. @@ -28,15 +75,20 @@

Click "Enroll" below this form to confirm your enrollment. {% endblocktrans %} {% endif %} + {% endif %} {% if activity.price != 0 %} {% trans 'The enrollment fee is' %} {{ activity.price }} euro. {% endif %}

- {% if activity_full and not person in obj.confirmed_participants %} + {% if not update and activity.enrollment_full and not person in obj.confirmed_participants %}

+ {% if indirect %} + {% trans 'There are no more openings. The person will be enrolled on the waiting list unless you override this.' %} + {% else %} {% trans 'Unfortunately, there are no more openings. You will be enrolled on the waiting list.' %} + {% endif %}

{% endif %}

@@ -48,27 +100,34 @@

{% trans 'Enrollment options' %}

- {% blocktrans %} - The activity has a few enrollment options. Please fill these out before you enroll. - Warning: some of these options may cost extra! - {% endblocktrans %} + {% trans 'The activity has a few enrollment options. Please fill these out before you enroll.' %} +

+

+ {% trans 'Warning: some of these options may cost extra!' %}

- {% for form in enrollmentoptions_forms %} - {% if form.enrollmentoption_type == 'EnrollmentoptionQuestion' %} - {% include "forms/enrollmentoption_question_answer.html" with form=form %} + + {% if form.non_field_errors %} +

+ {{ form.non_field_errors }} +

+ {% endif %} + + {% for form_id, subform in form.forms.items %} + {% if subform.enrollmentoption_type == 'EnrollmentoptionQuestion' %} + {% include "forms/enrollmentoption_question_answer.html" with form=subform %} {% endif %} - {% if form.enrollmentoption_type == 'EnrollmentoptionCheckbox' %} - {% include "forms/enrollmentoption_checkbox_answer.html" with form=form %} + {% if subform.enrollmentoption_type == 'EnrollmentoptionCheckbox' %} + {% include "forms/enrollmentoption_checkbox_answer.html" with form=subform %} {% endif %} - {% if form.enrollmentoption_type == 'EnrollmentoptionNumeric' %} - {% include "forms/enrollmentoption_numeric_answer.html" with form=form %} + {% if subform.enrollmentoption_type == 'EnrollmentoptionNumeric' %} + {% include "forms/enrollmentoption_numeric_answer.html" with form=subform %} {% endif %} - {% if form.enrollmentoption_type == 'EnrollmentoptionFood' %} - {% include "forms/enrollmentoption_foodquestion_answer.html" with form=form %} + {% if subform.enrollmentoption_type == 'EnrollmentoptionFood' %} + {% include "forms/enrollmentoption_foodquestion_answer.html" with form=subform %} {% endif %} {% endfor %}
@@ -81,14 +140,21 @@

{% trans 'Costs' %}

{% if indirect %}

- {% blocktrans with name=person.first_name %} - It is assumed that {{ name }} has given their permission to be enrolled. - {% endblocktrans %} + {% if update %} + {% blocktrans with name=person.first_name %} + It is assumed that {{ name }} has given their permission for their enrollment to be modified. + {% endblocktrans %} + {% else %} + {% blocktrans with name=person.first_name %} + It is assumed that {{ name }} has given their permission to be enrolled. + {% endblocktrans %} + {% endif %}

- {{ form_payment }} + {{ form.forms.indirect }}

{% else %} + {# Direct enrollments are only allowed if the person has an active mandate. #}

{% blocktrans %} By pressing the sign-up-button, I declare to be aware that any enrollment costs will be @@ -102,6 +168,27 @@

{% trans 'Costs' %}

{% trans 'Total' %}: {{ activity.price }} euro

+ {% if indirect and activity.price > 0 %} +
    + {% if person.has_mandate_activities %} +
  • + {% blocktrans with name=person.first_name %} + The costs will be deducted from {{ name }}'s personal tab + and will be paid via their direct debit mandate. + {% endblocktrans %} +
  • + {% else %} +
  • + {% blocktrans with name=person.first_name %} + {{ name }} has no direct debit mandate for activities. + The costs will be deducted from their personal tab balance and will + need to be paid manually, either at the activity or at the board. + {% endblocktrans %} +
  • + {% endif %} +
+ {% endif %} +
{% trans 'Cancel' %} diff --git a/amelie/activities/templates/activity_enrollment_mandate.html b/amelie/activities/templates/activity_enrollment_mandate.html index 496dd05e..fc3fa335 100644 --- a/amelie/activities/templates/activity_enrollment_mandate.html +++ b/amelie/activities/templates/activity_enrollment_mandate.html @@ -18,16 +18,17 @@

{% blocktrans %} - Unfortunately, you cannot enroll for this activity, because it has an entrance fee, and you have - not signed a direct debit mandate for consumptions and activities. Without this authorization, we - can not withdraw money from your account. Please ask the board for a mandate form. + Unfortunately, you cannot enroll for this activity, because it has an entrance fee, or you have + selected a paid enrollment option. You do not have a signed direct debit mandate for + consumptions and activities. Without this authorization, we cannot withdraw money from + your account. Please ask the board for a mandate form. {% endblocktrans %}

{% blocktrans %} - Would you prefer not to sign a direct debit mandate? Please contact the board or organizing committee - and pay in cash. + Would you prefer not to sign a direct debit mandate? Please contact the board or organizing + committee to see if it's possible to pay manually by cash or PIN for this activity. {% endblocktrans %}

diff --git a/amelie/activities/templates/activity_enrollment_person_search.html b/amelie/activities/templates/activity_enrollment_person_search.html index 7de4c90d..930f500d 100644 --- a/amelie/activities/templates/activity_enrollment_person_search.html +++ b/amelie/activities/templates/activity_enrollment_person_search.html @@ -22,7 +22,7 @@

-
{% csrf_token %} {{ form.as_p }} diff --git a/amelie/activities/templates/activity_enrollment_print.html b/amelie/activities/templates/activity_enrollment_print.html index 1c3e7eac..8cebfaae 100644 --- a/amelie/activities/templates/activity_enrollment_print.html +++ b/amelie/activities/templates/activity_enrollment_print.html @@ -1,59 +1,76 @@ +{% load humanize %} {% load i18n %} -{% block head %} - - - - -{% endblock head %} + td, th { + padding: 4px; + border: 1px solid #999; + } + + +

+ {% blocktrans with name=obj.summary date=obj.begin|date:"Y-m-d" %}Enrollments for {{ name }} ({{ date }}){% endblocktrans %}
+ {% blocktrans with date=print_date|date:"Y-m-d H:i" %}(printed on {{ date }}){% endblocktrans %} +

-

{% trans 'This enrollment list contains personal data. Please do not leave this lying around somewhere and make sure that it is destroyed after use. If you find this list somewhere, please give it to the board of Inter-Actief.' %}

+

{% trans 'This enrollment list contains personal data. Please do not leave this lying around somewhere and make sure that it is destroyed after use. If you find this list somewhere, please give it to the board of Inter-Actief.' %}

{% trans 'Number of participants' %} {{ number_participants }}

- +
+ - {% for enrollmentoption in obj.enrollmentoption_set.all %} {% endfor %} - - - - + {% for participation in participation_set %} + {% for answer in participation.enrollmentoptionanswer_set.all %} {% endfor %} - @@ -74,21 +91,3 @@

{% trans 'Order totals' %}

✓ {% trans 'Name' %} {{ enrollmentoption.title }}{% trans 'Method of payment' %}{% trans 'Payment' %}
{{ participation.person.incomplete_name }} {{ answer.display_answer }}{{ participation.get_payment_method_display }} - - {% trans 'Deregister' %} - - {% if participation.remark %} - + {% if participation.is_free %} + {% trans 'N/A (free)' %} + {% else %} + {% with participation.payment_details as details %} + {% if participation.is_paid %} + {% trans 'Paid' %} + {# Cannot use brackets in jinja templates, so we need to list the same block twice. #} + {% elif details.0 == 'unpaid' and details.1 != None %} + {% trans 'To be debited' %} + {% elif details.0 == 'reversed' %} + {% trans 'To be debited' %} + {% else %} + {% if details.0 == 'unpaid' and details.1 == None %} + {% trans 'Needs manual payment' %} + {% else %} + {% trans 'Not paid yet' %} + {% endif %} +
{% blocktrans with costs=participation.to_be_paid_costs|intcomma %}Open amount: € {{ costs }}{% endblocktrans %} + {% endif %} + {% endwith %} {% endif %}
{% endif %} - -

- {% if request.is_board or not obj.enrollment_full %} - - {% trans 'Enroll people' %} - - {% endif %} - - {% if number_participants %} - - {% trans 'Send mailing' %} - - {% endif %} - - {% if request.is_board %} - {% trans 'View transactions' %} - {% endif %} -

diff --git a/amelie/activities/templates/activity_list.html b/amelie/activities/templates/activity_list.html index f79b4b76..8b2dcbd9 100644 --- a/amelie/activities/templates/activity_list.html +++ b/amelie/activities/templates/activity_list.html @@ -126,11 +126,8 @@

{% trans 'Past activities' % {% trans 'Dutch-only' %} {{ activity.summary }} + href="{{ activity.get_absolute_url }}" + title="{{ activity.description|striptags|truncatewords:30 }}">{{ activity.summary }} {% include "includes/activity_list_photos.html" %} diff --git a/amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html b/amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html index 12c452f8..33a15547 100644 --- a/amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html +++ b/amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html @@ -1,19 +1,23 @@ {% load i18n %} -

- +

+ + +

+ {{ form.enrollmentoption.title }} +

+ + {% if form.non_field_errors %} {{ form.non_field_errors }} {% endif %} + {% if form.answer.errors %} +

{{ form.answer.errors }}

+ {% endif %} - -

- {{ form.enrollmentoption.title }} -

- - + {{ form.answer }} {% trans 'Yes' %} @@ -33,4 +37,4 @@

{% for field in form.hidden_fields %} {{ field }} {% endfor %} -

+

diff --git a/amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html b/amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html index 698b0990..d1888623 100644 --- a/amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html +++ b/amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html @@ -1,15 +1,8 @@ {% load i18n %} -

- - {% if form.non_field_errors %} - - {{ form.non_field_errors }} - - {% endif %} +

- {{ form.dishprice.errors }}

{{ form.enrollmentoption.title }} @@ -18,7 +11,17 @@

{% endif %}

- + + {% if form.non_field_errors %} + + {{ form.non_field_errors }} + + {% endif %} + {% if form.dishprice.errors %} +

{{ form.dishprice.errors }}

+ {% endif %} + + - {% if has_enrollment_options %} - {% trans 'Edit enrollment' %} - {% endif %} -

- +

+ + {% if request.person in obj.waiting_participants %}{% trans 'Deregister from waiting list' %}{% else %}{% trans 'Deregister' %}{% endif %} + + {% if has_enrollment_options %} + {% trans 'Edit enrollment' %} + {% endif %} +

{% blocktrans with date=obj.enrollment_end|date:"d F Y" time=obj.enrollment_end|date:"H:i" %} You can reverse your enrollment till {{ date }} at {{ time }}. @@ -144,7 +143,6 @@ {% endblocktrans %}

- {% if not obj.can_unenroll and obj.enrollment_open %}

{% blocktrans %} @@ -163,12 +161,11 @@

{% endif %} {% if obj.enrollment_open %} -
- {% csrf_token %} -

- -

-
+

+ + {% if obj.enrollment_full %}{% trans 'Place on waiting list' %}{% else %}{% trans 'Enroll' %}{% endif %} + +

{% endif %} {% endif %} {% else %} diff --git a/amelie/activities/templates/activity_enrollment_overview.html b/amelie/activities/templates/includes/activity_enrollment_overview.html similarity index 57% rename from amelie/activities/templates/activity_enrollment_overview.html rename to amelie/activities/templates/includes/activity_enrollment_overview.html index 5b9bc85f..705d6eda 100644 --- a/amelie/activities/templates/activity_enrollment_overview.html +++ b/amelie/activities/templates/includes/activity_enrollment_overview.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% block head %} @@ -33,7 +34,7 @@
{% if request.is_board %}
- + {% if only_show_underage %} {% trans 'Show all participants' %} {% else %} @@ -68,110 +69,9 @@ {# This block shows the participants table #}
- - - - {% if request.is_board or is_committee or is_roomduty %} - - - {% else %} - - {% endif %} - - {% if request.is_board or is_committee or is_roomduty %} - {% for enrollmentoption in obj.enrollmentoption_set.all %} - - {% endfor %} - - - {% endif %} - - {% if request.is_board %} - - - {% endif %} - - - - {# confirmed_participation_set will be filtered with only underage participants if this is given in the query parameter #} - {# The table for committee, roomduty and board #} - {% if request.is_board or is_committee or is_roomduty %} - {% for participation in confirmed_participation_set %} - - - - {% for answer in participation.enrollmentoptionanswer_set.all %} - - {% endfor %} - {% if request.is_board or is_roomduty %} - - {% elif is_committee %} - - {% endif %} - {% if request.is_board %} - - - {% endif %} - - {% endfor %} - {% else %} - {# The table for normal members #} - {% for participation in public_participation_set %} - - - - {% endfor %} - - - - {% endif %} - -
- {% trans 'Sequence' %} - - {% trans 'Name' %} - - {% trans 'Name' %} - {{ enrollmentoption.title }}{% trans 'Payment' %}
- {{ forloop.counter }} - - {% if request.is_board %} - {{ participation.person.incomplete_name }} - {% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} - {% elif deanonymise %} - {{ participation.person.incomplete_name }}{% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} - {% elif not obj.enrollment_private %} - {% if participation in public_participation_set %} - {{ participation.person.public_enrollment_name }} - {% else %} - {% trans 'Anonymous' %} - {% endif %} - {% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} - {% else %} - {% trans 'Anonymous' %} - {% endif %} - {{ answer.display_answer }}{% if participation.has_paid %}

{{ participation.get_payment_method_display }}{% else %}

{% trans "Register cash payment" %}{% endif %}

{{ participation.get_payment_method_display }} - - {% trans 'Edit enrollment' %} - - - - {% trans 'Deregister' %} - - {% if participation.remark %} - - {% endif %} -
- {{ participation.person.public_enrollment_name }} -
- - {% blocktranslate count count=anonymous_count %} - ...and {{ count }} Anonymous member. - {% plural %} - ...and {{ count }} Anonymous members. - {% endblocktranslate %} - -
+
+ {% include 'includes/activity_participants_table.html' %} +
{# This block shows the restaurant order #} @@ -240,7 +140,7 @@

{% blocktrans with n=number_waitin {{ enrollmentoption.title }} {% endfor %} - {% trans 'Method of payment' %} + {% trans 'Payment' %} {% if request.is_board or deanonymise %} @@ -267,15 +167,26 @@

{% blocktrans with n=number_waitin {% for answer in participation.enrollmentoptionanswer_set.all %} {{ answer.display_answer }} {% endfor %} - {{ participation.get_payment_method_display }} + + {% if participation.is_free %} + {% trans 'N/A (free)' %} + {% else %} + {% if participation.person.has_mandate_activities %} + {% trans 'To be debited' %} + {% else %} + {% trans 'Needs manual payment' %}
+ {% blocktrans with costs=participation.to_be_paid_costs|intcomma %}Open amount: € {{ costs }}{% endblocktrans %} + {% endif %} + {% endif %} + {% if request.is_board or deanonymise %} -
+ {% trans 'Edit enrollment' %} - + {% trans 'Deregister' %} {% if participation.remark %} diff --git a/amelie/activities/templates/includes/activity_participants_table.html b/amelie/activities/templates/includes/activity_participants_table.html new file mode 100644 index 00000000..9abebe14 --- /dev/null +++ b/amelie/activities/templates/includes/activity_participants_table.html @@ -0,0 +1,198 @@ +{% load humanize %} +{% load i18n %} + + + + {% if request.is_board or is_committee or is_roomduty %} + + + {% else %} + + {% endif %} + + {% if request.is_board or is_committee or is_roomduty %} + {% for enrollmentoption in obj.enrollmentoption_set.all %} + + {% endfor %} + + + {% endif %} + + {% if request.is_board %} + + + {% endif %} + + + + {# confirmed_participation_set will be filtered with only underage participants if this is given in the query parameter #} + {# The table for committee, roomduty and board #} + {% if request.is_board or is_committee or is_roomduty %} + {% for participation in confirmed_participation_set %} + + + + {% for answer in participation.enrollmentoptionanswer_set.all %} + + {% endfor %} + {% if request.is_board or is_roomduty %} + + {% elif is_committee %} + + {% endif %} + {% if request.is_board %} + + + {% endif %} + + {% endfor %} + {% else %} + {# The table for normal members #} + {% for participation in public_participation_set %} + + + + {% endfor %} + + + + {% endif %} + +
+ {% trans 'Sequence' %} + + {% trans 'Name' %} + + {% trans 'Name' %} + {{ enrollmentoption.title }}{% trans 'Payment' %}
+ {{ forloop.counter }} + + {% if request.is_board %} + {{ participation.person.incomplete_name }} + {% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} + {% elif deanonymise %} + {{ participation.person.incomplete_name }}{% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} + {% elif not obj.enrollment_private %} + {% if participation in public_participation_set %} + {{ participation.person.public_enrollment_name }} + {% else %} + {% trans 'Anonymous' %} + {% endif %} + {% if participation in confirmed_participation_set_turns_18_during_event %}{% endif %} + {% else %} + {% trans 'Anonymous' %} + {% endif %} + {{ answer.display_answer }} + {% if participation.is_free %} + {% trans 'N/A (free)' %} +   + {% if participation.activitytransaction_set.exists %} + {% if participation.unpaid_transactions|length != 0 %} + + {% endif %} + + + {% endif %} + {% else %} + {% with participation.payment_details as details %} + {% if participation.is_paid %} + {% trans 'Paid' %} + {% else %} + {# Cannot use brackets in jinja templates, so we need to list the same block twice. #} + {% if details.0 == 'unpaid' and details.1 != None %} + {% trans 'To be debited' %} + {% elif details.0 == 'reversed' %} + {% trans 'To be debited' %} + {% else %} + {% trans 'Not paid yet' %} + {% endif %} + {% endif %} +   + + + {% if details.0 == 'no_transaction' %} +
{% trans 'No transaction! Contact the WWW.' %} + {% elif details.0 == 'paid' %} + {% if details.1.settlement_type == "INSTR" %} +
+ {% blocktrans with date=details.1.batch.execution_date|date:"Y-m-d" %} + Debited on {{ date }} + {% endblocktrans %} + + {% elif details.1.settlement_type == "MANUAL" %} +
+ {% blocktrans with method=details.1.payment_method.name date=details.1.payment_date|date:"Y-m-d" %} + {{ method }} on {{ date }} + {% endblocktrans %} + + {% else %} +
+ {% trans 'Unknown payment! Contact the WWW.' %} + + {% endif %} + {% elif details.0 == 'reversed' %} +
+ {% trans 'Previous attempt was reversed' %} + + {% elif details.0 == 'unpaid' and details.1 == None %} +
+ {% blocktrans with costs=participation.to_be_paid_costs|intcomma %}Needs manual payment (€ {{ costs }}){% endblocktrans %} + + {% endif %} + {% if details.0 == 'unpaid' and details.1 == None %} +
+ {% trans 'Enter manual payment' %} + + {% endif %} + {% endwith %} + {% endif %} +
+ {% if participation.is_free %} + {% trans 'N/A (free)' %} + {% else %} + {% with participation.payment_details as details %} + {% if participation.is_paid %} + {% trans 'Paid' %} + {# Cannot use brackets in jinja templates, so we need to list the same block twice. #} + {% elif details.0 == 'unpaid' and details.1 != None %} + {% trans 'To be debited' %} + {% elif details.0 == 'reversed' %} + {% trans 'To be debited' %} + {% else %} + {% trans 'Not paid yet' %} + {% if details.0 == 'unpaid' and details.1 == None %} +
+ {% blocktrans with costs=participation.to_be_paid_costs|intcomma %}Needs manual payment (€ {{ costs }}){% endblocktrans %} + + {% endif %} + {% endif %} + {% endwith %} + {% endif %} +
+ + {% trans 'Edit enrollment' %} + + + + {% trans 'Deregister' %} + + {% if participation.remark %} + + {% endif %} +
+ {{ participation.person.public_enrollment_name }} +
+ + {% blocktranslate count count=anonymous_count %} + ...and {{ count }} Anonymous member. + {% plural %} + ...and {{ count }} Anonymous members. + {% endblocktranslate %} + +
diff --git a/amelie/activities/templates/includes/activity_participation_create_payment.html b/amelie/activities/templates/includes/activity_participation_create_payment.html new file mode 100644 index 00000000..4c0288b2 --- /dev/null +++ b/amelie/activities/templates/includes/activity_participation_create_payment.html @@ -0,0 +1,73 @@ +{% load humanize %} +{% load i18n %} +
+

+ {% blocktrans with participation=object %} + Create manual payment for {{ participation }} + {% endblocktrans %} +

+ +

{% trans 'You are creating a manual payment that will cover the following transactions:' %}

+ +
+ + + + + + + + + + {% for transaction in transactions %} + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
{% trans 'Timestamp' %}{% trans 'Description' %}{% trans 'Amount' %}
+ {{ transaction.date|date:"d-m-Y H:i" }} + + + {{ transaction.description }} + + + € {{ transaction.price|intcomma }} +
+ {% trans 'No transactions found. This is probably wrong! Please contact the WWW.' %} +
{% trans 'Total' %}€ {{ transactions_total|intcomma }}
+
+
+

{% trans 'Please select the payment method that was used for the payment.' %}

+ +
+ {% csrf_token %} + {{ form }} + +
+ + {% trans 'Cancel' %} + + + + +
+
+
+
diff --git a/amelie/activities/templates/includes/activity_participation_payments.html b/amelie/activities/templates/includes/activity_participation_payments.html new file mode 100644 index 00000000..04920276 --- /dev/null +++ b/amelie/activities/templates/includes/activity_participation_payments.html @@ -0,0 +1,72 @@ +{% load humanize %} +{% load i18n %} +
+ {# Messages to show if we redirected here from the create manual payment view #} + {% if status_msg == "created" %} +
    +
  • + {% trans 'Payment created. Please check the transactions below for correctness.' %} +
  • +
+ {% elif status_msg == "no_transactions" %} +
    +
  • + {% trans 'No unpaid transactions found. Please re-check and try again.' %} +
  • +
+ {% endif %} + +

+ {% blocktrans with participation=object %} + Payments for {{ participation }} + {% endblocktrans %} +   + {% if object.is_paid %} + ({% trans 'Paid' %}) + {% else %} + ({% trans 'Not paid yet' %}) + {% endif %} +

+ +
+ + + + + + + + + + + {% for transaction in transactions %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans 'Timestamp' %}{% trans 'Description' %}{% trans 'Amount' %}{% trans 'Settled in' %}
+ {{ transaction.date|date:"d-m-Y H:i" }} + + + {{ transaction.description }} + + + € {{ transaction.price|intcomma }} + + {% include 'info/transaction_settlement_info.html' with settlement=transaction.settlement highlight_unpaid=True %} +
+ {% trans 'No transactions found. If this is a paid activity, this is probably wrong! Please contact the WWW.' %} +
+
+ + {% trans 'Return' %} + +
diff --git a/amelie/activities/urls.py b/amelie/activities/urls.py index 49bd1ab3..8745ed37 100644 --- a/amelie/activities/urls.py +++ b/amelie/activities/urls.py @@ -3,7 +3,7 @@ from amelie.activities.models import EnrollmentoptionCheckbox, EnrollmentoptionFood, EnrollmentoptionQuestion, \ EnrollmentoptionNumeric -from amelie.activities import views +from amelie.activities import views, ajax_views from amelie.activities.views import EnrollmentoptionCreateView, ActivityUpdateView, ActivityCreateView, \ EnrollmentoptionUpdateView, EnrollmentoptionDeleteView, EnrollmentoptionListView @@ -26,8 +26,8 @@ urlpatterns = [ # URLs that are not about a specific activity - path('', views.activities, name='activities'), - path('old/', views.activities_old, name='activities_old'), + path('', views.ActivityListView.as_view(), name='activities'), + path('old/', views.OldActivityListView.as_view(), name='activities_old'), # URLs about the photo gallery path('photos/', views.photos, name='photos'), path('photos//', views.photos, name='photos'), @@ -38,7 +38,10 @@ path('photos/upload/clear/', views.ClearPhotoUploadDirView.as_view(), name='photo_upload_clear'), # URLs about an activity - path('/', views.activity, name='activity'), + path('/', views.ActivityDetailView.as_view(), name='activity'), + path('/ajax/participants/', ajax_views.ActivityParticipationsView.as_view(), name='activity_participations'), + path('/ajax/participants//payments/', ajax_views.ActivityParticipationPaymentsView.as_view(), name='activity_participation_payments'), + path('/ajax/participants//payment_create/', ajax_views.ActivityParticipationCreatePaymentView.as_view(), name='activity_participation_create_payment'), path('/mailing/', views.activity_mailing, name='mailing'), path('/photos/random/', views.activity_random_photo, name='random_photo'), path('/photo//', views.gallery_photo, name='gallery_photo'), @@ -49,19 +52,18 @@ path('/edit/', ActivityUpdateView.as_view(), name='edit'), path('new/', ActivityCreateView.as_view(), name='new'), - path('/cancel/', views.activity_cancel, name='cancel'), - path('/delete/', views.activity_delete, name='delete'), + path('/cancel/', views.ActivityCancelView.as_view(), name='cancel'), + path('/delete/', views.ActivityDeleteView.as_view(), name='delete'), # URLs about enrollments - path('/enrollment/', views.activity_enrollment, name='enrollment'), - path('/enrollment_export/', views.DataExport.as_view(), name='enrollment_data_export'), - path('/enrollmentform/', views.activity_enrollment_self, name='enrollment_self'), - path('/enrollmentform/search/', views.activity_enrollment_person_search, name='enrollment_person_search'), - path('/enrollmentform//', views.activity_enrollment_person, name='enrollment_person'), - path('/unenrollment//', views.activity_unenrollment, name='unenrollment'), - path('/unenrollment/', views.activity_unenrollment_self, name='unenrollment_self'), - path('/edit_enrollment/', views.activity_editenrollment_self, name='editenrollment_self'), - path('/edit_enrollment//', views.activity_editenrollment_other, name='editenrollment_other'), + path('/enrollment/', views.ActivityEnrollView.as_view(), name='enrollment'), + path('/enrollment//', views.ActivityEnrollView.as_view(), name='enrollment_person'), + path('/enrollment/search/', views.ActivityEnrollSearchPersonView.as_view(), name='enrollment_person_search'), + path('/enrollment/unenroll/', views.ActivityUnenrollView.as_view(), name='unenrollment'), + path('/enrollment//unenroll/', views.ActivityUnenrollView.as_view(), name='unenrollment_person'), + path('/enrollment/edit/', views.ActivityEditEnrollView.as_view(), name='editenrollment'), + path('/enrollment//edit/', views.ActivityEditEnrollView.as_view(), name='editenrollment_person'), + path('/enrollment/export/', views.DataExport.as_view(), name='enrollment_data_export'), # URLs about adding a new enrollmentoption to an activity path('/enrollmentoptions/checkbox/new/', EnrollmentoptionCreateView.as_view(model=EnrollmentoptionCheckbox), name='enrollmentoption_checkbox_new'), @@ -74,10 +76,10 @@ path('enrollmentoptions//edit/', EnrollmentoptionUpdateView.as_view(), name='enrollmentoption_edit'), path('enrollmentoptions//delete/', EnrollmentoptionDeleteView.as_view(), name='enrollmentoption_delete'), - # URLs about calendars, two times due to reverse-urls - path('calendar.ics', views.activities_ics, name='activities_ics'), - re_path(r'calendar_(?P(nl|en))\.ics', views.activities_ics, name='activities_ics_international'), - path('/activity.ics', views.activity_ics, name='ics'), + # URLs about calendars + path('calendar.ics', views.ActivitiesICSView.as_view(), name='activities_ics'), + re_path(r'calendar_(?P(nl|en))\.ics', views.ActivitiesICSView.as_view(), name='activities_ics_international'), + path('/activity.ics', views.ActivityICSView.as_view(), name='ics'), # URLs for the eventdesk check pages path('eventdesk/', views.EventDeskMessageList.as_view(), name='eventdesk_list'), @@ -93,5 +95,5 @@ path('/activiteit.ics', RedirectView.as_view(url='/activities/%(pk)s/activity.ics', permanent=True)), # Activity label filtering (last because it would override other URLs otherwise) - path('', views.activities, name='activities_type'), + path('', views.ActivityListView.as_view(), name='activities_type'), ] diff --git a/amelie/activities/utils.py b/amelie/activities/utils.py deleted file mode 100644 index f742ac48..00000000 --- a/amelie/activities/utils.py +++ /dev/null @@ -1,184 +0,0 @@ -import locale -import threading -from contextlib import contextmanager - -from django.contrib import messages -from django.db import transaction -from django.utils.translation import gettext as _ - -from amelie.activities.mail import activity_send_enrollmentmail -from amelie.calendar.models import Participation - - -def check_enrollment_allowed(request, activity, person=None, indirect=False, ignore_full=False, - ignore_start_enrollment=False): - """Checks whether a person can enroll for an activity - - :param request: The request object of the current request, needed to put it in django messages - :param activity: The activity for which we need to check whether the person can enroll for it - :param person: The person who wants to enroll for an activity, standard is request.person - :param indirect: Whether the enrollment is done by the person him-/herself, or done for him/her - :param ignore_full: Ignore the enrollment limit of the activity - :param ignore_start_enrollment: Ignore the enrollment period of the activity - :returns: Whether the person can enroll for the given activity - - :side_effect: Puts an elaborate description of the error in django messages - """ - - if not person: - person = request.person - indirect = False - - if not person.is_member(): - messages.error(request, - _('Enrollment for {activity} is not possible. You do not have an active membership at Inter-Actief').format( - activity=activity, - )) - - # shorted because we only want to return an error - return False - - if not activity.enrollment: - messages.error(request, - _('Unenrollment for {activity} is not possible.').format( - activity=activity, - )) - return False - - if activity.oauth_application: - messages.error(request, - _('Enrollment for {activity} is only possible on the website of this activity.').format( - activity=activity, - )) - return False - - if not activity.enrollment_open(): - if not ignore_start_enrollment: - messages.error(request, - _("The enrollment period for {activity} hasn't started yet or has already passed.").format( - activity=activity, - )) - return False - - if person in activity.participants.all(): - if indirect: - messages.error(request, - _('{person} is already enrolled for {activity}.').format( - person=person, - activity=activity, - )) - else: - messages.error(request, - _('You are already enrolled for {activity}.').format( - activity=activity, - )) - return False - - return True - - -def check_unenrollment_allowed(request, activity, person=None, indirect=False, ignore_can_unenroll=False, - ignore_unenrollment_period=False): - """Checks whether a person can unenroll for an activity - - :param request: The request object of the current request, needed to put it in django messages - :param activity: The activity for which we need to check whether the person can unenroll - :param person: The person who wants to unenroll for an activity, standard is request.person - :param indirect: Whether the unenrollment is done by the person him-/herself or for him/her - :param ignore_can_unenroll: Ignore the unenrollment prohibition of the activity - :param ignore_unenrollment_period: Ignore the unenrollment period of the activity - :returns: Whether the person can unenroll for the given activity - - :side_effect: Puts an elaborate description of the error in django messages - """ - - if not person: - person = request.person - indirect = False - - if not activity.enrollment: - messages.error(request, - _('Unenrollment for {activity} is not possible.').format( - activity=activity, - )) - return False - - if activity.oauth_application: - messages.error(request, - _('Unenrollment for {activity} is only possible on the website of this activity.') - .format( - activity=activity, - )) - return False - - try: - # The select_for_update makes sure to acquire a write lock - Participation.objects.select_for_update().get(person=person, event=activity) - except Participation.DoesNotExist: - if indirect: - messages.error(request, - _('{person} is not enrolled for {activity}.').format( - person=person, - activity=activity, - )) - else: - messages.error(request, - _('You are not enrolled for {activity}.').format( - activity=activity, - )) - return False - - if not (ignore_can_unenroll or activity.can_unenroll): - messages.error(request, - _('Unenrollment for {activity} is not possible.').format( - activity=activity, - )) - return False - - if not (ignore_unenrollment_period or activity.enrollment_open()): - messages.error(request, - _('The unenrollment term for {activity} has already passed.').format( - activity=activity, - )) - return False - - return True - - -@transaction.atomic -def update_waiting_list(activity=None, request=None, **kwargs): - if activity is None: - activity = kwargs.get('instance') - - while not activity.enrollment_full() and activity.has_waiting_participants() and not activity.waiting_list_locked: - new_participation = Participation.objects.get( - person=activity.waiting_participants.order_by('participation__added_on').first(), - event=activity) - - activity_send_enrollmentmail(new_participation, from_waiting_list=True) - new_participation.waiting_list = False - new_participation.save() - from amelie.personal_tab import transactions - transactions.add_participation(new_participation) - if request is not None and request.is_board: - messages.info(request, _(u'{person} has been promoted from the waiting list for {activity}.').format( - person=new_participation.person, activity=activity)) - - - -LOCALE_LOCK = threading.Lock() - - -@contextmanager -def setlocale(name): - """ - A Contextmanager to change the locale within the context to the given locale string. - This allows tools like strptime to parse time strings in other locales than the system locale. - e.g. "with setlocale('en_US'): ..." - """ - with LOCALE_LOCK: - saved = locale.setlocale(locale.LC_ALL) - try: - yield locale.setlocale(locale.LC_ALL, name) - finally: - locale.setlocale(locale.LC_ALL, saved) diff --git a/amelie/activities/views.py b/amelie/activities/views.py index d21ecf81..0e0b1972 100644 --- a/amelie/activities/views.py +++ b/amelie/activities/views.py @@ -8,6 +8,7 @@ import re import csv from pathlib import Path +from typing import Dict, Union, Optional, Tuple from PIL import Image from django.conf import settings @@ -15,8 +16,9 @@ from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied, ImproperlyConfigured +from django.core.exceptions import PermissionDenied, ImproperlyConfigured, ValidationError from django.core.paginator import EmptyPage, PageNotAnInteger +from django.forms import Form from django.urls import reverse, reverse_lazy from django.db import transaction from django.db.models import F, Q, Count, Sum @@ -26,268 +28,253 @@ from django.utils import translation from urllib.parse import quote from django.utils.safestring import mark_safe +from django.utils.timezone import get_current_timezone from django.utils.translation import gettext as _ from django.views.decorators.cache import never_cache -from django.views.decorators.http import require_POST from django.views.generic import ListView, DetailView from django.views.generic.base import TemplateView, View from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView -from amelie.activities.forms import ActivityForm, PaymentForm, PhotoUploadForm, \ - EnrollmentoptionCheckboxAnswerForm, EnrollmentoptionCheckboxForm, EnrollmentoptionFoodAnswerForm, \ - EnrollmentoptionFoodForm, EnrollmentoptionQuestionAnswerForm, EnrollmentoptionQuestionForm, \ - EventDeskActivityMatchForm, EnrollmentoptionNumericForm, EnrollmentoptionNumericAnswerForm, PhotoFileUploadForm +from amelie.activities.forms import ActivityForm, PhotoUploadForm, PhotoFileUploadForm, \ + ActivityEnrollmentOptionsForm, EnrollmentoptionAnswerForm, ENROLLMENTOPTION_FORM_TYPES, EventDeskActivityMatchForm from amelie.activities.mail import activity_send_enrollmentmail, activity_send_on_waiting_listmail, \ - activity_send_cancellationmail, activity_send_cashrefundmail, activity_send_price_change_mail, \ - activity_send_enrollment_option_price_change_mail + activity_send_price_change_mail, activity_send_enrollment_option_price_change_mail from amelie.activities.models import Activity, DishPrice, Enrollmentoption, EnrollmentoptionCheckbox, \ - EnrollmentoptionCheckboxAnswer, EnrollmentoptionFood, EnrollmentoptionFoodAnswer, EnrollmentoptionQuestion, \ - EnrollmentoptionQuestionAnswer, EventDeskRegistrationMessage, Restaurant, EnrollmentoptionNumeric, \ - EnrollmentoptionNumericAnswer, EnrollmentoptionAnswer + EnrollmentoptionFood, EnrollmentoptionQuestion, EventDeskRegistrationMessage, Restaurant, EnrollmentoptionNumeric from amelie.activities.tasks import save_photos -from amelie.activities.utils import check_enrollment_allowed, check_unenrollment_allowed, update_waiting_list from amelie.claudia.google import GoogleSuiteAPI from amelie.files.models import Attachment from amelie.calendar.models import Participation, Event -from amelie.graphql.helpers import is_board from amelie.members.forms import PersonSearchForm -from amelie.members.models import Person, Photographer, Preference +from amelie.members.models import Person, Photographer from amelie.members.query_forms import ActivityMailingForm -from amelie.personal_tab.models import ActivityTransaction +from amelie.personal_tab import transactions from amelie.tools import amelie_messages, types from amelie.tools.const import TaskPriority -from amelie.tools.decorators import require_actief, require_lid, require_committee, require_board -from amelie.tools.forms import PeriodForm, ExportForm, PeriodKeywordForm +from amelie.tools.decorators import require_committee +from amelie.tools.forms import ExportForm, PeriodKeywordForm from amelie.tools.calendar import ical_calendar from amelie.tools.mixins import RequireActiveMemberMixin, DeleteMessageMixin, PassesTestMixin, RequireBoardMixin, \ - RequireCommitteeMixin + RequireCommitteeMixin, RequireMemberMixin from amelie.tools.paginator import RangedPaginator + logger = logging.getLogger(__name__) -ENROLLMENTOPTION_ANSWER_TYPES = { - EnrollmentoptionQuestion: EnrollmentoptionQuestionAnswer, - EnrollmentoptionCheckbox: EnrollmentoptionCheckboxAnswer, - EnrollmentoptionFood: EnrollmentoptionFoodAnswer, - EnrollmentoptionNumeric: EnrollmentoptionNumericAnswer, -} - -ENROLLMENTOPTIONANSWER_FORM_TYPES = { - EnrollmentoptionQuestion: EnrollmentoptionQuestionAnswerForm, - EnrollmentoptionCheckbox: EnrollmentoptionCheckboxAnswerForm, - EnrollmentoptionFood: EnrollmentoptionFoodAnswerForm, - EnrollmentoptionNumeric: EnrollmentoptionNumericAnswerForm, -} - -ENROLLMENTOPTION_FORM_TYPES = { - EnrollmentoptionQuestion: EnrollmentoptionQuestionForm, - EnrollmentoptionCheckbox: EnrollmentoptionCheckboxForm, - EnrollmentoptionFood: EnrollmentoptionFoodForm, - EnrollmentoptionNumeric: EnrollmentoptionNumericForm, -} - - -def activities_ics(request, lang=None): - """ - ical-feed for activities. - """ - if lang is not None: - translation.activate(lang) - act = Activity.objects \ - .filter_public(request) \ - .filter(begin__gte=timezone.now() - datetime.timedelta(100)) \ - .order_by('begin') +class ActivitiesICSView(View): + """iCal-feed for activities.""" + def get(self, *args, **kwargs): + if self.kwargs.get('lang') is not None: + translation.activate(self.kwargs.get('lang')) - ical_contents = ical_calendar(_('Events of Inter-Actief'), act, - max_duration_before_split=datetime.timedelta(days=5)) + activities = Activity.objects.filter_public(self.request) \ + .filter(begin__gte=timezone.now() - datetime.timedelta(100)) \ + .order_by('begin') - return HttpResponse(ical_contents, content_type='text/calendar; charset=UTF-8') + return HttpResponse( + ical_calendar(_('Events of Inter-Actief'), activities, max_duration_before_split=datetime.timedelta(days=5)), + content_type='text/calendar; charset=UTF-8' + ) -def activity_ics(request, pk): - """ - Returns the ical for an activity. - """ - activity = get_object_or_404(Activity, pk=pk) - if not activity.public and not request.user.is_authenticated: - raise PermissionDenied +class ActivityICSView(View): + """Returns the ical for an activity.""" + def get(self, *args, **kwargs): + activity: Activity = get_object_or_404(Activity, pk=self.kwargs.get('pk')) - resp = HttpResponse(content_type='text/calendar; charset=UTF-8') - resp.write(ical_calendar(_(activity.summary), [activity, ])) + if not activity.public and not self.request.user.is_authenticated: + raise PermissionDenied - return resp + return HttpResponse( + ical_calendar(_(activity.summary), [activity, ]), + content_type='text/calendar; charset=UTF-8' + ) -def activities(request, act_type=None): +class ActivityListView(ListView): """ Gives an overview of all upcoming activities and recent past activities. """ - activities = Event.objects.filter_public(request) - if act_type: - activities = activities.filter(Q(activity__activity_label__name_en=act_type) | Q(activity__activity_label__name_nl=act_type)) + model = Event + template_name = "activity_list.html" + + def get_queryset(self): + qs = self.model.objects.filter_public(self.request) + act_type = self.kwargs.get('act_type') + if act_type is not None: + qs = qs.filter( + Q(activity__activity_label__name_en=act_type) | Q(activity__activity_label__name_nl=act_type) + ) + return qs + + def get_context_data(self, *args, **kwargs): + qs = self.get_queryset() + return { + 'old_activities': [a.as_leaf_class() for a in qs.filter(end__lt=timezone.now()).order_by('-begin')[:10]], + 'new_activities': [a.as_leaf_class() for a in qs.filter(end__gte=timezone.now())], + 'only_open_enrollments': 'openEnrollments' in self.request.GET + } + +class OldActivityListView(ListView): + """ + Gives an overview of past activities. + You can filter on a date and on a few keywords. + """ - old_activities = list(activities.filter(end__lt=timezone.now()))[-10:] - new_activities = list(activities.filter(end__gte=timezone.now())) + model = Activity + template_name = "activity_old.html" - old_activities = [a.as_leaf_class() for a in old_activities] - new_activities = [a.as_leaf_class() for a in new_activities] - only_open_enrollments = 'openEnrollments' in request.GET + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form = None - return render(request, "activity_list.html", locals()) + def _get_form(self): + if self.form is None: + self.form = PeriodKeywordForm(to_date_required=False, keywords_required=False, data=self.request.GET or None) + return self.form + def _get_params(self) -> Dict[str, Union[str, datetime.datetime]]: + form = self._get_form() + if form.is_valid(): + keywords = form.cleaned_data['keywords'] + start_date = form.cleaned_data['from_date'] + end_date = form.cleaned_data['to_date'] or timezone.now() + else: + keywords = "" + start_date = form.fields['from_date'].initial + end_date = form.fields['to_date'].initial + + # Convert dates to datetimes + return { + 'keywords': keywords, + 'from_date': timezone.make_aware(datetime.datetime.combine(start_date, datetime.time())), + 'to_date': timezone.make_aware(datetime.datetime.combine(end_date, datetime.time())) + } + + def get_queryset(self): + qs = self.model.objects.filter_public(self.request).order_by('-end') + get_params = self._get_params() + + # Filter date range + qs = qs.filter(end__range=(get_params.get('from_date'), get_params.get('to_date'))) + + # Filter keywords + keywords = get_params.get('keywords') + if keywords: + qs = qs.filter( + Q(summary_en__icontains=keywords) | Q(description_en__icontains=keywords) | + Q(summary_nl__icontains=keywords) | Q(description_nl__icontains=keywords) + ) + + return qs + + def get_context_data(self, *args, **kwargs): + return { + 'form': self._get_form(), + 'old_activities': self.get_queryset() + } + +class ActivityDetailView(DetailView): + """Shows an activity.""" + model = Activity + template_name = "activity.html" + deanonymise = False -def activities_old(request): - """ - Gives an overview of past activities. + def __init__(self, deanonymise=False, *args, **kwargs): + super().__init__(*args, **kwargs) + self.deanonymise = deanonymise - You can filter on a date and on a few keywords. - """ - form = PeriodKeywordForm(to_date_required=False, keywords_required=False, data=request.GET or None) - - if form.is_valid(): - keywords = form.cleaned_data['keywords'] - start_date = form.cleaned_data['from_date'] - end_date = form.cleaned_data['to_date'] or timezone.now() - else: - keywords = "" - start_date = form.fields['from_date'].initial - end_date = form.fields['to_date'].initial - - # Convert dates to datetimes - start_datetime = timezone.make_aware(datetime.datetime.combine(start_date, datetime.time())) - end_datetime = timezone.make_aware(datetime.datetime.combine(end_date, datetime.time())) - - # Filter for activities in tim range - old_activities = Activity.objects.filter_public(request).filter(end__range=(start_datetime, end_datetime)) \ - .order_by('-end') - - if 'keywords' in form.data.keys(): - # Filter for activities with keyword - old_activities = old_activities.filter(Q(summary_en__icontains=keywords) | - Q(description_en__icontains=keywords) | - Q(summary_nl__icontains=keywords) | - Q(description_nl__icontains=keywords)) - - return render(request, "activity_old.html", { - 'form': form, - 'old_activities': old_activities, - }) - - -def activity(request, pk, deanonymise=False): - """ - Shows an activity. - """ - activity = get_object_or_404(Activity, pk=pk) - if not activity.public and not request.user.is_authenticated: - raise PermissionDenied + def get_context_data(self, **kwargs): + if not self.object.public and not self.request.user.is_authenticated: + raise PermissionDenied - if activity.participants: - number_participants = activity.participants.count() - number_confirmed_participants = activity.confirmed_participants.count() - number_waiting_participants = activity.waiting_participants.count() - if request.user.is_authenticated and hasattr(request, 'person'): - can_edit = activity.can_edit(request.person) - else: - can_edit = False - - import icalendar - evt = icalendar.Event() - evt.add('dtstart', activity.begin) - evt.add('dtend', activity.end) - evt.add('summary', activity.summary) - - current_time = timezone.now() - - only_show_underage = hasattr(request, 'is_board') and request.is_board and (request.GET.get('underage') == "True") - - # Extra check to make sure that no sensitive data will be leaked - if can_edit: - restaurants = Restaurant.objects \ - .filter(enrollmentoptionfood__activity=activity, - dish__dishprice__enrollmentoptionfoodanswer__enrollmentoption__activity=activity) \ - .annotate(amount=Count('enrollmentoptionfood__activity'), - total_price=Sum('dish__dishprice__price')) \ - .distinct() - for restaurant in restaurants: - restaurant.dishes = DishPrice.objects \ - .filter(enrollmentoptionfoodanswer__enrollmentoption__activity=activity, - dish__restaurant=restaurant, enrollmentoptionfoodanswer__enrollment__waiting_list=False) \ - .annotate(amount=Count('enrollmentoptionfoodanswer', - filter=Q(enrollmentoptionfoodanswer__enrollment__waiting_list=False)), - total_price=Sum('price', - filter=Q(enrollmentoptionfoodanswer__enrollment__waiting_list=False))) - total_price = sum([(restaurant.dishes.aggregate(Sum('total_price')).get( - 'total_price__sum')) if restaurant.dishes.aggregate(Sum('total_price')).get( - 'total_price__sum') is not None else 0 for restaurant in restaurants]) - total_amount = sum( - [restaurant.dishes.aggregate(Sum('amount')).get('amount__sum') for restaurant in restaurants if restaurant is not None]) - - can_view_photos = activity.photos.filter_public(request).count() > 0 - obj = activity # Template laziness - - # Sorted set of participations that are used to show the enrollments - participation_set = activity.participation_set.order_by('added_on') - - - - - if only_show_underage: - confirmed_participation_set = [x for x in - activity.participation_set.filter(waiting_list=False).order_by('added_on') if - x.person.age(at=activity.begin) < 18] - confirmed_participation_set_turns_18_during_event = [x for x in confirmed_participation_set if - x.person.age(at=activity.end) >= 18] - else: - confirmed_participation_set = activity.participation_set.filter(waiting_list=False).order_by('added_on') - - public_participation_set = confirmed_participation_set.filter( - person__preferences__name="public_enrollment" - ).order_by("person__first_name") - anonymous_count = len(confirmed_participation_set) - len(public_participation_set) - - person_enrollment_public = hasattr(request, 'person') and request.person.has_preference(name="public_enrollment") - - waiting_participation_set = activity.participation_set.filter(waiting_list=True).order_by('added_on') - - if hasattr(request, 'person') and waiting_participation_set.filter(person=request.person).exists(): - added_on = waiting_participation_set.get(person=request.person).added_on - number_on_waiting_list = waiting_participation_set.filter(added_on__lte=added_on).count() - - if hasattr(request, 'person') and (request.person in obj.participants.all()): - participation = Participation.objects.get(person=request.person, event=activity) - - has_enrollment_options = activity.has_enrollmentoptions() - - # Data Export form for GDPR compliance. - export_form = ExportForm() - export_form.fields['export_details'].initial = str(obj) - - # Enable opengraph on this page - metadata_enable_opengraph = True - is_roomduty = hasattr(request, 'person') and request.person.is_room_duty() - is_committee = hasattr(request, 'person') and obj.organizer in request.person.current_committees().all() - return render(request, "activity.html", locals()) + request_person: Optional[Person] = self.request.person if hasattr(self.request, 'person') else None + context = super().get_context_data(**kwargs) + context['obj'] = self.object # Template laziness + if self.object.participants: + context['number_participants'] = self.object.participants.count() + context['number_confirmed_participants'] = self.object.confirmed_participants.count() + context['number_waiting_participants'] = self.object.waiting_participants.count() + if self.request.user.is_authenticated and request_person: + context['can_edit'] = self.object.can_edit(request_person) + else: + context['can_edit'] = False + context['current_time'] = timezone.now() # For determining whether to show the Cancel button. + context['only_show_underage'] = hasattr(self.request, 'is_board') and self.request.is_board and (self.request.GET.get('underage') == "True") + + # Extra check to make sure that no sensitive data will be leaked + if context['can_edit']: + context['restaurants'] = Restaurant.objects.filter( + enrollmentoptionfood__activity=self.object, + dish__dishprice__enrollmentoptionfoodanswer__enrollmentoption__activity=self.object + ).annotate( + amount=Count('enrollmentoptionfood__activity'), total_price=Sum('dish__dishprice__price') + ).distinct() + # Add dishes to restaurant data + for restaurant in context['restaurants']: + restaurant.dishes = DishPrice.objects.filter( + enrollmentoptionfoodanswer__enrollmentoption__activity=self.object, + dish__restaurant=restaurant, enrollmentoptionfoodanswer__enrollment__waiting_list=False + ).annotate( + amount=Count('enrollmentoptionfoodanswer', filter=Q(enrollmentoptionfoodanswer__enrollment__waiting_list=False)), + total_price=Sum('price', filter=Q(enrollmentoptionfoodanswer__enrollment__waiting_list=False)) + ) -def activity_random_photo(request, pk, *args, **kwargs): - """ Returns a random photo of a specific activity. """ - act = get_object_or_404(Activity, pk=pk) - photo = act.random_photo(only_public=not hasattr(request, 'person')) + context['total_price'] = sum( + restaurant.dishes.aggregate(Sum('total_price')).get('total_price__sum', 0) + for restaurant in context['restaurants'] + ) + context['total_amount'] = sum( + restaurant.dishes.aggregate(Sum('amount')).get('amount__sum', 0) + for restaurant in context['restaurants'] + ) + + # Sorted set of participations that are used to show the enrollments + context['participation_set'] = self.object.participation_set.order_by('added_on') + + context['confirmed_participation_set'] = self.object.participation_set.filter(waiting_list=False).order_by('added_on') + context['public_participation_set'] = context['confirmed_participation_set'].filter(person__preferences__name="public_enrollment").order_by("person__first_name") + context['anonymous_count'] = context['confirmed_participation_set'].count() - context['public_participation_set'].count() + if context['only_show_underage']: + context['confirmed_participation_set'] = [ + x for x in self.object.participation_set.filter(waiting_list=False).order_by('added_on') + if x.person.age(at=self.object.begin) < 18 + ] + context['confirmed_participation_set_turns_18_during_event'] = [ + x for x in context['confirmed_participation_set'] + if x.person.age(at=self.object.end) >= 18 + ] + + context['person_enrollment_public'] = request_person and request_person.has_preference(name="public_enrollment") + context['waiting_participation_set'] = self.object.participation_set.filter(waiting_list=True).order_by('added_on') + + if request_person and context['waiting_participation_set'].filter(person=request_person).exists(): + # Spot is equal to the count of people that were earlier or at the same point in the waiting list than the person. + added_on = context['waiting_participation_set'].get(person=request_person).added_on + context['number_on_waiting_list'] = context['waiting_participation_set'].filter(added_on__lte=added_on).count() + + if request_person and self.object.participation_set.filter(person=request_person).exists(): + context['participation'] = self.object.participation_set.get(person=request_person) + + context['has_enrollment_options'] = self.object.has_enrollmentoptions + + # Data Export form for GDPR compliance. + context['export_form'] = ExportForm() + context['export_form'].fields['export_details'].initial = str(self.object) + + # Enable opengraph on this page + context['metadata_enable_opengraph'] = True + context['is_roomduty'] = request_person and request_person.is_room_duty() + context['is_committee'] = request_person and self.object.organizer in request_person.current_committees().all() + context['deanonymise'] = self.deanonymise - # Generate a JSON response - if photo: - return photo.to_http_response(format='medium', response='json') - - # No photo - raise Http404(_('No photo')) + return context -@require_board -@transaction.atomic -def activity_delete(request, pk): +class ActivityDeleteView(RequireBoardMixin, DeleteView): """ Deletes an activity. @@ -295,504 +282,479 @@ def activity_delete(request, pk): Asks for a confirmation. """ - obj = get_object_or_404(Activity, pk=pk) - if not obj.can_edit(request.person): - raise PermissionDenied - - if request.POST: - if 'yes' in request.POST: - # Undo transactions and remove participations - from amelie.personal_tab import transactions - for participation in obj.participation_set.all(): - transactions.participation_transaction(participation, "Cancelled event", cancel=True, - added_by=request.person) - # Remove the participation - participation.delete() - - obj.delete() - return redirect(reverse('activities:activities')) - elif 'no' in request.POST: - return redirect(obj) + model = Activity + template_name = "activity_confirm_delete.html" + success_url = reverse_lazy('activities:activities') - return render(request, "activity_confirm_delete.html", locals()) + @transaction.atomic + def form_valid(self, request, *args, **kwargs): + """ + Make sure everyone is unenrolled from the activity, delete it, and then redirect to the success URL. + """ + self.object: Activity = self.get_object() + success_url = self.get_success_url() + # Make sure everyone is unenrolled from the activity + self.object.unenroll_all_participants(actor=self.request.person if hasattr(self.request, 'person') else None) + self.object.delete() + return HttpResponseRedirect(success_url) -@require_board # Cancelling an activity is only allowed by the board due to possible transactions that should be undone. -@transaction.atomic -def activity_cancel(request, pk): +class ActivityCancelView(RequireBoardMixin, DeleteView): """ Cancels or re-publishes an activity (depending on the current status of this event). + Note: Even though this view extends DeleteView, we override the Delete method so the Activity will not be deleted. Events may only be canceled if they have not yet started. + Cancelling an activity is only allowed by the board due to possible transactions that should be undone. In the case of cancellation, this function removes all enrollments (and closes the option to enroll) and undoes the corresponding transactions. - In the case of re-publishing, the event name will be reverted to its original name and enrollments will be re-opened. + In the case of re-publishing, the cancellation will be removed, but enrollments will not be re-opened. Asks for a confirmation. """ - obj = get_object_or_404(Activity, pk=pk) - - # If the event is already canceled, immediately re-publish it without any confirmation screen. - if obj.cancelled: - obj.cancelled = False - obj.save() - messages.success(request, _(f"{obj} has been successfully re-opened. Please note that any previous enrollments are not added again and that enrollments will have to be manually re-opened.")) - return redirect(obj) - - if request.POST: - if 'yes' in request.POST: - - if obj.begin < timezone.now(): - messages.error(_(f'We were unable to cancel {obj}, since it has already started.')) - return redirect(obj) - else: - # We need to explicitly set the waiting list to false because they are also included in this set. - activity_send_cancellationmail(obj.participation_set.filter(waiting_list=False), obj) - if obj.waiting_participations.exists(): - activity_send_cancellationmail(obj.waiting_participations.all(), obj, from_waiting_list=True) - - # Send an e-mail to the treasurer if people have paid in cash. - # This email contains the names and amount paid by each person. - if obj.participation_set.filter(payment_method=Participation.PaymentMethodChoices.CASH, cash_payment_made=True).exists(): - activity_send_cashrefundmail(obj.participation_set.filter(payment_method=Participation.PaymentMethodChoices.CASH, cash_payment_made=True).all(), obj, request) - - # Undo transactions and remove participations - from amelie.personal_tab import transactions - for participation in obj.participation_set.all(): - if participation.payment_method == Participation.PaymentMethodChoices.AUTHORIZATION: - transactions.participation_transaction(participation, f"Cancelled {obj}", cancel=True, added_by=request.person) - # Remove the participation - participation.delete() - - obj.cancelled = True - obj.enrollment = False - obj.save() - messages.success(request, _(f"{obj} was cancelled successfully.")) - return redirect(obj) - - return render(request, "activity_confirm_cancellation.html", locals()) + model = Activity + template_name = "activity_confirm_cancellation.html" + def get_success_url(self): + return reverse('activities:activity', kwargs={'pk': self.object.pk}) -@require_POST -@require_lid -@transaction.atomic -def activity_enrollment(request, pk): + @transaction.atomic + def form_valid(self, form): + self.object: Activity = self.get_object() + success_url = self.get_success_url() + + # If the event is already canceled, re-publish it. + if self.object.cancelled: + self.object.uncancel_activity() + messages.success( + self.request, + _("{obj} has been successfully re-opened. Please note that any previous enrollments are not added " + "again and that enrollments will have to be manually re-opened.").format(obj=self.object) + ) + return HttpResponseRedirect(success_url) + + # Else, cancel the activity. + try: + self.object.cancel_activity(actor=self.request.person if hasattr(self.request, 'person') else None) + messages.success(self.request, _("{obj} was cancelled successfully.").format(obj=self.object)) + return HttpResponseRedirect(success_url) + except ValueError: + # Error during canceling. Only occurs if the activity has already started. + messages.error( + self.request, _("We were unable to cancel {obj}, since it has already started.").format(obj=self.object) + ) + return redirect(self.object) + + +class ActivityEnrollSearchPersonView(RequireActiveMemberMixin, FormView): """ - Enrolls the currently logged in person for this activity. - - Redirect to the elaborate form if more data is needed, - or if there are any costs associated with the activity. + Search for a person to enroll for this activity. """ + template_name = "activity_enrollment_person_search.html" + form_class = PersonSearchForm - # Lock the Activity object for updating, prevent concurrent (un)enrollments - obj = get_object_or_404(Activity.objects.select_for_update(), pk=pk) - enrollment_full = obj.enrollment_full() + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.activity = None - if not check_enrollment_allowed(request, obj): - # The method check_enrollment_allowed makes sure that something is in django messages if the enrollment is not allowed. - return redirect(obj) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['activity'] = self.activity + return context - # Check whether the user needs to be redirected to an enrollment form - if obj.enrollmentoption_set.count() == 0 and obj.price == 0: - # Simple enrollment: no questions or costs - participation = Participation(person=request.person, event=obj, - payment_method=Participation.PaymentMethodChoices.NONE, added_by=request.person, - waiting_list=enrollment_full) - participation.save() - - # Send mail if preference asks for it - if request.person.has_preference(name='mail_enrollment'): - if not enrollment_full: - activity_send_enrollmentmail(participation) - messages.success(request, - _('You are now enrolled for {activity}.').format( - activity=obj, - )) - else: - activity_send_on_waiting_listmail(participation) - messages.success(request, - _('You are now on the waiting list for {activity}.').format( - activity=obj, - )) - else: - # Elaborate enrollment, redirect to the form - return redirect('activities:enrollment_self', pk=obj.pk) + def dispatch(self, request, *args, **kwargs): + self.activity: Activity = get_object_or_404(Activity, pk=self.kwargs['pk']) + if not (self.activity.can_edit(request.person) or request.person.is_room_duty()): + raise PermissionDenied + return super().dispatch(request=request, *args, **kwargs) - return redirect(obj) + def form_valid(self, form): + return redirect('activities:enrollment_person', pk=self.activity.id, person_id=form.cleaned_data['person']) -@require_POST -@require_lid -@transaction.atomic -def activity_unenrollment_self(request, pk): +class BaseActivityEnrollmentView(RequireMemberMixin, FormView): """ - Unenroll the person that is logged in for this activity. + Base view that has some shared functionality between Enrollment, Unenrollment and Edit views + to handle if someone accesses the page for themselves or for someone else (indirect as board/roomduty). """ + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.activity = None + self.person = None + self.indirect: bool = False - # Lock the Activity object for updating, prevent concurrent (un)enrollments - obj = get_object_or_404(Activity.objects.select_for_update(), pk=pk) - - if not check_unenrollment_allowed(request, obj): - # If something went wrong, it will be stated in the django errors - return redirect(obj) - - # No exception can occur here, this has been checked before - participation = Participation.objects.select_for_update().get(person=request.person, event=obj) + def indirect_permisison_error_msg(self): + return _("You are not allowed to access this activity page for other people.") - # If necessary, compensate the enrollment costs. - from amelie.personal_tab import transactions - transactions.remove_participation(participation) + def action_allowed_check(self) -> Tuple[bool, Optional[str]]: + return False, _("Not implemented on base enrollment view.") - # Delete the participation - participation.delete() + def dispatch_post_hook(self) -> Optional[HttpResponse]: + return None - messages.success(request, - _('You are now unenrolled for {activity}.').format( - activity=obj, - )) - - update_waiting_list(obj, request) - - # Redirect back to the activity - return redirect(obj) - - -@transaction.atomic -def activity_editenrollment(request, participation, obj, edited_by=None): - from amelie.personal_tab import transactions + @transaction.atomic + def dispatch(self, request, *args, **kwargs): + # Determine for which person the action is being taken + self.activity: Activity = get_object_or_404(Activity, pk=self.kwargs['pk']) + person_id = self.kwargs.get('person_id', None) + if person_id: + self.person: Person = get_object_or_404(Person, pk=person_id) + self.indirect = True + else: + self.person: Person = request.person - # It is important to not delete the Participation, because the person's spot in the activity or on the waiting list - # could be given away to the next waiting list entry. - # So we need to update the enrollment options of the participation in-place + # Indirect action is only allowed by board members or roomduty members + is_roomduty = hasattr(request, 'person') and request.person.is_room_duty() + if self.indirect and not (self.activity.can_edit(request.person) or is_roomduty): + messages.error(request, self.indirect_permisison_error_msg()) + return redirect(self.activity) - if not participation.waiting_list and participation.payment_method == Participation.PaymentMethodChoices.AUTHORIZATION: - # If this is a regular Participation (not waiting list and via mandate), we need to remove the old transaction from - # their personal tab to not charge them twice and to be able to re-add the updated costs later - transactions.remove_participation(participation, added_by=edited_by, is_edited_participation=True) + # If action is not possible, set the reason in the django messages and return back to the activity view. + action_allowed, reason = self.action_allowed_check() + if not action_allowed: + if reason is not None: + messages.error(request, reason) + return redirect(self.activity) - enrollmentoptions_forms = _build_enrollmentoptionsanswers_forms(obj, request.POST if request.POST else None, - participation.person.user) + resp = self.dispatch_post_hook() + if resp is not None: + return resp - # Make enrollmentoptions - for form in enrollmentoptions_forms: - if form.is_valid(): - answer = form.save(commit=False) - answer.enrollment = participation - answer.save() - form.save_m2m() + return super().dispatch(request, *args, **kwargs) - if not participation.waiting_list and participation.payment_method == Participation.PaymentMethodChoices.AUTHORIZATION: - # If this is a regular Participation (not waiting list and via mandate), we need to re-add - # the transaction to update the costs on the personal tab - transactions.add_participation(participation, added_by=edited_by, is_edited_participation=True) + @transaction.atomic + def post(self, *args, **kwargs): + # Locks the Activity object for updating, prevent concurrent (un)enrollments + Activity.objects.select_for_update().get(pk=self.activity.pk) + return super().post(*args, **kwargs) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['activity'] = self.activity + context['person'] = self.person + context['indirect'] = self.indirect + return context -@require_lid -@transaction.atomic -def activity_editenrollment_self(request, pk): - """ - Edit enrollment of the person that is logged in for this activity. +class ActivityEnrollView(BaseActivityEnrollmentView): """ + Enrolls a person for an activity. - # Lock the Activity object for updating, prevent concurrent (un)enrollments - obj = get_object_or_404(Activity.objects.select_for_update(), pk=pk) - activity = obj - - # No exception can occur here, this has been checked before - participation = Participation.objects.select_for_update().get(person=request.person, event=obj) - - if not check_unenrollment_allowed(request, obj, ignore_can_unenroll=participation.waiting_list): - # If something went wrong, it will be stated in the django errors - return redirect(obj) - - if request.method == 'POST': - activity_editenrollment(request, participation, obj, edited_by=None) - # Redirect back to the activity - return redirect(obj) - else: - enrollmentoptions_forms = _build_enrollmentoptionsanswers_forms(obj, - request.POST if request.POST else None, - request.user) - update = True - return render(request, "activity_enrollment_form.html", locals()) - + If the activity is free and has no options, the enrollment is instant. + If there are costs for the enrollment, or if it has any enrollment options, a form is shown. -@require_committee(settings.ROOM_DUTY_ABBREVIATION) -@transaction.atomic -def activity_editenrollment_other(request, pk, person_id): - """ - Edit enrollment of the person that is logged in for this activity. + The person is either the currently logged-in person or, + if the person is a board member and the `person_id` argument is given, it will enroll the selected person. """ - # Lock the Activity object for updating, prevent concurrent (un)enrollments - obj = get_object_or_404(Activity.objects.select_for_update(), pk=pk) - person = get_object_or_404(Person, pk=person_id) - activity = obj + template_name = "activity_enrollment_form.html" + form_class = ActivityEnrollmentOptionsForm + def indirect_permisison_error_msg(self): + return _("You are not allowed to enroll other people for this activity.") - try: - participation = Participation.objects.select_for_update().get(person=person, event=obj) - except Participation.DoesNotExist: - raise Http404 + def action_allowed_check(self) -> Tuple[bool, Optional[str]]: + return self.activity.check_enrollment_allowed( + person=self.person, indirect=self.indirect, ignore_start_enrollment=self.indirect + ) - if request.method == 'POST': - activity_editenrollment(request, participation, obj, edited_by=request.person) - # Redirect back to the activity - return redirect(obj) - else: - enrollmentoptions_forms = _build_enrollmentoptionsanswers_forms(obj, - request.POST if request.POST else None, - person.user) - update = True - return render(request, "activity_enrollment_form.html", locals()) + def dispatch_post_hook(self) -> Optional[HttpResponse]: + # If it's a self-enrollment for an activity with costs, and the person has no mandate, deny the enrollment. + if not self.indirect and not self.person.has_mandate_activities() and self.activity.has_costs: + return render(self.request, "activity_enrollment_mandate.html", {'activity': self.activity}) + return None + def get_form_kwargs(self): + return { + 'activity': self.activity, + 'participation': None, + 'add_indirect_form': self.indirect, + 'data': self.request.POST if self.request.method == 'POST' else None, + 'initial': { + 'indirect': {'waiting_list': self.activity.enrollment_full, 'board': self.request.is_board} + } if self.indirect else {} + } -@require_committee(settings.ROOM_DUTY_ABBREVIATION) -@transaction.atomic -def activity_unenrollment(request, pk, person_id): - """ - Unenroll the given person for this activity. - """ + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['activity_full'] = self.activity.enrollment_full + return context - # Lock the Activity object for updating, prevent concurrent (un)enrollments - obj = get_object_or_404(Activity.objects.select_for_update(), pk=pk) + def send_enrollment_mail(self, participation: Participation): + # Send mail if preference asks for it, or if it is an indirect enrollment + if self.indirect or self.person.has_preference(name='mail_enrollment'): + if participation.waiting_list: + activity_send_on_waiting_listmail(participation) + else: + activity_send_enrollmentmail(participation) - participation = get_object_or_404(Participation.objects.select_for_update(), person_id=person_id, event=obj) - person = participation.person + def set_enrolled_status_message(self, participation: Participation): + # Set the status message in the request. + if self.indirect: + if participation.waiting_list: + msg = _('{person} is now enrolled for the waitinglist of {activity}.') + else: + msg = _('{person} is now enrolled for {activity}.') + messages.success(self.request, msg.format(person=self.person, activity=self.activity)) + else: + if participation.waiting_list: + msg = _('You are now enrolled for the waitinglist of {activity}.') + else: + msg = _('You are now enrolled for {activity}.') + messages.success(self.request, msg.format(activity=self.activity)) - if not check_unenrollment_allowed(request, obj, person, indirect=True, ignore_can_unenroll=True, - ignore_unenrollment_period=True): - # if something went wrong, it will be stated in the django errors - return redirect(obj) + def get(self, *args, **kwargs): + """ + Immediately enroll the person if it is their own enrollment, there are no options and the activity is free. + Otherwise, render the enrollment form. + """ + if not self.indirect and self.activity.enrollmentoption_set.count() == 0 and self.activity.price == 0: + # Simple enrollment: no questions or costs. Enroll the person immediately + enrollment_full = self.activity.enrollment_full + participation = Participation( + person=self.person, + event=self.activity, + added_by=self.request.person, + waiting_list=enrollment_full + ) + participation.save() - if request.POST: - if 'yes' in request.POST: - # If necessary, compensate the enrollment costs. - from amelie.personal_tab import transactions - transactions.remove_participation(participation) + # Maybe send a notification e-mail to the person and set a status message in the request. + self.send_enrollment_mail(participation=participation) + self.set_enrolled_status_message(participation=participation) - # Delete the participation - participation.delete() + # Redirect back to the activity details + return redirect(self.activity) + else: + return super().get(*args, **kwargs) - update_waiting_list(obj, request) + def form_valid(self, form): + try: + with transaction.atomic(): + # Check if the enrollment was indirect and the option to skip the waiting list for this enrollment was selected. + force_skip_waiting_list = self.indirect and form.cleaned_data.get('indirect', {}).get('waiting_list', None) == "skip" + + # Enroll the person + participation = Participation( + person=self.person, + event=self.activity, + added_by=self.request.person, + waiting_list=(self.activity.enrollment_full and not force_skip_waiting_list) + ) - messages.success(request, - _('{person} is now unenrolled for {activity}.').format( - person=participation.person, - activity=obj, - )) - # Redirect back to the activity - return redirect(obj) + if self.indirect: + participation.remark = 'Indirect enrollment.' - # Render the confirmation page - return render(request, "activity_confirm_unenrollment.html", locals()) + participation.save() + # Save enrollment option answers + for subform_id, subform in form.forms.items(): + if isinstance(subform, EnrollmentoptionAnswerForm): + answer = subform.save(commit=False) + answer.enrollment = participation + answer.save() + subform.save_m2m() + + # Create personal tab transaction (if the activity is paid) + price, with_enrollmentoptions = participation.calculate_costs() + + if price < 0: + # The enrollment costs become negative with this change, that's not OK, abort!!! + raise ValidationError( + _("Your changes would make the costs of your participation negative! " + "Unfortunately that's not allowed, so the change was blocked. " + "Please contact the board or the organizing committee if you think this is a mistake.") + ) + + # Sanity check to disallow self-enrollment for options that cost money + if price and not self.indirect and not self.person.has_mandate_activities(): + # Remember to delete the participation we created! + participation.delete() + return render(self.request, "activity_enrollment_mandate.html", {'activity': self.activity}) -@require_actief -def activity_enrollment_person_search(request, pk): - """ - Search for a person to enroll for this activity. - """ - activity = get_object_or_404(Activity, pk=pk) - is_roomduty = request.person.is_room_duty() - if not (activity.can_edit(request.person) or is_roomduty): - raise PermissionDenied + if price and (force_skip_waiting_list or not self.activity.enrollment_full): + transactions.add_participation(participation=participation, added_by=self.request.person) - form = PersonSearchForm(request.POST if request.POST else None) - if form.is_valid(): - return redirect('activities:enrollment_person', activity.id, form.cleaned_data['person']) + # Maybe send a notification e-mail to the person and set a status message in the request. + self.send_enrollment_mail(participation=participation) + self.set_enrolled_status_message(participation=participation) - # Render the template - return render(request, "activity_enrollment_person_search.html", locals()) + # Redirect back to the activity details + return redirect(self.activity) + except ValidationError as e: + form.add_crossform_error(e) + return self.form_invalid(form) -@require_lid -def activity_enrollment_self(request, pk): - """ - Enroll yourself for an activity with an elaborate enrollment form. +class ActivityUnenrollView(BaseActivityEnrollmentView): """ - activity = get_object_or_404(Activity, pk=pk) + Unenrolls a person for an activity. - # Enrollment for an activity with enrollment options or a price may only be done through a special form. - # However, since enrollment options possibly have costs associated with them, the person should have a mandate. - # If the person has no mandate, then (s)he should be redirected to an explanation page. - if not request.person.has_mandate_activities() and activity.has_costs(): - return render(request, "activity_enrollment_mandate.html", locals()) + The person is either the currently logged-in person or, + if the person is a board member and the `person_id` argument is given, it will unenroll the selected person. + """ + template_name = "activity_confirm_unenrollment.html" + form_class = Form - return activity_enrollment_form(request, activity) + def indirect_permisison_error_msg(self): + return _("You are not allowed to unenroll other people for this activity.") + def action_allowed_check(self) -> Tuple[bool, Optional[str]]: + return self.activity.check_unenrollment_allowed( + person=self.person, indirect=self.indirect, + ignore_can_unenroll=self.indirect, ignore_unenrollment_period=self.indirect + ) -@require_lid -def activity_enrollment_person(request, pk, person_id): - """ - Enroll someone else for an activity with an elaborate enrollment form. - """ - activity = get_object_or_404(Activity, pk=pk) - person = get_object_or_404(Person, id=person_id) + @transaction.atomic + def form_valid(self, form): + # Retrieve the person's participation object and lock it for updating to prevent concurrent (un)enrollments + participation = get_object_or_404(Participation, person=self.person, event=self.activity) + Participation.objects.select_for_update().get(pk=participation.pk) - return activity_enrollment_form(request, activity, person) + # If necessary, compensate the person for the enrollment costs. + transactions.remove_participation(participation) + # Delete the participation + participation.delete() -def _build_enrollmentoptionsanswers_forms(activity_obj, data, user): - forms = [] + # Set a status message for the user so they know the action is successful. + if self.indirect: + messages.success(self.request, _('{person} is now unenrolled for {activity}.').format( + person=participation.person, activity=self.activity + )) + else: + messages.success(self.request, _('You are now unenrolled for {activity}.').format(activity=self.activity)) + + # Update the waitlist, and if the unenrollment was indirect, show if anyone new was promoted from the waitlist. + new_participants = self.activity.update_waiting_list() + if self.indirect: + for np in new_participants: + messages.info( + self.request, _('{person} has been promoted from the waiting list for {activity}.').format( + person=np.person.incomplete_name(), activity=self.activity + ) + ) - for enrollmentoption in activity_obj.enrollmentoption_set.all(): - prefix = 'enrollmentoption_{}'.format(enrollmentoption.pk) + # Redirect back to the activity + return redirect(self.activity) - answer_type = ENROLLMENTOPTION_ANSWER_TYPES[enrollmentoption.content_type.model_class()] - content_type = ContentType.objects.get_for_model(answer_type) - instance = answer_type(enrollmentoption=enrollmentoption, content_type=content_type) - # Try to resolve the instance to an actual object if the person is enrolled for the activity - try: - participation = Participation.objects.get(person__user=user, event=activity_obj) - try: - instance = answer_type.objects.get( - enrollment=participation, - enrollmentoption=enrollmentoption, - content_type=content_type - ) - except EnrollmentoptionAnswer.DoesNotExist: - pass - except Participation.DoesNotExist: - pass - - form_type = 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 still have the option to keep their spot. - checked = False - if instance.pk: - if form_type == EnrollmentoptionCheckboxAnswerForm: - checked = instance.answer - elif form_type == EnrollmentoptionNumericAnswerForm: - checked = instance.answer > 0 - - forms.append( - form_type(enrollmentoption=enrollmentoption, checked=checked, data=data, - prefix=prefix, instance=instance)) - return forms - - -@require_lid -@transaction.atomic -def activity_enrollment_form(request, activity, person=None): +class ActivityEditEnrollView(BaseActivityEnrollmentView): """ - Elaborate enrollment form. + Edits an enrollment of a person for an activity. - This is used to enroll for activities with costs and/or enrollment options. - or for enrolling other people. + The person is either the currently logged-in person or, + if the person is a board member and the `person_id` argument is given, it will enroll the selected person. """ + template_name = "activity_enrollment_form.html" + form_class = ActivityEnrollmentOptionsForm - # Lock the Activity object for updating, prevent concurrent (un)enrollments - Activity.objects.select_for_update().get(pk=activity.pk) + def indirect_permisison_error_msg(self): + return _("You are not allowed to edit enrollments of other people for this activity.") - per_mandate = False - indirect = person is not None - activity_full = activity.enrollment_full() - - if person is None: - person = request.person - - if not check_enrollment_allowed(request, activity, person=person, indirect=indirect, - ignore_full=request.is_board, ignore_start_enrollment=indirect): - # Django messages have been set in check_enrollment_allowed - return redirect(activity) + def action_allowed_check(self) -> Tuple[bool, Optional[str]]: + participation = Participation.objects.select_for_update().get(person=self.person, event=self.activity) + return self.activity.check_unenrollment_allowed( + person=self.person, ignore_can_unenroll=participation.waiting_list + ) - is_roomduty = request.person.is_room_duty() - if indirect and not (activity.can_edit(request.person) or is_roomduty): - raise PermissionDenied + def get_form_kwargs(self): + # Retrieve the person's participation object and lock it for updating to prevent concurrent (un)enrollments + participation = get_object_or_404(Participation, person=self.person, event=self.activity) + Participation.objects.select_for_update().get(pk=participation.pk) + return { + 'activity': self.activity, + 'participation': participation, + 'add_indirect_form': False, # Edits can't modify the waiting list placement. + 'data': self.request.POST if self.request.method == 'POST' else None, + 'initial': { + 'indirect': {'waiting_list': self.activity.enrollment_full, 'board': self.request.is_board} + } if self.indirect else {} + } - per_mandate = (request.is_board or settings.PERSONAL_TAB_COMMITTEE_CAN_AUTHORIZE) \ - and person.has_mandate_activities() + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['update'] = True + return context - enrollmentoptions_forms = _build_enrollmentoptionsanswers_forms(activity, request.POST if request.POST else None, - person.user) - if request.method == 'POST': + def send_enrollment_mail(self, participation: Participation): + # Send mail if preference asks for it, or if it is an indirect enrollment + if self.indirect or self.person.has_preference(name='mail_enrollment'): + if participation.waiting_list: + activity_send_on_waiting_listmail(participation) + else: + activity_send_enrollmentmail(participation) - if indirect: - form_payment = PaymentForm(mandate=per_mandate, data=request.POST, files=request.FILES, - waiting_list=activity_full, board=request.is_board) - valid = all([form.is_valid() for form in enrollmentoptions_forms]) and form_payment.is_valid() - errors = [form.errors for form in enrollmentoptions_forms] + def set_enrolled_status_message(self, participation: Participation): + # Set the status message in the request. + if self.indirect: + if participation.waiting_list: + msg = _('{person} is now enrolled for the waitinglist of {activity}.') + else: + msg = _('{person} is now enrolled for {activity}.') + messages.success(self.request, msg.format(person=self.person, activity=self.activity)) else: - valid = all([form.is_valid() for form in enrollmentoptions_forms]) - errors = [form.errors for form in enrollmentoptions_forms] - form_payment = None - - if valid: - # Check if a participation is forced to skip waiting list - force_skip_waiting_list = False - if form_payment is not None and form_payment.cleaned_data.get('waiting_list', None) == "Skip": - force_skip_waiting_list = True - - participation = Participation(person=person, event=activity, added_by=request.person, - waiting_list=(activity_full and not force_skip_waiting_list)) - - # Place a remark if someone else does the enrollment - if indirect: - participation.remark = 'Indirect enrollment.' - participation.payment_method = form_payment.cleaned_data['method'] + if participation.waiting_list: + msg = _('You are now enrolled for the waitinglist of {activity}.') else: - participation.payment_method = Participation.PaymentMethodChoices.AUTHORIZATION + msg = _('You are now enrolled for {activity}.') + messages.success(self.request, msg.format(activity=self.activity)) - # Make participation (Deelname) - participation.save() - - # Make enrollmentoptions - for form in enrollmentoptions_forms: - answer = form.save(commit=False) - answer.enrollment = participation - answer.save() - form.save_m2m() + def form_valid(self, form): + try: + with transaction.atomic(): + # It is important to NOT delete the Participation, because the person's spot in the activity + # or on the waiting list could be given away to the next waiting list entry. + # So we need to update the enrollment options of the participation in-place + + participation: Optional[Participation] = form.participation + if participation is None: + raise ValueError(_("Participation not found while editing enrollment.")) + + if not participation.waiting_list: + # If this is a regular Participation (not waiting list), we need to remove the old transaction from + # their personal tab to not charge them twice and to be able to re-add the updated costs later + transactions.remove_participation(participation, added_by=self.request.person, is_edited_participation=True) + + # Save enrollment option answers + for subform_id, subform in form.forms.items(): + if isinstance(subform, EnrollmentoptionAnswerForm): + answer = subform.save(commit=False) + answer.enrollment = participation + answer.save() + subform.save_m2m() + + # If this is a regular Participation (not waiting list) and it has costs after editing, we need to (re-)add + # the transaction to update the costs on the personal tab + price, with_enrollmentoptions = participation.calculate_costs() + if price < 0: + # The enrollment costs become negative with this change, that's not OK, abort!!! + raise ValidationError( + _("Your changes would make the costs of your participation negative! " + "Unfortunately that's not allowed, so the change was blocked. " + "Please contact the board or the organizing committee if you think this is a mistake.") + ) + if price and not participation.waiting_list: + # If this is a regular Participation (not waiting list), we need to re-add + # the transaction to update the costs on the personal tab + transactions.add_participation(participation, added_by=self.request.person, is_edited_participation=True) + + # Redirect back to the activity details + return redirect(self.activity) + except ValidationError as e: + form.add_crossform_error(e) + return self.form_invalid(form) - price, with_enrollmentoptions = participation.calculate_costs() - if not price: - # No payment method when nothing needs to be paid. - participation.payment_method = Participation.PaymentMethodChoices.NONE - participation.save() +def activity_random_photo(request, pk, *args, **kwargs): + """ Returns a random photo of a specific activity. """ + act = get_object_or_404(Activity, pk=pk) + photo = act.random_photo(only_public=not hasattr(request, 'person')) - # Make transactions if there is a mandate - if participation.payment_method == Participation.PaymentMethodChoices.AUTHORIZATION and ( - force_skip_waiting_list or not activity_full): - from amelie.personal_tab import transactions - transactions.add_participation(participation, request.person) - - # Send mail per preference. Always send email if indirect. - if indirect or person.has_preference(name='mail_enrollment'): - if activity_full: - activity_send_on_waiting_listmail(participation) - else: - activity_send_enrollmentmail(participation) - - msg_waiting_list = "" - if activity_full: - msg_waiting_list = _("the waitinglist of") - # Done - if indirect: - messages.success(request, - _('{person} is now enrolled for {waiting_list} {activity}.').format( - person=person, - activity=activity, - waiting_list=msg_waiting_list, - )) - else: - messages.success(request, - _('You are now enrolled for {waiting_list} {activity}.').format( - activity=activity, - waiting_list=msg_waiting_list - )) - return HttpResponseRedirect(activity.get_absolute_url()) - else: - for error in errors: - for key in error.keys(): - messages.error(request, error[key][0]) - else: - if indirect: - form_payment = PaymentForm(mandate=per_mandate, waiting_list=activity_full, board=request.is_board) + # Generate a JSON response + if photo: + return photo.to_http_response(format='medium', response='json') - # Render the template - return render(request, "activity_enrollment_form.html", locals()) + # No photo + raise Http404(_('No photo')) @require_committee('MediaCie') @@ -1211,63 +1173,90 @@ class ActivityUpdateView(ActivitySecurityMixin, ActivityEditMixin, UpdateView): form_class = ActivityForm def form_valid(self, form): - # If the date or price has changed, we have to update any existing transactions to the new activity date/price. - # The existing transactions are refunded and will be re-created on the same date. - person = self.request.person - if person is None: - raise Exception("Updating requires a person") - - old_obj = self.get_object() - new_obj = form.instance - - # Save changes to the parent activity so new transactions are calculated correctly in case of a new price/date - response = super().form_valid(form) - - # Determine if any activity fields that have an impact on the participation transactions have been changed - needs_new_transactions = False - change_reasons = [] - # If the activity start date is moved to a different day, the transactions need to be moved to the new date. - if form.cleaned_data['begin'].date() != old_obj.begin.date(): - needs_new_transactions = True - change_reasons.append(_("start date was changed")) - - # If the activity price is changed, the transactions need to be recreated with the new price - if form.cleaned_data['price'] != old_obj.price: - needs_new_transactions = True - change_reasons.append(_("price was changed")) - activity_send_price_change_mail(old_obj, new_obj) - - if old_obj is not None and needs_new_transactions: - # Undo and create new transactions - from amelie.personal_tab import transactions - # Only consider participations paid by a mandate - # (to avoid creating transactions for members without a personal tab mandate) - for participation in old_obj.participation_set.filter(payment_method=Participation.PaymentMethodChoices.AUTHORIZATION): - # Note - reason text is max 200 chars! - reason = _("Activity '{activity}' was changed (reversal of old transaction) ({change_reasons})").format( - activity=str(old_obj)[:90], change_reasons=", ".join(change_reasons) - ) - transactions.participation_transaction( - participation, - reason=reason, - cancel=True, added_by=person - ) - - # Create a new transaction - price, with_enrollment_options = participation.calculate_costs() - # Note - reason text is max 200 chars! - reason = _("Activity '{activity}' was changed (addition of new transaction) ({change_reasons})").format( - activity=str(new_obj.summary)[:90], change_reasons=", ".join(change_reasons) - ) - - # Can't use transactions.participation_transaction because we need to override the date. - transaction = ActivityTransaction(price=price, description=reason, - participation=participation, event=old_obj, - person=participation.person, date=new_obj.begin, - with_enrollment_options=with_enrollment_options, added_by=person) - transaction.save() - - return response + try: + with transaction.atomic(): + # If the date or price has changed, we have to update any existing transactions to the new activity date/price. + # The existing transactions are refunded and will be re-created on the same date. + person = self.request.person + if person is None: + raise Exception("Updating requires a person") + + old_obj = self.get_object() + new_obj = form.instance + + # The total possible activity price may not be negative, or it will interfere with the payment processing. + # So, if the base activity cost, plus the costs of all negatively priced enrollment options, is lower than 0, + # we raise a validation error. + activity_base_price = new_obj.price + enrollment_options_min_prices = sum(o.get_min_max_extra_costs()[0] for o in new_obj.enrollmentoption_set.all()) + if activity_base_price + enrollment_options_min_prices < 0: + raise ValidationError( + _("Editing the activity price to this value would make the minimum possible price for this activity negative! " + "This is not allowed. Please modify the pricing and limits of the activity or the enrollment options first.") + ) + + # Determine if any activity fields that have an impact on the participation transactions have been changed + needs_new_transactions = False + needs_price_change_mail = False + change_reasons = [] + # If the activity start date is moved to a different day, the transactions need to be moved to the new date. + old_date = old_obj.begin.astimezone(get_current_timezone()) + new_date = form.cleaned_data['begin'].astimezone(get_current_timezone()) + if new_date.date() != old_date.date(): + needs_new_transactions = True + change_reasons.append(_("start date was changed")) + + # If the activity price is changed, the transactions need to be recreated with the new price + if form.cleaned_data['price'] != old_obj.price: + needs_new_transactions = True + needs_price_change_mail = True + change_reasons.append(_("price was changed")) + + if needs_new_transactions: + # Undo any old transactions _before_ the updated price is saved (so the calculations use the old pricing) + for participation in old_obj.participation_set.all(): + # Note - reason text is max 200 chars! + reason = _("Activity '{activity}' was changed (reversal of old transaction) ({change_reasons})").format( + activity=str(old_obj)[:90], change_reasons=", ".join(change_reasons) + ) + transactions.participation_transaction( + participation, + reason=reason, + cancel=True, added_by=person + ) + + # Save changes to the parent activity so new transactions are calculated correctly in case of a new price/date + response = super().form_valid(form) + + if needs_new_transactions: + # Create new transactions if necessary + for participation in old_obj.participation_set.all(): + price, with_enrollment_options = participation.calculate_costs() + if price < 0: + # One of the current enrollment costs becomes negative with this change, abort!!! + raise ValidationError( + _("Editing the activity in this way would make the costs of (at least) one of the " + "current enrollments negative! This is not allowed. Please unenroll the affected " + "people, or modify the pricing and limits of the activity or the enrollment options first.") + ) + # Note - reason text is max 200 chars! + reason = _("Activity '{activity}' was changed (addition of new transaction) ({change_reasons})").format( + activity=str(new_obj.summary)[:90], change_reasons=", ".join(change_reasons) + ) + transactions.participation_transaction( + participation, + reason=reason, + added_by=person, + date_override=new_obj.begin + ) + + if needs_price_change_mail: + activity_send_price_change_mail(old_obj, new_obj) + + return response + except ValidationError as e: + form.add_error('price', e) + return self.form_invalid(form) class EnrollmentoptionListView(ActivitySecurityMixin, TemplateView): @@ -1314,11 +1303,26 @@ def get_form_class(self): return ENROLLMENTOPTION_FORM_TYPES.get(self.model, None) def form_valid(self, form): - self.object = form.save(commit=False) - self.object.activity = self.get_activity() + self.object: Enrollmentoption = form.save(commit=False) self.object.content_type = ContentType.objects.get_for_model(self.model) + activity = self.get_activity() + + # The total possible activity price may not be negative, or it will interfere with the payment processing. + # So, if the base activity cost, plus the costs of all negatively priced enrollment options, is lower than 0, + # we raise a validation error. + activity_base_price = activity.price + existing_enrollment_options_min_prices = sum(o.get_min_max_extra_costs()[0] for o in activity.enrollmentoption_set.all()) + new_option_min_price = self.object.get_min_max_extra_costs()[0] + if activity_base_price + existing_enrollment_options_min_prices + new_option_min_price < 0: + form.add_error(None, ValidationError( + _("Adding this option would make the minimum possible price for this activity negative! This is not " + "allowed. Please modify the pricing or limits of this or other enrollment options first.") + )) + return self.form_invalid(form) - return super(EnrollmentoptionCreateView, self).form_valid(form) + self.object.activity = activity + self.object.save() + return HttpResponseRedirect(self.get_success_url()) def get(self, request, *args, **kwargs): activity_ins = self.get_activity() @@ -1348,59 +1352,83 @@ def get_activity(self): return self.get_object().activity def form_valid(self, form): - # If the price has changed, we have to update any existing transactions to the new activity price. - # The existing transactions are refunded and will be re-created on the same date. - person = self.request.person - if person is None: - raise Exception("Updating requires a person") - - old_obj = self.get_object() - new_obj = form.instance - - # Save changes to the parent activity so new transactions are calculated correctly in case of a new price/date - response = super().form_valid(form) - - # Determine if any activity fields that have an impact on the participation transactions have been changed - needs_new_transactions = False - change_reasons = [] - - # If the enrollment option price is changed, the transactions need to be recreated with the new price - if "price_extra" in form.cleaned_data and form.cleaned_data["price_extra"] != old_obj.price_extra: - needs_new_transactions = True - change_reasons.append(_("enrollment option price was changed")) - activity_send_enrollment_option_price_change_mail(old_obj, new_obj) - - if old_obj is not None and needs_new_transactions: - # Undo and create new transactions - from amelie.personal_tab import transactions - # Only consider participations paid by a mandate - # (to avoid creating transactions for members without a personal tab mandate) - for participation in old_obj.activity.participation_set.filter(payment_method=Participation.PaymentMethodChoices.AUTHORIZATION): - # Note - reason text is max 200 chars! - reason = _("An enrollment option for activity '{activity}' was changed (reversal of old transaction) ({change_reasons})").format( - activity=str(old_obj.activity)[:80], change_reasons=", ".join(change_reasons) - ) - transactions.participation_transaction( - participation, - reason=reason, - cancel=True, added_by=person - ) - - # Create a new transaction - price, with_enrollment_options = participation.calculate_costs() - # Note - reason text is max 200 chars! - reason = _("An enrollment option for activity '{activity}' was changed (addition of new transaction) ({change_reasons})").format( - activity=str(new_obj.activity.summary)[:80], change_reasons=", ".join(change_reasons) - ) - - # Can't use transactions.participation_transaction because we need to override the date. - transaction = ActivityTransaction(price=price, description=reason, - participation=participation, event=old_obj.activity, - person=participation.person, date=new_obj.activity.begin, - with_enrollment_options=with_enrollment_options, added_by=person) - transaction.save() + try: + with transaction.atomic(): # This definitely needs rolling back if any ValidationErrors occur. + # If the price has changed, we have to update any existing transactions to the new activity price. + # The existing transactions are refunded and will be re-created on the same date. + person = self.request.person + if person is None: + raise Exception("Updating requires a person") + + old_obj = self.get_object() + new_obj: Enrollmentoption = form.instance + + # The total possible activity price may not be negative, or it will interfere with the payment processing. + # So, if the base activity cost, plus the costs of all negatively priced enrollment options, is lower than 0, + # we raise a validation error. + activity_base_price = old_obj.activity.price + existing_enrollment_options_min_prices = sum(o.get_min_max_extra_costs()[0] for o in old_obj.activity.enrollmentoption_set.all() if o != old_obj) + new_option_min_price = new_obj.get_min_max_extra_costs()[0] + if activity_base_price + existing_enrollment_options_min_prices + new_option_min_price < 0: + raise ValidationError( + _("Editing this option in this way would make the minimum possible price for this activity negative! " + "This is not allowed. Please modify the pricing or limits of this or other enrollment options first.") + ) + + # Determine if any activity fields that have an impact on the participation transactions have been changed + needs_new_transactions = False + change_reasons = [] + # If the enrollment option price is changed, the transactions need to be recreated with the new price + if "price_extra" in form.cleaned_data and form.cleaned_data["price_extra"] != old_obj.price_extra: + needs_new_transactions = True + change_reasons.append(_("enrollment option price was changed")) + + if needs_new_transactions: + # Undo any old transactions _before_ the updated price is saved (so the calculations use the old pricing) + for participation in old_obj.activity.participation_set.all(): + # Note - reason text is max 200 chars! + reason = _("An enrollment option for activity '{activity}' was changed (reversal of old transaction) ({change_reasons})").format( + activity=str(old_obj.activity)[:80], change_reasons=", ".join(change_reasons) + ) + transactions.participation_transaction( + participation, + reason=reason, + cancel=True, added_by=person + ) + + # Save changes to the parent activity so new transactions are calculated correctly in case of a new price/date + response = super().form_valid(form) + + if needs_new_transactions: + # Create new transactions if necessary + for participation in old_obj.activity.participation_set.all(): + price, with_enrollment_options = participation.calculate_costs() + if price < 0: + # One of the current enrollment costs becomes negative with this change, abort!!! + raise ValidationError( + _("Editing this option in this way would make the costs of (at least) one of the current enrollments negative! " + "This is not allowed. Please unenroll the affected people, or modify the pricing or limits of this " + "or other enrollment options first.") + ) + # Note - reason text is max 200 chars! + reason = _("An enrollment option for activity '{activity}' was changed (addition of new transaction) ({change_reasons})").format( + activity=str(new_obj.activity.summary)[:80], change_reasons=", ".join(change_reasons) + ) + transactions.participation_transaction( + participation, + reason=reason, + added_by=person, + date_override=new_obj.activity.begin + ) + + # Send price update mail + activity_send_enrollment_option_price_change_mail(old_obj, new_obj) + + return response + except ValidationError as e: + form.add_error(None, e) + return self.form_invalid(form) - return response def get_form_class(self): """ @@ -1515,14 +1543,17 @@ def export_print(request, pk): participation_set = obj.confirmed_participations.order_by('person__first_name') number_participants = obj.confirmed_participants.count() if obj.confirmed_participants else 0 + print_date = timezone.now() return render(request, "activity_enrollment_print.html", locals()) @staticmethod def export_html(request, pk): """ - Returns a HTML overview of participants for this activity. + Returns an HTML overview of participants for this activity. (Regular ActivityView but deanonimised). """ - return activity(request, pk, deanonymise=True) + av = ActivityDetailView(deanonymise=True) + av.setup(request=request, pk=pk) + return av.get(request=request, pk=pk) @staticmethod def export_csv(request, pk): diff --git a/amelie/api/activitystream.py b/amelie/api/activitystream.py index 5d258534..0639a6a0 100644 --- a/amelie/api/activitystream.py +++ b/amelie/api/activitystream.py @@ -15,7 +15,6 @@ from amelie.activities.models import Activity, EnrollmentoptionQuestion, EnrollmentoptionCheckbox, EnrollmentoptionFood from amelie.activities.models import EnrollmentoptionQuestionAnswer, EnrollmentoptionCheckboxAnswer, \ EnrollmentoptionFoodAnswer -from amelie.activities.utils import update_waiting_list from amelie.api.api import api_server from amelie.api.activitystream_utils import add_detailed_properties, add_images_property, add_thumbnails_property, \ get_basic_result @@ -654,20 +653,21 @@ def activity_signup(activity_id: int, price: float, options: dict, ctx: RpcReque try: # Lock the Activity object for updating, prevent concurrent (un)enrollments - activity = Activity.objects.select_for_update().get(id=activity_id) + activity: Activity = Activity.objects.select_for_update().get(id=activity_id) except Activity.DoesNotExist as e: raise DoesNotExistError(str(e)) - if activity.oauth_application and not activity.oauth_application == authentication.get_application(): - raise SignupError("You can only subscribe to this activity on the website of this activity.") - elif not authentication.represents(): + # Check signup restrictions + if not authentication.represents(): raise SignupError("Could not determine who you are based on your login credentials.") - elif authentication.represents() in activity.participants.all(): - raise SignupError("You are already signed up for this activity") - elif not activity.enrollment: - raise SignupError("You cannot sign up for this activity, there is no subscription.") - elif not activity.enrollment_open(): - raise SignupError("You cannot sign up for this activity, the subscription is not open") + enrollment_allowed, reason = activity.check_enrollment_allowed( + person=authentication.represents(), indirect=False, ignore_start_enrollment=False, + authentication_application=authentication.get_application() + ) + if not enrollment_allowed: + if reason is None: + raise SignupError("Could not enroll for this activity.") + raise SignupError(reason) # check if all required options are present and filled in for option in activity.enrollmentoption_set.all(): @@ -680,33 +680,34 @@ def activity_signup(activity_id: int, price: float, options: dict, ctx: RpcReque raise MissingOptionError() # save attendance - attendance = Participation(event=activity, - person=authentication.represents(), - remark='Enrollment through OAuth', - payment_method=Participation.PaymentMethodChoices.NONE, - added_by=authentication.represents(), - waiting_list=activity.enrollment_full()) - attendance.save() + participation = Participation( + person=authentication.represents(), + event=activity, + added_by=authentication.represents(), + waiting_list=activity.enrollment_full, + remark='Enrollment through OAuth' + ) + participation.save() # save options for option in activity.enrollmentoption_set.all(): answer = None if option.content_type.model_class() == EnrollmentoptionQuestion: result = optionset[option.id] if option.id in optionset.keys() else '' - answer = EnrollmentoptionQuestionAnswer(answer=result, enrollment=attendance, enrollmentoption=option) + answer = EnrollmentoptionQuestionAnswer(answer=result, enrollment=participation, enrollmentoption=option) elif option.content_type.model_class() == EnrollmentoptionCheckbox: result = option.id in optionset.keys() and optionset[option.id] - answer = EnrollmentoptionCheckboxAnswer(answer=result, enrollment=attendance, enrollmentoption=option) + answer = EnrollmentoptionCheckboxAnswer(answer=result, enrollment=participation, enrollmentoption=option) elif option.content_type.model_class() == EnrollmentoptionFood: result = option.dishprice_set.get(id=optionset[option.id]) if option.id in optionset.keys() else None - answer = EnrollmentoptionFoodAnswer(dishprice=result, enrollment=attendance, enrollmentoption=option) + answer = EnrollmentoptionFoodAnswer(dishprice=result, enrollment=participation, enrollmentoption=option) if answer: answer.content_type = ContentType.objects.get_for_model(answer) answer.save() # check for inconsistency in app - amount, with_options = attendance.calculate_costs() + amount, with_options = participation.calculate_costs() if abs(Decimal(price) - amount) > 0.02: # allow for floating point precision bugs raise SignupError("An inconsistency was detected. Please use the website to sign up.") @@ -716,20 +717,16 @@ def activity_signup(activity_id: int, price: float, options: dict, ctx: RpcReque if not authentication.represents().has_mandate_activities(): raise SignupError("A signed mandate is required. Please ask the board for a mandate.") - # Set payment by direct debit. - attendance.payment_method = Participation.PaymentMethodChoices.AUTHORIZATION - attendance.save() - - if not activity.enrollment_full(): + if not activity.enrollment_full: # add transaction - transactions.add_participation(attendance, authentication.represents()) + transactions.add_participation(participation=participation, added_by=authentication.represents()) # Send mail if desired if authentication.represents().has_preference(name='mail_enrollment'): - if not activity.enrollment_full(): - activity_send_enrollmentmail(attendance) + if participation.waiting_list: + activity_send_on_waiting_listmail(participation) else: - activity_send_on_waiting_listmail(attendance) + activity_send_enrollmentmail(participation) return True @@ -767,33 +764,34 @@ def activity_signup_revoke(activity_id: int, ctx: RpcRequestContext = None, **kw try: # Lock the Activity object for updating, prevent concurrent (un)enrollments - activity = Activity.objects.select_for_update().get(id=activity_id) + activity: Activity = Activity.objects.select_for_update().get(id=activity_id) except Activity.DoesNotExist as e: raise DoesNotExistError(str(e)) - if activity.oauth_application and not activity.oauth_application == authentication.get_application(): - raise SignupError("You can only unsubscribe to this activity on the website of this activity.") - elif not activity.enrollment: - raise SignupError("You cannot unsubscribe for this activity, it has no subscription.") - elif not authentication.represents(): + # Check signup restrictions + if not authentication.represents(): raise SignupError("Could not determine who you are based on your login credentials.") - elif not authentication.represents() in activity.participants.all(): - raise SignupError("You were not subscribed for this activity.") - elif not activity.can_unenroll: - raise SignupError( - "You cannot revoke your attendance anymore, it is not possible to unsubscribe for this activity.") - elif not activity.enrollment_open(): - raise SignupError("You cannot revoke your attendance anymore, subscription term has expired.") - - attendance = Participation.objects.select_for_update().get(person=authentication.represents(), - event=activity) - - # If necessary, create a compensation for attendance - transactions.remove_participation(attendance) - - # Delete attendance - attendance.delete() - - update_waiting_list(activity=activity) + unenrollment_allowed, reason = activity.check_unenrollment_allowed( + person=authentication.represents(), indirect=False, + ignore_can_unenroll=False, ignore_unenrollment_period=False, + authentication_application=authentication.get_application() + ) + if not unenrollment_allowed: + if reason is None: + raise SignupError("Could not unenroll for this activity.") + raise SignupError(reason) + + participation = Participation.objects.select_for_update().get( + person=authentication.represents(), event=activity + ) + + # If necessary, compensate the person for the enrollment costs. + transactions.remove_participation(participation) + + # Delete the participation + participation.delete() + + # Update the waiting list. + activity.update_waiting_list() return True diff --git a/amelie/api/activitystream_utils.py b/amelie/api/activitystream_utils.py index a721734c..8202db5e 100644 --- a/amelie/api/activitystream_utils.py +++ b/amelie/api/activitystream_utils.py @@ -36,6 +36,8 @@ def get_basic_result(activity): # Add detailed properties to a result item def add_detailed_properties(activity, authentication: Authentication, result): + from amelie.activities.models import Activity + activity: Activity # For type checking authenticated = not isinstance(authentication, AnonymousAuthentication) result["description"] = strip_markdown(activity.description) @@ -48,10 +50,10 @@ def add_detailed_properties(activity, authentication: Authentication, result): result["signupStart"] = activity.enrollment_begin.isoformat() if activity.enrollment_begin else None result["signupStop"] = activity.enrollment_end.isoformat() if activity.enrollment_end else None result["availability"] = activity.maximum if activity.maximum is not None else 0 - result["signupAvailable"] = activity.enrollment and activity.enrollment_open() \ - and not activity.enrollment_full() - result["signupWaitinglist"] = activity.enrollment_full() - result["resignAvailable"] = activity.enrollment and activity.enrollment_open() \ + result["signupAvailable"] = activity.enrollment and activity.enrollment_open \ + and not activity.enrollment_full + result["signupWaitinglist"] = activity.enrollment_full + result["resignAvailable"] = activity.enrollment and activity.enrollment_open \ and activity.can_unenroll result["signedUp"] = authentication.represents() in activity.participants.all() if authenticated else False diff --git a/amelie/api/narrowcasting.py b/amelie/api/narrowcasting.py index 378c6172..c73809d8 100644 --- a/amelie/api/narrowcasting.py +++ b/amelie/api/narrowcasting.py @@ -170,7 +170,7 @@ def get_television_promotions() -> List[Dict]: promotions = TelevisionPromotion.objects.filter(start__lte=timezone.now(), end__gte=timezone.now()) for promotion in promotions: - res_dict: Dict[str, Union[str, int, bool]] = { + res_dict: Dict[str, Union[str, int, bool, None]] = { "image": "%s%s" % (settings.MEDIA_URL, str(promotion.attachment)) } @@ -181,7 +181,7 @@ def get_television_promotions() -> List[Dict]: res_dict["signupStart"] = promotion.activity.enrollment_begin.isoformat() res_dict["signupStop"] = promotion.activity.enrollment_end.isoformat() res_dict["signupMaximum"] = promotion.activity.maximum - res_dict["signupAvailable"] = promotion.activity.places_available() + res_dict["signupAvailable"] = promotion.activity.places_available result.append(res_dict) diff --git a/amelie/api/person.py b/amelie/api/person.py index 46474a42..2fe0c0f0 100644 --- a/amelie/api/person.py +++ b/amelie/api/person.py @@ -253,14 +253,28 @@ def get_person_membership(ctx: RpcRequestContext = None, **kwargs) -> Union[Dict "hasEnded": person.membership.ended if person.membership.ended else False, } - if hasattr(person.membership, "payment") and person.membership.payment is not None: - result["payment"] = { - "date": person.membership.payment.date, + # Get payment info + if person.membership.is_paid(): + payment_transaction = person.membership.payment_transaction() + if payment_transaction.settlement.settlement_type == "INSTR": + payment_method_name = "Direct debit" + payment_method_description = "Authorization via Direct Debit." + payment_date = payment_transaction.settlement.debtcollectioninstruction.batch.execution_date + elif payment_transaction.settlement.settlement_type == "MANUAL": + payment_method_name = payment_transaction.settlement.manualpaymentsettlement.payment_method.name + payment_method_description = payment_transaction.settlement.manualpaymentsettlement.payment_method.description + payment_date = payment_transaction.settlement.manualpaymentsettlement.payment_date + else: + payment_method_name = "Unknown" + payment_method_description = "-" + payment_date = None + result['payment'] = { + "date": payment_date, "method": { - "name": person.membership.payment.payment_type.name, - "description": person.membership.payment.payment_type.description, + "name": payment_method_name, + "description": payment_method_description }, - "amount": person.membership.payment.amount, + "amount": payment_transaction.price } return result diff --git a/amelie/api/templates/api/doc_index.html b/amelie/api/templates/api/doc_index.html index 8c5aa5e4..831df8b3 100644 --- a/amelie/api/templates/api/doc_index.html +++ b/amelie/api/templates/api/doc_index.html @@ -274,7 +274,7 @@

Known Error Codes

- 403 or -32096 or -32603 + 403 or -32096 or -32098 NotLoggedInError / AuthenticationError Not logged in / The token was not recognized. diff --git a/amelie/api/test_activitystream.py b/amelie/api/test_activitystream.py index 86bc538f..064d1d0d 100644 --- a/amelie/api/test_activitystream.py +++ b/amelie/api/test_activitystream.py @@ -104,10 +104,10 @@ def _activity_data_detailed(activity, authenticated=False, signedup=False): "signupStart": activity.enrollment_begin.isoformat() if activity.enrollment_begin else None, "signupStop": activity.enrollment_end.isoformat() if activity.enrollment_end else None, "signedUp": signedup, - "resignAvailable": activity.enrollment and activity.enrollment_open() and activity.can_unenroll, - "signupAvailable": activity.enrollment and activity.enrollment_open() - and not activity.enrollment_full(), - "signupWaitinglist": activity.enrollment_full(), + "resignAvailable": activity.enrollment and activity.enrollment_open and activity.can_unenroll, + "signupAvailable": activity.enrollment and activity.enrollment_open + and not activity.enrollment_full, + "signupWaitinglist": activity.enrollment_full, "price": activity.price, } @@ -290,7 +290,7 @@ def test_invalid_token(self): for option in self.activity.enrollmentoption_set.all(): option.delete() - self.send_and_compare_request_error('activitySignup', [self.activity.pk, 0.00, []], 'invalidToken', -32603) + self.send_and_compare_request_error('activitySignup', [self.activity.pk, 0.00, []], 'invalidToken', -32098) def test_free(self): # Clear options diff --git a/amelie/blame/registrations.py b/amelie/blame/registrations.py index 9de50726..11e3c451 100644 --- a/amelie/blame/registrations.py +++ b/amelie/blame/registrations.py @@ -8,13 +8,13 @@ from amelie.gmm.models import GMM from amelie.files.models import GMMDocument, File from amelie.members.models import Person, Committee, Faculty, Department, Study, Dogroup, \ - DogroupGeneration, Association, PaymentType, CommitteeCategory, Function, Payment + DogroupGeneration, Association, CommitteeCategory, Function from amelie.news.models import NewsItem from amelie.narrowcasting.models import TelevisionPromotion from amelie.education.models import Category, Page, EducationEvent, Complaint, ComplaintComment from amelie.about.models import Page as AboutPage -from amelie.personal_tab.models import DiscountPeriod, CustomTransaction, LedgerAccount, Article, \ - Category as CookieCornerCategory, AuthorizationType, Authorization, Amendment, PrintLogEntry +from amelie.personal_tab.models import DiscountPeriod, CustomTransaction, ManualPaymentSettlement, LedgerAccount, \ + Article, Category as CookieCornerCategory, AuthorizationType, Authorization, Amendment, PrintLogEntry, PaymentMethod from amelie.weekmail.models import WeekMail """ @@ -49,7 +49,6 @@ auditlog.register(Dogroup) auditlog.register(DogroupGeneration) auditlog.register(Association) -auditlog.register(PaymentType) auditlog.register(Person, include_fields=["first_name", "last_name_prefix", "last_name", "initials", "slug", "picture", "date_of_birth", "email_address", "account_name", "shell", "webmaster", "user"]) @@ -77,13 +76,14 @@ # Personal Tab hooks auditlog.register(DiscountPeriod) auditlog.register(CustomTransaction) +auditlog.register(ManualPaymentSettlement) auditlog.register(LedgerAccount) auditlog.register(Article) auditlog.register(CookieCornerCategory) auditlog.register(AuthorizationType) auditlog.register(Authorization) auditlog.register(Amendment) -auditlog.register(Payment) +auditlog.register(PaymentMethod) auditlog.register(PrintLogEntry) # Week mailing hooks diff --git a/amelie/calendar/admin.py b/amelie/calendar/admin.py index c6c34a18..02496ac8 100644 --- a/amelie/calendar/admin.py +++ b/amelie/calendar/admin.py @@ -12,10 +12,9 @@ class EventAdmin(admin.ModelAdmin): class ParticipationAdmin(admin.ModelAdmin): - list_display = ('id', 'event', 'person', 'payment_method', 'added_on', 'added_by',) + list_display = ('id', 'event', 'person', 'added_on', 'added_by',) search_fields = ('event__summary_nl', 'event__summary_en', 'person__first_name', 'person__last_name',) date_hierarchy = 'added_on' - list_filter = ('payment_method',) raw_id_fields = ('event', 'person', 'added_by', ) diff --git a/amelie/calendar/migrations/0009_remove_participation_cash_payment_made_and_more.py b/amelie/calendar/migrations/0009_remove_participation_cash_payment_made_and_more.py new file mode 100644 index 00000000..ca410a44 --- /dev/null +++ b/amelie/calendar/migrations/0009_remove_participation_cash_payment_made_and_more.py @@ -0,0 +1,218 @@ +# Written by Kevin Alberts on 2026-07-21 + +import logging +from decimal import Decimal + +from django.db import migrations +from django.utils import translation +from django.utils.translation import gettext as _ + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +# This is a huge pain in the butt, because it's in a migration +# so we can't use any of the helper functions on the models... + +# Based on Participation.calculate_costs method (and underlying methods) +def calculate_costs(apps, participation): + # List of extra costs per enrollment option + prices_extra = [] + for enrollment_option_answer in participation.enrollmentoptionanswer_set.all(): + price_extra = Decimal(0) + + # Checkbox answers + if hasattr(enrollment_option_answer, 'enrollmentoptioncheckboxanswer'): + enrollment_option_answer = enrollment_option_answer.enrollmentoptioncheckboxanswer + if enrollment_option_answer.answer: + price_extra = enrollment_option_answer.enrollmentoption.enrollmentoptioncheckbox.price_extra + prices_extra.append(price_extra) + continue + + # Numeric answers + if hasattr(enrollment_option_answer, 'enrollmentoptionnumericanswer'): + enrollment_option_answer = enrollment_option_answer.enrollmentoptionnumericanswer + price_extra = enrollment_option_answer.enrollmentoption.enrollmentoptionnumeric.price_extra * enrollment_option_answer.answer + prices_extra.append(price_extra) + continue + + # Selectbox answers + if hasattr(enrollment_option_answer, 'enrollmentoptionselectboxanswer'): + enrollment_option_answer = enrollment_option_answer.enrollmentoptionselectboxanswer + price_extra = enrollment_option_answer.answer.price_extra + prices_extra.append(price_extra) + continue + + # Food answers + if hasattr(enrollment_option_answer, 'enrollmentoptionfoodanswer'): + enrollment_option_answer = enrollment_option_answer.enrollmentoptionfoodanswer + if enrollment_option_answer.dishprice: + price_extra = enrollment_option_answer.dishprice.price + prices_extra.append(price_extra) + continue + + # Question answers + if hasattr(enrollment_option_answer, 'enrollmentoptionquestionanswer'): + # Questions never have extra price. + prices_extra.append(price_extra) + continue + + raise ValueError(f"Unknown enrollment option answer class: ID {enrollment_option_answer.pk} {enrollment_option_answer.__class__.__name__}") + + # Calculate and return total costs, and if there were any additional costs due to enrollment options. + total_costs = participation.event.activity.price + sum(prices_extra) + return total_costs, len(prices_extra) > 0 + + +# Based on Person.incomplete_name method +def incomplete_name(person): + first_name = person.first_name + if not person.first_name and person.initials: + first_name = person.initials + if person.last_name_prefix: + return ' '.join([first_name, person.last_name_prefix, person.last_name]) + else: + return ' '.join([first_name, person.last_name]) + + +# Based on ManualPaymentSettlement.create_settlement_for_transactions method. +def create_settlement_for_transactions(ManualPaymentSettlement, SettlementManualPaymentTransaction, + transactions, payment_method, person, description, + payment_datetime, created_by=None): + transactions = list(transactions) + if not transactions: + raise ValueError("No transactions given! Cannot create an empty manual payment") + + # Calculate the total settlement amount + total_transactions_price = sum(t.price for t in transactions) or Decimal("0.00") + + # Create a ManualPayment settlement with the given payment method. + settlement = ManualPaymentSettlement.objects.create( + payment_date=payment_datetime.date(), + payment_method=payment_method, + description=description[:140], + person=person, + amount=total_transactions_price, + created_by=created_by + ) + + # Save the settlement reference in all transactions + for transaction in transactions: + transaction.settlement = settlement + transaction.save() + + # If transactions come out to a non-zero amount, add a SettlementManualPaymentTransaction for + # the total transaction amount to cancel out the balance of those transactions + if total_transactions_price != 0: + settlement_transaction = SettlementManualPaymentTransaction( + date=payment_datetime, price=-total_transactions_price, person=person, + description=description[:200], settlement=settlement, added_by=created_by + ) + settlement_transaction.save() + # Register this manual payment as the payment transaction for the settlement + settlement.manual_payment_transaction = settlement_transaction + settlement.save() + return settlement + + +def event_summary(person, event): + language = person.preferred_language + if language == "en" and event.summary_en: + summ = event.summary_en + else: + summ = event.summary_nl + if event.cancelled: + summ = f"[CANCELLED] {summ}" + return summ + + +def forwards(apps, schema_editor): + """Convert any old cash payments for activity participation to Personal Tab ActivityTransactions.""" + Participation = apps.get_model('calendar', 'Participation') + ActivityTransaction = apps.get_model('personal_tab', 'ActivityTransaction') + ManualPaymentSettlement = apps.get_model('personal_tab', 'ManualPaymentSettlement') + SettlementManualPaymentTransaction = apps.get_model('personal_tab', 'SettlementManualPaymentTransaction') + PaymentMethod = apps.get_model('personal_tab', 'PaymentMethod') + + all_participations_count = Participation.objects.all().count() + cash_participations = Participation.objects.filter(payment_method='C').order_by('pk') + cash_participation_count = cash_participations.count() + unpaid_cash_participation_count = Participation.objects.filter(payment_method='C', cash_payment_made=False).count() + logger.info('Migrating unpaid cash payments for activity participation to personal tab ActivityTransactions...') + logger.info('Total unpaid cash: {}, total cash: {}, total participations: {}'.format( + unpaid_cash_participation_count, cash_participation_count, all_participations_count + )) + try: + cash_payment_method = PaymentMethod.objects.get(name_en='Cash') + logger.info(f'Using existing Cash payment method with PK "{cash_payment_method.pk}...') + except PaymentMethod.DoesNotExist: + # No cash payment type exists, but we need one to link the new manual payment settlement to. Create one. + cash_payment_method = PaymentMethod.objects.create( + name_en="Cash", + name_nl="Contant", + description_en="Cash payment. Money goes into the brown cashbox and the payment is written down on the brown cashbox list with the student number.", + description_nl="Contante betaling. Geld gaat in de bruine kas en de betaling wordt genoteerd op de bruine kaslijst met het studentnummer.", + visible=True, visible_activities=True, visible_memberships=True + ) + logger.info(f'No cash payment type existed. Created a new one with PK "{cash_payment_method.pk}...') + for i, participation in enumerate(cash_participations): + # Translate any description strings to the user's preferred language + with translation.override(participation.person.preferred_language): + # ActivityTransaction creation code below adapted from + # `personal_tab.transactions.participation_transaction` as it was on 2026-07-21. + + # Take the beginning date/time as the date of the transaction. + price, with_enrollment_options = calculate_costs(apps=apps, participation=participation) + logger.info(f'- [{i+1}/{cash_participation_count}] {participation.id} - {incomplete_name(participation.person)} for {participation.event.summary_en} on {participation.event.begin.date()}') + + # Create the new ActivityTransaction. + activity_transaction = ActivityTransaction.objects.create( + price=price, + description=_("Manual payment required for enrollment to {event} on {date} (migrated)").format( + event=event_summary(participation.person, participation.event), + date=participation.event.begin.date(), + ), + participation=participation, + event=participation.event, + person=participation.person, + date=participation.event.begin, + with_enrollment_options=with_enrollment_options, + added_by=participation.added_by, + ) + + if participation.cash_payment_made: + # Also create a counterbalancing settlement payment, because the original payment was already made. + create_settlement_for_transactions( + ManualPaymentSettlement, + SettlementManualPaymentTransaction, + transactions=[activity_transaction], + payment_method=cash_payment_method, + person=participation.person, + description=_("Manual payment for enrollment to {event} on {date} (migrated)").format( + event=event_summary(participation.person, participation.event), + date=participation.event.begin.date(), + ), + payment_datetime=participation.event.begin, + created_by=participation.added_by, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar', '0008_alter_event_participants'), + ('personal_tab', '0015_personaltabsettlement_and_more'), + ('activities', '0013_activity_enrollment_private') + ] + + operations = [ + # Convert any old unpaid cash payments for activity participation to Personal Tab transactions. + migrations.RunPython(forwards), + migrations.RemoveField( + model_name='participation', + name='cash_payment_made', + ), + migrations.RemoveField( + model_name='participation', + name='payment_method', + ), + ] diff --git a/amelie/calendar/models.py b/amelie/calendar/models.py index d224ab40..ff985cab 100644 --- a/amelie/calendar/models.py +++ b/amelie/calendar/models.py @@ -1,5 +1,7 @@ -import itertools import uuid +from decimal import Decimal +from typing import List, Tuple, Optional + import pytz import logging @@ -7,10 +9,12 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import RegexValidator, URLValidator -from django.db import models +from django.db import models, transaction from django.db.models.signals import post_save, post_delete from django.dispatch import receiver +from django.utils import translation from django.utils.translation import get_language +from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _l from amelie.files.models import Attachment @@ -18,21 +22,14 @@ from amelie.calendar.tasks import send_participation_callback from amelie.members.models import Committee, Person from amelie.personal_tab.models import ActivityTransaction +from amelie.personal_tab.transactions import get_transaction_payment_status from amelie.tools.const import TaskPriority class Participation(models.Model): - class PaymentMethodChoices(models.TextChoices): - NONE = '-', _l('No payment') - CASH = 'C', _l('Cash at the board/committee') - AUTHORIZATION = 'M', _l('Mandate') - person = models.ForeignKey(Person, verbose_name=_l('Person'), on_delete=models.PROTECT) event = models.ForeignKey('Event', verbose_name=_l('Activities'), on_delete=models.PROTECT) remark = models.TextField(blank=True, verbose_name=_l('Remarks')) - - payment_method = models.CharField(max_length=1, choices=PaymentMethodChoices.choices, verbose_name=_l('Method of payment')) - cash_payment_made = models.BooleanField(verbose_name=_l("Cash payment made"), default=False) waiting_list = models.BooleanField(verbose_name=_l("On waiting list"), default=False) added_on = models.DateTimeField(verbose_name=_l('Added on'), auto_now_add=True, null=True, blank=True) @@ -51,10 +48,48 @@ def __str__(self): else: return '(%s -> %s)' % (self.person, _l('deleted activity')) - def has_paid(self): - return not (self.payment_method == Participation.PaymentMethodChoices.CASH and self.cash_payment_made == False) + def is_free(self) -> bool: + """ + Returns if this participation is free and thus requires no payment. + """ + return self.calculate_costs()[0] == 0 - def calculate_costs(self): + def is_paid(self): + """ + Participation is paid if it costs nothing, or if the newest ActivityTransaction that exists for it is paid. + """ + if self.is_free(): + return True + else: + # The latest (by creation time) positive ActivityTransaction for this participation is the one that should be paid. + # Other unpaid ones can exist if someone un/re-enrolled for the activity, + # but they should all be compensated by a negative ActivityTransaction, thus they can be ignored. + + # Newest (by creation date) positive (or 0) price transaction + current_transaction = self.activitytransaction_set.filter(price__gte=0).order_by('-added_on', '-pk').first() + if current_transaction is not None: + return current_transaction.is_paid() + else: + # Should not happen, but make a note of it in the logs if it does, and return that the participation is not paid. + logging.getLogger(__name__).exception(f"Participation {self.pk} has no ActivityTransaction linked to it!") + return False + + def payment_details(self): + """ + Returns information on how this participation was paid, or how it might probably be paid in the future. + Used mainly on the activity details page to quickly show the payment status and method of a Participation. + + If the participation is free, it returns ('free', None). + Else, see the docstring of `personal_tab.transactions.get_transaction_payment_status`. + """ + # If the participation is free, it returns None. + if self.is_free(): + return 'free', None + + current_transaction = self.activitytransaction_set.filter(price__gte=0).order_by('-added_on', '-pk').first() + return get_transaction_payment_status(_transaction=current_transaction, mandate_type='activities') + + def calculate_costs(self) -> Tuple[Decimal, bool]: from amelie.activities.models import Activity # For now only Activities! @@ -63,12 +98,53 @@ def calculate_costs(self): except: raise NotImplementedError("Non-activities are not yet supported!") - prices_extra = (enrollment_option_answer.get_price_extra() for enrollment_option_answer in - self.enrollmentoptionanswer_set.all()) + # List of extra costs per enrollment option + prices_extra: List[Decimal] = [ + enrollment_option_answer.get_price_extra() for enrollment_option_answer in self.enrollmentoptionanswer_set.all() + ] + # Calculate and return total costs, and if there were any additional costs due to enrollment options. + total_costs = activity.price + sum(prices_extra) + return total_costs, len(prices_extra) > 0 - with_enrollment_option, prices_extra = _has_next(prices_extra) + @property + def unpaid_transactions(self): + return [t for t in self.activitytransaction_set.all() if not t.is_paid()] + + @property + def to_be_paid_costs(self): + """ + Calculate the open costs for this participation, considering all transactions that relate to it. + """ + return sum(t.price for t in self.unpaid_transactions) or Decimal("0.00") - return activity.price + sum(prices_extra), with_enrollment_option + @transaction.atomic + def mark_as_paid(self, payment_method, actor: Optional[Person] = None): + """ + Mark any unpaid ActivityTransaction(s) for this participation as paid, with the given payment method. + """ + # Avoid circular import + from amelie.personal_tab.models import ManualPaymentSettlement + + # Get the ActivityTransaction(s) for this Participation that still need to be paid + ats = self.unpaid_transactions + if not ats: + return None + + # Override language to get the description strings in the person's preferred language + with translation.override(self.person.preferred_language): + # Create a ManualPayment settlement with the given payment method to mark the activity transactions as paid. + description = _("Payment for enrollment to {event} on {date}").format( + event=self.event.summary_en, date=self.event.begin.date() + ) + settlement = ManualPaymentSettlement.create_for_transactions( + transactions=ats, + payment_method=payment_method, + person=self.person, + settlement_description=description, + payment_description=description, + created_by=actor + ) + return settlement @receiver(post_save, sender=Participation) @@ -210,10 +286,3 @@ def description_short(self): if len(total_string) > char_limit: total_string = total_string[:char_limit] + '...' return total_string - -def _has_next(iterable): - try: - first = next(iterable) - except StopIteration: - return False, [] - return True, itertools.chain([first], iterable) diff --git a/amelie/calendar/participation_urls.py b/amelie/calendar/participation_urls.py deleted file mode 100644 index b9d372b5..00000000 --- a/amelie/calendar/participation_urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import re_path - -from amelie.calendar import views - -app_name = 'calendar' - -urlpatterns = [ - re_path(r'^(?P\d+)/pay/', views.PayParticipationView.as_view(), name='pay_participation'), -] diff --git a/amelie/calendar/views.py b/amelie/calendar/views.py deleted file mode 100644 index 408d1d90..00000000 --- a/amelie/calendar/views.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.conf import settings -from django.contrib import messages -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone -from django.utils.translation import gettext_lazy as _l -from django.views.generic import TemplateView - -from amelie.calendar.models import Participation -from amelie.tools.mixins import RequireBoardMixin, RequireCommitteeMixin - - -class PayParticipationView(RequireCommitteeMixin, TemplateView): - template_name = "confirm_payment.html" - abbreviation = settings.ROOM_DUTY_ABBREVIATION - - def get_context_data(self, **kwargs): - context = super(PayParticipationView, self).get_context_data() - participation = get_object_or_404(Participation, pk=self.kwargs['pk']) - context['obj'] = participation - return context - - def post(self, request, *args, **kwargs): - participation = get_object_or_404(Participation, pk=self.kwargs['pk']) - if 'yes' in request.POST: - participation.cash_payment_made = True - remark = _l( - f"Payment registered on {timezone.now().strftime('%d-%m-%Y')} by {request.user.person.incomplete_name()}") - participation.remark += f" {remark}" - participation.save() - messages.success(request, _l("Cash payment registered!")) - return redirect('activities:activity', participation.event_id) - - diff --git a/amelie/data_export/exporters/amelie.py b/amelie/data_export/exporters/amelie.py index 3857aff3..43bcc9d0 100644 --- a/amelie/data_export/exporters/amelie.py +++ b/amelie/data_export/exporters/amelie.py @@ -8,7 +8,8 @@ from amelie.claudia.models import Mapping, Timeline, Event as ClaudiaEvent from amelie.data_export.exporters.exporter import DataExporter from amelie.personal_tab.models import ReversalTransaction, DebtCollectionTransaction, CustomTransaction, \ - CookieCornerTransaction, ContributionTransaction, AlexiaTransaction, ActivityTransaction + CookieCornerTransaction, ContributionTransaction, AlexiaTransaction, ActivityTransaction, \ + SettlementManualPaymentTransaction, SettlementExtraBalanceTransaction, ManualPaymentSettlement class AmelieDataExporter(DataExporter): @@ -126,10 +127,30 @@ def export_calendar(self): for enrollment in person_enrollments: enrollment_data = { 'remark': str(enrollment.remark), - 'payment_method': str(enrollment.get_payment_method_display()), 'enrollment_time': str(enrollment.added_on), 'enrollment_options': [], + 'payment': {} } + if enrollment.is_paid(): + # Payment details + payment_status, payment_details = enrollment.payment_details() + payment_method = None + if payment_status == "reversed" or (payment_status == "paid" and payment_details.settlement_type == "INSTR"): + payment_method = { + "name": "Direct debit", + "date": payment_details.batch.execution_date, + "amount": payment_details.amount + } + elif payment_status == "paid" and payment_details.settlement_type == "MANUAL": + payment_method = { + "name": payment_details.payment_method.name, + "date": payment_details.payment_date, + "amount": payment_details.amount + } + enrollment_data['payment'] = { + 'status': payment_status, + 'method': payment_method + } if hasattr(enrollment, 'event'): enrollment_data['event'] = "{} ({} - {})".format(enrollment.event.summary, enrollment.event.begin, enrollment.event.end) @@ -255,13 +276,27 @@ def export_member(self): 'membership_type': str(membership.type), 'year': membership.year, 'ended_prematurely_on': str(membership.ended) if membership.ended else None, - 'payment': None, + 'payment': {} } - if hasattr(membership, 'payment') and membership.payment: + if membership.is_paid(): + # Payment details + payment_status, payment_details = membership.payment_details() + payment_method = None + if payment_status == "reversed" or (payment_status == "paid" and payment_details.settlement_type == "INSTR"): + payment_method = { + "name": "Direct debit", + "date": payment_details.batch.execution_date, + "amount": payment_details.amount + } + elif payment_status == "paid" and payment_details.settlement_type == "MANUAL": + payment_method = { + "name": payment_details.payment_method.name, + "date": payment_details.payment_date, + "amount": payment_details.amount + } membership_data['payment'] = { - 'date': str(membership.payment.date), - 'payment_type': str(membership.payment.payment_type), - 'price': str(membership.payment.amount) + 'status': payment_status, + 'method': payment_method } person_memberships.append(membership_data) member_data['memberships'] = person_memberships @@ -376,7 +411,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), 'event': str(transaction.event), 'participation': str(transaction.participation), @@ -387,7 +422,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), 'alexia_id': transaction.transaction_id, } for transaction in AlexiaTransaction.objects.filter(person=person)], @@ -396,7 +431,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), 'membership': str(transaction.membership) } for transaction in ContributionTransaction.objects.filter(person=person)], @@ -405,7 +440,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), 'article': str(transaction.article), 'amount': str(transaction.amount), @@ -415,7 +450,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), } for transaction in CustomTransaction.objects.filter(person=person)], 'debt_collection_transactions': [{ @@ -423,7 +458,7 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), } for transaction in DebtCollectionTransaction.objects.filter(person=person)], 'reversal_transactions': [{ @@ -431,10 +466,26 @@ def export_personal_tab(self): 'price': str(transaction.price), 'description': str(transaction.description), 'discount': str(transaction.discount) if transaction.discount else None, - 'debt_collection': str(transaction.debt_collection) if transaction.debt_collection else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, 'added_on': str(transaction.added_on), 'reversal': str(transaction.reversal), } for transaction in ReversalTransaction.objects.filter(person=person)], + 'manual_payment_transactions': [{ + 'date': str(transaction.date), + 'price': str(transaction.price), + 'description': str(transaction.description), + 'discount': str(transaction.discount) if transaction.discount else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, + 'added_on': str(transaction.added_on), + } for transaction in SettlementManualPaymentTransaction.objects.filter(person=person)], + 'extra_balance_transactions': [{ + 'date': str(transaction.date), + 'price': str(transaction.price), + 'description': str(transaction.description), + 'discount': str(transaction.discount) if transaction.discount else None, + 'settlement': str(transaction.settlement) if transaction.settlement else None, + 'added_on': str(transaction.added_on), + } for transaction in SettlementExtraBalanceTransaction.objects.filter(person=person)], }, 'rfid_cards': [{ 'code': str(card), @@ -478,6 +529,12 @@ def export_personal_tab(self): } if hasattr(instruction, 'reversal') else None, } for instruction in authorization.instructions.all()], } for authorization in person.authorization_set.all()], + 'manual_payment_settlements': [{ + 'reference': str(manual_payment.payment_reference()), + 'description': str(manual_payment.description), + 'amount': str(manual_payment.amount), + 'payment_date': str(manual_payment.payment_date), + } for manual_payment in ManualPaymentSettlement.objects.filter(person=person)], } return personal_tab_data, [] diff --git a/amelie/members/admin.py b/amelie/members/admin.py index 0dad9916..26860dbb 100644 --- a/amelie/members/admin.py +++ b/amelie/members/admin.py @@ -1,24 +1,11 @@ from django.contrib import admin -from amelie.members.models import Department, Payment, PaymentType, Committee, CommitteeCategory, Dogroup, \ +from amelie.members.models import Department, Committee, CommitteeCategory, Dogroup, \ DogroupGeneration, Faculty, Function, Membership, MembershipType, Employee, Person, Student, Study, \ StudyPeriod, Association, Preference, PreferenceCategory, UnverifiedEnrollment from amelie.tools.admin import NameAdmin, AbbreviationAdmin, AbbreviationTypeAdmin -class PaymentAdmin(admin.ModelAdmin): - list_display = ('id', 'membership', 'payment_type', 'date', 'amount') - search_fields = ('membership__member__first_name', 'membership__member__last_name',) - list_filter = ('payment_type', 'amount', 'membership__type', 'membership__year',) - date_hierarchy = 'date' - raw_id_fields = ('membership',) - -class PaymentTypeAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'description', 'visible') - list_filter = ('visible',) - search_fields = ('name', 'description') - - class CommitteeAdmin(admin.ModelAdmin): list_display = ('id', 'name', 'abbreviation', 'category', 'superuser',) search_fields = ('name', 'abbreviation') @@ -93,12 +80,10 @@ class StudyAdmin(admin.ModelAdmin): admin.site.register(Function, FunctionAdmin) admin.site.register(Committee, CommitteeAdmin) admin.site.register(CommitteeCategory, NameAdmin) -admin.site.register(Payment, PaymentAdmin) admin.site.register(Membership, MembershipAdmin) admin.site.register(Employee, EmployeeAdmin) admin.site.register(StudyPeriod, StudyPeriodAdmin) admin.site.register(Student, StudentAdmin) -admin.site.register(PaymentType, PaymentTypeAdmin) admin.site.register(MembershipType, MembershipTypeAdmin) admin.site.register(Person, PersonAdmin) admin.site.register(Preference, PreferenceAdmin) diff --git a/amelie/members/ajax_views.py b/amelie/members/ajax_views.py index ab38cd4b..fa69b703 100644 --- a/amelie/members/ajax_views.py +++ b/amelie/members/ajax_views.py @@ -2,25 +2,30 @@ from datetime import date import re +from typing import Optional, Dict from django.conf import settings +from django.db import transaction from django.forms.models import inlineformset_factory -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils import translation +from django.utils.translation import gettext as _ from django.views.generic import FormView, TemplateView from documenso_sdk import DocumensoError from amelie.members.forms import CommitteeForm, FunctionForm, MembershipEndForm, MembershipForm, \ - MandateEndForm, MandateForm, EmployeeForm, PersonPaymentForm, PersonStudyForm, PersonPreferencesForm, \ - SaveNewFirstModelFormSet, StudentForm, SignatureRequestForm -from amelie.members.models import Payment, Committee, Function, Membership, Employee, PaymentType, Person, Student, \ + MandateEndForm, MandateForm, EmployeeForm, PersonStudyForm, PersonPreferencesForm, \ + SaveNewFirstModelFormSet, StudentForm, SignatureRequestForm, MembershipManualPaymentForm +from amelie.members.models import Committee, Function, Membership, Employee, Person, Student, \ StudyPeriod, Preference, PreferenceCategory from amelie.members.query_views import filter_member_list_public -from amelie.personal_tab.models import Authorization +from amelie.personal_tab.models import Authorization, ManualPaymentSettlement, PaymentMethod, BadBIC from amelie.personal_tab.pos_views import require_cookie_corner_pos from amelie.tools.decorators import require_ajax, require_board, require_actief, require_committee -from amelie.tools.mixins import RequireCommitteeMixin +from amelie.tools.mixins import RequireCommitteeMixin, RequireAjaxMixin def person_data(request, obj, type, form_type, view_template=None, edit_template=None, *args, **kwargs): @@ -118,24 +123,49 @@ def person_study_new(request, id): @require_ajax @require_committee(settings.ROOM_DUTY_ABBREVIATION) -def person_payments(request, id, membership): - obj = get_object_or_404(Person, id=id) +def person_membership_payments(request, id, membership): + """ + Shows a table of ContributionTransasctions for this membership, and their payment states. + """ + person = get_object_or_404(Person, id=id) membership = get_object_or_404(Membership, id=membership) - if request.method == "GET": - if 'original' in request.GET: - return render(request, "person_membership.html", locals()) - form = PersonPaymentForm() - return render(request, "person_payment.html", locals()) - elif request.method == "POST": - form = PersonPaymentForm(request.POST) - if form.is_valid(): - payment = Payment(membership=membership, payment_type=form.cleaned_data['method'], - amount=membership.type.price, date=form.cleaned_data["date"]) - payment.save() - return render(request, "person_membership.html", locals()) + transactions = membership.contributiontransaction_set.all() + context = { + 'person': person, + 'membership': membership, + 'transactions': transactions, + 'status_msg': request.GET.get('msg') + } + return render(request, "person_membership_payments.html", context) + + +class MembershipCreatePaymentView(RequireAjaxMixin, RequireCommitteeMixin, FormView): + template_name = "person_membership_create_payment.html" + abbreviation = settings.ROOM_DUTY_ABBREVIATION + form_class = MembershipManualPaymentForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['person'] = get_object_or_404(Person, id=self.kwargs['id']) + context['membership'] = get_object_or_404(Membership, id=self.kwargs['membership']) + context['transactions'] = [t for t in context['membership'].contributiontransaction_set.all() if not t.is_paid()] + context['transactions_total'] = sum(t.price for t in context['transactions']) + return context + + def get_success_url(self, query: Optional[Dict] = None): + return reverse('members:person_membership_payments', kwargs={ + 'id': self.kwargs['id'], 'membership': self.kwargs['membership'] + }, query=query) + + def form_valid(self, form): + # Get the transactions that need to be paid. + membership = get_object_or_404(Membership, id=self.kwargs['membership']) + payment_method = form.cleaned_data['payment_method'] + settlement = membership.mark_as_paid(payment_method=payment_method, actor=self.request.person) + if settlement: + return HttpResponseRedirect(self.get_success_url(query={'msg': 'created'})) else: - response = render(request, "person_payment.html", locals()) - return response + return HttpResponseRedirect(self.get_success_url(query={'msg': 'no_transactions'})) @require_ajax @@ -152,9 +182,12 @@ def person_membership_new(request, id): if request.method == "POST": form = MembershipForm(request.POST) if form.is_valid(): - membership = form.save(commit=False) - membership.member = obj - membership.save() + with transaction.atomic(): + # Save the membership and create a ContributionTransaction on the person's tab. + membership = form.save(commit=False) + membership.member = obj + membership.save() + membership.create_contribution_transaction() return render(request, "person_membership.html", locals()) else: response = render(request, "person_new_membership.html", locals()) @@ -167,20 +200,43 @@ def person_membership_new(request, id): @require_ajax @require_committee(settings.ROOM_DUTY_ABBREVIATION) def person_membership_end(request, id): - obj = get_object_or_404(Person, id=id) - membership = obj.membership_set.latest('year', 'pk') + obj: Person = get_object_or_404(Person, id=id) + membership: Membership = obj.membership_set.latest('year', 'pk') if request.method == "POST": form = MembershipEndForm(request.POST, instance=membership) - if form.is_valid(): - if form.cleaned_data['payment_needed'] == False and not Payment.objects.filter(membership=membership).exists(): - # If payment is not needed and payment does not exist yet, create one - # PaymentType is 10: "Voortijdige beëindiging, betaling niet noodzakelijk." - Payment.objects.create(membership=membership, payment_type=PaymentType.objects.get(pk=10), amount=membership.type.price, date=form.cleaned_data['ended']) - form.save() - return render(request, "person_membership.html", locals()) - else: - response = render(request, "person_end_membership.html", locals()) - return response + with transaction.atomic(): + if form.is_valid(): + if form.cleaned_data['payment_needed'] == False and not membership.is_paid(): + # If payment is no longer needed and payment does not exist yet, create a settlement for all open + # ContributionTransactions of this membership, with payment method "Early termination" (configured in settings) + transactions = [t for t in membership.contributiontransaction_set.all() if not t.is_paid()] + payment_method = PaymentMethod.objects.get(pk=settings.MEMBERS_EARLY_TERMINATION_PAYMENT_METHOD_ID) + + # Create a ManualPaymentSettlement with the selected payment method + if transactions: + # Translate the description to the user's preferred language + with translation.override(membership.member.preferred_language): + settlement_description = _( + "Early termination of membership {membership_type} ({begin_year}/{end_year})").format( + membership_type=membership.type.name, + begin_year=membership.year, + end_year=(membership.year + 1) + ) + ManualPaymentSettlement.create_for_transactions( + transactions=transactions, + payment_method=payment_method, + person=membership.member, + settlement_description=settlement_description, + payment_description=settlement_description, + created_by=request.person, + ) + else: + raise ValueError(f"Tried to create early termination payment for unpaid membership {membership.pk} but no transactions exist!") + form.save() + return render(request, "person_membership.html", locals()) + else: + response = render(request, "person_end_membership.html", locals()) + return response else: form = MembershipEndForm(instance=membership) return render(request, "person_end_membership.html", locals()) @@ -246,7 +302,13 @@ def person_mandate_new(request, id): obj = get_object_or_404(Person, id=id) if request.method == "POST": form = MandateForm(request.POST) + if form.is_valid(): + # If the filled BIC is in the Bad BIC list, ask for a confirmation. + if BadBIC.objects.filter(bic=form.cleaned_data['bic']).exists(): + if 'confirm' not in request.POST: + return render(request, "person_confirm_mandate.html", locals()) + mandate: Authorization = form.save(commit=False) mandate.person = obj mandate.save() diff --git a/amelie/members/forms.py b/amelie/members/forms.py index 9f5bdcef..7a2699ca 100644 --- a/amelie/members/forms.py +++ b/amelie/members/forms.py @@ -22,10 +22,10 @@ from amelie.tools.const import TaskPriority from amelie.iamailer.mailtask import MailTask, Recipient -from amelie.members.models import Department, PaymentType, Committee, CommitteeCategory, DogroupGeneration, \ +from amelie.members.models import Department, Committee, CommitteeCategory, DogroupGeneration, \ Function, Membership, MembershipType, Employee, Person, Student, Study, StudyPeriod, Preference, \ LANGUAGE_CHOICES, UnverifiedEnrollment -from amelie.personal_tab.models import Authorization, AuthorizationType +from amelie.personal_tab.models import Authorization, AuthorizationType, PaymentMethod, BadBIC from amelie.tools.logic import current_academic_year_with_holidays, current_association_year from amelie.tools.validators import CheckDigitValidator from amelie.tools.widgets import DateSelector, MemberSelect @@ -286,11 +286,6 @@ class Meta: fields = ("study", "begin", "dogroup",) -class PersonPaymentForm(forms.Form): - date = forms.DateField(initial=datetime.date.today()) - method = forms.ModelChoiceField(PaymentType.objects.filter(visible=True)) - - class MembershipForm(forms.ModelForm): year = forms.IntegerField(initial=current_association_year, widget=widgets.RadioSelect()) type = forms.ModelChoiceField(MembershipType.objects.filter(active=True), label=_l('Type')) @@ -305,6 +300,13 @@ def __init__(self, *args, **kwargs): (current_association_year() + 1, _l('Upcoming association year')),) +class MembershipManualPaymentForm(forms.Form): + payment_method = forms.ModelChoiceField( + queryset=PaymentMethod.objects.filter(visible_memberships=True), + label=_l('Payment method') + ) + + class MembershipEndForm(forms.ModelForm): ended = forms.DateField(widget=widgets.RadioSelect) payment_needed = forms.BooleanField( @@ -324,6 +326,7 @@ def __init__(self, *args, **kwargs): (date(current_association_year() + 1, 7, 1), _l('At the end of the association year')),) + class SignatureRequestForm(forms.Form): send_check = forms.BooleanField(label=_l('Send the signature request'), required=True) @@ -356,7 +359,7 @@ def clean(self): if not cleaned_data['iban']: raise forms.ValidationError(_l('An IBAN is required.')) - cleaned_data['iban'], cleaned_data['bic'] = clean_iban_and_bic(cleaned_data.get('iban'), cleaned_data.get('bic')) + cleaned_data['iban'], cleaned_data['bic'] = clean_iban_and_bic(cleaned_data.get('iban'), cleaned_data.get('bic'), ignore_bad_bic=True) return cleaned_data @@ -660,7 +663,7 @@ def clean_student_number(self): return self.cleaned_data["student_number"] -def clean_iban_and_bic(iban, bic): +def clean_iban_and_bic(iban, bic, ignore_bad_bic=False): iban = str(iban).strip() bic = str(bic).strip() @@ -676,11 +679,13 @@ def clean_iban_and_bic(iban, bic): raise forms.ValidationError(_l('BIC has to be entered for foreign bankaccounts.')) elif iban[4:8] in settings.COOKIE_CORNER_BANK_CODES: bic = settings.COOKIE_CORNER_BANK_CODES[iban[4:8]] - # BIC is known, so cleaning is done - return iban, bic else: raise forms.ValidationError(_l('BIC could not be generated, please enter it yourself.')) + # If the BIC is in the Bad BICs list, and it can't be ignored, do not accept it. + if not ignore_bad_bic and BadBIC.objects.filter(bic=bic).exists(): + raise forms.ValidationError(_l('The bank associated with your BIC does not accept SEPA Direct Debits.')) + if bic[4:6] != iban[:2]: raise forms.ValidationError(_l('The BIC country does not match the IBAN country.')) @@ -711,5 +716,5 @@ def clean_iban_and_bic(iban, bic): raise IOError(u"Reading file at {} failed (run 'python manage.py update_bic_csv' to create it if it does not exist): {}" .format(os.path.join(settings.MEDIA_ROOT, 'data/bic_list.csv'), e)) - # If the BIC is not in the list, the BIC is not from a SEPA country - raise forms.ValidationError(_l("BIC is not from a SEPA country")) + # If the BIC is not in the list, the BIC bank does not accept SEPA Direct Debits + raise forms.ValidationError(_l("The bank associated with your BIC does not accept SEPA Direct Debits.")) diff --git a/amelie/members/management/commands/yeartransfer.py b/amelie/members/management/commands/yeartransfer.py index f6e3efdd..afc3be5b 100644 --- a/amelie/members/management/commands/yeartransfer.py +++ b/amelie/members/management/commands/yeartransfer.py @@ -1,13 +1,13 @@ import datetime -from decimal import Decimal from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone from django.utils.encoding import smart_str from amelie.tools.const import TaskPriority from amelie.iamailer.mailtask import MailTask from amelie.members.management.commands.dataupdatemail import dataupdate_context -from amelie.members.models import Payment, PaymentType, Membership, MembershipType +from amelie.members.models import Membership, MembershipType from amelie.personal_tab.debt_collection import authorization_contribution from amelie.tools.mail import PersonRecipient @@ -47,7 +47,6 @@ def handle(self, *args, **options): except ValueError: raise CommandError("Type numbers for years...") - # These memberships are automatically prolonged without change, # unless the membership was terminated by the member yearmembershiptypes_text = [ @@ -136,9 +135,6 @@ def handle(self, *args, **options): priority=TaskPriority.LOW ) - paymenttype_continuation = PaymentType.objects.filter(name='Studielang (vervolg)')[0] - paymenttype_sponsored = PaymentType.objects.filter(name='Sponsoring')[0] - if prev_prolong: memberships = Membership.objects.filter(year=current_year, type__needs_verification=True) else: @@ -153,16 +149,12 @@ def handle(self, *args, **options): next_type = next_membership_type[membership.type] prolong = False - payment_type = None if membership.type in yearmembershiptypes: prolong = True - if next_type.price == Decimal("0.00"): - payment_type = paymenttype_sponsored elif membership.type in studylongmembershiptypes and membership.is_verified(): prolong = True - payment_type = paymenttype_continuation # Check if there actually is a new membership already, # and set prolong to True if it was not yet, so they get a prolongation mail instead of a discontinuation mail. @@ -193,23 +185,16 @@ def handle(self, *args, **options): context=context)) if commit: - l, created = Membership.objects.get_or_create(member=membership.member, type=next_type, - year=new_year) - if not created: + m, created = Membership.objects.get_or_create( + member=membership.member, type=next_type, year=new_year + ) + if created: + # Create a ContributionTransaction on 07-01 for the membership, if necessary, so it will be paid for. + ct_date = timezone.make_aware(datetime.datetime(year=membership.year, month=7, day=1)) + m.create_contribution_transaction(date=ct_date) + else: errorfile.write(smart_str(f"{membership.member} ({membership.type}) was already enrolled for the new year\n")) - if payment_type: - if created: - b = Payment.objects.create(date=datetime.datetime(new_year, 7, 1), - membership=l, - payment_type=payment_type, - amount=next_type.price) - b.save() - else: - errorfile.write(smart_str(f"{membership.member} ({membership.type}) was already enrolled for the new year, no payment was created!\n")) - - l.save() - else: if not prev_prolong and membership.type != ENIAC_MEMBERSHIP: discontinuationmails_first.add_recipient(PersonRecipient(recipient=membership.member)) diff --git a/amelie/members/members_urls.py b/amelie/members/members_urls.py index d5734360..e5644361 100644 --- a/amelie/members/members_urls.py +++ b/amelie/members/members_urls.py @@ -3,7 +3,6 @@ from django.views.generic import RedirectView from amelie.members import ajax_views, query_views, views -from amelie.members.views import PaymentDeleteView urlpatterns = [ path('', query_views.query, name='query'), @@ -11,7 +10,6 @@ path('mailing/', query_views.send_mailing, name='send_mailing'), path('push/', query_views.SendNotification.as_view(), name='send_push'), path('data_export/', query_views.DataExport.as_view(), name='data_export'), - path('statistics/payments/', views.payment_statistics, name='statistics_payments'), path('statistics/', views.statistics, name='statistics'), path('//', views.person_view, name='person_view'), path('//edit/', views.person_edit, name='person_edit'), @@ -21,8 +19,9 @@ path('/ajax/employee/', ajax_views.person_employee, name='person_employee'), path('/ajax/preferences/', ajax_views.person_preferences, name='person_preferences'), path('/ajax/study/', ajax_views.person_study, name='person_study'), - path('/ajax/membership/payments//', ajax_views.person_payments, name='person_pay'), path('/ajax/membership/', ajax_views.person_membership, name='person_membership'), + path('/ajax/membership//payments/', ajax_views.person_membership_payments, name='person_membership_payments'), + path('/ajax/membership//payment_create/', ajax_views.MembershipCreatePaymentView.as_view(), name='person_membership_create_payment'), path('/ajax/membership/new/', ajax_views.person_membership_new, name='person_membership_new'), path('/ajax/membership/end/', ajax_views.person_membership_end, name='person_membership_end'), path('/ajax/membership//', ajax_views.person_membership, name='person_membership'), @@ -45,8 +44,6 @@ path('groupinfo/', views.person_groupinfo, name='person_groupinfo'), path('send_link_code//', views.person_send_link_code, name='send_oauth_link_code'), - path('payment//delete/', PaymentDeleteView.as_view(), name='payment_delete'), - path('birthdays/', views.birthdays, name='birthdays'), path('new/general/', views.RegisterNewGeneralWizardView.as_view(), name='person_new_general'), @@ -58,6 +55,7 @@ path('preregistration/', views.PreRegistrationStatus.as_view(), name='preregistration_status'), path('preregistration/print/', views.PreRegistrationPrintAll.as_view(), name='preregistration_print_all'), path('preregistration/print/dogroup/', views.PreRegistrationPrintDogroup.as_view(), name='preregistration_print_dogroup'), + path('registration/', views.RegistrationStatus.as_view(), name='registration_status'), path('registration_form///', views.registration_form, name='registration_form'), path('membership_form///', views.membership_form, name='membership_form'), diff --git a/amelie/members/migrations/0020_add_public_enrollment_preference.py b/amelie/members/migrations/0020_add_public_enrollment_preference.py index 90100757..b795002b 100644 --- a/amelie/members/migrations/0020_add_public_enrollment_preference.py +++ b/amelie/members/migrations/0020_add_public_enrollment_preference.py @@ -1,14 +1,13 @@ # Made by Conner v5.2.12 on 2026-05-06 08:41 from django.db import migrations -import time import logging + logger = logging.getLogger(__name__) -handler = logging.StreamHandler() -logger.addHandler(handler) logger.setLevel(logging.INFO) + def forwards(apps, schema_editor): Preference = apps.get_model("members", "Preference") pref = Preference.objects.filter(name='public_enrollment').first() diff --git a/amelie/members/migrations/0024_delete_payment_delete_paymenttype.py b/amelie/members/migrations/0024_delete_payment_delete_paymenttype.py new file mode 100644 index 00000000..976d3a29 --- /dev/null +++ b/amelie/members/migrations/0024_delete_payment_delete_paymenttype.py @@ -0,0 +1,299 @@ +# Written by Kevin Alberts on 2026-07-23 +import datetime +from datetime import timezone as tz +import logging +from decimal import Decimal + +from django.db import migrations +from django.db.models import Model +from django.utils import timezone, translation +from django.utils.translation import gettext as _ + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +# Based on Person.incomplete_name method +def incomplete_name(person): + first_name = person.first_name + if not person.first_name and person.initials: + first_name = person.initials + if person.last_name_prefix: + return ' '.join([first_name, person.last_name_prefix, person.last_name]) + else: + return ' '.join([first_name, person.last_name]) + + +# Based on ManualPaymentSettlement.create_settlement_for_transactions method. +def create_settlement_for_transactions(ManualPaymentSettlement, SettlementManualPaymentTransaction, + transactions, payment_method, person, description, + payment_datetime, created_by=None): + transactions = list(transactions) + if not transactions: + raise ValueError("No transactions given! Cannot create an empty manual payment") + + # Calculate the total settlement amount + total_transactions_price = sum(t.price for t in transactions) or Decimal("0.00") + + # Create a ManualPayment settlement with the given payment method. + settlement = ManualPaymentSettlement.objects.create( + payment_date=payment_datetime.date(), + payment_method=payment_method, + description=description[:140], + person=person, + amount=total_transactions_price, + created_by=created_by + ) + + # Save the settlement reference in all transactions + for transaction in transactions: + transaction.settlement = settlement + transaction.save() + + # If transactions come out to a non-zero amount, add a SettlementManualPaymentTransaction for + # the total transaction amount to cancel out the balance of those transactions + if total_transactions_price != 0: + settlement_transaction = SettlementManualPaymentTransaction( + date=payment_datetime, price=-total_transactions_price, person=person, + description=description[:200], settlement=settlement, added_by=created_by + ) + settlement_transaction.save() + # Register this manual payment as the payment transaction for the settlement + settlement.manual_payment_transaction = settlement_transaction + settlement.save() + return settlement + + +def membership_type_name(person, membership_type): + language = person.preferred_language + if language == "en" and membership_type.name_en: + return membership_type.name_en + else: + return membership_type.name_nl + + +def forwards(apps, schema_editor): + """ + Create ContributionTransactions for all memberships that do not have one, + and add a Manual Payments for all memberships that have a Payment that is not direct debit. + """ + Membership = apps.get_model('members', 'Membership') + PaymentType = apps.get_model('members', 'PaymentType') + ManualPaymentSettlement = apps.get_model('personal_tab', 'ManualPaymentSettlement') + ContributionTransaction = apps.get_model('personal_tab', 'ContributionTransaction') + ReversalTransaction = apps.get_model('personal_tab', 'ReversalTransaction') + SettlementManualPaymentTransaction = apps.get_model('personal_tab', 'SettlementManualPaymentTransaction') + PaymentMethod = apps.get_model('personal_tab', 'PaymentMethod') + logger.info(f'Creating ContributionTransactions and Manual Payments for any unpaid memberships and memberships that were not paid by Authorization...') + try: + internal_settlement_payment_method = PaymentMethod.objects.get(name_en='Internal settlement') + logger.info(f'Using existing Internal settlement payment method with PK "{internal_settlement_payment_method.pk}"...') + except PaymentMethod.DoesNotExist: + # No Internal settlement payment type exists, but we need one to link any settlements for reversed contributions to. Create one. + internal_settlement_payment_method = PaymentMethod.objects.create( + name_en="Internal settlement", + name_nl="Interne verrekening", + description_en="Internal settlement. These transactions cancel each other out to a zero balance.", + description_nl="Interne verrekening. Deze transacties zijn met elkaar verrekend tot een nulbalans.", + visible=True, visible_memberships=False, visible_activities=False + ) + logger.info(f'No Internal settlement payment type existed. Created a new one with PK "{internal_settlement_payment_method.pk}"...') + try: + pre_sepa_payment_method = PaymentMethod.objects.get(name_en='Pre-SEPA') + logger.info(f'Using existing Pre-SEPA payment method with PK "{pre_sepa_payment_method.pk}"...') + except PaymentMethod.DoesNotExist: + # No Pre-SEPA payment type exists, but we need one to link the new manual payment settlement to. Create one. + pre_sepa_payment_method = PaymentMethod.objects.create( + name_en="Pre-SEPA", + name_nl="Pre-SEPA", + description_en="Pre-SEPA payment. This payment was made before the introduction of SEPA authorizations.", + description_nl="Pre-SEPA-betaling. Deze betaling is gedaan voor de introductie van SEPA-machtigingen.", + visible=False, visible_memberships=False, visible_activities=False + ) + logger.info(f'No Pre-SEPA payment type existed. Created a new one with PK "{pre_sepa_payment_method.pk}"...') + + # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET + pre_sepa_begin_date = datetime.date(2013, 10, 31) + pre_sepa_transaction_date = datetime.datetime(2013, 10, 30, 22, 59, 59, tzinfo=tz.utc) + + # Create ContributionTransactions for all memberships that do not have one but need one, + # and create a Manual Payments for the memberships that have been paid. + membership_count = Membership.objects.count() + num_cts_created = 0 + num_settlements_created = 0 + num_left_unpaid = 0 + created_internal_settlements = 0 + try: + authorization_payment_type = PaymentType.objects.get(name="Annual authorization") # Annual authorization + except PaymentType.DoesNotExist: + authorization_payment_type = None + for i, membership in enumerate(Membership.objects.all().order_by('pk')): + # Translate any description strings to the user's preferred language + with translation.override(membership.member.preferred_language): + # If this concerns a paid membership (free memberships don't need any transactions) + if membership.type.price != 0: + if i % 1000 == 0: + logger.info(f'- [{i}/{membership_count}] Processing ID {membership.pk}...') + # ContributionTransactions need to be created for memberships that: + if ( + # - Are not paid and currently have no positive ContributionTransaction without a settlement + (not hasattr(membership, 'payment') and not membership.contributiontransaction_set.filter(price__gt=0, settlement=None).exists()) + or + # - Are paid with a payment type that is not Authorization (manual payments) + (hasattr(membership, 'payment') and membership.payment.payment_type != authorization_payment_type) + or + # - Are paid with an Authorization, but has no positive ContributionTransaction + # (if pre-sepa: that's ok, it will be marked paid later, if post-sepa: manually created authorization payment, wrong, will be left unpaid) + (hasattr(membership, 'payment') and membership.payment.payment_type == authorization_payment_type and not membership.contributiontransaction_set.filter(price__gt=0).exists()) + ): + # If this membership is paid, use the payment date as the date for the transaction, else, just guesstimate. + if hasattr(membership, 'payment') and membership.payment.date: + date = datetime.datetime.combine( + membership.payment.date, datetime.time(0, 0) + ).replace(tzinfo=timezone.get_default_timezone()) + else: + # 1st of September in the year of the membership. + date = datetime.datetime(membership.year, 9, 1, 0, 0, 0, tzinfo=tz.utc) + + # Create the contributiontransaction + ct = ContributionTransaction( + date=date, price=membership.type.price, person=membership.member, + membership=membership, settlement=None, + description=_("Contribution {membership_type} ({begin_year}/{end_year})").format( + membership_type=membership_type_name(membership.member, membership.type), + begin_year=membership.year, + end_year=(membership.year + 1) + ) + ) + ct.save() + num_cts_created += 1 + + # Negative ContributionTransactions should cancel out against a positive ReversalTransaction on the same date. + # If any unpaid negative transactions exist, find their corresponding Reversal and make a settlement so they are marked as paid. + unpaid_negative_transactions = membership.contributiontransaction_set.filter(price__lt=0, settlement=None) + for unpaid_negative_transaction in unpaid_negative_transactions: + # Try to find the corresponding unpaid ReversalTransaction + try: + reversal = ReversalTransaction.objects.get( + date=unpaid_negative_transaction.date, + price=-unpaid_negative_transaction.price, + person=unpaid_negative_transaction.person, + settlement=None + ) + except ReversalTransaction.DoesNotExist: + # Give up, either the reversal was already settled against something else or this is a different + # situation all together. The negative transaction will remain unpaid in the personal tab. + logger.info(f' - Membership {membership.pk} - Cannot find reversal for transaction {unpaid_negative_transaction.pk} ({unpaid_negative_transaction.price}) "{unpaid_negative_transaction.description}".') + continue + except ReversalTransaction.MultipleObjectsReturned: + # Give up, somehow there are multiple reversals on the exact same datetime. + # The negative transaction will remain unpaid in the personal tab. + logger.info(f' - Membership {membership.pk} - Found multiple reversals for transaction {unpaid_negative_transaction.pk} ({unpaid_negative_transaction.price}) "{unpaid_negative_transaction.description}".') + continue + + # Create a settlement that will cancel out the two transactions + logger.info(f' - Membership {membership.pk} - Cancelling transaction {unpaid_negative_transaction.pk} ({unpaid_negative_transaction.price}) against {reversal.pk} ({reversal.price}).') + create_settlement_for_transactions( + ManualPaymentSettlement, + SettlementManualPaymentTransaction, + transactions=[unpaid_negative_transaction, reversal], + payment_method=internal_settlement_payment_method, + person=membership.member, + description=_("Internal settlement for reversed contribution {membership_type} ({begin_year}/{end_year}) (migrated)").format( + membership_type=membership_type_name(membership.member, membership.type), + begin_year=membership.year, end_year=(membership.year + 1) + ), + payment_datetime=unpaid_negative_transaction.date, + created_by=None + ) + created_internal_settlements += 1 + + # If a payment exists for this membership, add any unpaid positive transactions to a ManualPayment. + # (the negative transactions cross off against positive ReversalTransactions that are not linked to the contribution) + if hasattr(membership, 'payment') and membership.payment.payment_type != authorization_payment_type: + unpaid_transactions = membership.contributiontransaction_set.filter(price__gt=0, settlement=None) + if unpaid_transactions.exists(): + # Must exist due to being created in migration personal_tab.0015_personaltabsettlement_and_more + payment_method = PaymentMethod.objects.get(name_en=membership.payment.payment_type.name) + if membership.payment.date: + transaction_date = datetime.datetime.combine( + membership.payment.date, datetime.time(0, 0) + ).replace(tzinfo=timezone.get_default_timezone()) + else: + # 1st of September in the year of the membership. + transaction_date = datetime.datetime( + membership.year, 9, 1, 0, 0, 0, tzinfo=tz.utc + ) + # Create a ManualPayment settlement with the same payment method as the old Payment. + create_settlement_for_transactions( + ManualPaymentSettlement, + SettlementManualPaymentTransaction, + transactions=unpaid_transactions, + payment_method=payment_method, + person=membership.member, + description=_("Manual payment for contribution {membership_type} ({begin_year}/{end_year}) (migrated)").format( + membership_type=membership_type_name(membership.member, membership.type), + begin_year=membership.year, end_year=(membership.year + 1) + ), + payment_datetime=transaction_date, + created_by=None + ) + num_settlements_created += 1 + # If the membership was paid with Annual Authorization, but an unpaid ContributionTransaction exists, + # then EITHER that payment was created manually after the Pre-SEPA date and is wrong. It needs to remain unpaid. + # OR it was created BEFORE the Pre-SEPA date, and it is an old transaction. It needs to be marked paid with the Pre-SEPA method. + elif hasattr(membership, 'payment') and membership.payment.payment_type == authorization_payment_type: + unpaid_transactions = membership.contributiontransaction_set.filter(price__gt=0, settlement=None) + if unpaid_transactions.exists(): + # Check if pre-sepa + date = membership.payment.date or datetime.date(membership.year, 9, 1) + if date < pre_sepa_begin_date: + # Correct, needs to be marked as paid + if membership.payment.date: + transaction_date = datetime.datetime.combine( + membership.payment.date, datetime.time(0, 0) + ).replace(tzinfo=timezone.get_default_timezone()) + else: + # Use the pre-sepa transaction date. + transaction_date = pre_sepa_transaction_date + # Create a ManualPayment settlement with the Pre-SEPA payment method. + create_settlement_for_transactions( + ManualPaymentSettlement, + SettlementManualPaymentTransaction, + transactions=unpaid_transactions, + payment_method=pre_sepa_payment_method, + person=membership.member, + description=_("Pre-SEPA payment for contribution {membership_type} ({begin_year}/{end_year}) (migrated)").format( + membership_type=membership_type_name(membership.member, membership.type), + begin_year=membership.year, end_year=(membership.year + 1) + ), + payment_datetime=transaction_date, + created_by=None + ) + num_settlements_created += 1 + else: + # Manually created after the pre-sepa date. Needs to be left unpaid. + num_left_unpaid += 1 + else: + num_left_unpaid += 1 + logger.info(f'- [DONE] Created {num_cts_created} ContributionTransactions, {num_settlements_created} settlements, settled {created_internal_settlements} reversals and left {num_left_unpaid} memberships untouched.') + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0023_documenso_id_signed_document'), + ('personal_tab', '0015_personaltabsettlement_and_more'), + ] + + operations = [ + # Create ContributionTransactions for all memberships that do not have one, + # and add a Manual Payments for all memberships that have a Payment that is not direct debit. + migrations.RunPython(forwards), + migrations.DeleteModel( + name='Payment', + ), + migrations.DeleteModel( + name='PaymentType', + ), + ] diff --git a/amelie/members/models.py b/amelie/members/models.py index 4aa52ebf..1214737e 100644 --- a/amelie/members/models.py +++ b/amelie/members/models.py @@ -2,14 +2,14 @@ import datetime import logging -import re import uuid import os +from decimal import Decimal from io import BytesIO from typing import Optional - from colorfield.fields import ColorField + from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError @@ -17,11 +17,13 @@ from django.core.validators import RegexValidator, MinLengthValidator, MaxLengthValidator, MaxValueValidator, \ MinValueValidator from django.db import models, transaction -from django.db.models import Q, F +from django.db.models import Q, F, Sum from django.db.models.signals import post_save, m2m_changed from django.template.defaultfilters import slugify from django.urls import reverse +from django.utils import translation, timezone from django.utils.translation import get_language +from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _l from documenso_sdk import EnvelopeDistributeResponse @@ -426,16 +428,25 @@ def is_employee(self): def is_student(self): return hasattr(self, 'student') + @property + def dogroup(self): + if self.is_student(): + for period in self.student.studyperiod_set.all().order_by('-begin'): + if period.dogroup: + return period.dogroup + return None + def is_active_member(self): return self.function_set.filter(end__isnull=True, committee__abolished__isnull=True).exists() is_active_member.boolean = True def is_member_strict(self): - member = self.membership_set.filter(Q(type__price=0) | Q(payment__isnull=False), - year=current_association_year()) - member = member.filter(models.Q(ended__isnull=True) | models.Q(ended__gt=datetime.date.today())) - return member.count() > 0 + memberships = self.membership_set.filter( + Q(ended__isnull=True) | Q(ended__gt=datetime.date.today()), + year=current_association_year() + ) + return any(m.type.price == 0 or m.is_paid() for m in memberships) is_member_strict.boolean = True @@ -521,6 +532,12 @@ def initials_last_name(self): else: return ' '.join([first_name, self.last_name]) + def sortable_name(self): + first_name = self.first_name + if not self.first_name and self.initials: + first_name = self.initials + return ', '.join([self.last_name, first_name]) + def current_committees(self): """ Returns a queryset with all committees this person is currently in. @@ -604,6 +621,16 @@ def has_mandate_other_payments(self): """ return self.has_mandate('other_payments') + def unpaid_transactions(self, transaction_type: Optional[str] = None): + if not transaction_type: + return self.transaction_set.filter(settlement=None) + if transaction_type not in ['activity', 'alexia', 'contribution', 'cookiecorner', 'custom', 'reversal']: + raise ValueError(_('Invalid transaction type')) + return self.transaction_set.filter(**{f'{transaction_type}transaction__isnull': False, f'{transaction_type}transaction__settlement': None}) + + def unpaid_transactions_total_cost(self, transaction_type: Optional[str] = None) -> Decimal: + return self.unpaid_transactions(transaction_type=transaction_type).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") + def oauth_consumer_set(self): consumers = {} for auth in self.oauth_authorization_set.order_by('-last_use').all(): @@ -841,22 +868,6 @@ class Meta(object): def __str__(self): return '{} (€{})'.format(self.name, self.price) -class PaymentType(models.Model): - """ - e.g. Cash or Debit transaction - """ - name = models.CharField(max_length=20, unique=True, verbose_name=_l('Name')) - description = models.TextField(verbose_name=_l('Description')) - visible = models.BooleanField(default=True, verbose_name=_l('Visible')) - - class Meta(object): - ordering = ['description'] - verbose_name = _l('way of payment') - verbose_name_plural = _l('ways of payment') - - def __str__(self): - return '%s' % self.description - class Student(models.Model): """ @@ -1005,11 +1016,99 @@ def get_as_pdf(self) -> bytes: pdf = buffer.getvalue() return pdf - def is_paid(self): - try: - return self.payment - except Payment.DoesNotExist: - return self.type.price == 0 + def is_free(self) -> bool: + """ + Returns if this membership is free and thus requires no payment. + """ + return self.type.price == 0 + + def is_paid(self) -> bool: + """ + A membership is paid if it costs nothing, or if all ContributionTransactions that exist for it are paid. + """ + if self.is_free(): + return True + else: + # ContributionTransactions are created when the membership is created but also when the + # direct debit collection is reversed. So you can have multiple, that all need to be paid. + return self.contributiontransaction_set.exists() and all(t.is_paid() for t in self.contributiontransaction_set.all()) + + def payment_transaction(self): + """ + Returns the most recently created ContributionTransaction for this membership. + """ + return self.contributiontransaction_set.order_by('-pk').first() + + def payment_details(self): + """ + Returns information on how this membership was paid or how it might probably be paid in the future. + Used mainly on the membership details tab to quickly show the payment status and method of a Membership. + + If the membership is free, it returns ('free', None). + Else, see the docstring of `personal_tab.transactions.get_transaction_payment_status`. + """ + from amelie.personal_tab.transactions import get_transaction_payment_status # Avoid circular import + # If the participation is free, it returns None. + if self.is_free(): + return 'free', None + + current_transaction = self.payment_transaction() + return get_transaction_payment_status(_transaction=current_transaction, mandate_type='contribution') + + @transaction.atomic + def create_contribution_transaction(self, date: Optional[datetime.datetime] = None): + """ + Create a ContributionTransaction if none exist and if the member needs to pay for this membership. + """ + from amelie.personal_tab.models import ContributionTransaction # Avoid circular import + if date is None: + # Today if the membership is for a current or past association year, 07-01-year if it is in the future. + if self.year <= current_association_year(): + date = timezone.now() + else: + date = timezone.make_aware(datetime.datetime(year=self.year, month=7, day=1)) + if not self.contributiontransaction_set.exists() and self.type.price != 0: + with translation.override(self.member.preferred_language): + ct = ContributionTransaction( + date=date, price=self.type.price, person=self.member, membership=self, settlement=None, + description=_('Contribution {membership_type} ({begin_year}/{end_year})').format( + membership_type=self.type.name, + begin_year=self.year, + end_year=(self.year + 1) + ) + ) + ct.save() + return ct + return None + + @transaction.atomic + def mark_as_paid(self, payment_method, actor: Optional[Person] = None): + """ + Mark any unpaid ContributionTransaction(s) for this membership as paid, with the given payment method. + """ + # Avoid circular import + from amelie.personal_tab.models import ManualPaymentSettlement + + # Get the ContributionTransaction(s) for this Membership that still need to be paid + cts = [t for t in self.contributiontransaction_set.all() if not t.is_paid()] + if not cts: + return None + + # Override language to get the description strings in the person's preferred language + with translation.override(self.member.preferred_language): + # Create a ManualPayment settlement with the given payment method to mark the contribution transaction as paid. + description = _("Payment for contribution {membership_type} ({begin_year}/{end_year})").format( + membership_type=self.type.name, begin_year=self.year, end_year=(self.year + 1) + ) + settlement = ManualPaymentSettlement.create_for_transactions( + transactions=cts, + payment_method=payment_method, + person=self.member, + settlement_description=description, + payment_description=description, + created_by=actor + ) + return settlement def is_verified(self): if self.type.needs_verification and self.member.membership_set.filter(ended__isnull=True, year=current_association_year()+1).count() == 0: @@ -1054,7 +1153,7 @@ def process_signed_document(self): raise ValueError(_l("Documenso ID is not set. No documents are due to be signed.")) # Retrieve the document and save it to the database from amelie.tools.documenso import retrieve_documents - documents, _ = retrieve_documents(self.documenso_id) + documents, unused = retrieve_documents(self.documenso_id) if len(documents) > 1: logging.getLogger(__name__).warning(f"Received multiple documents from Documenso for {self}. Only saving the first one.") self.signed_document = ContentFile(content=documents[0].data, name=documents[0].filename) @@ -1067,27 +1166,6 @@ def documenso_url(self): return "#" -class Payment(models.Model): - """ - Payment of a Membership. - Please note that processing properties of this model may be subject to privacy regulations. Refer to - https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. - """ - date = models.DateField(null=True, verbose_name=_l('Date')) - payment_type = models.ForeignKey(PaymentType, verbose_name=_l('Payment'), on_delete=models.PROTECT) - amount = models.DecimalField(max_digits=5, decimal_places=2, verbose_name=_l('Price')) - - membership = models.OneToOneField(Membership, verbose_name=_l('Membership'), on_delete=models.PROTECT) - - class Meta(object): - ordering = ['date'] - verbose_name = _l('payment') - verbose_name_plural = _l('payments') - - def __str__(self): - return '%s (%.2f)' % (self.membership, self.amount) - - class CommitteeCategory(models.Model): """ Category in which a committee can be placed (e.g. Activity committees). @@ -1410,10 +1488,11 @@ def activate_registration(self) -> Person: person.preferences.set(self.preferences.all()) person.save() - # Add membership details + # Add membership details and create a transaction for it if necessary membership = Membership(member=person, type=self.membership_type, year=self.membership_year) membership.save() + membership.create_contribution_transaction() # Add student details student = Student(person=person, number=self.student_number) diff --git a/amelie/members/query_forms.py b/amelie/members/query_forms.py index 300c1fe8..5ba38541 100644 --- a/amelie/members/query_forms.py +++ b/amelie/members/query_forms.py @@ -41,7 +41,7 @@ class QueryForm(forms.Form): member = forms.BooleanField(required=False, label=_l('Return former members')) old_member = forms.BooleanField(required=False, label=_l('Only return former members')) - paid = forms.BooleanField(required=False, label=_l('Has not paid yet')) + unpaid = forms.BooleanField(required=False, label=_l('Has not paid yet')) member_in_year = forms.MultipleChoiceField(required=False, label=_l('Was a member in one of these years')) study = forms.ModelChoiceField(Study.objects.all(), required=False) @@ -191,8 +191,10 @@ def filter(self, persons): persons = persons.exclude(membership__in=Membership.objects.filter( Q(ended__gt=date.today()) | Q(ended__isnull=True), year=current_association_year())) - if cleaned_data['paid']: - memberships = memberships.filter(type__price__gt=0, payment__isnull=True) + if cleaned_data['unpaid']: + # Usually payment of a transaction cannot be determined with a filter, but only with the `.is_paid()` method. + # This shortcut works here because a new ContributionTransaction (without a settlement) is created each time a payment is reversed. + memberships = memberships.filter(type__price__gt=0, contributiontransaction__settlement__isnull=True) if cleaned_data['membership']: memberships = memberships.filter(type__in=cleaned_data['membership']) diff --git a/amelie/members/templates/includes/query/query_membership.html b/amelie/members/templates/includes/query/query_membership.html index 236c887c..70939f7c 100644 --- a/amelie/members/templates/includes/query/query_membership.html +++ b/amelie/members/templates/includes/query/query_membership.html @@ -4,7 +4,7 @@

{% trans 'Membership' %}

- {% fieldset membership,member,not_verified_membership,verified_membership,old_member,paid,member_in_year as form_membership from form %} + {% fieldset membership,member,not_verified_membership,verified_membership,old_member,unpaid,member_in_year as form_membership from form %} {{ form_membership }}
diff --git a/amelie/members/templates/includes/registration/authorizations.html b/amelie/members/templates/includes/registration/authorizations.html index 708a72a9..641ecdce 100644 --- a/amelie/members/templates/includes/registration/authorizations.html +++ b/amelie/members/templates/includes/registration/authorizations.html @@ -68,7 +68,7 @@

{% trans 'Mandates' %}

{{ wizard.form.bic|form_bootstrapify:"noplaceholder" }}
- {% trans 'Your BIC-code. Not required for Dutch bank accounts. For example "ABNANL2A". Find your BIC by clicking here (opens in new tab).' %} + {% trans 'Your BIC-code. Not required for Dutch bank accounts. For example "ABNANL2A". Find your BIC by clicking here (opens in new tab).' %}

diff --git a/amelie/members/templates/members/payment_confirm_delete.html b/amelie/members/templates/members/payment_confirm_delete.html deleted file mode 100644 index ad33bb0b..00000000 --- a/amelie/members/templates/members/payment_confirm_delete.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "basis.html" %} -{% load i18n %} - -{% block titel %} - {% trans 'Remove payment' %} -{% endblock titel %} - -{% block content %} -
-

{% blocktrans %}Remove payment{% endblocktrans %}

-
-
{% csrf_token %} -

- {% blocktrans with activity=object.activiteit %} - Are you sure you want to remove the payment {{ object }}? - {% endblocktrans %} -

-

- - - {% trans 'Cancel' %} - -

-
-
-
-{% endblock %} diff --git a/amelie/members/templates/person_confirm_mandate.html b/amelie/members/templates/person_confirm_mandate.html new file mode 100644 index 00000000..fa4b83db --- /dev/null +++ b/amelie/members/templates/person_confirm_mandate.html @@ -0,0 +1,29 @@ +{% load i18n %} +
+

{% trans 'Confirm BIC for mandate' %}

+ +
+ {% csrf_token %} + {% for field in form.visible_fields %} + + {% endfor %} + + +

{% blocktrans with bic=form.cleaned_data.bic iban=form.cleaned_data.iban %} + The BIC for this mandate is on the list of known BICs that do not accept SEPA Direct Debits. + Are you sure you want to add a mandate with the following IBAN: {{ iban }} and BIC: {{ bic }}? + {% endblocktrans %}

+ +

{% blocktrans %} + Only do this if you are sure that this bank accepts SEPA Direct Debits. + {% endblocktrans %}

+ + +
+
diff --git a/amelie/members/templates/person_membership.html b/amelie/members/templates/person_membership.html index 4b98764c..224e921c 100644 --- a/amelie/members/templates/person_membership.html +++ b/amelie/members/templates/person_membership.html @@ -44,7 +44,7 @@ {% trans 'Course year' %} {% trans 'Sort' %} - {% trans 'Payment' %} + {% trans 'Paid?' %} {% if request.is_board %}{% endif %} @@ -68,21 +68,74 @@ {{ membership.type.name }} - {% if membership.payment %} - {{ membership.payment.payment_type.name }} - {% if membership.payment.amount != 0 %} - {% if membership.contributiontransaction_set.count == 0 %} - {% trans 'Delete payment' %} - {% endif %} + {% if membership.is_free %} + {% trans 'N/A' %} +   + {% if membership.contributiontransaction_set.exists %} + + + {% endif %} -
- {{ membership.payment.date|date:"j b Y"|default:"?" }} - {% endif %} - - {% if not membership.is_paid %} - - {% trans 'Process payment' %} - + {% else %} + {% with membership.payment_details as details %} + {% if membership.is_paid %} + {% trans 'Paid' %} + {% else %} + {# Cannot use brackets in jinja templates, so we need to list the same block twice. #} + {% if details.0 == 'unpaid' and details.1 != None %} + {% trans 'To be debited' %} + {% elif details.0 == 'reversed' %} + {% trans 'To be debited' %} + {% else %} + {% trans 'Not paid yet' %} + {% endif %} + {% endif %} +   + + + {% if details.0 == 'no_transaction' %} +
{% trans 'No transaction! Contact the WWW.' %} + {% elif details.0 == 'paid' %} + {% if details.1.settlement_type == "INSTR" %} +
+ {% blocktrans with date=details.1.batch.execution_date|date:"Y-m-d" %} + Debited on {{ date }} + {% endblocktrans %} + + {% elif details.1.settlement_type == "MANUAL" %} +
+ {% blocktrans with method=details.1.payment_method.name date=details.1.payment_date|date:"Y-m-d" %} + {{ method }} on {{ date }} + {% endblocktrans %} + + {% else %} +
+ {% trans 'Unknown payment! Contact the WWW.' %} + + {% endif %} + {% elif details.0 == 'reversed' %} +
+ {% trans 'Previous attempt was reversed' %} + + {% elif details.0 == 'unpaid' %} + {% if details.1 == None %} +
+ {% trans 'Needs manual payment' %} + + {% endif %} + {% endif %} + {% if details.0 == 'unpaid' and details.1 == None %} +
+ {% trans 'Enter manual payment' %} + + {% endif %} + {% endwith %} {% endif %} {% if request.is_board %} diff --git a/amelie/members/templates/person_membership_create_payment.html b/amelie/members/templates/person_membership_create_payment.html new file mode 100644 index 00000000..1998f918 --- /dev/null +++ b/amelie/members/templates/person_membership_create_payment.html @@ -0,0 +1,73 @@ +{% load humanize %} +{% load i18n %} +
+

+ {% blocktrans with membership=membership person=person %} + Create manual payment for {{ membership }} of {{ person }} + {% endblocktrans %} +

+ +

{% trans 'You are creating a manual payment that will cover the following transactions:' %}

+ +
+ + + + + + + + + + {% for transaction in transactions %} + + + + + + {% empty %} + + + + {% endfor %} + + + + + + +
{% trans 'Timestamp' %}{% trans 'Description' %}{% trans 'Amount' %}
+ {{ transaction.date|date:"d-m-Y H:i" }} + + + {{ transaction.description }} + + + € {{ transaction.price|intcomma }} +
+ {% trans 'No transactions found. This is probably wrong! Please contact the WWW.' %} +
{% trans 'Total' %}€ {{ transactions_total|intcomma }}
+
+
+

{% trans 'Please select the payment method that was used for the payment.' %}

+ +
+ {% csrf_token %} + {{ form }} + +
+ + {% trans 'Cancel' %} + + + + +
+
+
+
diff --git a/amelie/members/templates/person_membership_payments.html b/amelie/members/templates/person_membership_payments.html new file mode 100644 index 00000000..713e1035 --- /dev/null +++ b/amelie/members/templates/person_membership_payments.html @@ -0,0 +1,72 @@ +{% load humanize %} +{% load i18n %} +
+ {# Messages to show if we redirected here from the create manual payment view #} + {% if status_msg == "created" %} +
    +
  • + {% trans 'Payment created. Please check the transactions below for correctness.' %} +
  • +
+ {% elif status_msg == "no_transactions" %} +
    +
  • + {% trans 'No unpaid transactions found. Please re-check and try again.' %} +
  • +
+ {% endif %} + +

+ {% blocktrans with membership=membership.type.name year_1=membership.year year_2=membership.year|add:1 %} + Payments for {{ membership }} {{ year_1 }}/{{ year_2 }} + {% endblocktrans %} +   + {% if membership.is_paid %} + ({% trans 'Paid' %}) + {% else %} + ({% trans 'Not paid yet' %}) + {% endif %} +

+ +
+ + + + + + + + + + + {% for transaction in transactions %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans 'Timestamp' %}{% trans 'Description' %}{% trans 'Amount' %}{% trans 'Settled in' %}
+ {{ transaction.date|date:"d-m-Y H:i" }} + + + {{ transaction.description }} + + + € {{ transaction.price|intcomma }} + + {% include 'info/transaction_settlement_info.html' with settlement=transaction.settlement highlight_unpaid=True %} +
+ {% trans 'No transactions found. If this is a paid membership, this is probably wrong! Please contact the WWW.' %} +
+
+ + {% trans 'Return' %} + +
diff --git a/amelie/members/templates/person_payment.html b/amelie/members/templates/person_payment.html deleted file mode 100644 index f408eca1..00000000 --- a/amelie/members/templates/person_payment.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load i18n %} -
-

{% trans 'Execute payment' %}

- -
- {% csrf_token %} -

- {{ membership.year }} ({{ membership.type }}, EUR {{ membership.type.price }}) -

- - {{ form }} - - -
-
diff --git a/amelie/members/templates/preregistration_status.html b/amelie/members/templates/preregistration_status.html index bda4a7c9..c689bb0f 100644 --- a/amelie/members/templates/preregistration_status.html +++ b/amelie/members/templates/preregistration_status.html @@ -23,7 +23,15 @@

{% trans 'Pre-enrollment status' %}

{% trans 'These are the current pre-enrollments that are not activated yet, per do-group.' %}

- {% trans 'If the member has signed their membership and authorization forms, the membership and any authorizations should automatically get activated and it will disappear from this list.' %}
+ {% trans 'If the member has signed their membership and authorization forms, the membership and any authorizations should automatically get activated and it will disappear from this list.' %} +

+

+ {% url 'members:registration_status' as enrollment_url %} + {% blocktrans with url=enrollment_url %} + To view already activated memberships from this academic year, check the current enrollment status instead. + {% endblocktrans %} +

+

{% trans 'You can (re-)print the forms for that member by clicking the "Print" () button.' %} {% if request.is_board %} {% trans 'Additionally, as a board member, you can view the state of the signature requests (), and attempt re-download of the signed forms () or even re-send them () if it failed for whatever reason.' %} @@ -103,7 +111,7 @@

{{ dogroup.grouper }} - {% blocktrans with len=dogroup.list|length %}{{ len

{% trans 'No pre-enrollments' %}

- {% trans 'There are currently no pre-enrollments.' %} + {% trans 'There are currently no pending pre-enrollments.' %}

diff --git a/amelie/members/templates/registration_status.html b/amelie/members/templates/registration_status.html new file mode 100644 index 00000000..0b3e9b8c --- /dev/null +++ b/amelie/members/templates/registration_status.html @@ -0,0 +1,59 @@ +{% extends "basis.html" %} +{% load i18n form_stylish %} + +{% block titel %} + {% trans 'Enrollment status' %} +{% endblock titel %} + +{% block content %} +
+
+

{% trans 'Enrollment status' %}

+
+

+ {% trans 'These are the new members this association year, per do-group. These memberships are already activated.' %} +

+

+ {% url 'members:preregistration_status' as pre_enrollment_url %} + {% blocktrans with url=pre_enrollment_url %} + To view unactivated memberships, check the current pre-enrollment status instead. + {% endblocktrans %} +

+
+
+
+ + {% regroup memberships by member.dogroup as memberships_by_dogroup %} + + {% for dogroup in memberships_by_dogroup %} +
+
+

{{ dogroup.grouper }} - {% blocktrans with len=dogroup.list|length %}{{ len }} member(s){% endblocktrans %}

+
+ + {% for enrollment in dogroup.list %} + + + + {% empty %} + + {% endfor %} +
+ {{ enrollment.member.incomplete_name }} +
{% trans 'No members' %}
+
+
+
+ {% empty %} +
+
+

{% trans 'No enrollments' %}

+
+

+ {% trans 'There have not been any enrollments this year yet.' %} +

+
+
+
+ {% endfor %} +{% endblock content %} diff --git a/amelie/members/templates/statistics/payments.html b/amelie/members/templates/statistics/payments.html deleted file mode 100644 index 238d5bf1..00000000 --- a/amelie/members/templates/statistics/payments.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "basis.html" %} -{% load i18n %} - -{% block titel %} - {% trans 'Payment statistics' %} -{% endblock titel %} - -{% block content %} -
-
-

{% trans "Payments" %}

-
- - - - {% for ptype in payment_types %} - - {% endfor %} - - - {% for row in rows %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - - - {% for totalcell in totalcells %} - - {% endfor %} - -
{{ ptype.name }}{% trans 'Total' %}
{{ cell }}
{% trans 'Totals' %}{{ totalcell }}
-
-
-
-{% endblock content %} diff --git a/amelie/members/views.py b/amelie/members/views.py index 7854b204..2b86e601 100644 --- a/amelie/members/views.py +++ b/amelie/members/views.py @@ -6,7 +6,6 @@ import uuid from datetime import date -from datetime import timezone as tz from decimal import Decimal from functools import lru_cache from io import BytesIO @@ -16,7 +15,7 @@ from django.views import View from django.contrib import messages from django.core.exceptions import PermissionDenied, ValidationError, BadRequest, ImproperlyConfigured -from django.db.models import Q, Sum, Max, F +from django.db.models import Q, Sum, Max, F, Count from django.utils.dateparse import parse_date from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView @@ -32,7 +31,7 @@ from django.shortcuts import get_object_or_404, render, redirect from django.utils import timezone, translation from django.utils.translation import gettext as _ -from django.views.generic.edit import DeleteView, FormView +from django.views.generic.edit import FormView from amelie.claudia.models import Mapping, ExtraPerson from amelie.iamailer import MailTask @@ -44,7 +43,7 @@ RegistrationFormStepPersonalPreferences, RegistrationFormStepFinalCheck, RegistrationFormStepGeneralStudyDetails, \ RegistrationFormStepFreshmenMembershipDetails, RegistrationFormStepEmployeeDetails, \ RegistrationFormPersonalDetailsEmployee, RegistrationFormStepEmployeeMembershipDetails, PreRegistrationPrintAllForm -from amelie.members.models import Payment, PaymentType, Committee, Function, Membership, MembershipType, Employee, \ +from amelie.members.models import Committee, Function, Membership, MembershipType, Employee, \ Person, Student, Study, StudyPeriod, Preference, PreferenceCategory, UnverifiedEnrollment, Dogroup, \ DogroupGeneration from amelie.personal_tab.forms import RFIDCardForm @@ -54,7 +53,7 @@ from amelie.tools.encodings import normalize_to_ascii from amelie.tools.http import HttpResponseSendfile, HttpJSONResponse, is_allowed_ip from amelie.tools.logic import current_academic_year_with_holidays, current_association_year, association_year -from amelie.tools.mixins import DeleteMessageMixin, RequireBoardMixin, RequireCommitteeMixin, RequireAllowlistedIPMixin +from amelie.tools.mixins import RequireCommitteeMixin, RequireAllowlistedIPMixin from amelie.tools.mail import PersonRecipient from amelie.tools.pdf import pdf_separator_page, pdf_membership_page, pdf_authorization_page @@ -288,26 +287,6 @@ def statistics(request): return render(request, 'statistics/overview.html', locals()) -@require_board -def payment_statistics(request, start_year=2012): - rows = [] - payment_types = PaymentType.objects.all() - membership_types = MembershipType.objects.all() - for mtype in membership_types: - cells = [mtype, ] - count = 0 - for ptype in payment_types: - memberships = Membership.objects.filter(year=start_year, payment__payment_type=ptype, type=mtype) - mcount = memberships.count() - count += mcount - cells.append(mcount) - cells.append(count) - rows.append(cells) - ziprows = zip(*rows) - totalcells = map(sum, list(zip(*rows))[1:]) - return render(request, 'statistics/payments.html', locals()) - - @require_committee(settings.ROOM_DUTY_ABBREVIATION) def person_view(request, id, slug): obj = get_object_or_404(Person, id=id, slug=slug) @@ -353,10 +332,7 @@ def _person_can_be_anonymized(person): "({year}/{next_year} {type}).".format( year=membership.year, next_year=membership.year + 1, type=membership.type ))) - # Date where new SEPA authorizations came into effect. - begin = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) - personal_tab_credit = Transaction.objects.filter(person=person, date__gte=begin).aggregate(Sum('price'))[ - 'price__sum'] or Decimal('0.00') + personal_tab_credit = Transaction.objects.filter(person=person).aggregate(Sum('price'))['price__sum'] or Decimal('0.00') if personal_tab_credit != 0: unable_to_anonymize_reasons.append(_("This person still has an outstanding balance on their personal tab.")) @@ -577,10 +553,11 @@ def done(self, form_list, **kwargs): start_date=date.today()) authorization_other.save() - # Add membership details + # Add membership details and create a transaction for it if necessary membership = Membership(member=person, type=cleaned_data['membership'], year=current_association_year()) membership.save() + membership.create_contribution_transaction() # Add student details student = Student(person=person, number=cleaned_data['student_number']) @@ -692,10 +669,11 @@ def done(self, form_list, **kwargs): start_date=date.today()) authorization_other.save() - # Add membership details + # Add membership details and create a transaction for it if necessary membership = Membership(member=person, type=MembershipType.objects.get(name_en='Primary yearlong'), year=current_association_year()) membership.save() + membership.create_contribution_transaction() # Create user for logging in person.get_or_create_user(f"e{person.pk}") @@ -805,10 +783,11 @@ def done(self, form_list, **kwargs): start_date=date.today()) authorization_other.save() - # Add membership details + # Add membership details and create a transaction for it if necessary membership = Membership(member=person, type=cleaned_data['membership'], year=current_association_year()) membership.save() + membership.create_contribution_transaction() # Add employee details employee = Employee(person=person, number=cleaned_data['employee_number']) @@ -923,10 +902,11 @@ def done(self, form_list, **kwargs): start_date=date.today()) authorization_other.save() - # Add membership details + # Add membership details and create a transaction for it if necessary membership = Membership(member=person, type=cleaned_data['membership'], year=current_association_year()) membership.save() + membership.create_contribution_transaction() # Add student details student = Student(person=person, number=cleaned_data['student_number']) @@ -1087,6 +1067,26 @@ class PreRegistrationCompleteView(TemplateView): template_name = "person_registration_form_preregister_complete.html" +class RegistrationStatus(RequireCommitteeMixin, TemplateView): + template_name = "registration_status.html" + abbreviation = settings.ROOM_DUTY_ABBREVIATION + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.error_msg = None + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + memberships = Membership.objects.filter( + year=current_association_year() + ).annotate(membership_count=Count('member__membership')).filter( + membership_count=1 + ) + memberships = sorted(memberships, key=lambda x: str(x.member.dogroup) + " " + str(x.member.sortable_name())) + context['memberships'] = memberships + return context + + class PreRegistrationStatus(RequireCommitteeMixin, TemplateView): template_name = "preregistration_status.html" abbreviation = settings.ROOM_DUTY_ABBREVIATION @@ -1664,27 +1664,6 @@ def books_list(request): 'student_numbers_other': student_numbers_other, }) -class PaymentDeleteView(RequireBoardMixin, DeleteMessageMixin, DeleteView): - model = Payment - template_name = "members/payment_confirm_delete.html" - - def get_object(self, queryset=None): - payment = super(PaymentDeleteView, self).get_object() - # You are not allowed to delete 'zero'-memberships manually - if payment.amount == 0.0: - raise PermissionDenied(_("You are not allowed to manually delete the payments of free memberships.")) - # You are not allowed to delete payments that are (currently) debited manually - if payment.membership.contributiontransaction_set.count() > 0: - raise PermissionDenied(_("You are not allowed to delete payments that are already (being) debited.")) - return payment - - def get_delete_message(self): - return _('Payment %(object)s has been removed' % {'object': self.get_object()}) - - def get_success_url(self): - return self.object.membership.member.get_absolute_url() - - class DoGroupTreeView(TemplateView): template_name = "dogroups.html" diff --git a/amelie/personal_tab/admin.py b/amelie/personal_tab/admin.py index d140de62..6032de7a 100644 --- a/amelie/personal_tab/admin.py +++ b/amelie/personal_tab/admin.py @@ -1,10 +1,13 @@ +from typing import Optional + from django.contrib import admin -from amelie.personal_tab.models import Declaration, DiscountPeriod, Discount, DiscountCredit, Transaction, \ +from amelie.personal_tab.models import BadBIC, Declaration, DiscountPeriod, Discount, DiscountCredit, Transaction, \ CustomTransaction, ActivityTransaction, CookieCornerTransaction, AlexiaTransaction, ContributionTransaction, \ Article, Category, RFIDCard, AuthorizationType, Authorization, Amendment, DebtCollectionAssignment, \ DebtCollectionBatch, \ - DebtCollectionInstruction, Reversal, DebtCollectionTransaction, ReversalTransaction, LedgerAccount, PrintLogEntry + DebtCollectionInstruction, Reversal, DebtCollectionTransaction, ReversalTransaction, LedgerAccount, PrintLogEntry, \ + PaymentMethod, ManualPaymentSettlement, SettlementExtraBalanceTransaction, SettlementManualPaymentTransaction class RFIDAdmin(admin.ModelAdmin): @@ -19,21 +22,21 @@ class CookieCornerTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['article__name_nl', 'article__name_en', 'person__slug'] - raw_id_fields = ('person', 'discount', 'debt_collection',) + raw_id_fields = ('person', 'discount', 'settlement',) readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[CookieCornerTransaction] = None): + if obj and obj.settlement: return False else: return super(CookieCornerTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[CookieCornerTransaction] = None): + if obj and obj.settlement: return False else: return super(CookieCornerTransactionAdmin, self).has_delete_permission(request, obj) @@ -44,7 +47,7 @@ class TransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug'] - raw_id_fields = ('person', 'discount', 'debt_collection',) + raw_id_fields = ('person', 'discount', 'settlement',) readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] @@ -54,14 +57,14 @@ class TransactionAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[Transaction] = None): + if obj and obj.settlement: return False else: return super(TransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[Transaction] = None): + if obj and obj.settlement: return False else: return super(TransactionAdmin, self).has_delete_permission(request, obj) @@ -72,21 +75,21 @@ class CustomTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug'] - raw_id_fields = ('person', 'discount', 'debt_collection',) + raw_id_fields = ('person', 'discount', 'settlement',) readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[CustomTransaction] = None): + if obj and obj.settlement: return False else: return super(CustomTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[CustomTransaction] = None): + if obj and obj.settlement: return False else: return super(CustomTransactionAdmin, self).has_delete_permission(request, obj) @@ -97,21 +100,21 @@ class ActivityTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on', 'with_enrollment_options') date_hierarchy = 'date' search_fields = ['description', 'person__slug', 'event__summary_nl', 'event__summary_en'] - raw_id_fields = ('person', 'discount', 'debt_collection', 'event', 'participation') + raw_id_fields = ('person', 'discount', 'settlement', 'event', 'participation') readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[ActivityTransaction] = None): + if obj and obj.settlement: return False else: return super(ActivityTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[ActivityTransaction] = None): + if obj and obj.settlement: return False else: return super(ActivityTransactionAdmin, self).has_delete_permission(request, obj) @@ -122,21 +125,21 @@ class AlexiaTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug', 'transaction_id'] - raw_id_fields = ('person', 'discount', 'debt_collection') + raw_id_fields = ('person', 'discount', 'settlement') readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[AlexiaTransaction] = None): + if obj and obj.settlement: return False else: return super(AlexiaTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[AlexiaTransaction] = None): + if obj and obj.settlement: return False else: return super(AlexiaTransactionAdmin, self).has_delete_permission(request, obj) @@ -147,21 +150,21 @@ class ContributionTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug'] - raw_id_fields = ('person', 'membership', 'discount', 'debt_collection') + raw_id_fields = ('person', 'membership', 'discount', 'settlement') readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[ContributionTransaction] = None): + if obj and obj.settlement: return False else: return super(ContributionTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[ContributionTransaction] = None): + if obj and obj.settlement: return False else: return super(ContributionTransactionAdmin, self).has_delete_permission(request, obj) @@ -172,21 +175,21 @@ class DebtCollectionTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug'] - raw_id_fields = ('person', 'discount', 'debt_collection') + raw_id_fields = ('person', 'discount', 'settlement') readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[DebtCollectionTransaction] = None): + if obj and obj.settlement: return False else: return super(DebtCollectionTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[DebtCollectionTransaction] = None): + if obj and obj.settlement: return False else: return super(DebtCollectionTransactionAdmin, self).has_delete_permission(request, obj) @@ -197,26 +200,64 @@ class ReversalTransactionAdmin(admin.ModelAdmin): list_filter = ('date', 'added_on') date_hierarchy = 'date' search_fields = ['description', 'person__slug'] - raw_id_fields = ('person', 'reversal', 'discount', 'debt_collection') + raw_id_fields = ('person', 'reversal', 'discount', 'settlement') readonly_fields = ('added_on', 'added_by') ordering = ['-added_on'] # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored actions = None - def has_change_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_change_permission(self, request, obj: Optional[ReversalTransaction] = None): + if obj and obj.settlement: return False else: return super(ReversalTransactionAdmin, self).has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - if obj and obj.debt_collection: + def has_delete_permission(self, request, obj: Optional[ReversalTransaction] = None): + if obj and obj.settlement: return False else: return super(ReversalTransactionAdmin, self).has_delete_permission(request, obj) +class SettlementExtraBalanceTransactionAdmin(admin.ModelAdmin): + list_display = ('id', 'manual_settlement', 'description', 'price', 'person', 'discount', 'date', 'added_on') + list_filter = ('date', 'added_on') + date_hierarchy = 'date' + search_fields = ['description', 'person__slug'] + raw_id_fields = ('person', 'discount', 'settlement') + readonly_fields = ('added_on', 'added_by') + ordering = ['-added_on'] + + # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored + actions = None + + def has_change_permission(self, request, obj: Optional[SettlementExtraBalanceTransaction] = None): + return False + + def has_delete_permission(self, request, obj: Optional[SettlementExtraBalanceTransaction] = None): + return False # Deletion must go via ManualPaymentSettlement deletion + + +class SettlementManualPaymentTransactionAdmin(admin.ModelAdmin): + list_display = ('id', 'manual_settlement', 'description', 'price', 'person', 'discount', 'date', 'added_on') + list_filter = ('date', 'added_on') + date_hierarchy = 'date' + search_fields = ['description', 'person__slug'] + raw_id_fields = ('person', 'discount', 'settlement') + readonly_fields = ('added_on', 'added_by') + ordering = ['-added_on'] + + # Jelte 2015-04-01: Disable mass deletion because has_delete_permission seems to be ignored + actions = None + + def has_change_permission(self, request, obj: Optional[SettlementManualPaymentTransaction] = None): + return False + + def has_delete_permission(self, request, obj: Optional[SettlementManualPaymentTransaction] = None): + return False # Deletion must go via ManualPaymentSettlement deletion + + class CategoryAdmin(admin.ModelAdmin): list_display = ('id', 'name_nl', 'name_en', 'is_available', 'order') list_editable = ('order',) @@ -284,11 +325,34 @@ def has_add_permission(self, request): return False +class TransactionsInline(admin.TabularInline): + model = Transaction + fk_name = 'settlement' + extra = 0 + can_delete = False + show_change_link = True + + fields = ('id', 'date', 'price', 'description') + ordering = ['-added_on'] + + def has_add_permission(self, *args, **kwargs): + return False + + def has_change_permission(self, *args, **kwargs): + return False + + def has_delete_permission(self, *args, **kwargs): + return False + + class DebtCollectionInstructionAdmin(admin.ModelAdmin): list_display = ('id', 'batch', 'authorization', 'end_to_end_id', 'amount',) search_fields = ['authorization__person__first_name', 'authorization__person__last_name', 'authorization__iban', 'authorization__account_holder_name', 'description'] raw_id_fields = ('batch', 'authorization',) + inlines = [ + TransactionsInline + ] def has_add_permission(self, request): return False @@ -304,6 +368,46 @@ def has_add_permission(self, request): return False +class BadBICAdmin(admin.ModelAdmin): + list_display = ('id', 'bic', 'date_added', 'last_reversal', 'first_reversal') + search_fields = ['bic'] + ordering = ['bic'] + + +class PaymentMethodAdmin(admin.ModelAdmin): + list_display = ('id', 'name_nl', 'name_en', 'description_nl', 'description_en', 'visible', 'visible_memberships', 'visible_activities', 'frontend_icon_display') + list_filter = ('visible', 'visible_memberships', 'visible_activities') + search_fields = ('name_nl', 'name_en', 'description_nl', 'description_en') + + +class ManualPaymentSettlementAdmin(admin.ModelAdmin): + list_display = ('id', 'payment_date', 'person', 'get_payment_method_display', 'amount', 'description') + list_filter = ('payment_date', 'payment_method') + search_fields = ['person__first_name', 'person__last_name', 'description'] + raw_id_fields = ('person', 'payment_method',) + readonly_fields = ('amount', 'person', 'payment_date', 'created_by', 'manual_payment_transaction', 'extra_balance_transaction') + inlines = [ + TransactionsInline + ] + + def get_payment_method_display(self, obj): + return obj.payment_method.name + get_payment_method_display.short_description = 'Payment method' + get_payment_method_display.admin_order_field = 'payment_method' + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj: Optional[ManualPaymentSettlement] = None): + if not obj or not obj.deletion_possible()[0]: + return False + else: + return super().has_change_permission(request, obj=obj) + + def has_delete_permission(self, request, obj: Optional[ManualPaymentSettlement] = None): + return False # Deletion only via website due to required deletion procedure + + class DiscountPeriodAdmin(admin.ModelAdmin): list_display = ('id', 'description_nl', 'description_en', 'begin', 'end', 'ledger_account_number', 'balance_account_number') @@ -351,6 +455,7 @@ def has_transaction(self, obj): has_transaction.boolean = True has_transaction.short_description = 'Paid' + class DeclarationAdmin(admin.ModelAdmin): list_display = ('id', 'person', 'committee', 'amount', 'submission_date') list_filter = ('submission_date', 'payment_method') @@ -362,26 +467,37 @@ class DeclarationAdmin(admin.ModelAdmin): admin.site.register(RFIDCard, RFIDAdmin) + admin.site.register(Transaction, TransactionAdmin) -admin.site.register(CookieCornerTransaction, CookieCornerTransactionAdmin) admin.site.register(ActivityTransaction, ActivityTransactionAdmin) admin.site.register(AlexiaTransaction, AlexiaTransactionAdmin) admin.site.register(ContributionTransaction, ContributionTransactionAdmin) +admin.site.register(CookieCornerTransaction, CookieCornerTransactionAdmin) +admin.site.register(CustomTransaction, CustomTransactionAdmin) admin.site.register(DebtCollectionTransaction, DebtCollectionTransactionAdmin) admin.site.register(ReversalTransaction, ReversalTransactionAdmin) -admin.site.register(CustomTransaction, CustomTransactionAdmin) +admin.site.register(SettlementExtraBalanceTransaction, SettlementExtraBalanceTransactionAdmin) +admin.site.register(SettlementManualPaymentTransaction, SettlementManualPaymentTransactionAdmin) + admin.site.register(Category, CategoryAdmin) admin.site.register(LedgerAccount, LedgerAccountAdmin) admin.site.register(Article, ArticleAdmin) + +admin.site.register(DiscountPeriod, DiscountPeriodAdmin) +admin.site.register(Discount, DiscountAdmin) +admin.site.register(DiscountCredit, DiscountCreditAdmin) +admin.site.register(PrintLogEntry, PrintLogAdmin) +admin.site.register(Declaration, DeclarationAdmin) + admin.site.register(AuthorizationType, AuthorizationTypeAdmin) admin.site.register(Authorization, AuthorizationAdmin) admin.site.register(Amendment, AmendmentAdmin) + admin.site.register(DebtCollectionAssignment, DebtCollectionAssignmentAdmin) admin.site.register(DebtCollectionBatch, DebtCollectionBatchAdmin) admin.site.register(DebtCollectionInstruction, DebtCollectionInstructionAdmin) admin.site.register(Reversal, ReversalAdmin) -admin.site.register(DiscountPeriod, DiscountPeriodAdmin) -admin.site.register(Discount, DiscountAdmin) -admin.site.register(DiscountCredit, DiscountCreditAdmin) -admin.site.register(PrintLogEntry, PrintLogAdmin) -admin.site.register(Declaration, DeclarationAdmin) +admin.site.register(BadBIC, BadBICAdmin) + +admin.site.register(PaymentMethod, PaymentMethodAdmin) +admin.site.register(ManualPaymentSettlement, ManualPaymentSettlementAdmin) diff --git a/amelie/personal_tab/debt_collection.py b/amelie/personal_tab/debt_collection.py index 481945e6..a042bf05 100644 --- a/amelie/personal_tab/debt_collection.py +++ b/amelie/personal_tab/debt_collection.py @@ -1,14 +1,14 @@ import datetime -from datetime import timezone as tz +from django.db import transaction from django.db.models import Sum, Q from django.template.defaultfilters import date as _date from django.utils import timezone, translation from django.utils.translation import gettext as _ -from amelie.members.models import Person, Membership, PaymentType, Payment +from amelie.members.models import Person, Membership from amelie.personal_tab.models import Transaction, DebtCollectionInstruction, DebtCollectionBatch, \ - DebtCollectionTransaction, ContributionTransaction, ReversalTransaction, Reversal, Amendment + DebtCollectionTransaction, ContributionTransaction, ReversalTransaction, Reversal, Amendment, BadBIC from amelie.tools.encodings import normalize_to_ascii @@ -25,8 +25,6 @@ def authorization_contribution(person): :type person: Person """ - # 2 Contribution - # 4 Contribution (old version) authorizations = person.authorization_set.filter(is_signed=True, end_date__isnull=True, authorization_type__contribution=True).order_by('authorization_type__id') return authorizations[0] if authorizations else None @@ -48,10 +46,6 @@ def authorization_cookie_corner(person): :type person: Person """ - # 1 Consumptions, activities and other - # 4 Consumptions and activities - # 5 Consumptions - authorizations = person.authorization_set.filter( is_signed=True, end_date__isnull=True, authorization_type__consumptions=True ).order_by('authorization_type__id') @@ -74,18 +68,17 @@ def generate_contribution_instructions(years): The DebtCollectionInstruction objects that are returned are not saved yet. """ - memberships = Membership.objects.filter(payment__isnull=True, type__price__gt=0, year__in=years) - result = { 'ongoing_frst': [], 'frst': [], 'rcur': [] } - for m in memberships: - person = m.member - price = m.type.price - sumf = ("%.2f" % price).replace('.', ',') + unpaid_membership_transactions = ContributionTransaction.objects.filter( + settlement=None, membership__type__price__gt=0, membership__year__in=years + ) + for umt in unpaid_membership_transactions: + person = umt.membership.member authorization = authorization_contribution(person) @@ -97,22 +90,23 @@ def generate_contribution_instructions(years): with translation.override(person.preferred_language): description = _('Contribution Inter-Actief {membership_year} {membership_type} {name} Questions? Email treasurer@inter-actief.net')\ - .format(membership_year="{}-{}".format(m.year, m.year + 1), membership_type=m.type.name, name=name) + .format(membership_year="{}-{}".format(umt.membership.year, umt.membership.year + 1), membership_type=umt.membership.type.name, name=name) description = normalize_to_ascii(description) instruction = None sequence_type = authorization.next_sequence_type() if sequence_type: - instruction = DebtCollectionInstruction(amount=price, authorization=authorization, description=description, - amendment=authorization.next_amendment()) + instruction = DebtCollectionInstruction( + amount=umt.price, authorization=authorization, description=description, + amendment=authorization.next_amendment() + ) row = { 'person': person, 'authorization': authorization, - 'membership': m, - 'sum': price, - 'sumf': sumf, + 'transaction': umt, + 'sum': umt.price, 'instruction': instruction } @@ -130,7 +124,7 @@ def filter_contribution_instructions(rows, post): res = [] for row in rows: - if 'contribution_%i' % row['membership'].id in post: + if 'contribution_%i' % row['transaction'].membership.id in post: res.append(row) return res @@ -146,18 +140,14 @@ def save_contribution_instructions(rows, batch): debt_collection_datetime = timezone.make_aware(datetime.datetime.combine(execution_date, datetime.time(0, 0)), timezone.get_default_timezone()) # Annual authorization - payment_method = PaymentType.objects.get(id=4) - for row in rows: - person = row['person'] - price = row['sum'] - membership = row['membership'] - instruction = row['instruction'] + person: Person = row['person'] + contribution_transaction: ContributionTransaction = row['transaction'] + instruction: DebtCollectionInstruction = row['instruction'] with translation.override(person.preferred_language): - dct_description = _('Direct withdrawal of contribution {date}').format(date=_date(execution_date, "j F Y")) - ct_description = _('Contribution {membership_type} ({begin_year}/{end_year})').format( - membership_type=membership.type.name, begin_year=membership.year, end_year=(membership.year + 1) + dct_description = _('Direct withdrawal of contribution {date}').format( + date=_date(execution_date, "j F Y") ) instruction.batch = batch @@ -166,25 +156,19 @@ def save_contribution_instructions(rows, batch): instruction.end_to_end_id = instruction.debt_collection_reference() instruction.save() - ct = ContributionTransaction(date=debt_collection_datetime, price=price, person=person, - description=ct_description, membership=membership, debt_collection=instruction) - ct.save() + # Set the contribution transaction settlement, so it is considered paid. + contribution_transaction.settlement = instruction + contribution_transaction.save() - dct = DebtCollectionTransaction(date=debt_collection_datetime, price=-price, person=person, - description=dct_description, debt_collection=instruction) + dct = DebtCollectionTransaction( + date=debt_collection_datetime, price=-instruction.amount, person=person, + description=dct_description, settlement=instruction + ) dct.save() - payment = Payment(date=execution_date, payment_type=payment_method, amount=price, membership=membership) - payment.save() - def generate_cookie_corner_instructions(end_date): - all_transactions = Transaction.objects.filter(debt_collection=None) - - # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET - begin_date = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) - - all_transactions = all_transactions.filter(date__gte=begin_date, date__lt=end_date) + all_transactions = Transaction.objects.filter(settlement=None, date__lt=end_date) # Order only by person, so distinct works as intended. See Django manual. people = all_transactions.filter(person__isnull=False).order_by('person').distinct().values('person') @@ -204,7 +188,6 @@ def generate_cookie_corner_instructions(end_date): person = Person.objects.get(id=p['person']) transactions = all_transactions.filter(person=person) price = transactions.aggregate(Sum('price'))['price__sum'] - sumf = ("%.2f" % price).replace('.', ',') if price == 0: continue @@ -233,7 +216,6 @@ def generate_cookie_corner_instructions(end_date): 'person': person, 'authorization': authorization, 'sum': price, - 'sumf': sumf, 'instruction': instruction, 'transactions': transactions } @@ -293,11 +275,11 @@ def save_cookie_corner_instructions(rows, batch): instruction.save() for transaction in transactions: - transaction.debt_collection = instruction + transaction.settlement = instruction transaction.save() dct = DebtCollectionTransaction(date=debt_collection_datetime, price=-price, person=person, - description=description, debt_collection=instruction) + description=description, settlement=instruction) dct.save() @@ -318,19 +300,36 @@ def process_reversal(reversal, actor): person=person, description=description, added_by=actor) rt.save() - for ct in ContributionTransaction.objects.filter(debt_collection=instruction): - # Only reverse real transactions, do not reverse other reversals. - if ct.price > 0: - Payment.objects.filter(membership__contributiontransaction=ct).delete() + # Filter for real (positive) ContributionTransactions to avoid reversing other reversals. + for ct in ContributionTransaction.objects.filter(settlement=instruction, price__gt=0): + with translation.override(ct.person.preferred_language): + description = _('Reversal contribution {membership_type} ({start_year}/{end_year})').format( + membership_type=ct.membership.type.name, start_year=ct.membership.year, + end_year=ct.membership.year + 1 + ) - with translation.override(ct.person.preferred_language): - description = _('Reversal contribution {membership_type} ({start_year}/{end_year})').format( - membership_type=ct.membership.type.name, start_year=ct.membership.year, - end_year=ct.membership.year + 1 - ) - cct = ContributionTransaction(date=reversal_datetime, price=-ct.price, person=ct.person, - description=description, membership=ct.membership) - cct.save() + # TODO: This next bit is a bit convoluted, but it is the quickest way to get it working nicely. + # Maybe another refactor step is necessary to get rid of all of these extra ContributionTransactions. + # albertskja - 2026-07-22 + + # Create a ContributionTransaction to reverse the original transaction which was marked paid in the debt collection instruction + cct = ContributionTransaction(date=reversal_datetime, price=-ct.price, person=ct.person, + description=description, membership=ct.membership) + cct.save() + # And create another ContributionTransaction to mark that the contribution still needs to be paid. + cct = ContributionTransaction(date=reversal_datetime, price=ct.price, person=ct.person, + description=ct.description, membership=ct.membership) + cct.save() + + # If the reversal reason is DNOR, the BIC of the authorization must be added to the Bad BIC list + if reversal.reason == Reversal.ReversalReasons.DNOR: + bad_bic = BadBIC.objects.filter(bic=instruction.authorization.bic).first() + if bad_bic: + bad_bic.update_reversals() + else: + BadBIC.objects.create( + bic=instruction.authorization.bic, first_reversal=reversal, last_reversal=reversal + ) def edit_reversal(reversal, actor): @@ -346,29 +345,45 @@ def edit_reversal(reversal, actor): rt.added_by = actor rt.save() + # Update any BadBic objects with this BIC or this reversal as their first/last reversal + bad_bics = list(BadBIC.objects.filter(Q(bic=reversal.instruction.authorization.bic) | Q(first_reversal=reversal) | Q(last_reversal=reversal))) + for bad_bic in bad_bics: + bad_bic.update_reversals() + +@transaction.atomic def delete_reversal(reversal): """ :type reversal: Reversal """ - # First we delete the ReversalTransaction and the Reversal object itself. - instruction = reversal.instruction rt = ReversalTransaction.objects.get(reversal=reversal) + + # TODO: This next bit is a bit convoluted, but we need to clean up what we did in `process_reversal`. + # Maybe another refactor step is necessary to get rid of all of this hassle with ContributionTransactions. + # albertskja - 2026-07-22 + + # If there were any (positive) contribution transactions in the original instruction, we need to delete the + # contribution transactions that were created to reverse that payment and re-incur it. We assume that + # multiple reversals have not been made for the same membership on the same date. + for ct in ContributionTransaction.objects.filter(settlement=reversal.instruction, price__gt=0): + # Delete the reversal ContributionTransactions that were created when the reversal was processed. + ContributionTransaction.objects.filter( + Q(price=-ct.price) | Q(price=ct.price), + date=rt.date, + person=ct.person, membership=ct.membership, + ).delete() + + # Then, delete the ReversalTransaction that re-incurred the costs of the original direct debit to the personal tab. rt.delete() - reversal.delete() - # If there were contribution transactions, we need to reinstate payments and delete reversal contribution transactions. - for ct in ContributionTransaction.objects.filter(debt_collection=instruction): - if ct.price > 0: + # If the reversal reason was DNOR, the BadBIC with this BIC should be re-checked if it exists + if reversal.reason == Reversal.ReversalReasons.DNOR: + bad_bic = BadBIC.objects.filter(bic=reversal.instruction.authorization.bic).first() + if bad_bic: + bad_bic.update_reversals() - # Then we need to reinstate the payment that was deleted when the reversal was processed. - pm = Payment(membership=ct.membership, payment_type=PaymentType.objects.get(id=4), - amount=ct.price, date=ct.date) - pm.save() - - # Now we filter for the reversal ContributionTransaction that was created when the reversal was processed and delete it. - cct = ContributionTransaction.objects.filter(membership=ct.membership, price=-ct.price) - cct.delete() + # Finally, delete the entire Reversal. + reversal.delete() def process_amendment(authorization, date, iban, bic, reason): @@ -413,7 +428,7 @@ def delete_amendment(amendment): Delete an unsent amendment to an authorization. Make sure you call this method only from within a database transaction! - + Make sure you call this method only for amendments that have not yet been sent to the bank! """ diff --git a/amelie/personal_tab/forms.py b/amelie/personal_tab/forms.py index 053a0663..55df36b1 100644 --- a/amelie/personal_tab/forms.py +++ b/amelie/personal_tab/forms.py @@ -5,14 +5,14 @@ from typing import Optional, List from django.forms.models import ModelChoiceIterator +from django.utils.timezone import get_current_timezone from localflavor.generic.forms import BICFormField, IBANFormField from django import forms from django.conf import settings from django.db import transaction -from django.db.models import Exists, OuterRef, TextChoices +from django.db.models import TextChoices from django.utils import timezone -from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _l from amelie.members.models import Membership, Person, Committee @@ -20,8 +20,7 @@ from amelie.personal_tab.transactions import cookie_corner_sale from amelie.personal_tab import statistics from amelie.personal_tab.models import CustomTransaction, CookieCornerTransaction, Declaration, RFIDCard, Reversal, \ - AuthorizationType, \ - DebtCollectionBatch, Authorization, Article, Category + AuthorizationType, DebtCollectionBatch, Authorization, ManualPaymentSettlement, PaymentMethod, Article, Category from amelie.tools.http import get_client_ips from amelie.tools.ipp_printer import IPPPrinter from amelie.tools.widgets import DateSelector, DateTimeSelector, MemberSelect @@ -66,8 +65,7 @@ def __init__(self, minimal_execution_date, *args, **kwargs): self.fields['execution_date'].initial = minimal_execution_date # Select the years with unpaid memberships to show contribution_years = Membership.objects.filter( - payment__isnull=True, - type__price__gt=0 + contributiontransaction__settlement=None, type__price__gt=0 ).distinct().order_by('-year').values_list('year', flat=True) self.fields['contribution_years'].choices = [(year, "{}-{}".format(year, year+1)) for year in contribution_years] @@ -129,6 +127,40 @@ def clean(self): return cleaned_data +class ManualPaymentSettlementForm(forms.ModelForm): + payment_date = forms.DateField(label=_l('Payment date'), widget=DateSelector, initial=timezone.now) + payment_method = forms.ModelChoiceField(PaymentMethod.objects.filter(visible=True), label=_l('Payment method'), required=True) + paid_amount = forms.DecimalField( + max_digits=5, decimal_places=2, label=_l('Paid amount'), initial=0, + help_text=_l("The amount that was paid in this settlement. Can be different from the amount that needs to be paid, " + "can even be negative (if money was paid out by the association). " + "Useful if, say, the transactions add up to 9 euro, you can enter a payment of 10 euro, " + "which leaves 1 euro extra as balance on the person's personal tab.")) + + class Meta: + model = ManualPaymentSettlement + fields = ('payment_date', 'payment_method') + + def __init__(self, person: Person, *args, **kwargs): + super().__init__(*args, **kwargs) + self.person = person + + +class ManualPaymentTransactionForm(forms.Form): + id = forms.IntegerField() + include = forms.BooleanField(initial=False, required=False) + + def __init__(self, *args, initial=None, **kwargs): + # initial = {'transaction': t, 'include': False} + if initial is not None: + self.transaction = initial.pop('transaction') + initial['id'] = self.transaction.id + super().__init__(*args, **kwargs) + + +ManualPaymentTransactionFormSet = forms.formset_factory(ManualPaymentTransactionForm, extra=0) + + class SearchAuthorizationForm(forms.Form): class AuthorizationStatuses(TextChoices): UNSIGNED = 'unsigned', _l("Not signed") @@ -396,6 +428,12 @@ def __init__(self, person: Optional[Person], *args, **kwargs): super().__init__(*args, **kwargs) +class MyBookkeepingChoiceField(forms.ModelChoiceField): + # Overriding label_from_instance to show the abbreviation instead of full committee name + def label_from_instance(self, obj: Committee): + return obj.abbreviation + + class DeclarationForm(forms.Form): committee = MyCommitteesFirstChoiceField( person=None, # Will be set in __init__ @@ -442,10 +480,18 @@ class DeclarationForm(forms.Form): required = False, help_text=_l('Select documents to attach to the declaration.'), ) + bookkeeping = MyBookkeepingChoiceField( + queryset=Committee.objects.filter(abbreviation__in=settings.DECLARATION_EMAIL_COMMITTEE_OVERRIDE), + empty_label=_l("Inter-Actief"), + required=False, + label=_l('Bookkeeping'), + help_text=_l('Select the bookkeeping where the declaration should be sent.\nIf you do not know what this means, do not change it.') + ) def __init__(self, person: Person, *args, **kwargs): super().__init__(*args, **kwargs) + self.person = person # Only show parent committees, sorted by name, but set person to show own committees first self.fields['committee'].person = person @@ -458,7 +504,6 @@ def __init__(self, person: Person, *args, **kwargs): self.fields['iban_choice'].choices = list(set((iban, iban) for iban in person.has_mandate().distinct().order_by('start_date').values_list('iban', flat=True))) self.fields['iban_choice'].choices += [('', _l("-- Other --"))] self.initial['iban_choice'] = self.fields['iban_choice'].choices[0][0] if self.fields['iban_choice'].choices else None - self.person = person def clean_documents(self): documents = self.cleaned_data.get('documents') @@ -527,6 +572,7 @@ def save(self, request): amount = self.cleaned_data.get('amount') description = self.cleaned_data.get('description') documents = self.cleaned_data.get('documents', []) + bookkeeping = self.cleaned_data.get('bookkeeping') # Create database entry for the declaration declaration = Declaration.objects.create( @@ -537,11 +583,24 @@ def save(self, request): amount=amount, description=description, # Saving the document names as a JSON list - document_names=json.dumps([doc.name for doc in documents]) + document_names=json.dumps([doc.name for doc in documents]), + bookkeeping=bookkeeping, ) + # Set the declaration email to committee email if a different bookkeeping was selected, otherwise normal declaration email + if bookkeeping: + email_to = settings.DECLARATION_EMAIL_COMMITTEE_OVERRIDE.get(bookkeeping.abbreviation, settings.DECLARATION_EMAIL) + reply_to = settings.TREASURER_EMAIL_COMMITTEE_OVERRIDE.get(bookkeeping.abbreviation, settings.TREASURER_EMAIL) + else: + email_to = settings.DECLARATION_EMAIL + reply_to = settings.TREASURER_EMAIL + # Prepare context for the email - context = {'declaration': declaration} + context = { + 'declaration': declaration, + 'submitter_name': person.incomplete_name(), + 'submission_date': declaration.submission_date.astimezone(get_current_timezone()) + } # Generate PDF of the declaration form and add it to the attachments pdf = declaration.get_pdf() @@ -554,10 +613,11 @@ def save(self, request): task = MailTask(template_name='declaration/declaration.mail', report_to=settings.EMAIL_REPORT_TO, report_always=False, priority=TaskPriority.MEDIUM) - task.add_recipient(Recipient(tos=[settings.DECLARATION_EMAIL], - context=context, - language='en', - headers={'Reply-To': settings.TREASURER_EMAIL}, attachments=attachments)) + task.add_recipient(Recipient(tos=[email_to], + ccs=[person.email_address], + context=context, + headers={'Reply-To': reply_to}, + attachments=attachments)) task.send() diff --git a/amelie/personal_tab/management/commands/unpaid_balance_reminder_mail.py b/amelie/personal_tab/management/commands/unpaid_balance_reminder_mail.py new file mode 100644 index 00000000..d193924e --- /dev/null +++ b/amelie/personal_tab/management/commands/unpaid_balance_reminder_mail.py @@ -0,0 +1,65 @@ +from urllib.parse import urljoin + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.urls import reverse + +from amelie.iamailer import MailTask +from amelie.personal_tab.transactions import get_personal_tab_balances +from amelie.tools.const import TaskPriority +from amelie.tools.mail import PersonRecipient + + +class Command(BaseCommand): + help = "Send open balance reminder to members that have a positive open personal tab balance but no authorization." + + def add_arguments(self, parser): + super(Command, self).add_arguments(parser=parser) + parser.add_argument( + '--send', + action='store_true', + dest='send', + default=False, + help='Actually send the e-mails' + ) + + def handle(self, *args, **options): + send_mails = options['send'] + if not send_mails: + self.stderr.write('** TEST MODE **') + self.stderr.write('Add --send to save changes') + + self.stdout.write(self.style.SUCCESS(f'Calculating personal tab balances...')) + + balances_per_person = get_personal_tab_balances(only_members=True, only_without_mandate=True) + task = MailTask(from_="I.C.T.S.V. Inter-Actief ", + template_name='personal_tab/balance_reminder.mail', + report_to="I.C.T.S.V. Inter-Actief ", + report_always=False, + priority=TaskPriority.MEDIUM) + + # Filter balances list to only positive, above minimum amount. + num_recipients = 0 + for bal in balances_per_person: + if bal['person'].pk != 3289: + continue + if bal['balance'] <= settings.PERSONAL_TAB_MINIMUM_BALANCE_REMINDER: + continue # Balance too low for reminder + task.add_recipient(PersonRecipient(bal['person'], context={ + 'balance': bal['balance'], + 'num_unpaid_transactions': bal['unpaid_transactions'].count(), + 'overview_link': urljoin(settings.ABSOLUTE_PATH_TO_SITE, reverse('personal_tab:person_transactions_unpaid', kwargs={ + 'pk': bal['person'].pk, 'slug': bal['person'].slug + })), + })) + num_recipients += 1 + + if num_recipients > 0: + self.stdout.write(self.style.SUCCESS(f'Sending balance reminder emails to {num_recipients} members...')) + if send_mails: + task.send() + else: + self.stdout.write("...but not really... (option --send not given).") + else: + self.stdout.write(self.style.SUCCESS(f'No recipients, not sending e-mails...')) + self.stdout.write("Done") diff --git a/amelie/personal_tab/managers.py b/amelie/personal_tab/managers.py index e4d99632..95454020 100644 --- a/amelie/personal_tab/managers.py +++ b/amelie/personal_tab/managers.py @@ -4,7 +4,6 @@ from django.db.models import Q from django.db.models.aggregates import Sum from django.utils import timezone -from datetime import timezone as tz from amelie.members.models import Person @@ -21,11 +20,7 @@ def _people_with_outstanding_balance(): """ Returns a QuerySet with all Persons having a non-zero cookie corner balance. """ - # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET - begin = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) - - return Person.objects.filter(transaction__date__gte=begin).annotate(balance=Sum('transaction__price')).filter( - balance__gt=0) + return Person.objects.all().annotate(balance=Sum('transaction__price')).filter(balance__gt=0) def _people_with_recent_or_future_transactions(days): diff --git a/amelie/personal_tab/migrations/0015_personaltabsettlement_and_more.py b/amelie/personal_tab/migrations/0015_personaltabsettlement_and_more.py new file mode 100644 index 00000000..dc1e3a65 --- /dev/null +++ b/amelie/personal_tab/migrations/0015_personaltabsettlement_and_more.py @@ -0,0 +1,367 @@ +# Written by Kevin Alberts on 2026-07-20 +import datetime +from datetime import timezone as tz +import logging +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models +from django.utils import translation +from django.utils.translation import gettext as _ + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +# Based on ManualPaymentSettlement.create_settlement_for_transactions method. +def create_settlement_for_transactions(ManualPaymentSettlement, SettlementManualPaymentTransaction, + transactions, payment_method, person, description, + payment_datetime, created_by=None): + transactions = list(transactions) + if not transactions: + raise ValueError("No transactions given! Cannot create an empty manual payment") + + # Calculate the total settlement amount + total_transactions_price = sum(t.price for t in transactions) or Decimal("0.00") + + # Create a ManualPayment settlement with the given payment method. + settlement = ManualPaymentSettlement.objects.create( + payment_date=payment_datetime.date(), + payment_method=payment_method, + description=description[:140], + person=person, + amount=total_transactions_price, + created_by=created_by + ) + + # Save the settlement reference in all transactions + for transaction in transactions: + transaction.settlement = settlement + transaction.save() + + # If transactions come out to a non-zero amount, add a SettlementManualPaymentTransaction for + # the total transaction amount to cancel out the balance of those transactions + if total_transactions_price != 0: + settlement_transaction = SettlementManualPaymentTransaction( + date=payment_datetime, price=-total_transactions_price, person=person, + description=description[:200], settlement=settlement, added_by=created_by + ) + settlement_transaction.save() + # Register this manual payment as the payment transaction for the settlement + settlement.manual_payment_transaction = settlement_transaction + settlement.save() + return settlement + + +def import_payment_methods_forwards(apps, schema_editor): + """Copy each old members.PaymentType into personal_tab.PaymentMethod, preserving PKs.""" + PaymentType = apps.get_model('members', 'PaymentType') + PaymentMethod = apps.get_model('personal_tab', 'PaymentMethod') + + logger.info('Migrating old members.PaymentType to new personal_tab.PaymentMethod...') + total_count = PaymentType.objects.count() + for i, old in enumerate(PaymentType.objects.all().order_by('pk')): + logger.info(f'- [{i+1}/{total_count}] Processing {old.pk} - {old.name}...') + # Create the new inherited DebtCollectionInstruction, re-using the same ID. + PaymentMethod.objects.create( + id=old.id, + name_en=old.name, + name_nl=old.name, + description_en=old.description, + description_nl=old.description, + visible=old.visible, + visible_memberships=old.visible, + visible_activities=old.visible + ) + + +def import_payment_methods_backwards(apps, schema_editor): + """Reverse: Delete all PaymentMethods""" + PaymentMethod = apps.get_model('personal_tab', 'PaymentMethod') + PaymentMethod.objects.all().delete() + + +def migrate_debt_collection_instructions_forwards(apps, schema_editor): + """Copy each old DebtCollectionInstruction into PersonalTabSettlement + new DebtCollectionInstruction, preserving PKs.""" + OldDebtCollectionInstruction = apps.get_model('personal_tab', 'OldDebtCollectionInstruction') + DebtCollectionInstruction = apps.get_model('personal_tab', 'DebtCollectionInstruction') + + logger.info('Migrating old debt collections to new model...') + total_count = OldDebtCollectionInstruction.objects.count() + for i, old in enumerate(OldDebtCollectionInstruction.objects.all().order_by('pk')): + if i % 1000 == 0: + logger.info(f'- [{i}/{total_count}] Processing ID {old.id}...') + # Create the new inherited DebtCollectionInstruction, re-using the same ID. + DebtCollectionInstruction.objects.create( + id=old.id, + description=old.description, + amount=old.amount, + end_to_end_id=old.end_to_end_id, + amendment=old.amendment, + authorization=old.authorization, + batch=old.batch, + ) + + +def migrate_debt_collection_instructions_backwards(apps, schema_editor): + """Reverse: flatten PersonalTabSettlement + DebtCollectionInstruction back into a single table.""" + DebtCollectionInstruction = apps.get_model('personal_tab', 'DebtCollectionInstruction') + OldDebtCollectionInstruction = apps.get_model('personal_tab', 'OldDebtCollectionInstruction') + + logger.info('Reverting new debt collections to old model...') + for dci in DebtCollectionInstruction.objects.all(): + logger.info(f'- {dci.id} - {dci.amount} eur') + OldDebtCollectionInstruction.objects.create( + id=dci.id, + description=dci.description, # comes from the parent via multi-table inheritance + amount=dci.amount, # comes from the parent via multi-table inheritance + end_to_end_id=dci.end_to_end_id, + amendment=dci.amendment, + authorization=dci.authorization, + batch=dci.batch, + ) + + +def incomplete_name(person): + first_name = person.first_name + if not person.first_name and person.initials: + first_name = person.initials + if person.last_name_prefix: + return ' '.join([first_name, person.last_name_prefix, person.last_name]) + else: + return ' '.join([first_name, person.last_name]) + + +def create_manual_batch_for_pre_sepa_transactions_forwards(apps, scheme_editor): + Person = apps.get_model('members', 'Person') + Transaction = apps.get_model('personal_tab', 'Transaction') + ManualPaymentSettlement = apps.get_model('personal_tab', 'ManualPaymentSettlement') + SettlementManualPaymentTransaction = apps.get_model('personal_tab', 'SettlementManualPaymentTransaction') + PaymentMethod = apps.get_model('personal_tab', 'PaymentMethod') + logger.info(f'Create manual payment settlements for any transactions from before the SEPA debt collections came into effect....') + try: + pre_sepa_payment_method = PaymentMethod.objects.get(name_en='Pre-SEPA') + logger.info(f'Using existing Pre-SEPA payment method with PK "{pre_sepa_payment_method.pk}"...') + except PaymentMethod.DoesNotExist: + # No Pre-SEPA payment type exists, but we need one to link the new manual payment settlement to. Create one. + pre_sepa_payment_method = PaymentMethod.objects.create( + name_en="Pre-SEPA", + name_nl="Pre-SEPA", + description_en="Pre-SEPA payment. This payment was made before the introduction of SEPA authorizations.", + description_nl="Pre-SEPA-betaling. Deze betaling is gedaan voor de introductie van SEPA-machtigingen.", + visible=False, visible_memberships=False, visible_activities=False + ) + logger.info(f'No Pre-SEPA payment type existed. Created a new one with PK "{pre_sepa_payment_method.pk}"...') + + # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET + begin_date = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) + transaction_date = datetime.datetime(2013, 10, 30, 22, 59, 59, tzinfo=tz.utc) + # Get all the pre-SEPA transactions that have not been settled + pre_sepa_transactions = Transaction.objects.filter(settlement=None, date__lt=begin_date) + + # Group by person + people = pre_sepa_transactions.filter(person__isnull=False).order_by('person').distinct().values('person') + people_count = len(people) + for i, p in enumerate(people): + person = Person.objects.get(id=p['person']) + # Translate any description strings to the user's preferred language + with translation.override(person.preferred_language): + person_name = incomplete_name(person) + transactions = pre_sepa_transactions.filter(person=person) + logger.info(f'- [{i+1}/{people_count}] {person_name} - {transactions.count()} pre-SEPA transactions...') + + # Create a settlement payment to mark the old transactions as paid on the SEPA introduction date. + description = _("Settlement for transactions handled before SEPA debits were introduced - {person_name}").format(person_name=person_name) + create_settlement_for_transactions( + ManualPaymentSettlement, + SettlementManualPaymentTransaction, + transactions=transactions, + payment_method=pre_sepa_payment_method, + person=person, + description=description, + payment_datetime=transaction_date, + created_by=None + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0023_documenso_id_signed_document'), + ('personal_tab', '0014_pendingregistertoken'), + ] + + operations = [ + # Create the new PaymentMethod model. + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_en', models.CharField(max_length=20, unique=True, verbose_name='Name (EN)')), + ('name_nl', models.CharField(max_length=20, unique=True, verbose_name='Name (NL)')), + ('description_en', models.TextField(verbose_name='Description (EN)')), + ('description_nl', models.TextField(verbose_name='Description (NL)')), + ('visible', models.BooleanField(default=True, help_text="This payment method will be visible when creating a manual payment directly in someone's personal tab.", verbose_name='Visible')), + ('visible_activities', models.BooleanField(default=True, help_text='This payment method will be visible when privileged members enter a manual payment for an activity.', verbose_name='Visible for activities')), + ('visible_memberships', models.BooleanField(default=True, help_text='This payment method will be visible when privileged members enter a manual payment for a membership.', verbose_name='Visible for memberships')), + ('frontend_icon_name', models.CharField(blank=True, max_length=20, null=True, verbose_name='Icon name for frontend')), + ], + options={ + 'verbose_name': 'payment method', + 'verbose_name_plural': 'payment methods', + 'ordering': ['name_en', 'name_nl'] + }, + ), + + # Create PaymentMethod instances from existing PaymentType instances. + migrations.RunPython(import_payment_methods_forwards, import_payment_methods_backwards), + + # Create the new PersonalTabSettlement parent model. + migrations.CreateModel( + name='PersonalTabSettlement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=140, validators=[django.core.validators.RegexValidator(message='Only alphanumerical signs are allowed', regex="^[a-zA-Z0-9-?:().,\\'+ ]*$")], verbose_name='description')), + ('amount', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='amount')), + ], + options={ + 'verbose_name': 'personal tab settlement', + 'verbose_name_plural': 'personal tab settlements', + 'ordering': ['pk'], + }, + ), + + # Create new transaction models to support manual settlements. + migrations.CreateModel( + name='SettlementExtraBalanceTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='personal_tab.transaction')), + ], + bases=('personal_tab.transaction',), + ), + migrations.CreateModel( + name='SettlementManualPaymentTransaction', + fields=[ + ('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='personal_tab.transaction')), + ], + bases=('personal_tab.transaction',), + ), + + # Create the new ManualPaymentSettlement sibling model (no data to migrate) + migrations.CreateModel( + name='ManualPaymentSettlement', + fields=[ + ('personaltabsettlement_ptr', models.OneToOneField( + auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='personal_tab.personaltabsettlement') + ), + ('payment_date', models.DateField(verbose_name='payment date')), + ('person', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + to='members.person', verbose_name='person') + ), + ('payment_method', models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to='personal_tab.paymentmethod', verbose_name='Payment method') + ), + ('created_by', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='+', to='members.person', verbose_name='Created by') + ), + ('extra_balance_transaction', models.OneToOneField( + blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, + related_name='manual_settlement', to='personal_tab.settlementextrabalancetransaction') + ), + ('manual_payment_transaction', models.OneToOneField( + blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, + related_name='manual_settlement', to='personal_tab.settlementmanualpaymenttransaction') + ) + ], + options={ + 'verbose_name': 'manual payment settlement', + 'verbose_name_plural': 'manual payment settlements', + 'ordering': ['-payment_date', '-person', '-id'], + }, + bases=('personal_tab.personaltabsettlement',), + ), + + # Rename the old DebtCollectionInstruction so the new one can take its name. + migrations.RenameModel('DebtCollectionInstruction', 'OldDebtCollectionInstruction'), + + # 4. Create the new DebtCollectionInstruction inheriting from PersonalTabSettlement. + migrations.CreateModel( + name='DebtCollectionInstruction', + fields=[ + ('personaltabsettlement_ptr', + models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, + primary_key=True, serialize=False, to='personal_tab.personaltabsettlement')), + ('end_to_end_id', models.CharField(max_length=35, validators=[ + django.core.validators.RegexValidator(message='Only alphanumerical signs are allowed', + regex="^[a-zA-Z0-9-?:().,\\'+ ]*$")], + verbose_name='end-to-end-id')), + ('amendment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='instruction', to='personal_tab.amendment', + verbose_name='amendment')), + ('authorization', + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instructions', + to='personal_tab.authorization', verbose_name='mandate')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instructions', + to='personal_tab.debtcollectionbatch', verbose_name='batch')), + ], + options={ + 'verbose_name': 'direct withdrawal-instruction', + 'verbose_name_plural': 'direct withdrawal-instructions', + 'ordering': ['batch', 'authorization'], + }, + bases=('personal_tab.personaltabsettlement',), + ), + + # Move the data over, preserving the original IDs. + migrations.RunPython(migrate_debt_collection_instructions_forwards, migrate_debt_collection_instructions_backwards), + + # Re-point foreign keys on Transactions to point to PersonalTabSettlement. + migrations.AlterField( + model_name='transaction', + name='debt_collection', + field=models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='transactions', + to='personal_tab.personaltabsettlement', + verbose_name='Settlement' + ), + ), + + # Rename Transaction.debt_collection foreign key to Transaction.settlement + migrations.RenameField( + model_name='transaction', + old_name='debt_collection', + new_name='settlement', + ), + + # Re-point foreign keys on Reversals to point to the new DebtCollectionInstruction model. + migrations.AlterField( + model_name='reversal', + name='instruction', + field=models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name='reversal', + to='personal_tab.debtcollectioninstruction', + verbose_name='instruction' + ), + ), + + # Drop the old table. + migrations.DeleteModel('OldDebtCollectionInstruction'), + + # Create a manual payment settlement for any transactions before the SEPA debt collections came into effect. + # (2013-10-30, 23:00:00, tz.utc) + migrations.RunPython(create_manual_batch_for_pre_sepa_transactions_forwards), + + # Edit ordering on Transactions so even if added on date is identical, newest transaction by PK is still shown first. + migrations.AlterModelOptions( + name='transaction', + options={'ordering': ['-date', '-added_on', '-pk'], 'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'}, + ), + ] diff --git a/amelie/personal_tab/migrations/0016_badbic.py b/amelie/personal_tab/migrations/0016_badbic.py new file mode 100644 index 00000000..b66c801e --- /dev/null +++ b/amelie/personal_tab/migrations/0016_badbic.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.12 on 2026-07-15 11:26 + +import django.db.models.deletion +import localflavor.generic.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('personal_tab', '0015_personaltabsettlement_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='BadBIC', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bic', localflavor.generic.models.BICField(max_length=11, unique=True, verbose_name='BIC')), + ('date_added', models.DateTimeField(auto_now_add=True, verbose_name='Date added')), + ('first_reversal', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='personal_tab.reversal', verbose_name='First reversal')), + ('last_reversal', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='personal_tab.reversal', verbose_name='Last reversal')), + ], + options={ + 'verbose_name': 'known bad BIC', + 'verbose_name_plural': 'known bad BICs', + 'ordering': ['bic'], + }, + ), + ] diff --git a/amelie/personal_tab/migrations/0017_declaration_bookkeeping.py b/amelie/personal_tab/migrations/0017_declaration_bookkeeping.py new file mode 100644 index 00000000..ee6d2993 --- /dev/null +++ b/amelie/personal_tab/migrations/0017_declaration_bookkeeping.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.12 on 2026-07-16 13:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0024_delete_payment_delete_paymenttype'), + ('personal_tab', '0016_badbic'), + ] + + operations = [ + migrations.AddField( + model_name='declaration', + name='bookkeeping', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='members.committee', verbose_name='bookkeeping'), + ), + ] diff --git a/amelie/personal_tab/models.py b/amelie/personal_tab/models.py index 2e4099dd..4e295998 100644 --- a/amelie/personal_tab/models.py +++ b/amelie/personal_tab/models.py @@ -1,28 +1,33 @@ # -*- coding: UTF-8 -*- import datetime +import decimal import logging -from io import BytesIO -from typing import Optional +from decimal import Decimal +from typing import Optional, Iterable, Tuple from io import BytesIO import json from django.conf import settings +from django.contrib import admin from django.core.files.base import ContentFile +from django.templatetags.static import static from django.urls import reverse from django.shortcuts import get_object_or_404 from django.core.validators import RegexValidator from django.db import models, transaction -from django.db.models import Q +from django.db.models import Q, Sum from django.db.models.signals import pre_save, post_save, post_delete -from django.utils import timezone -from django.utils.translation import get_language, gettext_lazy as _l, gettext +from django.utils import timezone, translation +from django.utils.safestring import mark_safe +from django.utils.translation import get_language, gettext as _, gettext_lazy as _l from documenso_sdk import EnvelopeDistributeResponse from localflavor.generic.models import BICField, IBANField from amelie.claudia.tools import verify_instance from amelie.members.models import Person, Membership, Committee from amelie.personal_tab.managers import AuthorizationManager, DebtCollectionInstructionManager +from amelie.tools.logic import current_association_year class DiscountPeriod(models.Model): @@ -121,8 +126,8 @@ class Transaction(models.Model): price: The price that this transaction is for. person: The person this transaction is for. description: The description of this transaction. - discount: Links to the Discount object in which this credit is used. - debt_collection: Links to the Debt Collection Entry on which this transaction was collected. + discount: Links to the Discount object in which credit was used to pay for (part of) this transaction. + settlement: Links to the Personal Tab Settlement with which this transaction was settled. added_on: The date and time on which this object was created. added_by: The person that created this object. @@ -134,8 +139,8 @@ class Transaction(models.Model): description = models.CharField(max_length=200, blank=True, verbose_name=_l('Description')) discount = models.OneToOneField(Discount, blank=True, null=True, default=None, verbose_name=_l('discount'), on_delete=models.SET_NULL) - debt_collection = models.ForeignKey('DebtCollectionInstruction', verbose_name=_l('Direct withdrawal'), blank=True, null=True, - related_name="transactions", on_delete=models.PROTECT) + settlement = models.ForeignKey('PersonalTabSettlement', verbose_name=_l('Settlement'), blank=True, null=True, + related_name="transactions", on_delete=models.PROTECT) added_on = models.DateTimeField(verbose_name=_l('Added on'), auto_now_add=True) @@ -143,7 +148,7 @@ class Transaction(models.Model): added_by = models.ForeignKey(Person, verbose_name=_l('Added by'), blank=True, null=True, related_name="+", on_delete=models.PROTECT) class Meta: - ordering = ['-date', '-added_on'] + ordering = ['-date', '-added_on', '-pk'] verbose_name = _l('Transaction') verbose_name_plural = _l("Transactions") @@ -153,6 +158,37 @@ def get_absolute_url(self): def __str__(self): return '{} ({})'.format(self.description, self.price) + @property + def transaction_type(self): + if hasattr(self, 'activitytransaction'): + return "activitytransaction", _l("Activity Transaction") + elif hasattr(self, 'alexiatransaction'): + return "alexiatransaction", _l("Alexia Transaction") + elif hasattr(self, 'contributiontransaction'): + return "contributiontransaction", _l("Contribution Transaction") + elif hasattr(self, 'cookiecornertransaction'): + return "cookiecornertransaction", _l("Cookie Corner Transaction") + elif hasattr(self, 'customtransaction'): + return "customtransaction", _l("Custom Transaction") + elif hasattr(self, 'debtcollectiontransaction'): + return "debtcollectiontransaction", _l("Debt Collection Transaction") + elif hasattr(self, 'reversaltransaction'): + return "reversaltransaction", _l("Reversal Transaction") + elif hasattr(self, 'settlementextrabalancetransaction'): + return "settlementextrabalancetransaction", _l("Extra Balance Transaction") + elif hasattr(self, 'settlementmanualpaymenttransaction'): + return "settlementmanualpaymenttransaction", _l("Manual Payment Transaction") + else: + return None + + @property + def price_cents(self): + return (self.price * 100).quantize(Decimal("0"), decimal.ROUND_HALF_UP) + + def is_paid(self): + # A transaction is paid if a settlement exists, and that settlement is also paid. + return self.settlement.is_paid() if self.settlement else False + @property def editable(self): return False @@ -166,7 +202,7 @@ class CustomTransaction(Transaction): """ @property def editable(self): - return not self.debt_collection + return not self.settlement class ActivityTransaction(Transaction): @@ -218,7 +254,7 @@ def get_absolute_url(self): @property def editable(self): - return not self.debt_collection + return not self.settlement class AlexiaTransaction(Transaction): @@ -633,10 +669,10 @@ def send_signature_request(self) -> Optional[EnvelopeDistributeResponse]: def process_signed_document(self): if not self.documenso_id: - raise ValueError(gettext("Documenso ID is not set. No documents are due to be signed.")) + raise ValueError(_l("Documenso ID is not set. No documents are due to be signed.")) # Retrieve the document and save it to the database from amelie.tools.documenso import retrieve_documents - documents, _ = retrieve_documents(self.documenso_id) + documents, unused = retrieve_documents(self.documenso_id) if len(documents) > 1: logging.getLogger(__name__).warning(f"Received multiple documents from Documenso for {self}. Only saving the first one.") @@ -805,7 +841,59 @@ class Meta: verbose_name_plural = _l('direct withdrawal-batches') -class DebtCollectionInstruction(models.Model): +class PersonalTabSettlement(models.Model): + """ + Base class to represent a payment to settle the personal tab balance. + + This can either represent a DebtCollectionInstruction, or a ManualPaymentSettlement. + + Please note that processing properties of this model may be subject to privacy regulations. Refer to + https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. + """ + """Description rule.""" + description = models.CharField(max_length=140, verbose_name=_l('description'), validators=[SEPA_CHAR_VALIDATOR]) + + """Amount of the settlement. (Either to be collected via debit, or paid manually.)""" + amount = models.DecimalField(max_digits=8, decimal_places=2, verbose_name=_l('amount')) + + @property + def settlement_type(self): + if hasattr(self, 'debtcollectioninstruction'): + return self.debtcollectioninstruction.settlement_type + elif hasattr(self, 'manualpaymentsettlement'): + return self.manualpaymentsettlement.settlement_type + raise NotImplementedError("Base class Personal tab settlement has no settlement type.") + + def is_paid(self): + if hasattr(self, 'debtcollectioninstruction'): + return self.debtcollectioninstruction.is_paid() + elif hasattr(self, 'manualpaymentsettlement'): + return self.manualpaymentsettlement.is_paid() + raise NotImplementedError("Base class Personal tab settlement cannot be paid.") + + def __str__(self): + if hasattr(self, 'debtcollectioninstruction'): + return self.debtcollectioninstruction.__str__() + elif hasattr(self, 'manualpaymentsettlement'): + return self.manualpaymentsettlement.__str__() + if self.id: + return f'Personal tab settlement #{self.id}' + return 'Personal tab settlement (new)' + + def get_absolute_url(self): + if hasattr(self, 'debtcollectioninstruction'): + return self.debtcollectioninstruction.get_absolute_url() + elif hasattr(self, 'manualpaymentsettlement'): + return self.manualpaymentsettlement.get_absolute_url() + raise NotImplementedError("Base class Personal tab settlement has no detail view.") + + class Meta: + ordering = ['pk'] + verbose_name = _l('personal tab settlement') + verbose_name_plural = _l('personal tab settlements') + + +class DebtCollectionInstruction(PersonalTabSettlement): """ Instruction to collect some amount from a certain account. @@ -829,22 +917,25 @@ class DebtCollectionInstruction(models.Model): """End-to-end-id. This will be passed on to the cashed (the person paying).""" end_to_end_id = models.CharField(max_length=35, verbose_name=_l('end-to-end-id'), validators=[SEPA_CHAR_VALIDATOR]) - """Description rule.""" - description = models.CharField(max_length=140, verbose_name=_l('description'), validators=[SEPA_CHAR_VALIDATOR]) - - """Amount to be collected.""" - amount = models.DecimalField(max_digits=8, decimal_places=2, verbose_name=_l('amount')) - """An amendment given with this instruction.""" amendment = models.OneToOneField(Amendment, related_name='instruction', on_delete=models.PROTECT, verbose_name=_l('amendment'), null=True, blank=True) objects = DebtCollectionInstructionManager() + @property + def settlement_type(self): + return "INSTR" + + def is_paid(self): + # A debt collection is paid if there is no reversal for it, or if the reversal is also paid. + return self.reversal.is_paid() if hasattr(self, 'reversal') else True + def debt_collection_reference(self): - """Returns the debt collection reference with prefix.""" + """Returns the debt collection reference with a prefix.""" if self.id: return '%s%08d' % (DebtCollectionInstruction.PREFIX, self.id) + return None def __str__(self): return self.debt_collection_reference() or 'New debt collection instruction' @@ -858,6 +949,281 @@ class Meta: verbose_name_plural = _l('direct withdrawal-instructions') +class PaymentMethod(models.Model): + """ + Payment method used for a manual payment. + """ + name_nl = models.CharField(max_length=20, unique=True, verbose_name=_l('Name (NL)')) + name_en = models.CharField(max_length=20, unique=True, verbose_name=_l('Name (EN)')) + description_nl = models.TextField(verbose_name=_l('Description (NL)')) + description_en = models.TextField(verbose_name=_l('Description (EN)')) + visible = models.BooleanField(default=True, verbose_name=_l('Visible'), help_text=_l("This payment method will be visible when creating a manual payment directly in someone's personal tab.")) + visible_memberships = models.BooleanField(default=True, verbose_name=_l('Visible for memberships'), help_text=_l('This payment method will be visible when privileged members enter a manual payment for a membership.')) + visible_activities = models.BooleanField(default=True, verbose_name=_l('Visible for activities'), help_text=_l('This payment method will be visible when privileged members enter a manual payment for an activity.')) + frontend_icon_name = models.CharField(max_length=20, blank=True, null=True, verbose_name=_l('Icon name for frontend')) + + class Meta(object): + ordering = ['name_en', 'name_nl'] + verbose_name = _l('payment method') + verbose_name_plural = _l('payment methods') + + def __str__(self): + return f"{self.name} - {self.description}" + + @property + def name(self): + language = get_language() + + if language == "en" and self.name_en: + return self.name_en + else: + return self.name_nl + + @property + def description(self): + language = get_language() + + if language == "en" and self.description_en: + return self.description_en + else: + return self.description_nl + + @property + def icon_name(self): + return self.frontend_icon_name or 'wand' + + @admin.display(description=_l("Frontend Icon")) + @mark_safe + def frontend_icon_display(self): + if self.frontend_icon_name: + static_path = static(f"img/icons/{self.frontend_icon_name}.png") + return f'{self.frontend_icon_name}' + return "—" + + +class SettlementManualPaymentTransaction(Transaction): + """ + Transaction for an executed manual payment or refund of some amount to/from the personal tab balance. + + Please note that processing properties of this model may be subject to privacy regulations. Refer to + https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. + """ + pass + + +class SettlementExtraBalanceTransaction(Transaction): + """ + Transaction representing any extra balance that was left over after a manual settlement. + Can only be created from extra deposited money in a ManualPaymentSettlement. + + Please note that processing properties of this model may be subject to privacy regulations. Refer to + https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. + """ + pass + + +class ManualPaymentSettlement(PersonalTabSettlement): + """ + Record of a manual payment to settle a personal tab balance. + + Please note that processing properties of this model may be subject to privacy regulations. Refer to + https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. + """ + + """Prefix for payment reference.""" + PREFIX = 'IA-MANUAL-' + + """The date on which this manualpayment was made.""" + payment_date = models.DateField(verbose_name=_l('payment date')) + + """Person that the settlement relates to.""" + person = models.ForeignKey(Person, verbose_name=_l('person'), null=True, blank=True, on_delete=models.PROTECT) + + """Payment method""" + payment_method = models.ForeignKey(PaymentMethod, verbose_name=_l('Payment method'), on_delete=models.PROTECT) + + """Person that created the manual payment settlement.""" + created_by = models.ForeignKey(Person, verbose_name=_l('Created by'), blank=True, null=True, related_name="+", on_delete=models.PROTECT) + # '+' related_name makes sure no reverse relation is added to Person + + """The SettlementManualPaymentTransaction that was created in this settlement.""" + manual_payment_transaction = models.OneToOneField(SettlementManualPaymentTransaction, on_delete=models.RESTRICT, related_name='manual_settlement', blank=True, null=True) + + """The SettlementExtraBalanceTransaction that was created in this settlement.""" + extra_balance_transaction = models.OneToOneField(SettlementExtraBalanceTransaction, on_delete=models.RESTRICT, related_name='manual_settlement', blank=True, null=True) + + class Meta: + ordering = ['-payment_date', '-person', '-id'] + verbose_name = _l('manual payment settlement') + verbose_name_plural = _l('manual payment settlements') + + @property + def settlement_type(self): + return "MANUAL" + + def is_paid(self): + # A manual payment settlement is considered to always be paid when it is created. + return True + + def payment_reference(self): + """Returns the payment reference with a prefix.""" + if self.id: + return '%s%08d' % (ManualPaymentSettlement.PREFIX, self.id) + return None + + def __str__(self): + return self.payment_reference() or 'New manual payment settlement' + + def get_absolute_url(self): + return reverse('personal_tab:manual_payment_settlement_view', args=(), kwargs={'id': self.id, }) + + def deletion_possible(self) -> Tuple[bool, str]: + """ + Deletion is possible if: + - The settlement payment date falls in the current association year + - The SettlementExtraBalanceTransaction is not settled in any other settlement. + + - The sum of the transactions (excluding the SettlementManualPaymentTransaction) should equal negative the SettlementManualPaymentTransaction price. + - The settlement amount should equal the SettlementManualPaymentTransaction price plus the SettlementExtraBalanceTransaction price. + """ + # General limitation to avoid creating inconsistencies in closed book years. + # - The settlement was created in the current association year + if current_association_year(dt=self.payment_date) != current_association_year(): + return False, _("Manual settlements can only be deleted in the same association year as they were paid in.") + + # Get some info for the next few checks + manual_payment_transaction = self.manual_payment_transaction + manual_payment_transaction_price = manual_payment_transaction.price if manual_payment_transaction else Decimal("0.00") + extra_balance_transaction = self.extra_balance_transaction + extra_balance_transaction_price = extra_balance_transaction.price if extra_balance_transaction else Decimal("0.00") + transactions = self.transactions.all() + if manual_payment_transaction: + transactions = transactions.exclude(pk=manual_payment_transaction.pk) + transactions_sum = (transactions.aggregate(total=Sum('price'))['total'] or Decimal("0.00")) + + # General limitation to avoid creating inconsistencies in other settlements. + # - The SettlementExtraBalanceTransaction is not settled in any other settlement. + if extra_balance_transaction and extra_balance_transaction.settlement is not None: + return False, _("The extra balance that was created in this settlement was already used elsewhere.") + + # Sanity checks to avoid creating an inconsistent state after deletion (in the case of any weird settlements): + # - The sum of the transactions (excluding the SettlementManualPaymentTransaction) should equal negative the SettlementManualPaymentTransaction price. + if transactions_sum != -manual_payment_transaction_price: + return False, _("The total sum of the transactions ({transactions_sum}) does not inversely equal the linked manual payment's amount ({manual_amount}).").format( + transactions_sum=transactions_sum, manual_amount=manual_payment_transaction_price, + ) + # - The settlement amount should equal the SettlementManualPaymentTransaction price plus the SettlementExtraBalanceTransaction price. + if self.amount != -(manual_payment_transaction_price + extra_balance_transaction_price): + return False, _("The settlement's amount ({settlement_amount}) does not inversely equal the manual payment's amount and the extra balance ({manual_amount} + {extra_balance} = {total}).").format( + settlement_amount=self.amount, + manual_amount=manual_payment_transaction_price, + extra_balance=extra_balance_transaction_price, + total=(manual_payment_transaction_price + extra_balance_transaction_price) + ) + + return True, "" + + @classmethod + def create_for_transactions( + cls, transactions: Iterable[Transaction], payment_method: PaymentMethod, person: Person, + settlement_description: str, payment_description: Optional[str] = None, leftover_balance_description: Optional[str] = None, + paid_amount: Optional[Decimal] = None, payment_datetime: Optional[datetime.datetime] = None, + created_by: Optional[Person] = None + ) -> Optional['ManualPaymentSettlement']: + """ + Create a manual personal tab settlement. + + :param transactions: The list of transactions that will be settled by this settlement. Can be an empty list. + :param payment_method: The PaymentMethod that was used for the payment. + :param person: The Person that this settlement is for. + :param settlement_description: A description used for the settlement as a whole. + :param paid_amount: Optional. The total amount paid by the person (as Decimal) (can be negative for a refund + or 0 for an internal settlement with the personal tab balance). + Will equal the sum of transactions if None. + :param payment_description: Optional. The description that is used for the transaction representing the amount paid. + :param leftover_balance_description: Optional. The description that is used for the transaction representing + any leftover (extra) balance after settling the transactions. + :param payment_datetime: The datetime of the settlement and transactions (current datetime is used if None). + :param created_by: Optional. The person that created the settlement, for auditing reasons. + :return: The ManualPaymentSettlement object that was created or `None` if no object was created. + """ + with transaction.atomic(): + # Get the current datetime if none was given + if payment_datetime is None: + payment_datetime = timezone.now() + + # If no transactions are given and no paid amount is given or the paid amount is 0, the settlement is useless. Cancel creation. + if not transactions and (paid_amount is None or paid_amount == 0): + return None + + # Calculate the total settlement amount + total_transaction_price = sum(t.price for t in transactions) or Decimal("0.00") + if paid_amount is None: + paid_amount = total_transaction_price + leftover_balance_amount = total_transaction_price - paid_amount + + # Get the default descriptions if none were given + payment_date = payment_datetime.date() + with translation.override(person.preferred_language): + if payment_description is None: + if paid_amount > 0: + payment_description = _("Deposit to personal tab balance on {date} for {name}").format( + date=payment_date, name=person.incomplete_name() + ) + else: + payment_description = _("Refund of personal tab balance on {date} for {name}").format( + date=payment_date, name=person.incomplete_name() + ) + if leftover_balance_description is None: + leftover_balance_description = _("Leftover balance after settlement on {date} for {name}").format( + date=payment_date, name=person.incomplete_name() + ) + + # Create a ManualPayment settlement with the given payment method. + settlement = ManualPaymentSettlement.objects.create( + payment_date=payment_datetime.date(), + payment_method=payment_method, + description=settlement_description[:140], + person=person, + created_by=created_by, + amount=paid_amount, + ) + + # Save the settlement reference in all transactions + for t in transactions: + if t.settlement is not None: + # The ValueError breaks the atomic transaction, which reverts any previous DB changes. + raise ValueError(f"Transaction '{t}' has already been settled, cannot settle it again!") + t.settlement = settlement + t.save() + + # If transactions were settled, create a SettlementManualPaymentTransaction to + # register the amount of money added/removed from the balance. + if total_transaction_price != 0: + payment_transaction = SettlementManualPaymentTransaction( + date=payment_datetime, price=-total_transaction_price, person=person, + description=payment_description[:200], settlement=settlement, added_by=created_by + ) + payment_transaction.save() + settlement.manual_payment_transaction = payment_transaction + settlement.save() + + # If there is an amount of leftover balance that needs to be kept, create a + # SettlementExtraBalanceTransaction to register the open balance as an unpaid transaction. + if leftover_balance_amount != 0: + leftover_balance_transaction = SettlementExtraBalanceTransaction( + date=payment_datetime, price=leftover_balance_amount, person=person, + description=leftover_balance_description[:200], + settlement=None, # Leaves it as unpaid so it can be used. + added_by=created_by + ) + leftover_balance_transaction.save() + settlement.extra_balance_transaction = leftover_balance_transaction + settlement.save() + + return settlement + + class Reversal(models.Model): """ Reversal of an already collected amount. @@ -866,7 +1232,6 @@ class Reversal(models.Model): https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. """ - class ReversalReasons(models.TextChoices): """Possible reasons for reversals""" AC01 = 'AC01', 'AC01 - IncorrectAccountNumber' @@ -917,6 +1282,10 @@ class ReversalReasons(models.TextChoices): def __str__(self): return _l('Debit reversal %s') % self.instruction.debt_collection_reference() + def is_paid(self): + # A reversal is paid when the related ReversalTransaction is paid. + return self.transaction.is_paid() + class Meta: ordering = ['date'] verbose_name = _l('debit reversal') @@ -949,6 +1318,49 @@ def get_absolute_url(self): return reverse('personal_tab:reversal_transaction_detail', args=[self.pk]) +class BadBIC(models.Model): + """ + A known bad SEPA BIC of a bank that is in the SDD Core list but does not in practice accept direct debits + + Please note that processing properties of this model may be subject to privacy regulations. Refer to + https://privacy.ia.utwente.nl/ and check whether processing the property is allowed for your purpose. + """ + + bic = BICField(verbose_name=_l('BIC'), unique=True) + date_added = models.DateTimeField(verbose_name=_l('Date added'), auto_now_add=True) + first_reversal = models.ForeignKey(Reversal, verbose_name=_l("First reversal"), related_name='+', null=True, on_delete=models.SET_NULL) + last_reversal = models.ForeignKey(Reversal, verbose_name=_l("Last reversal"), related_name='+', null=True, on_delete=models.SET_NULL) + + def __str__(self): + return self.bic + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.update_reversals() + + def update_reversals(self): + # Update the first and last reversal for this BadBIC + self.first_reversal = Reversal.objects.filter( + instruction__authorization__bic=self.bic, + reason=Reversal.ReversalReasons.DNOR + ).order_by('date').first() + self.last_reversal = Reversal.objects.filter( + instruction__authorization__bic=self.bic, + reason=Reversal.ReversalReasons.DNOR + ).order_by('date').last() + + # If both first and last are None (no reversals any more for this BIC), self-destruct. + if self.first_reversal is None and self.last_reversal is None: + self.delete() + else: + self.save() + + class Meta: + ordering = ['bic'] + verbose_name = _l('known bad BIC') + verbose_name_plural = _l('known bad BICs') + + def get_sentinel_person() -> Person: return Person.objects.get(pk=settings.ANONIMIZATION_SENTINEL_PERSON_ID) @@ -1040,6 +1452,10 @@ class Declaration(models.Model): submission_date = models.DateTimeField(auto_now_add=True, blank=False) """Submission timestamp of the declaration.""" + # '+' related_name makes sure no reverse relation is added to Committee + bookkeeping = models.ForeignKey(Committee, verbose_name=_l('bookkeeping'), related_name='+', on_delete=models.PROTECT, blank=True, null=True) + """The committee that the declaration should be sent to.""" + def get_payment_method(self): return dict(self.DECLARATION_PAYMENT_METHODS).get(self.payment_method, self.payment_method) diff --git a/amelie/personal_tab/print_views.py b/amelie/personal_tab/print_views.py index 29c96f44..cbc28d3e 100644 --- a/amelie/personal_tab/print_views.py +++ b/amelie/personal_tab/print_views.py @@ -7,12 +7,14 @@ from django.db import transaction from django.http import Http404, HttpResponse from django.urls import reverse_lazy +from django.utils import translation from django.utils.translation import gettext as _ from django.views.generic import FormView, ListView, DeleteView from pyipp import IPPConnectionError from amelie.personal_tab.forms import PrintDocumentForm -from amelie.personal_tab.models import PrintLogEntry, Article, CustomTransaction +from amelie.personal_tab.models import CookieCornerTransaction, PrintLogEntry, Article, PaymentMethod, \ + ManualPaymentSettlement from amelie.tools.decorators import require_superuser from amelie.tools.mixins import RequirePersonalTabAuthorizationOrActiveMemberMixin, RequireBoardMixin @@ -95,12 +97,32 @@ def get_object(self, *args, **kwargs): def _refund_transaction(self): old_transaction = self.object.transaction - CustomTransaction.objects.create( - person=old_transaction.person, - added_by=self.request.user.person, - description=_("Refund for print, transaction #{tid}").format(tid=old_transaction.id), - price=-old_transaction.price, - ) + person = old_transaction.person + # Translate the description to the user's preferred language + with translation.override(person.preferred_language): + refund_description = _("Refund for print, transaction #{tid}").format(tid=old_transaction.id) + + # Create a negative transaction to refund the costs + refund_transaction = CookieCornerTransaction.objects.create( + price=-old_transaction.price, person=person, + description=refund_description, + article=old_transaction.article, amount=old_transaction.amount, added_by=self.request.user.person + ) + + # If the old transaction was already settled, we can't create a settlement for it as it would overwrite + # the old settlement. So instead we just leave the negative transaction unsettled so it can be counted later. + if old_transaction.settlement is None: + # Create a settlement to pay the old transaction by cancelling it out against the new transaction. + payment_method = PaymentMethod.objects.get(pk=settings.INTERNAL_SETTLEMENT_PAYMENT_METHOD_ID) + ManualPaymentSettlement.create_for_transactions( + transactions=[old_transaction, refund_transaction], + payment_method=payment_method, + person=person, + settlement_description=refund_description, + payment_description=refund_description, + created_by=self.request.user.person, + ) + self.object.transaction = None self.object.save() messages.success(self.request, _("Print by {person} was refunded successfully.").format(person=self.object.actor)) diff --git a/amelie/personal_tab/static/css/personal_tab_pos.css b/amelie/personal_tab/static/css/personal_tab_pos.css index f58e80e0..d1c95f49 100644 --- a/amelie/personal_tab/static/css/personal_tab_pos.css +++ b/amelie/personal_tab/static/css/personal_tab_pos.css @@ -120,7 +120,6 @@ html, body { /* Beta warning */ #beta-warning { display: block; - /*display: none; !* TODO: Remove *!*/ position: absolute; bottom: 6vh; right: 6vw; @@ -1457,4 +1456,4 @@ CALCULATOR .calculator-buttons .row a.add-calc-to-cart:hover { width: 26vh; background-color: var(--ia-green-dark); -} \ No newline at end of file +} diff --git a/amelie/personal_tab/statistics.py b/amelie/personal_tab/statistics.py index 2a8da3a9..1a5448f9 100644 --- a/amelie/personal_tab/statistics.py +++ b/amelie/personal_tab/statistics.py @@ -1,4 +1,5 @@ -from django.db import connection +from decimal import Decimal + from django.db.models import Count, Sum from django.db.models.functions import TruncDay from django.utils.translation import gettext_lazy as _l @@ -30,29 +31,29 @@ def statistics_cookie_corner_breakdown_for_ledger(start, end): for article_aggregation in articles_aggregator: if article_aggregation['amount__sum']: - article_sum = article_aggregation['price__sum'] - article_discount_sum = article_aggregation['discount__amount__sum'] + article_sum = article_aggregation['price__sum'] or Decimal("0.00") + article_discount_sum = article_aggregation['discount__amount__sum'] or Decimal("0.00") rows.append({ 'article': all_articles[article_aggregation['article']], - 'count': article_aggregation['amount__sum'], + 'count': article_aggregation['amount__sum'] or 0, 'discount': article_discount_sum, 'sum': article_sum, - 'total': article_sum + (article_discount_sum or 0) + 'total': article_sum + article_discount_sum }) # Total row ledger_aggregation = cookie_corner_transactions.aggregate( Sum('amount'), Sum('price'), Sum('discount__amount')) - ledger_sum = ledger_aggregation['price__sum'] or 0 - ledger_discount_sum = ledger_aggregation['discount__amount__sum'] + ledger_sum = ledger_aggregation['price__sum'] or Decimal("0.00") + ledger_discount_sum = ledger_aggregation['discount__amount__sum'] or Decimal("0.00") total = { 'count': ledger_aggregation['amount__sum'] or 0, 'discount': ledger_discount_sum, 'sum': ledger_sum, - 'total': ledger_sum + (ledger_discount_sum or 0) + 'total': ledger_sum + ledger_discount_sum } return {'rows': rows, 'total': total} @@ -71,8 +72,8 @@ def statistics_cookie_corner_breakdown(start, end): category_transactions = cookie_corner_transactions.filter(article__category_id=category) category_aggregation = category_transactions.aggregate(Sum('amount'), Sum('price'), Sum('discount__amount')) category_count = category_aggregation['amount__sum'] or 0 - category_sum = category_aggregation['price__sum'] or 0 - category_discount_sum = category_aggregation['discount__amount__sum'] or 0 + category_sum = category_aggregation['price__sum'] or Decimal("0.00") + category_discount_sum = category_aggregation['discount__amount__sum'] or Decimal("0.00") category_total = category_sum + category_discount_sum articles = category_transactions.order_by().distinct().values('article') @@ -84,10 +85,10 @@ def statistics_cookie_corner_breakdown(start, end): article_aggregation = category_transactions.filter(article=article).aggregate(Sum('amount'), Sum('price'), Sum('discount__amount')) - article_count = article_aggregation['amount__sum'] - article_sum = article_aggregation['price__sum'] - article_discount_sum = article_aggregation['discount__amount__sum'] - article_total = article_sum + (article_discount_sum or 0) + article_count = article_aggregation['amount__sum'] or 0 + article_sum = article_aggregation['price__sum'] or Decimal("0.00") + article_discount_sum = article_aggregation['discount__amount__sum'] or Decimal("0.00") + article_total = article_sum + article_discount_sum cat_rows.append({ 'article': article, @@ -122,8 +123,8 @@ def _statistics_cookie_corner_totals_category(cookie_corner_transactions, ledger transactions = cookie_corner_transactions.filter(article__ledger_account_id=ledger_account) totals = transactions.aggregate(Sum('amount'), Sum('price'), Sum('discount__amount')) amount = totals['amount__sum'] or 0 - paid = totals['price__sum'] or 0 - discount = totals['discount__amount__sum'] or 0 + paid = totals['price__sum'] or Decimal("0.00") + discount = totals['discount__amount__sum'] or Decimal("0.00") total = paid + discount return [LedgerAccount.objects.get(id=ledger_account), amount, discount, paid, total] @@ -131,7 +132,7 @@ def _statistics_cookie_corner_totals_category(cookie_corner_transactions, ledger def statistics_cookie_corner_total(start, end): totals = CookieCornerTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'), Sum('discount__amount')) - return (totals['price__sum'] or 0) + (totals['discount__amount__sum'] or 0) + return (totals['price__sum'] or Decimal("0.00")) + (totals['discount__amount__sum'] or Decimal("0.00")) def statistics_activities(start, end): @@ -140,7 +141,7 @@ def statistics_activities(start, end): number_of_transactions = activity_transactions.count() number_of_enrollments = activity_transactions.filter(participation__isnull=False).count() - total = activity_transactions.aggregate(Sum('price'))['price__sum'] or 0 + total = activity_transactions.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") # Do not use order, so that distinct works well. See Django manual. events = activity_transactions.order_by().distinct().values('event') @@ -154,7 +155,7 @@ def statistics_activities(start, end): event_transaction_count = activity_transactions.filter(event=event).count() event_participants = event.participants.count() if event else activity_transactions.filter(event=event, participation__isnull=False).count() - event_transaction_sum = activity_transactions.filter(event=event).aggregate(Sum('price'))['price__sum'] + event_transaction_sum = activity_transactions.filter(event=event).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") rows.append({ 'event': event, 'count': event_transaction_count, @@ -165,12 +166,12 @@ def statistics_activities(start, end): def statistics_activities_total(start, end): - return ActivityTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or 0 + return ActivityTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") def statistics_contribution_transactions(start, end): contribution_transactions = ContributionTransaction.objects.filter(date__gte=start, date__lt=end) - contribution_sum = contribution_transactions.aggregate(Sum('price'))['price__sum'] or 0 + contribution_sum = contribution_transactions.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") # Do not use order, so that values works well. See Django manual. sum_per_membership_type = contribution_transactions.order_by().values('membership__type').annotate(Sum('price')) @@ -179,11 +180,11 @@ def statistics_contribution_transactions(start, end): # Count positive and negative transactions positive_transactions_per_membership_type = contribution_transactions.filter(price__gt=0).order_by().values( 'membership__type').annotate(Count('price')) - positive_transactions_per_membership_type_dict = {x['membership__type']: x['price__count'] + positive_transactions_per_membership_type_dict = {x['membership__type']: x['price__count'] or 0 for x in positive_transactions_per_membership_type} negative_transactions_per_membership_type = contribution_transactions.filter(price__lt=0).order_by().values( 'membership__type').annotate(Count('price')) - negative_transactions_per_membership_type_dict = {x['membership__type']: x['price__count'] + negative_transactions_per_membership_type_dict = {x['membership__type']: x['price__count'] or 0 for x in negative_transactions_per_membership_type} types = MembershipType.objects.filter(pk__in=sum_per_membership_type_dict.keys()) @@ -204,12 +205,12 @@ def statistics_contribution_transactions(start, end): def statistics_contribution_transactions_total(start, end): return ContributionTransaction.objects.filter(date__gte=start, date__lt=end).aggregate( - Sum('price'))['price__sum'] or 0 + Sum('price'))['price__sum'] or Decimal("0.00") def statistics_discount_periods(start, end): discount_transactions = Discount.objects.filter(date__gte=start, date__lt=end) - discount_sum = discount_transactions.aggregate(Sum('amount'))['amount__sum'] or 0 + discount_sum = discount_transactions.aggregate(Sum('amount'))['amount__sum'] or Decimal("0.00") discount_aggregated = discount_transactions.order_by().values('discount_period').annotate( Count('amount'), Sum('amount')).order_by('discount_period') @@ -218,8 +219,8 @@ def statistics_discount_periods(start, end): for discount in discount_aggregated: discount_period = DiscountPeriod.objects.get(id=discount['discount_period']) - discount_count = discount['amount__count'] - discount_sum = discount['amount__sum'] + discount_count = discount['amount__count'] or 0 + discount_sum = discount['amount__sum'] or Decimal("0.00") rows.append({ 'discount_period': discount_period, @@ -233,32 +234,35 @@ def statistics_discount_periods(start, end): def statistics_other_transactions(start, end): rows = CustomTransaction.objects.filter(date__gte=start, date__lt=end).select_related('person') count = rows.count() - custom_sum = rows.aggregate(Sum('price'))['price__sum'] or 0 + custom_sum = rows.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") return {'rows': rows, 'count': count, 'sum': custom_sum} def statistics_other_transactions_total(start, end): - return CustomTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or 0 + return CustomTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") def statistics_discount_credits(start, end): transactions = DiscountCredit.objects.filter(date__gte=start, date__lt=end, discount__isnull=True) credit_count = transactions.count() - credit_sum = transactions.aggregate(Sum('price'))['price__sum'] or 0 + credit_sum = transactions.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") credit_aggregated = transactions.order_by().values('discount_period').annotate( Count('price'), Sum('price')).order_by('discount_period') rows = [] for credit in credit_aggregated: discount_period = DiscountPeriod.objects.get(id=credit['discount_period']) - row = {'discount_period': discount_period, 'count': credit['price__count'], 'sum': credit['price__sum'], } - rows.append(row) + rows.append({ + 'discount_period': discount_period, + 'count': credit['price__count'] or 0, + 'sum': credit['price__sum'] or Decimal("0.00") + }) return {'count': credit_count, 'rows': rows, 'sum': credit_sum} def statistics_alexia_transactions(start, end): alexia_transactions = AlexiaTransaction.objects.filter(date__gte=start, date__lt=end) - alexia_sum = alexia_transactions.aggregate(Sum('price'))['price__sum'] or 0 + alexia_sum = alexia_transactions.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") # https://stackoverflow.com/questions/8746014/django-group-sales-by-month alexia_rows = alexia_transactions.annotate(day=TruncDay('date')).values('day', 'description').annotate( @@ -267,7 +271,7 @@ def statistics_alexia_transactions(start, end): def statistics_alexia_transactions_total(start, end): - return AlexiaTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or 0 + return AlexiaTransaction.objects.filter(date__gte=start, date__lt=end).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") def statistics_functions_ledgers(): diff --git a/amelie/personal_tab/templates/authorization/list.html b/amelie/personal_tab/templates/authorization/list.html index 591bbfcd..7bc594bd 100644 --- a/amelie/personal_tab/templates/authorization/list.html +++ b/amelie/personal_tab/templates/authorization/list.html @@ -88,6 +88,11 @@

{% trans 'Mandates' %}

{{ authorization.person }} +   + + + {% else %} — {% endif %} diff --git a/amelie/personal_tab/templates/authorization/view.html b/amelie/personal_tab/templates/authorization/view.html index 6aef7a9e..eca8bae4 100644 --- a/amelie/personal_tab/templates/authorization/view.html +++ b/amelie/personal_tab/templates/authorization/view.html @@ -24,6 +24,11 @@

{% trans 'Mandate' %}

{{ authorization.person }} +   + + + {% else %} — {% endif %} @@ -92,7 +97,7 @@

{% trans 'Amendments' %}

{% endif %} {% if authorization.next_amendment %} - + {% if amendment == authorization.next_amendment %} {% trans 'Edit' %}
{% trans 'Delete' %}
diff --git a/amelie/personal_tab/templates/balance/balance.html b/amelie/personal_tab/templates/balance/balance.html index 18394bc5..4ddc8f00 100644 --- a/amelie/personal_tab/templates/balance/balance.html +++ b/amelie/personal_tab/templates/balance/balance.html @@ -40,7 +40,16 @@

{% trans 'Personal tab' %}

{% for former_member, total in former_member_totals %} - {{ former_member }} + + + {{ former_member }} + +   + + + + {{ total|stringformat:".2f" }} {% endfor %} @@ -64,7 +73,16 @@

{% trans 'Personal tab' %}

{% for member, total in member_totals %} - {{ member }} + + + {{ member }} + +   + + + + {{ total|stringformat:".2f" }} {% endfor %} @@ -94,7 +112,14 @@

{% trans 'Exam credits' %}

{% for former_member, total in exam_cookie_former_member_totals %} - {{ former_member }} + + {{ former_member }} + +   + + + {{ total|stringformat:".2f" }} @@ -120,7 +145,14 @@

{% trans 'Exam credits' %}

{% for member, total in exam_cookie_member_totals %} - {{ member }} + + {{ member }} + +   + + + {{ total|stringformat:".2f" }} diff --git a/amelie/personal_tab/templates/cookie_corner_dashboard.html b/amelie/personal_tab/templates/cookie_corner_dashboard.html index 43486cf4..99f6df17 100644 --- a/amelie/personal_tab/templates/cookie_corner_dashboard.html +++ b/amelie/personal_tab/templates/cookie_corner_dashboard.html @@ -10,7 +10,15 @@

-

{% blocktrans %}Your current balance is € {{ curr_balance }}. Including future transactions that will be € {{ all_balance }}.{% endblocktrans %}

+

{% blocktrans %} + Your current balance is € {{ curr_balance }}. Including future transactions that will be € {{ all_balance }}. + {% endblocktrans %}

+ +

{% blocktrans %} + If you signed a direct debit mandate, the balance is usually settled by withdrawing it from your bank account periodically. + If you do not have a mandate, you can settle any outstanding balance with a manual payment. To arrange such a payment, please contact the board. + {% endblocktrans %}

+ {% for instruction in future_debt_collection_instructions %}

{% blocktrans with date=instruction.batch.execution_date amount=instruction.amount url=instruction.get_absolute_url %} @@ -48,6 +56,9 @@

{% trans 'Previous transactions' %} + + {% trans 'Unpaid transactions' %} + {% trans 'History of exam credit' %} @@ -55,25 +66,26 @@

{% trans 'Cookie Corner Wrapped' %} {{ wrapped_year }} {% endif %} -

- {% if request.is_board %} - - {% endif %} + {% if request.is_board %} + + {% endif %} +
{% if person and not date_from %} - {% include "info/debt_collection_instructions.html" %} + {% include "info/payments.html" %} {% include "info/account_information.html" %}
diff --git a/amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html b/amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html new file mode 100644 index 00000000..bd962a40 --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html @@ -0,0 +1,98 @@ +{% extends "basis.html" %} +{% load i18n %} + +{% block titel %}{% trans 'Manual Payment Settlement' %}{% endblock titel %} + +{% block content %} +
+

{% trans 'Manual Payment Settlement' %}

+
+
+ + + + + + + {% if request.is_board %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + {% if request.is_board %} + + + + + {% endif %} + +
{% trans 'Reference of manual payment' %}{{ settlement.payment_reference }}
{% trans 'Person' %} + {% if settlement.person %} + + {{ settlement.person }} + +   + + + + {% else %} + — + {% endif %} +
{% trans 'Date of payment' %}{{ settlement.payment_date|date:"d F Y" }}
{% trans 'Method of payment' %} + + {{ settlement.payment_method.name }}
+ {{ settlement.payment_method.description }} +
{% trans 'Description' %}{{ settlement.description }}
{% trans 'Price' %}€ {{ settlement.amount }}
{% trans 'Created by' %} + {% if settlement.created_by %} + + {{ settlement.created_by }} + + {% else %} + — + {% endif %} +
+
+ + {% if request.is_board %} + {% with deletion_possible=settlement.deletion_possible %} + {% if deletion_possible.0 %} + + {% trans 'Delete manual payment' %} + + {% else %} + + {% trans 'Delete manual payment' %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+ +
+

{% trans 'Transactions' %}

+
+ {% include 'lists/transactions_categorized.html' with transactions=transactions total=show_total totals=totals hide_kcal=True %} +
+
+{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_manual_payments_list.html b/amelie/personal_tab/templates/cookie_corner_manual_payments_list.html new file mode 100644 index 00000000..a712f152 --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_manual_payments_list.html @@ -0,0 +1,84 @@ +{% extends "basis.html" %} +{% load i18n %} + +{% block titel %}{% blocktrans with page=page_obj.number total=page_obj.paginator.num_pages %}Manual payments - Page {{ page }}/{{ total }}{% endblocktrans %}{% endblock titel %} + +{% block content %} +
+

{% blocktrans with page=page_obj.number total=page_obj.paginator.num_pages %}Manual payments - Page {{ page }}/{{ total }}{% endblocktrans %}

+
+ +

+ {% blocktrans %} + These are payments that people made manually directly to the association, for example, by cash or by PIN. + A manual payment can pay for transactions that were made against their personal tab, or it can add + a negative amount to their personal tab to create a credit they can use for activities. + {% endblocktrans %} +

+

+ {% blocktrans %} + Click on a payment description to view details about which transactions the manual payment paid for, + and if there was any extra money deposited. + {% endblocktrans %} +

+ + +

+ {% blocktrans %} + To create a manual payment, go to the person's personal tab overview + and click on the "Add manual payment" button in the relevant block. + {% endblocktrans %} +

+ + {% include 'paginator.html' with page=page_obj %} + + {% if page_obj.object_list %} +
+ + + + + + + + + + + + {% for manual_payment in page_obj.object_list.select_related %} + + + + + + + + {% endfor %} + +
{% trans 'Date' %}{% trans 'Person' %}{% trans 'Description' %}{% trans 'Payment method' %}{% trans 'Price' %}
+ {{ manual_payment.payment_date }} + + + {{ manual_payment.person }} + +   + + + + + + {{ manual_payment.description }} + + + {{ manual_payment.payment_method.name }} + € {{ manual_payment.amount }}
+
+ {% else %} +

{% trans "No manual payments have been found." %}

+ {% endif %} + + {% include 'paginator.html' with page=page_obj %} +
+
+{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_overview.html b/amelie/personal_tab/templates/cookie_corner_overview.html index 8ec23498..f5c4cb9c 100644 --- a/amelie/personal_tab/templates/cookie_corner_overview.html +++ b/amelie/personal_tab/templates/cookie_corner_overview.html @@ -25,7 +25,9 @@

{% trans 'Digital cookie and candy list' %}

  • {% trans 'Direct withdrawal-tasks (SEPA)' %}
  • +
  • {% trans 'Manual payments' %}
  • {% trans 'Unpaid memberships' %}
  • +
  • {% trans 'Unpaid transactions' %}
  • diff --git a/amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html b/amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html new file mode 100644 index 00000000..3559796a --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html @@ -0,0 +1,26 @@ +{% extends "basis.html" %} +{% load i18n %} + +{% block titel %}{% trans 'Delete manual payment' %}{% endblock titel %} + +{% block content %} +
    +

    {% blocktrans %}Delete manual payment '{{ object }}'{% endblocktrans %}

    + +
    +

    {% blocktrans with person=object.person %} + Are you sure you want to delete '{{ object }}' for {{ person }}? + {% endblocktrans %}

    + + + {% trans 'Cancel' %} + + +
    + {% csrf_token %} + +
    +
    +
    + +{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html b/amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html new file mode 100644 index 00000000..6b30dd04 --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html @@ -0,0 +1,203 @@ +{% extends "basis.html" %} +{% load i18n %} + +{% block titel %}{% blocktrans %}Create manual payment for {{ person }}{% endblocktrans %}{% endblock titel %} + +{% block content %} +
    +
    +

    {% blocktrans %}New manual payment for {{ person }}{% endblocktrans %}

    +
    + {% if not object %} +

    + {% if transaction_type == 'cookie_corner' %} + {% trans 'You are now adding a transaction to a personal tab' %} + {% elif transaction_type == 'other' %} + {% trans 'You are now adding a transaction in the category "Other"' %} + {% endif %} +

    + {% endif %} + + {% csrf_token %} + {{ form }} + +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    {% trans 'Total of selected' %}{% trans 'Amount being paid' %}{% trans 'Personal tab balance before payment' %}{% trans 'Personal tab balance after payment' %}
    {% trans 'Today' %}{% trans 'Including future' %}{% trans 'Today' %}{% trans 'Including future' %}
    € 0.00€ € 0.00€ 0.00€ 0.00€ 0.00
    +
    +
    +

    {% blocktrans %}Unpaid transactions{% endblocktrans %}

    +
    +

    {% blocktrans %} + Please select the transactions that will be marked as paid with this settlement. + {% endblocktrans %}

    +

    {% blocktrans %} + Rows marked in green are transactions that have a date in the future. + {% endblocktrans %}

    + {{ transaction_formset.management_form }} + + + {% if transaction_formset.non_form_errors or transaction_formset.total_error_count %} +

    {% trans 'There were errors while submitting the form.' %}

    + {% if formset.non_form_errors %} +
      + {% for error in transaction_formset.non_form_errors %} +
    • + {{ error }} +
    • + {% endfor %} +
    + {% if formset.total_error_count %} +

    {% trans 'For more errors, see below.' %}

    + {% endif %} + {% else %} +

    {% trans 'See below for the errors.' %}

    + {% endif %} + {% endif %} + + + + + + + + + + + + + {% for form in transaction_formset %} + + date_now %} style="background: rgba(0, 255, 0, 0.15)"{% endif %}> + + + + + + + {% empty %} + + + + {% endfor %} + +
    +
    + +
    {% trans '#ID' %}{% trans 'Date' %}{% trans 'Description' %}{% trans 'Price' %}
    + {% if form.errors %} + {{ form.include.errors }} + {% endif %} + {{ form.include }} + + {{ form.transaction.id }} + {{ form.transaction.date }} + + {{ form.transaction.description }} + + + € {{ form.transaction.price }} +
    {% trans 'No unpaid transactions found!' %}
    +
    +
    +
    + + {{ balance|json_script:"balance-data" }} + + + +{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_person_manual_payments.html b/amelie/personal_tab/templates/cookie_corner_person_manual_payments.html new file mode 100644 index 00000000..ab8a4f63 --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_person_manual_payments.html @@ -0,0 +1,46 @@ +{% extends "basis.html" %} +{% load i18n %} + +{% block titel %}{% blocktrans %}Manual payments for {{ person }}{% endblocktrans %}{% endblock titel %} + +{% block content %} +
    +

    {% blocktrans %}Manual payments for {{ person }}{% endblocktrans %}

    +
    + {% if manual_payments %} +
    + + + + + + + + + + + {% for manual_payment in manual_payments.select_related %} + + + + + + + {% endfor %} + +
    {% trans 'Date' %}{% trans 'Description' %}{% trans 'Payment method' %}{% trans 'Price' %}
    + {{ manual_payment.payment_date }} + + + {{ manual_payment.description }} + + + {{ manual_payment.payment_method.name }} + € {{ manual_payment.amount }}
    +
    + {% else %} +

    {% trans "No manual payments have been found." %}

    + {% endif %} +
    +
    +{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_transactions.html b/amelie/personal_tab/templates/cookie_corner_transactions.html index 6796fcb3..10bfc8b6 100644 --- a/amelie/personal_tab/templates/cookie_corner_transactions.html +++ b/amelie/personal_tab/templates/cookie_corner_transactions.html @@ -45,8 +45,14 @@

    {% trans 'Activities' %}

    {% trans 'Alexia' %}

    {% include "lists/alexia_transactions.html" with transactions=all_transactions.2 totals=totals.2 %} +

    {% trans 'Contribution' %}

    + {% include "lists/contribution_transactions.html" with transactions=all_transactions.3 totals=totals.3 %} +

    {% trans 'Other' %}

    - {% include "lists/custom_transactions.html" with transactions=all_transactions.3 totals=totals.3 %} + {% include "lists/custom_transactions.html" with transactions=all_transactions.4 totals=totals.4 %} + +

    {% trans 'Payments' %}

    + {% include "lists/payment_transactions.html" with transactions=payment_transactions totals=payment_totals %} {% else %} {% include "lists/transactions_totals.html" %} {% endif %} diff --git a/amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html b/amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html new file mode 100644 index 00000000..95f642b9 --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html @@ -0,0 +1,82 @@ +{% extends "basis.html" %} +{% load humanize %} +{% load i18n transaction_link %} + +{% block titel %}{% trans 'Unpaid Transactions' %}{% endblock titel %} + +{% block content %} +
    +

    {% blocktrans with name=person.incomplete_name %} + Unpaid Transactions for {{ name }} + {% endblocktrans %}

    + +
    +

    + {% blocktrans with curr_balance=curr_balance all_balance=all_balance %} + Your current balance is € {{ curr_balance }}. Including future transactions that will be € {{ all_balance }}. + {% endblocktrans %} + {% trans 'Back to overview' %} +

    + +

    {% trans 'Overview' %}

    +
    + + + + + + + + + {% if sums_per_type %} + {% for entry_name, entry_sum in sums_per_type %} + + + + + {% endfor %} + + + + + + {% endif %} + + + + + + + + + + +
    {% trans 'Transaction type' %}{% trans 'Price' %}
    {{ entry_name }}€ {{ entry_sum|intcomma }}
    {% trans 'Subtotal' %}€ {{ totals|last|intcomma }}
    {% trans 'Payments' %}€ {{ payment_totals|intcomma }}
    {% trans 'Balance' %}€ {{ sums_balance|intcomma }}
    +
    + +

    {% trans 'Transactions per type' %}

    + {% if not has_transactions %} +

    {% trans 'No unpaid transactions have been found' %}

    + {% else %} +

    {% trans 'Personal tab' %}

    + {% include "lists/cookie_corner_transactions.html" with transactions=all_transactions.0 totals=totals.0 %} + +

    {% trans 'Activities' %}

    + {% include "lists/activity_transactions.html" with transactions=all_transactions.1 totals=totals.1 %} + +

    {% trans 'Alexia' %}

    + {% include "lists/alexia_transactions.html" with transactions=all_transactions.2 totals=totals.2 %} + +

    {% trans 'Contribution' %}

    + {% include "lists/contribution_transactions.html" with transactions=all_transactions.3 totals=totals.3 %} + +

    {% trans 'Other' %}

    + {% include "lists/custom_transactions.html" with transactions=all_transactions.4 totals=totals.4 %} + +

    {% trans 'Payments' %}

    + {% include "lists/payment_transactions.html" with transactions=payment_transactions totals=payment_totals %} + {% endif %} +
    +
    + +{% endblock content %} diff --git a/amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html b/amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html new file mode 100644 index 00000000..7490f20c --- /dev/null +++ b/amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html @@ -0,0 +1,188 @@ +{% extends "basis.html" %} +{% load unpaid_transaction_helpers %} +{% load humanize %} +{% load i18n %} + +{% block titel %}{% blocktrans with page=page_obj.number total=page_obj.paginator.num_pages %}Unpaid transactions - Page {{ page }}/{{ total }}{% endblocktrans %}{% endblock titel %} + +{% block content %} +
    +

    {% blocktrans with page=page_obj.number total=page_obj.paginator.num_pages %}Unpaid transactions - Page {{ page }}/{{ total }}{% endblocktrans %}

    +
    +

    {% trans 'These are people with transactions that have not been paid for.' %}

    + + {% if filter_no_mandate %}

    + {% if filter_mandate_type %} + {% blocktrans with filtered_type=filter_mandate_type %} + Only people without a mandate of type '{{ filtered_type }}' are shown. + {% endblocktrans %} + {% else %} + {% trans 'Only people without a mandate are shown.' %} + {% endif %} +

    {% endif %} + + {% if filter_only_mandate %}

    + {% if filter_mandate_type %} + {% blocktrans with filtered_type=filter_mandate_type %} + Only people with a mandate of type '{{ filtered_type }}' are shown. + {% endblocktrans %} + {% else %} + {% trans 'Only people with a mandate are shown.' %} + {% endif %} +

    {% endif %} + + {% if filter_transactions %}

    + {% blocktrans with filtered_type=filter_transactions %} + Only transactions of type '{{ filter_transactions }}' are shown. + {% endblocktrans %} +

    {% endif %} + +

    {% trans 'Each person can be expanded to view details about the individual transactions.' %}

    + + + + + + + + + + + + + + +
    {% trans 'Total number of people' %}{{ person_count|default_if_none:0 }}
    {% trans 'Total number of transactions' %}{{ transaction_count|default_if_none:0 }}
    {% trans 'Total sum of unpaid costs' %}€ {{ total_costs|default_if_none:0|intcomma }}
    + +

    {% trans 'Filter options' %}

    + + + + + + + + + + + + + +
    {% trans 'Only people with:' %} + {% trans 'Contribution mandate' %} + {% trans 'Consumption mandate' %} + {% trans 'Any mandate' %} +
    {% trans 'Only people without:' %} + {% trans 'Contribution mandate' %} + {% trans 'Consumption mandate' %} + {% trans 'Any mandate' %} +
    {% trans 'Only transactions of type:' %} + {% trans 'Contribution transactions' %} + {% trans 'Activity transactions' %} + {% trans 'Cookie corner transactions' %} + {% trans 'Alexia transactions' %} + {% trans 'Custom transactions' %} + {% trans 'Reversal transactions' %} +
    + {% if filter_no_mandate or filter_only_mandate or filter_transactions %} + {% trans 'Clear filters' %} + {% endif %} + + {% trans 'Toggle expand/collapse all' %} + + {% querystring page=None as qstring %} + {% include 'paginator.html' with page=page_obj query=qstring|slice:'1:' %} +
    +
    + + {% if page_obj.object_list %} + {% for person in page_obj.object_list.select_related %} +
    +

    + {% with unpaid_transactions=person|unpaid_transactions_of_type:filter_transactions unpaid_costs=person|unpaid_transactions_total_cost_of_type:filter_transactions %} + {% blocktrans with name=person.incomplete_name num_transactions=unpaid_transactions.count total_cost=unpaid_costs|intcomma %} + {{ name }} - ({{ num_transactions }}) - € {{ total_cost }} + {% endblocktrans %} + {% endwith %} +

    +
    + +
    +
    + {% endfor %} + {% else %} +
    +

    {% trans 'No unpaid transactions' %}

    +
    +

    {% trans 'There are no people with unpaid transactions! Woah! 🎉🎉🎉' %}

    +
    +
    + {% endif %} + + +{% endblock content %} diff --git a/amelie/personal_tab/templates/debt_collection/instruction_view.html b/amelie/personal_tab/templates/debt_collection/instruction_view.html index 6f9c0342..e3fe51d0 100644 --- a/amelie/personal_tab/templates/debt_collection/instruction_view.html +++ b/amelie/personal_tab/templates/debt_collection/instruction_view.html @@ -24,7 +24,12 @@

    {% trans 'Direct withdrawal-instruction' %}

    {% if instruction.authorization.person %} - {{ instruction.authorization.person }} + {{ instruction.authorization.person }} + +   + + {% else %} — @@ -68,7 +73,7 @@

    {% trans 'Direct withdrawal-instruction' %}

    {% trans 'Price' %} - {{ instruction.amount }} + € {{ instruction.amount }} {% if request.is_board %} diff --git a/amelie/personal_tab/templates/debt_collection/person_instructions.html b/amelie/personal_tab/templates/debt_collection/person_instructions.html index 223b8c36..3f776b3a 100644 --- a/amelie/personal_tab/templates/debt_collection/person_instructions.html +++ b/amelie/personal_tab/templates/debt_collection/person_instructions.html @@ -28,7 +28,7 @@

    {% blocktrans %}Direct debit for {{ person }}{% endblocktrans %}

    {{ instruction.description }} - {{ instruction.amount }} + € {{ instruction.amount }} {% endfor %} diff --git a/amelie/personal_tab/templates/debt_collection/process_batch.html b/amelie/personal_tab/templates/debt_collection/process_batch.html index d550dd30..e44b1966 100644 --- a/amelie/personal_tab/templates/debt_collection/process_batch.html +++ b/amelie/personal_tab/templates/debt_collection/process_batch.html @@ -30,7 +30,7 @@

    {{ batch.reference_number }}

    {% trans 'Total cost' %} - {{ batch.control_sum }} + € {{ batch.control_sum }} {% trans 'Status' %} @@ -65,6 +65,11 @@

    {{ batch.reference_number }}

    {{ instruction.authorization.person }} +   + + + {% else %} — {% endif %} @@ -82,7 +87,7 @@

    {{ batch.reference_number }}

    — {% endif %} - {{ instruction.amount }} + € {{ instruction.amount }} {{ instruction.description }} diff --git a/amelie/personal_tab/templates/debt_collection/view.html b/amelie/personal_tab/templates/debt_collection/view.html index 7e5a0663..e6c91405 100644 --- a/amelie/personal_tab/templates/debt_collection/view.html +++ b/amelie/personal_tab/templates/debt_collection/view.html @@ -27,11 +27,11 @@

    {% trans 'direct withdrawal-task' %}

    {% trans 'Total reversed' %} - {{ assignment.reversed_sum }} + € {{ assignment.reversed_sum }} {% trans 'Total cost' %} - {{ assignment.control_sum }} + € {{ assignment.control_sum }} @@ -70,11 +70,11 @@

    {{ batch.reference_number }}

    {% trans 'Total reversed' %} - {{ batch.reversed_sum }} + € {{ batch.reversed_sum }} {% trans 'Total cost' %} - {{ batch.control_sum }} + € {{ batch.control_sum }} {% trans 'Status' %} @@ -108,6 +108,11 @@

    {{ batch.reference_number }}

    {{ instruction.authorization.person }} +   + + + {% else %} — {% endif %} @@ -125,7 +130,7 @@

    {{ batch.reference_number }}

    — {% endif %} - {{ instruction.amount }} + € {{ instruction.amount }} {{ instruction.description }} diff --git a/amelie/personal_tab/templates/declaration/declaration.mail b/amelie/personal_tab/templates/declaration/declaration.mail index 6d210534..3dcbe156 100644 --- a/amelie/personal_tab/templates/declaration/declaration.mail +++ b/amelie/personal_tab/templates/declaration/declaration.mail @@ -1,4 +1,4 @@ -{% load attach i18n only subject absolute_url htmlify%}{% comment %} Whitespace IS important in this file {% endcomment %}{% comment %} Copy of email_basic, because the logo must be removed. This is to prevent the logo from being saved in the bookkeeping for every declaration. {% endcomment %}{% subject %}{% blocktrans %}Declaration submitted via Amélie{% endblocktrans %}{% endsubject %}{% onlyhtml %} +{% load attach i18n only subject absolute_url htmlify%}{% comment %} Whitespace IS important in this file {% endcomment %}{% comment %} Copy of email_basic, because the logo must be removed. This is to prevent the logo from being saved in the bookkeeping for every declaration. {% endcomment %}{% subject %}{% blocktrans with name=submitter_name date=submission_date|date:"Y-m-d H:i" %}Declaration submitted via Amélie by {{name}} on {{date}}{% endblocktrans %}{% endsubject %}{% onlyhtml %} diff --git a/amelie/personal_tab/templates/declaration/declaration_form.html b/amelie/personal_tab/templates/declaration/declaration_form.html index c97fe97e..c1f28aad 100644 --- a/amelie/personal_tab/templates/declaration/declaration_form.html +++ b/amelie/personal_tab/templates/declaration/declaration_form.html @@ -132,6 +132,18 @@

    {% trans "Declaration Information" %}

    {% endif %} + + + {{ form.bookkeeping.label }} + + {{ form.bookkeeping.errors|safe }} +
    {{ form.bookkeeping }}
    + {% if form.bookkeeping.help_text %} + {{ form.bookkeeping.help_text|linebreaks }} + {% endif %} + + +
    diff --git a/amelie/personal_tab/templates/info/debt_collection_instructions.html b/amelie/personal_tab/templates/info/debt_collection_instructions.html deleted file mode 100644 index 96c2f41f..00000000 --- a/amelie/personal_tab/templates/info/debt_collection_instructions.html +++ /dev/null @@ -1,48 +0,0 @@ -{% load i18n %} - -
    -

    {% trans "Direct debits" %}

    - -
    - {% if debt_collection_instructions %} -

    - {% blocktrans with debt_collection_instructions|length as amount %} - Last {{ amount }} executed or planned direct debits. - {% endblocktrans %} -

    -
    - - - - - - - - - - {% for instruction in debt_collection_instructions %} - - - - - - {% endfor %} - -
    {% trans 'Date' %}{% trans 'Description' %}{% trans 'Price' %}
    - {{ instruction.batch.execution_date }} - - - {{ instruction.description }} - - {{ instruction.amount }}
    -
    -

    - - {% trans "View all direct debits." %} - -

    - {% else %} -

    {% trans "There are no direct debits executed or planned yet." %}

    - {% endif %} -
    -
    diff --git a/amelie/personal_tab/templates/info/payments.html b/amelie/personal_tab/templates/info/payments.html new file mode 100644 index 00000000..36ea48b2 --- /dev/null +++ b/amelie/personal_tab/templates/info/payments.html @@ -0,0 +1,117 @@ +{% load i18n %} + +
    +

    {% trans "Payments" %}

    + +
    +
    +
    +

    {% trans "Direct debits" %}

    +

    + {% blocktrans count counter=debt_collection_instructions|length %} + Your most recently executed or planned direct debit. + {% plural %} + Your most recently executed or planned direct debits. + {% endblocktrans %} +

    +
    + + + + + + + + + + {% for instruction in debt_collection_instructions %} + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Date' %}{% trans 'Description' %}{% trans 'Price' %}
    + {{ instruction.batch.execution_date }} + + + {{ instruction.description }} + + € {{ instruction.amount }}
    +

    {% trans "There are no direct debits executed or planned yet." %}

    +
    +
    + {% if debt_collection_instructions %} +

    + + {% trans "View all direct debits." %} + +

    + {% endif %} +
    +
    +

    + {% trans "Manual payments" %} + {% if request.is_board %} + + {% trans 'Add manual payment' %} + + {% endif %} +

    +

    + {% blocktrans count counter=manual_payment_settlements|length %} + Your most recent manual payment. + {% plural %} + Your most recent manual payments. + {% endblocktrans %} +

    +
    + + + + + + + + + + + {% for settlement in manual_payment_settlements %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans 'Date' %}{% trans 'Description' %}{% trans 'Payment method' %}{% trans 'Price' %}
    + {{ settlement.payment_date }} + + + {{ settlement.description }} + + {{ settlement.payment_method.name }}€ {{ settlement.amount }}
    +

    {% trans "There are no manual payments yet." %}

    +
    +
    + {% if manual_payment_settlements %} +

    + + {% trans "View all manual payments." %} + +

    + {% endif %} +
    +
    +
    +
    diff --git a/amelie/personal_tab/templates/info/transaction_information.html b/amelie/personal_tab/templates/info/transaction_information.html index 0923d625..3edb6e2a 100644 --- a/amelie/personal_tab/templates/info/transaction_information.html +++ b/amelie/personal_tab/templates/info/transaction_information.html @@ -1,4 +1,8 @@ {% load i18n %} + + {% trans 'Transaction type' %} + {{ object.transaction_type.1 }} + {% trans 'Date' %} {{ object.date }} @@ -9,19 +13,12 @@ {% trans 'Price' %} - {{ object.price }} + € {{ object.price }} - {% trans 'Direct withdrawal' %} + {% trans 'Settled in' %} - {% if object.debt_collection %} - - {{ object.debt_collection }} -
    - {{ object.debt_collection.batch.execution_date }} - {% else %} - — - {% endif %} + {% include 'info/transaction_settlement_info.html' with settlement=object.settlement %} {% if object.discount %} @@ -39,7 +36,14 @@ {% trans 'Person' %} {% if object.person %} - {{ object.person }} + + {{ object.person }} + +   + + + {% else %} — {% endif %} @@ -53,4 +57,76 @@ {% trans 'Added by' %} {{ object.added_by|default:"—" }} + {% if object.transaction_type.0 == "activitytransaction" %} + + {% trans 'Activities' %} + + {% if object.activitytransaction.event %} + {% if request.is_board %} + {{ object.activitytransaction.event.summary }} + {% else %} + {{ object.activitytransaction.event.summary }} + {% endif %} + {% else %} + {% trans 'Deleted event' %} + {% endif %} + + + {% if object.activitytransaction.participation.remark %} + + {% trans 'Remarks' %} + {{ object.activitytransaction.participation.remark }} + + {% endif %} + {% elif object.transaction_type.0 == "alexiatransaction" %} + + {% trans 'Transaction number Alexia' %} + {{ object.alexiatransaction.transaction_id }} + + {% elif object.transaction_type.0 == "cookiecornertransaction" %} + + {% trans 'Article' %} + {{ object.cookiecornertransaction.article|default:"—" }} + + + {% trans 'Amount' %} + {{ object.cookiecornertransaction.amount }} + + + {% trans 'kCal' %} + {{ object.cookiecornertransaction.kcal|default_if_none:"—" }} + + {% elif object.transaction_type.0 == "contributiontransaction" %} + + {% trans 'Membership' %} + {% with membership=object.contributiontransaction.membership %} + {{ membership.type.name }} ({{ membership.year }}/{{ membership.year|add:'1' }}) + {% endwith %} + + {% elif object.transaction_type.0 == "reversaltransaction" %} + + {% trans 'Reversed collection' %} + + + {{ object.reversaltransaction.reversal.instruction }} +
    + {{ object.reversaltransaction.reversal.instruction.batch.execution_date }} + + + + {% trans 'Reason' %} + {{ object.reversaltransaction.reversal.get_reason_display }} + + {% elif object.transaction_type.0 == "settlementextrabalancetransaction" or object.transaction_type.0 == "settlementmanualpaymenttransaction" %} + + {% trans 'Originated in' %} + + {% if object.transaction_type.0 == "settlementextrabalancetransaction" %} + {% include 'info/transaction_settlement_info.html' with settlement=object.settlementextrabalancetransaction.manual_settlement %} + {% else %} + {% include 'info/transaction_settlement_info.html' with settlement=object.settlementmanualpaymenttransaction.manual_settlement %} + {% endif %} + + + {% endif %} {% endif %} diff --git a/amelie/personal_tab/templates/info/transaction_settlement_info.html b/amelie/personal_tab/templates/info/transaction_settlement_info.html new file mode 100644 index 00000000..19e396dd --- /dev/null +++ b/amelie/personal_tab/templates/info/transaction_settlement_info.html @@ -0,0 +1,44 @@ +{% load i18n %} +{% if settlement %} + + {{ settlement }} +
    + {% if settlement.settlement_type == "INSTR" %} + + + {% blocktrans with date=settlement.debtcollectioninstruction.batch.execution_date %} + Direct debit on {{ date }} + {% endblocktrans %} + + {% if settlement.debtcollectioninstruction.reversal %} +
    + + + {% blocktrans with date=settlement.debtcollectioninstruction.reversal.date %} + Reversal on {{ date }} + {% endblocktrans %} + + {% endif %} + {% elif settlement.settlement_type == "MANUAL" %} + + {% blocktrans with date=settlement.manualpaymentsettlement.payment_date %} + Manual payment on {{ date }} + {% endblocktrans %} + +
    + + + {{ settlement.manualpaymentsettlement.payment_method.name }} + + {% else %} + + {% trans 'UNKNOWN payment!' %} + + {% endif %} +{% else %} + {% if highlight_unpaid %} + {% trans 'Unpaid!' %} + {% else %} + — + {% endif %} +{% endif %} diff --git a/amelie/personal_tab/templates/lists/activity_transactions.html b/amelie/personal_tab/templates/lists/activity_transactions.html index f6940183..68cf46da 100644 --- a/amelie/personal_tab/templates/lists/activity_transactions.html +++ b/amelie/personal_tab/templates/lists/activity_transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -22,7 +23,14 @@ {% if transaction.person %} {% if request.is_board %} - {{ transaction.person }} + + {{ transaction.person }} + +   + + + {% else %} {{ transaction.person }} {% endif %} @@ -47,14 +55,14 @@ {{ transaction.with_enrollment_options|yesno:"Ja,Nee" }} - {{ transaction.price }} + € {{ transaction.price|intcomma }} {% endfor %} {% if totals %} - + {% trans 'Total' %} - {{ totals }} + € {{ totals|intcomma }} {% endif %} diff --git a/amelie/personal_tab/templates/lists/alexia_transactions.html b/amelie/personal_tab/templates/lists/alexia_transactions.html index 5fe71279..2a047c10 100644 --- a/amelie/personal_tab/templates/lists/alexia_transactions.html +++ b/amelie/personal_tab/templates/lists/alexia_transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -20,7 +21,14 @@ {% if transaction.person %} {% if request.is_board %} - {{ transaction.person }} + + {{ transaction.person }} + +   + + + {% else %} {{ transaction.person }} {% endif %} @@ -33,14 +41,14 @@ {{ transaction.description }} - {{ transaction.price }} + € {{ transaction.price|intcomma }} {% endfor %} {% if total %} - + {% trans 'Total' %} - {{ totals }} + € {{ totals|intcomma }} {% endif %} diff --git a/amelie/personal_tab/templates/lists/authorization_select.html b/amelie/personal_tab/templates/lists/authorization_select.html index 3be88208..eb13c73a 100644 --- a/amelie/personal_tab/templates/lists/authorization_select.html +++ b/amelie/personal_tab/templates/lists/authorization_select.html @@ -35,6 +35,11 @@ {{ authorization.person }} +   + + + {% else %} — {% endif %} diff --git a/amelie/personal_tab/templates/lists/contribution_transactions.html b/amelie/personal_tab/templates/lists/contribution_transactions.html new file mode 100644 index 00000000..e879cd8d --- /dev/null +++ b/amelie/personal_tab/templates/lists/contribution_transactions.html @@ -0,0 +1,59 @@ +{% load humanize %} +{% load i18n %} + +{% if transactions %} +
    + + + + + + + + + + + + {% for transaction in transactions %} + + + + + + + + {% endfor %} + {% if total %} + + + + + + {% endif %} + +
    {% trans 'Date' %}{% trans 'Time' %}{% trans 'Person' %}{% trans 'Description' %}{% trans 'Price' %}
    {{ transaction.date|date:"d F Y" }}{{ transaction.date|date:"G:i"}} + {% if transaction.person %} + {% if request.is_board %} + + {{ transaction.person }} + +   + + + + {% else %} + {{ transaction.person }} + {% endif %} + {% else %} + — + {% endif %} + + + {{ transaction.description }} + + € {{ transaction.price|intcomma }}
    {% trans 'Total' %}€ {{ totals|intcomma }}
    +
    +{% else %} +

    {% trans 'No contribution transactions.' %}

    +{% endif %} diff --git a/amelie/personal_tab/templates/lists/cookie_corner_transactions.html b/amelie/personal_tab/templates/lists/cookie_corner_transactions.html index 983a6860..e9ef9f46 100644 --- a/amelie/personal_tab/templates/lists/cookie_corner_transactions.html +++ b/amelie/personal_tab/templates/lists/cookie_corner_transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -23,7 +24,14 @@ {% if transaction.person %} {% if request.is_board %} - {{ transaction.person }} + + {{ transaction.person }} + +   + + + {% else %} {{ transaction.person }} {% endif %} @@ -38,15 +46,15 @@ {{ transaction.amount }} {{ transaction.discount.amount|default:"—" }} - {{ transaction.price }} + € {{ transaction.price|intcomma }} {{ transaction.kcal|default:"—" }} {% endfor %} {% if totals %} - + {% trans 'Total' %} - {{ totals }} + € {{ totals|intcomma }} {{ kcal_totals }} {% endif %} diff --git a/amelie/personal_tab/templates/lists/custom_transactions.html b/amelie/personal_tab/templates/lists/custom_transactions.html index a4978592..8a78dadc 100644 --- a/amelie/personal_tab/templates/lists/custom_transactions.html +++ b/amelie/personal_tab/templates/lists/custom_transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -20,7 +21,14 @@ {% if transaction.person %} {% if request.is_board %} - {{ transaction.person }} + + {{ transaction.person }} + +   + + + {% else %} {{ transaction.person }} {% endif %} @@ -33,14 +41,14 @@ {{ transaction.description }} - {{ transaction.price }} + € {{ transaction.price|intcomma }} {% endfor %} {% if total %} - + {% trans 'Total' %} - {{ totals }} + € {{ totals|intcomma }} {% endif %} diff --git a/amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html b/amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html index 9c9eaa06..cd074de4 100644 --- a/amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html +++ b/amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if rows %} @@ -9,7 +10,7 @@ {% trans 'Person' %} {% trans 'Membership type' %} {% trans 'Mandate' %} - {% trans 'Price' %} + {% trans 'Price' %} @@ -18,22 +19,31 @@ {% if enabled %} + name="contribution_{{ row.transaction.membership.id }}" value="true"/> {% endif %} {{ row.person }} +   + + + - {{ row.membership.type }} + {{ row.transaction.membership.type }} - {{ row.instruction.authorization }} + {% if row.instruction.authorization %} + {{ row.instruction.authorization }} + {% else %} + — + {% endif %} {% if row.instruction.amendment %} ({% trans 'A ' context 'amendment abbr' %}) {% endif %} - {{ row.sumf }} + € {{ row.sum|intcomma }} @@ -43,14 +53,14 @@ {% endfor %} - + {% if enabled %} {% endif %} {% trans 'Total' %} - {{ total }} + € {{ total|intcomma }} diff --git a/amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html b/amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html index 324da830..a4723d0f 100644 --- a/amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html +++ b/amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if rows %} @@ -8,7 +9,7 @@ {% if enabled %}{% trans 'Collect' %}{% endif %} {% trans 'Person' %} {% trans 'Mandate' %} - {% trans 'Price' %} + {% trans 'Price' %} @@ -24,30 +25,41 @@ {{ row.person }} +   + + + - {{ row.authorization }} + {% if row.authorization %} + {{ row.authorization }} + {% else %} + — + {% endif %} {% if row.instruction.amendment %} ({% trans 'A ' context 'amendment abbr' %}) {% endif %} - {{ row.sumf }} - - - - {{ row.instruction.description }} - + € {{ row.sum|intcomma }} + {% if row.instruction.description %} + + + {{ row.instruction.description }} + + + {% endif %} {% endfor %} - + {% if enabled %} {% endif %} {% trans 'Total' %} - {{ total }} + € {{ total|intcomma }} diff --git a/amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html b/amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html index 42c9d9b2..e9eea398 100644 --- a/amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html +++ b/amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n transaction_link %} {% if rows %} @@ -41,15 +42,15 @@ {% for amount in row.amounts %} - {{ amount }} + € {{ amount|intcomma }} {% endfor %} {% endfor %} - + {% trans 'Total' %} {% for total in totals %} - {{ total }} + € {{ total|intcomma }} {% endfor %} diff --git a/amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html b/amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html index b0f0977b..50951196 100644 --- a/amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html +++ b/amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -20,7 +21,14 @@ {% if transaction.person %} {% if request.is_board %} - {{ transaction.person }} + + {{ transaction.person }} + +   + + + {% else %} {{ transaction.person }} {% endif %} @@ -29,14 +37,14 @@ {% endif %} {{ transaction.description }} - {{ transaction.price }} + € {{ transaction.price|intcomma }} {% endfor %} {% if total %} - + {% trans 'Total' %} - {{ totals }} + € {{ totals|intcomma }} {% endif %} diff --git a/amelie/personal_tab/templates/lists/payment_transactions.html b/amelie/personal_tab/templates/lists/payment_transactions.html new file mode 100644 index 00000000..e6c30d9e --- /dev/null +++ b/amelie/personal_tab/templates/lists/payment_transactions.html @@ -0,0 +1,79 @@ +{% load class_helpers %} +{% load humanize %} +{% load i18n %} + +{% if transactions.0 or transactions.1 or transactions.2 or transactions.3 %} +
    + + + + + + + + + + + + + {% for transaction_set in transactions %} + {% for transaction in transaction_set %} + + + + + + + + + {% endfor %} + {% endfor %} + {% if total %} + + + + + + {% endif %} + +
    {% trans 'Date' %}{% trans 'Time' %}{% trans 'Person' %}{% trans 'Payment method' %}{% trans 'Description' %}{% trans 'Price' %}
    {{ transaction.date|date:"d F Y" }}{{ transaction.date|date:"G:i"}} + {% if transaction.person %} + {% if request.is_board %} + + {{ transaction.person }} + +   + + + + {% else %} + {{ transaction.person }} + {% endif %} + {% else %} + — + {% endif %} + + {% if transaction|get_class == "DebtCollectionTransaction" %} + {% trans 'Direct debit' %} + {% elif transaction|get_class == "ReversalTransaction" %} + {% trans 'Reversal of direct debit' %} + {% elif transaction|get_class == "ExtraManualPaymentTransaction" %} + {{ transaction.settlement.manualpaymentsettlement.payment_method.name|default:_("Unknown") }} + {% elif transaction|get_class == "ManualPaymentSettlementTransaction" %} + {{ transaction.settlement.manualpaymentsettlement.payment_method.name|default:_("Unknown") }} + {% else %} + {% trans 'Unknown' %} + {% endif %} + + + {{ transaction.description }} + {% if transaction.settlement.description %}
    + + {{ transaction.settlement.description }} + {% endif %} +
    € {{ transaction.price|intcomma }}
    {% trans 'Total' %}€ {{ totals|intcomma }}
    +
    +{% else %} +

    {% trans 'No payments.' %}

    +{% endif %} diff --git a/amelie/personal_tab/templates/lists/transaction_row.html b/amelie/personal_tab/templates/lists/transaction_row.html new file mode 100644 index 00000000..921ebb05 --- /dev/null +++ b/amelie/personal_tab/templates/lists/transaction_row.html @@ -0,0 +1,33 @@ +{% load humanize %} +{% load i18n %} + + {{ transaction.date|date:"d F Y" }} + {{ transaction.date|date:"G:i"}} + + {% if transaction.person %} + {% if request.is_board %} + + {{ transaction.person }} + + {% url 'personal_tab:dashboard' transaction.person.pk transaction.person.slug as personal_overview %} + {% if request.path != personal_overview %} +   + + + + {% endif %} + {% else %} + {{ transaction.person }} + {% endif %} + {% else %} + — + {% endif %} + + + + {{ transaction.description }} + + + € {{ transaction.price|intcomma }} + {% if not hide_kcal %}{{ transaction.cookiecornertransaction.kcal|default:"—" }}{% endif %} + diff --git a/amelie/personal_tab/templates/lists/transactions.html b/amelie/personal_tab/templates/lists/transactions.html index fa2b26a7..79fe5144 100644 --- a/amelie/personal_tab/templates/lists/transactions.html +++ b/amelie/personal_tab/templates/lists/transactions.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n %} {% if transactions %} @@ -10,40 +11,19 @@ {% trans 'Person' %} {% trans 'Description' %} {% trans 'Price' %} - {% trans 'kCal' %} + {% if not hide_kcal %}{% trans 'kCal' %}{% endif %} {% for transaction in transactions %} - - {{ transaction.date|date:"d F Y" }} - {{ transaction.date|date:"G:i"}} - - {% if transaction.person %} - {% if request.is_board %} - {{ transaction.person }} - {% else %} - {{ transaction.person }} - {% endif %} - {% else %} - — - {% endif %} - - - - {{ transaction.description }} - - - {{ transaction.price }} - {{ transaction.cookiecornertransaction.kcal|default:"—" }} - + {% include 'lists/transaction_row.html' with transaction=transaction hide_kcal=hide_kcal %} {% endfor %} {% if total %} - + {% trans 'Total' %} - {{ totals }} - {{ kcal_totals }} + € {{ totals|intcomma }} + {% if not hide_kcal %}{{ kcal_totals }}{% endif %} {% endif %} diff --git a/amelie/personal_tab/templates/lists/transactions_categorized.html b/amelie/personal_tab/templates/lists/transactions_categorized.html new file mode 100644 index 00000000..ada26f16 --- /dev/null +++ b/amelie/personal_tab/templates/lists/transactions_categorized.html @@ -0,0 +1,49 @@ +{% load humanize %} +{% load i18n %} + +{% if transactions %} +
    + + + + + + + + + {% if not hide_kcal %}{% endif %} + + + + {% for category in transactions %} + {% if category.title %} + + + + {% endif %} + {% for transaction in category.transactions %} + {% include 'lists/transaction_row.html' with transaction=transaction hide_kcal=hide_kcal %} + {% endfor %} + {% if category.show_total %} + + + + + {% if not hide_kcal %}{% endif %} + + {% endif %} + {% endfor %} + {% if total %} + + + + + {% if not hide_kcal %}{% endif %} + + {% endif %} + +
    {% trans 'Date' %}{% trans 'Time' %}{% trans 'Person' %}{% trans 'Description' %}{% trans 'Price' %}{% trans 'kCal' %}
    {{ category.title }}
    {% trans 'Subtotal' %}€ {{ category.totals|intcomma }}{{ category.kcal_totals }}
    {% trans 'Total' %}€ {{ totals|intcomma }}{{ kcal_totals }}
    +
    +{% else %} +

    {% trans 'No transactions' %}

    +{% endif %} diff --git a/amelie/personal_tab/templates/lists/transactions_totals.html b/amelie/personal_tab/templates/lists/transactions_totals.html index 615140cb..d752462e 100644 --- a/amelie/personal_tab/templates/lists/transactions_totals.html +++ b/amelie/personal_tab/templates/lists/transactions_totals.html @@ -1,3 +1,4 @@ +{% load humanize %} {% load i18n transaction_link %} {% if rows %} @@ -21,9 +22,12 @@ {% trans 'Personal tab' %} {% trans 'Activities' %} {% trans 'Alexia' %} + {% trans 'Contribution' %} {% trans 'Other' %} {% trans 'Total' %} + {% trans 'Payments' %} + {% trans 'kCal' %} @@ -42,22 +46,30 @@ {% if overview_type == 'total' %} {{ row.date.year }} {% endif %} + + {% if row.date > date_now %} +   + {% endif %} {% for amount in row.amounts %} - {{ amount }} + € {{ amount|intcomma }} {% endfor %} + € {{ row.payment_totals|intcomma }} + {{ row.kcal_total }} {% endfor %} - + {% trans 'Total' %} {% for total in totals %} - {{ total }} + € {{ total|intcomma }} {% endfor %} + € {{ payment_totals|intcomma }} + {{ kcal_totals }} diff --git a/amelie/personal_tab/templates/personal_tab/balance_reminder.mail b/amelie/personal_tab/templates/personal_tab/balance_reminder.mail new file mode 100644 index 00000000..f3b66849 --- /dev/null +++ b/amelie/personal_tab/templates/personal_tab/balance_reminder.mail @@ -0,0 +1,21 @@ +{% extends "iamailer/email_basic.mail" %} +{% load i18n htmlify only %} + +{% block subject %}{% blocktrans %}[Inter-Actief] Reminder of open balance on your personal tab{% endblocktrans %}{% endblock %} + +{% block content %}{% htmlify %}{% blocktrans %}Dear{% endblocktrans %} {{ recipient.first_name }}, + +{% blocktrans %}This e-mail serves as a reminder that you have an open balance on your personal tab that needs to be paid.{% endblocktrans %} + +{% blocktrans %}Because you do not have an active, signed mandate for consumptions, we cannot collect this money from your bank automatically, so you will need to pay manually. Please visit the association room or contact the board to pay the balance by cash, bank transfer or PIN.{% endblocktrans %} + +{% blocktrans with costs=balance num_unpaid=num_unpaid_transactions %}Your open balance is {{ costs }} euro. This consists of {{ num_unpaid }} transactions.{% endblocktrans %} + +{% onlyhtml %}{% blocktrans with link=overview_link %}To view more details about the balance and the unpaid transactions, you can view your unpaid transaction overview.{% endblocktrans %} +{% endonlyhtml %}{% onlyplain %}{% blocktrans with link=overview_link %}To view more details about the balance and the unpaid transactions, you can view your unpaid transaction overview at {{ link }}{% endblocktrans %} +{% endonlyplain %} +{% blocktrans %}If you have any questions you can contact us by replying to this email.{% endblocktrans %} + +{% blocktrans %}Kind regards, + +The treasurer of Inter-Actief{% endblocktrans %}{% endhtmlify %}{% endblock %} diff --git a/amelie/personal_tab/templates/print/log_list.html b/amelie/personal_tab/templates/print/log_list.html index 004e96ed..f0af6175 100644 --- a/amelie/personal_tab/templates/print/log_list.html +++ b/amelie/personal_tab/templates/print/log_list.html @@ -52,6 +52,11 @@

    {% trans "Print Log" %}

    {{ entry.actor }} +   + + + {% if entry.job_id %} diff --git a/amelie/personal_tab/templates/statistics/statistics.html b/amelie/personal_tab/templates/statistics/statistics.html index 028420ec..d09bc60e 100644 --- a/amelie/personal_tab/templates/statistics/statistics.html +++ b/amelie/personal_tab/templates/statistics/statistics.html @@ -27,13 +27,13 @@

    {% for row in tables.t.rows %} {{ row.0 }} - {{ row.1|intcomma }} + € {{ row.1|intcomma }} {% endfor %} {% trans 'Total costs' %} - {{ tables.t.sum|intcomma }} + € {{ tables.t.sum|intcomma }} {% endif %} @@ -62,14 +62,14 @@

    {% trans 'Personal tab statistics' %}

    {% for category in tables.u.rows %} - + {{ category.category }} {{ category.count }} - {{ category.discount|stringformat:".2f" }} - {{ category.sum|stringformat:".2f" }} - {{ category.total|stringformat:".2f" }} + € {{ category.discount|intcomma }} + € {{ category.sum|intcomma }} + € {{ category.total|intcomma }} {% for article in category.articles %} @@ -77,9 +77,9 @@

    {% trans 'Personal tab statistics' %}

    {{ article.article }} {{ article.article.ledger_account|default:"—" }} {{ article.count }} - {{ article.discount|stringformat:".2f" }} - {{ article.sum|stringformat:".2f" }} - {{ article.total|stringformat:".2f" }} + {% if article.discount %}€ {{ article.discount|intcomma }}{% endif %} + € {{ article.sum|intcomma }} + € {{ article.total|intcomma }} {% endfor %} {% endfor %} @@ -108,20 +108,20 @@

    {% trans 'Personal tab balance' %}

    {% for row in tables.s.rows %} - + {{ row.0|default:"—" }} {{ row.1 }} - {{ row.2|stringformat:".2f" }} - {{ row.3|stringformat:".2f" }} - {{ row.4|stringformat:".2f" }} + € {{ row.2|intcomma }} + € {{ row.3|intcomma }} + € {{ row.4|intcomma }} {% endfor %} - + {% trans 'Total' %} {{ tables.s.totals.0 }} - {{ tables.s.totals.1 }} - {{ tables.s.totals.2 }} - {{ tables.s.totals.3 }} + € {{ tables.s.totals.1|intcomma }} + € {{ tables.s.totals.2|intcomma }} + € {{ tables.s.totals.3|intcomma }} @@ -142,7 +142,7 @@

    {{ table.title }}

    {% trans 'Article' %} {% trans 'Amount' %} - {% trans 'discount' %} + {% trans 'Discount' %} {% trans 'Total cost' %} {% trans 'Total costs' %} @@ -152,17 +152,17 @@

    {{ table.title }}

    {{ article.article }} {{ article.count }} - {{ article.discount|stringformat:".2f" }} - {{ article.sum|stringformat:".2f" }} - {{ article.total|stringformat:".2f" }} + {% if article.discount %}€ {{ article.discount|intcomma }}{% endif %} + € {{ article.sum|intcomma }} + € {{ article.total|intcomma }} {% endfor %} - + {% trans 'Total' %} {{ table.total.count }} - {{ table.total.discount|stringformat:".2f" }} - {{ table.total.sum|stringformat:".2f" }} - {{ table.total.total|stringformat:".2f" }} + € {{ table.total.discount|intcomma }} + € {{ table.total.sum|intcomma }} + € {{ table.total.total|intcomma }} @@ -191,7 +191,7 @@

    {% trans 'Activities' %}

    {% for row in tables.a.rows %} - + {{ row.event.begin|date:"d F Y" }} {{ row.event.begin|date:"G:i"}} @@ -207,16 +207,16 @@

    {% trans 'Activities' %}

    {{ row.count }} {{ row.active_count }} - {{ row.sum }} + € {{ row.sum|intcomma }} {% endfor %} - + {% trans 'Total' %} {{ tables.a.count }} {{ tables.a.active_count }} - {{ tables.a.sum }} + € {{ tables.a.sum|intcomma }} @@ -254,17 +254,17 @@

    {% trans 'Alexia transactions' %}

    {{ row.description }} - {{ row.price__sum }} + € {{ row.price__sum|intcomma }} {% endfor %} - + {% trans 'Total' %} - {{ tables.x.sum }} + € {{ tables.x.sum|intcomma }} @@ -289,16 +289,16 @@

    {% trans 'Membership fee' %}

    {% for row in tables.c.rows %} - + {{ row.membership_type }} {{ row.amount }} - {{ row.total }} + € {{ row.total|intcomma }} {% endfor %} - + {% trans 'Total' %} - {{ tables.c.sum }} + € {{ tables.c.sum|intcomma }} @@ -344,18 +344,16 @@

    {% trans 'Other' %}

    {{ row.description }} - {{ row.price|stringformat:".2f" }} + € {{ row.price|intcomma }} {% endfor %} - {% if total %} - - - - - {% trans 'Total' %} ({{ tables.o.count }}) - {{ tables.o.sum|stringformat:".2f" }} - - {% endif %} + + + + + {% trans 'Total' %} ({{ tables.o.count }}) + € {{ tables.o.sum|intcomma }} +
    @@ -382,13 +380,13 @@

    {% trans 'Discount balances' %}

    {{ row.discount_period }} {{ row.count }} - {{ row.sum|stringformat:".2f" }} + € {{ row.sum|intcomma }} {% endfor %} - + {% trans 'Total' %} {{ tables.g.count }} - {{ tables.g.sum|stringformat:".2f" }} + € {{ tables.g.sum|intcomma }} @@ -426,17 +424,17 @@

    {% trans 'Discount offers' %}

    {{ row.count }} - {{ row.sum }} + € {{ row.sum|intcomma }} {% endfor %} - + {% trans 'Total' %} - {{ tables.k.sum }} + € {{ tables.k.sum|intcomma }} diff --git a/amelie/personal_tab/templates/transactions/activity_transaction_detail.html b/amelie/personal_tab/templates/transactions/activity_transaction_detail.html index adb5089e..94db3b7f 100644 --- a/amelie/personal_tab/templates/transactions/activity_transaction_detail.html +++ b/amelie/personal_tab/templates/transactions/activity_transaction_detail.html @@ -10,26 +10,6 @@

    {% trans 'Transaction information' %}

    {% include "info/transaction_information.html" %} - - - - - {% if object.participation.remark %} - - - - - {% endif %}
    {% trans 'Activities' %} - {% if object.event %} - {% if request.is_board %} - {{ object.event.summary }} - {% else %} - {{ object.event.summary }} - {% endif %} - {% else %} - {% trans 'Deleted event' %} - {% endif %} -
    {% trans 'Remarks' %}{{ object.participation.remark }}
    @@ -44,7 +24,7 @@

    {% trans 'Enrollment options' %}

    {% trans 'Question' %} - {% trans 'Respons' %} + {% trans 'Response' %} {% trans 'Costs' %} diff --git a/amelie/personal_tab/templates/transactions/alexia_transaction_detail.html b/amelie/personal_tab/templates/transactions/alexia_transaction_detail.html index d63e0455..676af42b 100644 --- a/amelie/personal_tab/templates/transactions/alexia_transaction_detail.html +++ b/amelie/personal_tab/templates/transactions/alexia_transaction_detail.html @@ -10,10 +10,6 @@

    {% trans 'Transaction information' %}

    {% include "info/transaction_information.html" %} - - - -
    {% trans 'Transaction number Alexia' %}{{ object.transaction_id }}
    diff --git a/amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html b/amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html index 8508e7ac..e1256e62 100644 --- a/amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html +++ b/amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html @@ -10,18 +10,6 @@

    {% trans 'Transaction information' %}

    {% include "info/transaction_information.html" %} - - - - - - - - - - - -
    {% trans 'Article' %}{{ object.article|default:"—" }}
    {% trans 'Amount' %}{{ object.amount }}
    {% trans 'kCal' %}{{ object.kcal|default_if_none:"—" }}
    {% if object.editable and request.is_board %} diff --git a/amelie/personal_tab/templates/transactions/reversal_transaction_detail.html b/amelie/personal_tab/templates/transactions/reversal_transaction_detail.html index badeb1b4..4d8a8322 100644 --- a/amelie/personal_tab/templates/transactions/reversal_transaction_detail.html +++ b/amelie/personal_tab/templates/transactions/reversal_transaction_detail.html @@ -10,19 +10,6 @@

    {% trans 'Transaction information' %}

    {% include "info/transaction_information.html" %} - - - - - - - -
    {% trans 'Reversed collection' %} - - {{ object.reversal.instruction }} -
    - {{ object.reversal.instruction.batch.execution_date }} -
    {% trans 'Reason' %}{{ object.reversal.get_reason_display }}
    diff --git a/amelie/personal_tab/templates/unpaid_memberships/forgive.html b/amelie/personal_tab/templates/unpaid_memberships/forgive.html index 0b8af58f..cded15aa 100644 --- a/amelie/personal_tab/templates/unpaid_memberships/forgive.html +++ b/amelie/personal_tab/templates/unpaid_memberships/forgive.html @@ -18,12 +18,17 @@

    {% trans 'You are about to forgive the membership fee of the {% trans 'Price' %} - + {% for membership in memberships %} - - {{ membership.member.incomplete_name }} + + {{ membership.member }} + +   + + @@ -34,9 +39,13 @@

    {% trans 'You are about to forgive the membership fee of the {% endif %} {{ membership.year }} - {{ membership.type.price }} + € {{ membership.type.price }} {% endfor %} + + Total + € {{ total_price }} + @@ -51,4 +60,4 @@

    {% trans 'You are about to forgive the membership fee of the -{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/amelie/personal_tab/templates/unpaid_memberships/overview.html b/amelie/personal_tab/templates/unpaid_memberships/overview.html index f3107019..8be9a0cb 100644 --- a/amelie/personal_tab/templates/unpaid_memberships/overview.html +++ b/amelie/personal_tab/templates/unpaid_memberships/overview.html @@ -21,7 +21,7 @@

    {% trans 'Unpaid Memberships' %}

    - {{ year }} + {{ year }}/{{ year|add:1 }} {% for type, count in tuple.0.items %} @@ -30,13 +30,13 @@

    {% trans 'Unpaid Memberships' %}

    {% endfor %} - {{ tuple.1 }} + € {{ tuple.1 }} {% endfor %} - + {% endblock content %} diff --git a/amelie/personal_tab/templates/unpaid_memberships/year_overview.html b/amelie/personal_tab/templates/unpaid_memberships/year_overview.html index 2b858339..b70e555d 100644 --- a/amelie/personal_tab/templates/unpaid_memberships/year_overview.html +++ b/amelie/personal_tab/templates/unpaid_memberships/year_overview.html @@ -4,9 +4,18 @@ {% block titel %}{% trans 'Unpaid Memberships' %} {{ year }}{% endblock titel %} {% block content %} -
    +

    {% trans 'Unpaid Memberships' %} {{ year }}

    +
    +

    + {% trans 'Use these buttons to send a mailing or forgive the membership fee of the selected members.' %} +

    + +
    {% for membership_type, membership_list in unpaid_memberships.items %} @@ -26,15 +35,20 @@

    {{ membership_type.0 }}

    {% trans 'Price' %} - + {% for membership in membership_list %} - - {{ membership.member.incomplete_name }} + + {{ membership.member }} + +   + + @@ -64,30 +78,38 @@

    {{ membership_type.0 }}

    - {% endif %} - {{ membership.type.price }} + € {{ membership.type.price }} {% endfor %} - {% trans 'Send mailing' %} - {% trans 'Forgive membership fee' %}

    - + + + + {% empty %} +
    +
    +

    {% trans 'No unpaid memberships' %}

    +
    +

    {% trans 'There are no unpaid memberships in this year! Woah! 🎉🎉🎉' %}

    +
    {% endfor %} {% endblock content %} - \ No newline at end of file diff --git a/amelie/personal_tab/templates/wrapped/wrapped.html b/amelie/personal_tab/templates/wrapped/wrapped.html index c83a60ab..f977b5e6 100644 --- a/amelie/personal_tab/templates/wrapped/wrapped.html +++ b/amelie/personal_tab/templates/wrapped/wrapped.html @@ -132,7 +132,7 @@

    {% trans 'Total spent in the cookie corner: ' %} - €{{ total.price }} + € {{ total.price }}

    diff --git a/amelie/personal_tab/templatetags/unpaid_transaction_helpers.py b/amelie/personal_tab/templatetags/unpaid_transaction_helpers.py new file mode 100644 index 00000000..3730397a --- /dev/null +++ b/amelie/personal_tab/templatetags/unpaid_transaction_helpers.py @@ -0,0 +1,13 @@ +from django import template + +register = template.Library() + + +@register.filter() +def unpaid_transactions_of_type(person, transaction_type=None): + return person.unpaid_transactions(transaction_type=transaction_type) + + +@register.filter() +def unpaid_transactions_total_cost_of_type(person, transaction_type=None): + return person.unpaid_transactions_total_cost(transaction_type=transaction_type) diff --git a/amelie/personal_tab/transactions.py b/amelie/personal_tab/transactions.py index 34b7ceb6..e4cdab26 100644 --- a/amelie/personal_tab/transactions.py +++ b/amelie/personal_tab/transactions.py @@ -1,75 +1,128 @@ import datetime import random +from decimal import Decimal +from typing import Optional, Tuple, Union, List, Dict, Any from django.conf import settings +from django.db import transaction from django.db.models import Sum, Q from django.utils import timezone, translation from django.utils.translation import gettext as _ -from amelie.members.models import Study, Student +from amelie.members.models import Study, Student, Person from amelie.personal_tab.models import ActivityTransaction, DiscountPeriod, DiscountCredit, CookieCornerTransaction, \ - Discount -from amelie.tools.logic import current_academic_year_strict + Discount, Transaction, PersonalTabSettlement, ReversalTransaction, Authorization, ManualPaymentSettlement, \ + PaymentMethod +from amelie.tools.logic import current_academic_year_strict, current_association_year -def participation_transaction(participation, reason, cancel=False, added_by=None): +@transaction.atomic +def participation_transaction(participation, reason, cancel=False, added_by=None, date_override=None): """ Helper function that adds an ActivityTransaction. participation: Object to generate a transaction for. reason: Reason/description of this transaction - cancel: If True, make a compensation for a participation. This requires the participation.pk != None. + cancel: If True, make a compensation for the participation. This requires the participation.pk != None. added_by: Who created the transaction. + date_override: Use this date for the transactions if it is given """ - # Take the begin date/time as the date of the transaction, unless this moment has already passed. Jelte 2013-09-25 - date = max(participation.event.begin, timezone.now()) + if date_override is not None: + date = date_override + else: + # Take the beginning date/time as the date of the transaction, unless this moment has already passed. Jelte 2013-09-25 + date = max(participation.event.begin, timezone.now()) if cancel: - # The latest (by create time) positive ActivityTransaction for this participation should be compensated - old_transaction = ActivityTransaction.objects.filter( + # If the participation was free, there will not be a transaction to compensate. + if participation.is_free(): + return + + # The latest (by creation time) positive ActivityTransaction for this participation should be compensated + old_transactions = ActivityTransaction.objects.filter( participation=participation, event=participation.event, person=participation.person, price__gte=0 # Positive (or 0) price - ).order_by('-added_on').first() # Latest transaction, by creation time, or None if none exists + ).order_by('-added_on', '-pk') + old_transaction = old_transactions.first() # Latest transaction, by creation time, or None if none exists if old_transaction is None: # A compensation does not need to be created because there is no transaction to compensate. return - price = -1 * old_transaction.price - with_enrollment_options = old_transaction.with_enrollment_options - else: - price, with_enrollment_options = participation.calculate_costs() + if old_transactions.filter(added_on=old_transaction.added_on).count() > 1: + raise ValueError("Found multiple ActivityTransaction to cancel against.") + + # Create reversal activity transaction + tr = ActivityTransaction.objects.create( + price=(-1 * old_transaction.price), + description=reason, participation=participation, + event=participation.event, person=participation.person, date=date, + with_enrollment_options=old_transaction.with_enrollment_options, added_by=added_by + ) - # Create transaction - transaction = ActivityTransaction(price=price, description=reason, participation=participation, - event=participation.event, person=participation.person, date=date, - with_enrollment_options=with_enrollment_options, added_by=added_by) + # If the old transaction was already settled, we can't create a settlement for it as it would overwrite + # the old settlement. So instead we just leave the negative transaction unsettled so it can be counted later. + if old_transaction.settlement is not None: + return - # And save - transaction.save() + # Create a settlement to pay the old transaction by cancelling it out against the new reversal transaction. + payment_method = PaymentMethod.objects.get(pk=settings.INTERNAL_SETTLEMENT_PAYMENT_METHOD_ID) + # Translate the description to the user's preferred language + with translation.override(participation.person.preferred_language): + settlement_description = _( + "Settlement of previous costs for {activity}").format( + activity=participation.event.summary + ) + ManualPaymentSettlement.create_for_transactions( + transactions=[old_transaction, tr], + payment_method=payment_method, + person=participation.person, + settlement_description=settlement_description, + payment_description=settlement_description, + created_by=added_by, + ) + else: + # If the participation is free, don't add a transaction. + if participation.is_free(): + return + price, with_enrollment_options = participation.calculate_costs() + # Create regular activity transaction + ActivityTransaction.objects.create( + price=price, description=reason, participation=participation, + event=participation.event, person=participation.person, date=date, + with_enrollment_options=with_enrollment_options, added_by=added_by + ) -def add_participation(participation, added_by=None, is_edited_participation=False): +@transaction.atomic +def add_participation(participation, added_by=None, is_edited_participation=False, reason=None): """Adds a transaction for a participation in an event.""" - with translation.override(participation.person.preferred_language): - if is_edited_participation: - reason = _("Edited enrollment for {activity} (addition of updated costs)").format(activity=participation.event.summary) - else: - reason = _("Enrolled for {activity}").format(activity=participation.event.summary) + if reason is None: + with translation.override(participation.person.preferred_language): + if is_edited_participation: + reason = _("Edited enrollment for {activity} (addition of updated costs)").format( + activity=participation.event.summary + ) + else: + reason = _("Enrolled for {activity}").format(activity=participation.event.summary) participation_transaction(participation, reason, added_by=added_by) -def remove_participation(participation, added_by=None, is_edited_participation=False): +@transaction.atomic +def remove_participation(participation, added_by=None, is_edited_participation=False, reason=None): """Adds a transaction to nullify a participation in an event.""" - with translation.override(participation.person.preferred_language): - if is_edited_participation: - reason = _("Edited enrollment for {activity} (reversal of old costs)").format(activity=participation.event.summary) - else: - reason = _("Unenrolled for {activity}").format(activity=participation.event.summary) + if reason is None: + with translation.override(participation.person.preferred_language): + if is_edited_participation: + reason = _("Edited enrollment for {activity} (reversal of old costs)").format( + activity=participation.event.summary + ) + else: + reason = _("Unenrolled for {activity}").format(activity=participation.event.summary) participation_transaction(participation, reason, cancel=True, added_by=added_by) @@ -138,10 +191,10 @@ def cookie_corner_sale(article, amount, person, added_by): DiscountCredit(discount_period=discount_period, date=now, price=-discount_amount, person=person, description=description, discount=discount, added_by=added_by).save() - transaction = CookieCornerTransaction(date=now, price=total_price, person=person, description=description, + tr = CookieCornerTransaction(date=now, price=total_price, person=person, description=description, discount=discount, article=article, amount=amount, added_by=added_by) - transaction.save() - return transaction + tr.save() + return tr def free_cookie_discount(): @@ -198,6 +251,7 @@ def free_cookies_allowed(article): return article in articles +@transaction.atomic def free_cookies_sale(article, amount, person, added_by): """ Register a sale in the cookie corner with the free cookie discount. @@ -225,7 +279,70 @@ def free_cookies_sale(article, amount, person, added_by): discount = Discount(amount=discount_amount, date=now, discount_period=discount_period) discount.save() - transaction = CookieCornerTransaction(date=now, price=total_price, person=person, description=description, + tr = CookieCornerTransaction(date=now, price=total_price, person=person, description=description, discount=discount, article=article, amount=amount, added_by=added_by) - transaction.save() - return transaction + tr.save() + return tr + + +def get_transaction_payment_status(_transaction: Optional[Transaction], mandate_type: Optional[str] = None) -> Tuple[str, Optional[Union[PersonalTabSettlement, ReversalTransaction, Authorization]]]: + """ + Returns information on how the given transaction was paid, or how it might probably be paid in the future. + + If the transaction is None, it returns ('no_transaction', None) + If the transaction is paid, returns ('paid', ). + If the transaction is not paid: + - If the transaction was reversed, it returns ('reversed', ). + - If the transaction was not reversed: + - If the person has a direct debit mandate, returns ('unpaid', ). + - If the person has no mandate, it returns ('unpaid', None). + """ + # Short-circuit if no transaction was given + if not _transaction: + return 'no_transaction', None # No transaction! + + # If the participation is paid, returns the settlement in which it was paid. + if _transaction.is_paid(): + settlement_type = _transaction.settlement.settlement_type + if settlement_type == "INSTR" and hasattr(_transaction.settlement, 'debtcollectioninstruction'): + return 'paid', _transaction.settlement.debtcollectioninstruction + elif settlement_type == "MANUAL" and hasattr(_transaction.settlement, 'manualpaymentsettlement'): + return 'paid', _transaction.settlement.manualpaymentsettlement + return 'paid', _transaction.settlement + + # If the participation is not paid: + else: + # If the transaction was reversed, it returns the ReversalTransaction. + if _transaction.settlement and _transaction.settlement.settlement_type == "INSTR" and hasattr(_transaction.settlement, 'debtcollectioninstruction') and hasattr(_transaction.settlement.debtcollectioninstruction, 'reversal'): + return 'reversed', _transaction.settlement.debtcollectioninstruction.reversal.transaction + # If the transaction was not reversed, returns the direct debit mandate if the person has one, else None. + return 'unpaid', _transaction.person.has_mandate(mandate_type=mandate_type).order_by('authorization_type__id').first() # Returns None if no authorization exists. + + +def get_personal_tab_balances(only_members: bool = False, only_without_mandate: bool = True) -> List[Dict[str, Any]]: + """ + Gets a list of dictionaries, containing the personal tab balances of all people/members. + """ + people = Person.objects.all() + if only_members: + people = people.filter(Q(membership__ended__isnull=True) | Q(membership__ended__gt=datetime.date.today()), membership__year=current_association_year()).distinct() + if only_without_mandate: + people = people.exclude(authorization__is_signed=True, authorization__end_date__isnull=True, authorization__authorization_type__consumptions=True).distinct() + + results = [] + now = timezone.now() + + for person in people: + person_transactions = Transaction.objects.filter(person=person, date__lt=now) + unpaid_transactions = person_transactions.filter(settlement=None) + curr_balance = person_transactions.aggregate(Sum('price'))['price__sum'] or Decimal('0.00') + results.append({ + 'person': person, + 'balance': curr_balance, + 'transactions': person_transactions, + 'unpaid_transactions': unpaid_transactions, + 'unpaid_sum': unpaid_transactions.aggregate(Sum('price'))['price__sum'], + }) + + return results + diff --git a/amelie/personal_tab/urls.py b/amelie/personal_tab/urls.py index 76c0add7..97babf23 100644 --- a/amelie/personal_tab/urls.py +++ b/amelie/personal_tab/urls.py @@ -22,6 +22,7 @@ path('me/', views.my_dashboard, name='my_dashboard'), path('transactions/', views.transaction_form, name='transactions'), + path('transactions/unpaid/', views.UnpaidTransactionsListView.as_view(), name='unpaid_transactions_list'), path('transactions///', views.transaction_overview, name='transactions'), path('transactions/activity//', ActivityTransactionDetail.as_view(), name='activity_transaction_detail'), @@ -34,7 +35,7 @@ name='cookie_corner_transaction_delete'), path('unpaid_memberships/', views.unpaid_memberships, name='unpaid_memberships'), - path('unpaid_memberships//', views.unpaid_memberships, name='unpaid_memberships_year'), + path('unpaid_memberships//', views.unpaid_memberships_year, name='unpaid_memberships_year'), path('unpaid_memberships//mailing/', views.unpaid_memberships_mailing, name='unpaid_memberships_mailing'), path('unpaid_memberships//forgive/', views.unpaid_memberships_forgive, name='unpaid_memberships_forgive'), @@ -54,8 +55,8 @@ path('person///', views.dashboard, name='dashboard'), path('person///transactions/', views.person_transactions, name='person_transactions'), - path('person///transactions///', - views.person_transactions, name='person_transactions'), + path('person///transactions///', views.person_transactions, name='person_transactions'), + path('person///transactions/unpaid/', views.person_transactions_unpaid, name='person_transactions_unpaid'), path('person///new//', views.person_new_transaction, name='person_new_transaction'), @@ -69,6 +70,12 @@ path('person///exam_cookie_credit/new/', views.person_exam_cookie_credit_new, name='person_exam_cookie_credit_new'), + path('manual_payments/', views.ManualPaymentsListView.as_view(), name='manual_payments_list'), + path('person///manual_payments/', views.person_manual_payments, name='person_manual_payments'), + path('person///manual_payments/new/', views.CreateManualPaymentSettlementView.as_view(), name='person_manual_payment_new'), + path('manual_payments//', views.manual_payment_settlement_view, name='manual_payment_settlement_view'), + path('manual_payments//delete/', views.DeleteManualPaymentSettlementView.as_view(), name='manual_payment_settlement_delete'), + path('rfid//edit//', views.rfid_change_status, name='rfid_change_status'), path('rfid//remove/', views.rfid_remove, name='rfid_remove'), diff --git a/amelie/personal_tab/views.py b/amelie/personal_tab/views.py index 033c5f5c..e602e704 100644 --- a/amelie/personal_tab/views.py +++ b/amelie/personal_tab/views.py @@ -7,47 +7,47 @@ import traceback import operator from functools import reduce +from typing import List, Dict import django.conf from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.urls import reverse, reverse_lazy -from django.utils.translation import get_language, gettext_lazy as _l from django.db import transaction from django.db.models import Sum, Q, Count, Subquery, OuterRef from django.db.models.functions import TruncDay -from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseForbidden +from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseForbidden, HttpResponseNotAllowed from django.shortcuts import render, get_object_or_404, redirect -from django.utils import formats, timezone +from django.utils import formats, timezone, translation from django.utils.decorators import method_decorator -from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _l from django.utils.translation import gettext as _, get_language -from django.views.generic import CreateView +from django.views.generic import ListView, CreateView from django.views.generic.detail import DetailView -from django.views.generic.edit import UpdateView, DeleteView +from django.views.generic.edit import UpdateView, DeleteView, CreateView from django.views.generic.edit import FormView from amelie.calendar.models import Event -from amelie.members.models import MembershipType, Payment, PaymentType, Person, Membership +from amelie.members.models import MembershipType, Person, Membership from amelie.members.query_forms import MailingForm from amelie.settings.generic import DATE_PRE_SEPA_AUTHORIZATIONS from amelie.personal_tab.alexia import get_alexia, parse_datetime from amelie.personal_tab.helpers import kcal_equivalent from amelie.personal_tab.forms import CookieCornerTransactionForm, CustomTransactionForm, ExamCookieCreditForm, \ DebtCollectionForm, ReversalForm, SearchAuthorizationForm, AmendmentForm, DebtCollectionBatchForm, \ - AuthorizationSelectForm, \ - StatisticsForm, DeclarationForm, ArticleForm + AuthorizationSelectForm, StatisticsForm, DeclarationForm, ManualPaymentSettlementForm, \ + ManualPaymentTransactionFormSet, ArticleForm from amelie.personal_tab.debt_collection import delete_amendment, delete_reversal, edit_amendment, edit_reversal, generate_contribution_instructions, filter_contribution_instructions, \ save_contribution_instructions, generate_cookie_corner_instructions, filter_cookie_corner_instructions, save_cookie_corner_instructions, \ process_reversal, process_amendment from amelie.personal_tab.models import Amendment, Category, Declaration, Transaction, CookieCornerTransaction, \ - ActivityTransaction, \ - CustomTransaction, AlexiaTransaction, RFIDCard, Authorization, DebtCollectionAssignment, DebtCollectionBatch, \ - DiscountCredit, \ - DebtCollectionInstruction, ReversalTransaction, Article + ActivityTransaction, CustomTransaction, AlexiaTransaction, RFIDCard, Authorization, DebtCollectionAssignment, \ + DebtCollectionBatch, DiscountCredit, DebtCollectionInstruction, ReversalTransaction, ManualPaymentSettlement, \ + SettlementManualPaymentTransaction, SettlementExtraBalanceTransaction, DebtCollectionTransaction, \ + ContributionTransaction, PaymentMethod, Article from amelie.personal_tab.statistics import get_functions, statistics_totals from amelie.personal_tab.transactions import exam_cookie_discount, \ exam_cookie_credit as transactions_exam_cookie_credit, add_exam_cookie_credit @@ -160,7 +160,8 @@ def generate_overview(request, person, date_from=None, date_to=None): start_url = _urlize(date_from) if date_from else None end_url = _urlize(date_to) if date_to else None - transaction_types = [CookieCornerTransaction, ActivityTransaction, AlexiaTransaction, CustomTransaction] + transaction_types = [CookieCornerTransaction, ActivityTransaction, AlexiaTransaction, ContributionTransaction, CustomTransaction] + payment_transaction_types = [DebtCollectionTransaction, ReversalTransaction, SettlementManualPaymentTransaction, SettlementExtraBalanceTransaction] transaction_filter = [] if date_from or date_to: transaction_filter.append(Q(date__gte=date_from, date__lt=date_to)) @@ -179,8 +180,10 @@ def generate_overview(request, person, date_from=None, date_to=None): overview_type = None total = False totals = None + all_payment_totals = None kcal_totals = None all_transactions = None + payment_transactions = None rows = None if has_transactions: @@ -202,20 +205,22 @@ def generate_overview(request, person, date_from=None, date_to=None): # Execute query if len(transaction_filter) > 0: all_transactions = [x.objects.filter(reduce(operator.and_, transaction_filter)) for x in transaction_types] + payment_transactions = [x.objects.filter(reduce(operator.and_, transaction_filter)) for x in payment_transaction_types] else: all_transactions = [x.objects.all() for x in transaction_types] + payment_transactions = [x.objects.all() for x in payment_transaction_types] if overview_type == 'day': all_transactions[0] = all_transactions[0].select_related('person', 'article', 'discount') all_transactions[1] = all_transactions[1].select_related('person', 'event') all_transactions[2] = all_transactions[2].select_related('person') all_transactions[3] = all_transactions[3].select_related('person') + all_transactions[4] = all_transactions[4].select_related('person') - # On total page show the latest 10 transactions - # TODO: variable does not seem to be used? - albertskja 26-04-2018 - if not date_from: - # Table with latest 10 transactions - latest_10_transactions = _trans.order_by('-added_on')[:10] + payment_transactions[0] = payment_transactions[0].select_related('person') + payment_transactions[1] = payment_transactions[1].select_related('person') + payment_transactions[2] = payment_transactions[2].select_related('person') + payment_transactions[3] = payment_transactions[3].select_related('person') # Generate table with data if overview_type != 'day': @@ -251,13 +256,20 @@ def _datetime_end(t): for trans in itertools.chain.from_iterable(all_transactions): data.add(_datetime_start(trans.date.astimezone(localtz))) + for trans in itertools.chain.from_iterable(payment_transactions): + data.add(_datetime_start(trans.date.astimezone(localtz))) for date in sorted(data): amounts = [ - x.filter(date__gte=date, date__lt=_datetime_end(date)).aggregate(Sum('price'))['price__sum'] or 0 - for x in all_transactions] - - # Add the total to the end + x.filter(date__gte=date, date__lt=_datetime_end(date)).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") + for x in all_transactions + ] + payment_totals = [ + x.filter(date__gte=date, date__lt=_datetime_end(date)).aggregate(Sum('price'))['price__sum'] or Decimal("0.00") + for x in payment_transactions + ] + + # Add the totals to the end amounts.append(sum(amounts)) # TODO: BLEGH, custom SQL! - albertskja 2018-04-25 @@ -274,13 +286,15 @@ def _datetime_end(t): rows.append({ 'date': date, 'amounts': amounts, + 'payment_totals': sum(payment_totals), 'kcal_total': kcal_total, 'start_url': start_url_d, 'end_url': end_url_d }) # Calculate totals - totals = [x.aggregate(Sum('price'))['price__sum'] or 0 for x in all_transactions] + totals = [x.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") for x in all_transactions] + all_payment_totals = [x.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") for x in payment_transactions] # Add totals to the end totals.append(sum(totals)) @@ -300,11 +314,14 @@ def _datetime_end(t): 'form': form, 'date_from': date_from, 'date_to': date_to, + 'date_now': datetime.datetime.now(tz.utc), 'has_transactions': has_transactions, 'all_transactions': all_transactions, + 'payment_transactions': payment_transactions, 'overview_type': overview_type, 'total': total, 'totals': totals, + 'payment_totals': sum(all_payment_totals), 'kcal_totals': kcal_totals, 'rows': rows, 'view_name': view_name @@ -546,33 +563,35 @@ def transaction_overview(request, date_from, date_to): @require_board -def unpaid_memberships(request, year=None): - """Give an overview of unpaid memberships per year or, if a year is specified, per type.""" - - # If no year is given, show overview of all years with unpaid memberships - if year is None: - unpaid_memberships = Membership.objects.filter(payment__isnull=True, type__price__gt=0) - membership_types = [(mt.name, mt.pk) for mt in MembershipType.objects.filter(membership__payment__isnull=True, price__gt=0).distinct()] - years = unpaid_memberships.values('year').distinct().order_by('-year') - price = unpaid_memberships.values('type__price', 'year').order_by('year') - +def unpaid_memberships(request): + """Give an overview of unpaid memberships per year.""" + # For memberships specifically, checking for the existence of a ContributionTransaction without settlement + # is enough to see if it is unpaid, because even if a contribution direct debit is cancelled, it will create + # a new ContributionTransaction without a settlement so it can be debited again later. + unpaid_memberships = Membership.objects.filter(contributiontransaction__settlement=None, type__price__gt=0).distinct() + membership_types = [(mt.name, mt.pk) for mt in MembershipType.objects.filter(membership__contributiontransaction__settlement=None, price__gt=0).distinct()] + years = unpaid_memberships.values('year').distinct().order_by('-year') + price = unpaid_memberships.values('type__price', 'year').order_by('year') + + # Amount totals looks like this: + # {'2025': ({'Primary Yearlong': 47, 'Secondary Yearlong': 47}, 470.00), '2024': ...} + totals = {} + for year_total in years: + totals[year_total['year']] = ({}, unpaid_memberships.aggregate(price_sum=Sum('type__price', filter=Q(year=year_total['year'])))['price_sum']) + for membership_type in membership_types: + totals[year_total['year']][0][membership_type[0]] = unpaid_memberships.filter(type=membership_type[1], year=year_total['year']).count() + + return render(request, 'unpaid_memberships/overview.html', {'totals': totals, 'membership_types': membership_types}) - # Amount totals looks like this: - # {'2025' : ({'Primary Yearlong' : 47, 'Secondary Yearlong' : 47}, 470.00), '2024' : ...} - totals = {} - for year_total in years: - totals[year_total['year']] = ({}, sum(item['type__price'] for item in price if item['year'] == year_total['year'])) - for membership_type in membership_types: - totals[year_total['year']][0][membership_type[0]] = unpaid_memberships.filter(type=membership_type[1], year=year_total['year']).count() - - return render(request, 'unpaid_memberships/overview.html', {'totals': totals, 'membership_types': membership_types}) - - - # If a year is given, show the unpaid memberships for that year, grouped by membership type - unpaid_memberships = Membership.objects.filter(payment__isnull=True, type__price__gt=0, year=year).select_related('member').order_by('type', 'member__first_name') +@require_board +def unpaid_memberships_year(request, year): + """Give an overview of unpaid memberships for a year, grouped by membership type.""" + unpaid = Membership.objects.filter( + contributiontransaction__settlement=None, type__price__gt=0, year=year + ).distinct().select_related('member').order_by('type', 'member__first_name') grouped = {} - for membership in unpaid_memberships: + for membership in unpaid: membership_type = (membership.type.name, membership.type.pk) if membership_type not in grouped: grouped[membership_type] = [] @@ -599,20 +618,46 @@ def unpaid_memberships_forgive(request, year): messages.error(request, _("No members were selected.")) return redirect('personal_tab:unpaid_memberships_year', year) + total_price = sum((membership.type.price for membership in memberships), Decimal('0.00')) + if request.method == "POST": if 'confirm' in request.POST: - # Payment Type 12 is the "Forgiven" payment type - forgiven_payment_type = PaymentType.objects.get(pk=12) - for membership in memberships: - # Create a Payment object for the membership with the "Forgiven" payment type - payment = Payment( - membership=membership, - amount=membership.type.price, - date=timezone.now(), - payment_type=forgiven_payment_type, - ) - payment.save() - messages.success(request, _("The membership fees have been forgiven.")) + with transaction.atomic(): + # Get the payment method for "Forgiven" (configured in settings) + payment_method = PaymentMethod.objects.get(pk=settings.MEMBERS_FORGIVEN_PAYMENT_METHOD_ID) + num_forgiven = 0 + + for membership in memberships: + # Skip any memberships that are already paid (we shouldn't encounter any but just to be safe) + if membership.is_paid(): + continue + + # Create a settlement for all open ContributionTransactions of this membership, + transactions = [t for t in membership.contributiontransaction_set.all() if not t.is_paid()] + + # Create a ManualPaymentSettlement with the selected payment method + if transactions: + # Translate the description to the user's preferred language + with translation.override(membership.member.preferred_language): + settlement_description = _( + "Forgiven membership costs for {membership_type} ({begin_year}/{end_year})").format( + membership_type=membership.type.name, + begin_year=membership.year, + end_year=(membership.year + 1) + ) + ManualPaymentSettlement.create_for_transactions( + transactions=transactions, + payment_method=payment_method, + person=membership.member, + settlement_description=settlement_description, + payment_description=settlement_description, + created_by=request.person, + ) + num_forgiven += 1 + else: + raise ValueError(f"Tried to forgive payment for unpaid membership {membership.pk} but no transactions exist!") + + messages.success(request, _("The membership fees for {amount} memberships have been forgiven.").format(amount=num_forgiven)) return redirect('personal_tab:unpaid_memberships_year', year) else: @@ -745,7 +790,6 @@ def unpaid_memberships_mailing(request, year): }) - class TransactionSecurityMixin(RequirePersonMixin): """ Mixin for SingleObjectMixin and MultipleObjectMixin to select only transactions @@ -879,15 +923,11 @@ def dashboard(request, pk, slug): personal_transactions = Transaction.objects.filter(person=person).order_by('-added_on')[:5] - # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET - begin = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) now = timezone.now() today = now.astimezone(timezone.get_default_timezone()).date() - curr_balance = Transaction.objects.filter(person=person, date__gte=begin, date__lt=now).aggregate( - Sum('price'))['price__sum'] or Decimal('0.00') - all_balance = Transaction.objects.filter(person=person, date__gte=begin).aggregate( - Sum('price'))['price__sum'] or Decimal('0.00') + curr_balance = Transaction.objects.filter(person=person, date__lt=now).aggregate(Sum('price'))['price__sum'] or Decimal('0.00') + all_balance = Transaction.objects.filter(person=person).aggregate(Sum('price'))['price__sum'] or Decimal('0.00') future_debt_collection_instructions = DebtCollectionInstruction.objects.filter(authorization__person=person, batch__execution_date__gt=today) @@ -895,6 +935,7 @@ def dashboard(request, pk, slug): exam_cookie_credits = transactions_exam_cookie_credit(person) debt_collection_instructions = DebtCollectionInstruction.objects.filter(authorization__person=person ).order_by('-batch__execution_date')[:5] + manual_payment_settlements = ManualPaymentSettlement.objects.filter(person=person)[:5] # Date on which the old (pre-SEPA) authorizations are registered date_old_authorizations = DATE_PRE_SEPA_AUTHORIZATIONS @@ -906,6 +947,7 @@ def dashboard(request, pk, slug): 'all_balance': all_balance, 'future_debt_collection_instructions': future_debt_collection_instructions, 'debt_collection_instructions': debt_collection_instructions, + 'manual_payment_settlements': manual_payment_settlements, 'exam_cookie_credits': exam_cookie_credits, 'date_old_authorizations': date_old_authorizations, 'wrapped_year': django.conf.settings.COOKIE_CORNER_WRAPPED_YEAR @@ -952,6 +994,82 @@ def person_transactions(request, pk, slug, date_from=None, date_to=None): return generate_overview(request, person, start, end) +@require_lid +def person_transactions_unpaid(request, pk, slug): + person = get_object_or_404(Person, pk=pk, slug=slug) + + # Only the person themselves or the board has access to this. + if not request.person == person and not request.is_board: + return HttpResponseForbidden() + + transaction_types = [ + # (TransactionType, ['related', 'objects', 'to_select'], "Display Name") + (CookieCornerTransaction, ['person', 'article', 'discount'], _l("Cookie Corner Transaction")), + (ActivityTransaction, ['person', 'event'], _l("Activity Transaction")), + (AlexiaTransaction, ['person'], _l("Alexia Transaction")), + (ContributionTransaction, ['person'], _l("Contribution Transaction")), + (CustomTransaction, ['person'], _l("Custom Transaction")), + ] + payment_transaction_types = [ + # (TransactionType, ['related', 'objects', 'to_select'], "Display Name") + (DebtCollectionTransaction, ['person'], _l("Debt Collection Transaction")), + (ReversalTransaction, ['person'], _l("Reversal Transaction")), + (SettlementManualPaymentTransaction, ['person'], _l("Manual Payment Transaction")), + (SettlementExtraBalanceTransaction, ['person'], _l("Extra Balance Transaction")), + ] + + _trans = Transaction.objects.filter(person=person, settlement=None) + has_transactions = _trans.exists() + + # Vars for template context that might not be initialized sometimes + total = False + totals = [] + all_payment_totals = [] + transactions_total = Decimal('0.00') + payment_total = [] + all_transactions = None + payment_transactions = None + + # Person's balances + now = timezone.now() + curr_balance = Transaction.objects.filter(person=person, date__lt=now).aggregate(Sum('price'))['price__sum'] or Decimal('0.00') + all_balance = Transaction.objects.filter(person=person).aggregate(Sum('price'))['price__sum'] or Decimal('0.00') + + if has_transactions: + total = True # Generate totals + + # Get transactions and their related objects per type + all_transactions = [x[0].objects.filter(person=person, settlement=None).select_related(*x[1]) for x in transaction_types] + payment_transactions = [x[0].objects.filter(person=person, settlement=None).select_related(*x[1]) for x in payment_transaction_types] + + # Calculate totals + totals = [x.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") for x in all_transactions] + all_payment_totals = [x.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") for x in payment_transactions] + + transactions_total = sum(totals) + payment_total = sum(all_payment_totals) + + # Add totals to the end + totals.append(transactions_total) + + sums_per_type = [(tt[2], totals[i]) for i, tt in enumerate(transaction_types) if totals[i] != 0] + + # Done! + return render(request, 'cookie_corner_unpaid_transactions.html', { + 'person': person, + 'has_transactions': has_transactions, + 'all_transactions': all_transactions, + 'payment_transactions': payment_transactions, + 'total': total, + 'totals': totals, + 'payment_totals': payment_total, + 'curr_balance': curr_balance, + 'all_balance': all_balance, + 'sums_per_type': sums_per_type, + 'sums_balance': transactions_total + payment_total, + }) + + @require_board def person_new_transaction(request, person_id, slug, transaction_type): person = get_object_or_404(Person, id=person_id, slug=slug) @@ -991,6 +1109,302 @@ def person_debt_collection_instructions(request, person_id, slug): }) +class ManualPaymentsListView(ListView): + model = ManualPaymentSettlement + template_name = 'cookie_corner_manual_payments_list.html' + paginate_by = 100 + + +class UnpaidTransactionsListView(ListView): + model = Person + template_name = 'cookie_corner_unpaid_transactions_list.html' + paginate_by = 50 + + def get_context_data(self, *args, **kwargs): + context = super(UnpaidTransactionsListView, self).get_context_data(*args, **kwargs) + context['filter_no_mandate'] = self.request.GET.get('filter_no_mandate', False) + context['filter_only_mandate'] = self.request.GET.get('filter_only_mandate', False) + context['filter_mandate_type'] = self.request.GET.get('filter_mandate_type', None) + if context['filter_mandate_type'] not in ['contribution', 'consumptions']: + context['filter_mandate_type'] = None + context['filter_transactions'] = self.request.GET.get('filter_transactions', None) + + qs = self.get_queryset() + # Get total counts for in the summary table at the top of the page + filter_transaction_type = self.request.GET.get('filter_transactions', '').lower() + if filter_transaction_type not in ['activity', 'alexia', 'contribution', 'cookiecorner', 'custom', 'reversal']: + filter_transaction_type = None + context['person_count'] = qs.count() + context['transaction_count'] = sum(person.unpaid_transactions(transaction_type=filter_transaction_type).count() for person in qs) + context['total_costs'] = sum(person.unpaid_transactions_total_cost(transaction_type=filter_transaction_type) for person in qs) + return context + + def get_queryset(self): + qs = Person.objects.all().prefetch_related('transaction_set') + + ## + # Selectable filters + ## + + # Mandate filters + if self.request.GET.get('filter_no_mandate', '').lower() in ['true', 't', '1']: + qs = qs.exclude(authorization__is_signed=True, authorization__end_date__isnull=True).distinct() + if self.request.GET.get('filter_only_mandate', '').lower() in ['true', 't', '1']: + qs = qs.filter(authorization__is_signed=True, authorization__end_date__isnull=True).distinct() + + # Mandate type filters + filter_mandate_type = self.request.GET.get('filter_mandate_type', '').lower() + if filter_mandate_type == 'contribution': + qs = qs.filter(authorization__authorization_type__contribution=True).distinct() + elif filter_mandate_type == 'consumptions': + qs = qs.filter(authorization__authorization_type__consumptions=True).distinct() + + # Transaction type filters + filter_transaction_type = self.request.GET.get('filter_transactions', '').lower() + if filter_transaction_type in ['activity', 'alexia', 'contribution', 'cookiecorner', 'custom', 'reversal']: + qs = qs.filter(**{f'transaction__{filter_transaction_type}transaction__isnull': False, f'transaction__{filter_transaction_type}transaction__settlement': None}) + + # Actual transaction filter + qs = qs.filter( + transaction__isnull=False, transaction__settlement=None + ).distinct() + + return qs + + +@login_required +def person_manual_payments(request, person_id, slug): + person = get_object_or_404(Person, id=person_id, slug=slug) + manual_payments = ManualPaymentSettlement.objects.filter(person=person) + + # Done + return render(request, 'cookie_corner_person_manual_payments.html', { + 'person': person, + 'manual_payments': manual_payments + }) + + +@login_required +def manual_payment_settlement_view(request, id): + settlement = get_object_or_404(ManualPaymentSettlement, id=id) + if not request.is_board and request.person != settlement.person: + raise PermissionDenied + + manual_payment_transaction = settlement.manual_payment_transaction + extra_balance_transaction = settlement.extra_balance_transaction + settlement_transactions = settlement.transactions.all() + if manual_payment_transaction: + settlement_transactions = settlement_transactions.exclude(pk=manual_payment_transaction.pk) + + categorized_transactions: List[Dict] = [] + if settlement_transactions: + # First, show all regular settlement transactions + categorized_transactions.append({ + 'title': _l("Personal tab transactions in this settlement"), # No title for this category + 'transactions': settlement_transactions, + 'show_total': True, + 'totals': settlement_transactions.aggregate(Sum('price'))['price__sum'] or Decimal("0.00") + }) + # Then show the manual payment that was created to settle those (if present) + if manual_payment_transaction: + categorized_transactions.append({ + 'title': _l("Payment for the transactions above"), + 'transactions': [manual_payment_transaction], + 'show_total': False, + 'totals': manual_payment_transaction.price + }) + # Then show the balance that was settled with the personal tab (if present) + if extra_balance_transaction: + if extra_balance_transaction.price > 0: + extra_balance_title = _l("Residual balance debited from personal tab") + else: + extra_balance_title = _l("Residual balance credited to personal tab") + categorized_transactions.append({ + 'title': extra_balance_title, + 'transactions': [extra_balance_transaction], + 'show_total': False, + 'totals': extra_balance_transaction.price + }) + + return render(request, 'cookie_corner_manual_payment_settlement_view.html', { + 'settlement': settlement, + 'transactions': categorized_transactions, + 'show_total': True, + 'totals': sum(c['totals'] for c in categorized_transactions), + }) + + +class DeleteManualPaymentSettlementView(DeleteView): + model = ManualPaymentSettlement + template_name = 'cookie_corner_person_manual_payment_confirm_delete.html' + + object: ManualPaymentSettlement + + def get_object(self, *args, **kwargs): + obj: ManualPaymentSettlement = super().get_object(*args, **kwargs) + can_delete, reason = obj.deletion_possible() + if not can_delete: + raise ValueError(reason) + return obj + + def get_success_url(self): + return reverse('personal_tab:dashboard', kwargs={'pk': self.object.person.pk, 'slug': self.object.person.slug}) + + def delete(self, request, *args, **kwargs): + # Only allow deletion via POST form submission on confirmation page. + return HttpResponseNotAllowed(["GET", "POST"]) + + def form_valid(self, form): + success_url = self.get_success_url() + with transaction.atomic(): + # First, we need to unlink all the transactions that are in some way linked to this settlement. + # This avoids triggering the PROTECT setting on the foreign key relation when deleting the settlement. + + # Then, all regular (other) transactions need to be unlinked from the settlement. + for t in self.object.transactions.all(): + t.settlement = None + t.save() + + # Delete the settlement. + self.object.delete() + + # If the settlement had a SettlementManualPaymentTransaction, it needs to be deleted as the settlement will be undone. + if self.object.manual_payment_transaction: + self.object.manual_payment_transaction.delete() + + # If the settlement had a SettlementExtraBalanceTransaction, it needs to be deleted as the extra payment will be undone. + if self.object.extra_balance_transaction: + self.object.extra_balance_transaction.delete() + + messages.success( + self.request, + _("Manual payment '{payment}' of '{person}' deleted.").format( + payment=str(self.object), person=str(self.object.person.incomplete_name()) + ) + ) + return HttpResponseRedirect(success_url) + + +class CreateManualPaymentSettlementView(CreateView): + model = ManualPaymentSettlement + form_class = ManualPaymentSettlementForm + template_name = 'cookie_corner_person_manual_payment_create.html' + + def __init__(self, *args, **kwargs): + self.object = None + super().__init__(*args, **kwargs) + + def dispatch(self, request, *args, **kwargs): + self.person = Person.objects.get(pk=self.kwargs['person_id']) + return super().dispatch(request=request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(CreateManualPaymentSettlementView, self).get_context_data(**kwargs) + + context['person'] = self.person + person_unpaid_transactions = [ + {'transaction': t, 'include': False} + for t in Transaction.objects.filter(person=context['person'], settlement=None) + ] + context['transaction_formset'] = ManualPaymentTransactionFormSet(self.request.POST or None, initial=person_unpaid_transactions) + context['date_now'] = datetime.datetime.now(tz.utc) + # Current personal tab balances (in cents) + context['balance'] = { + 'today': (Transaction.objects.filter(person=self.person, date__lt=timezone.now()).aggregate(Sum('price'))['price__sum'] or Decimal('0.00')) * 100, + 'all': (Transaction.objects.filter(person=self.person).aggregate(Sum('price'))['price__sum'] or Decimal('0.00')) * 100 + } + return context + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({ + 'person': Person.objects.get(pk=self.kwargs['person_id']) + }) + return kwargs + + def post(self, request, *args, **kwargs): + """ + Handle POST requests: instantiate a form instance with the passed + POST variables and then check if it's valid. + """ + form = self.get_form() # ManualPaymentSettlement metadata + transaction_formset = ManualPaymentTransactionFormSet(self.request.POST) # Selected transactions + if form.is_valid() and transaction_formset.is_valid(): + try: + return self.form_valid(form, transaction_formset) + except ValidationError as e: + # Could be thrown if no transactions were selected and no extra money is deposited (empty payment) + form.add_error(None, e) + return self.form_invalid(form) + else: + return self.form_invalid(form) + + @transaction.atomic + def form_valid(self, form: ManualPaymentSettlementForm, transaction_formset): + payment_date: datetime.date = form.cleaned_data.get('payment_date') + payment_method: PaymentMethod = form.cleaned_data.get('payment_method') + paid_amount: Decimal = form.cleaned_data.get('paid_amount') or Decimal("0.00") + + # Get datetime for settlement and transactions + timezone_amsterdam = timezone.get_default_timezone() + transaction_datetime = datetime.datetime.combine(payment_date, datetime.time(0, 0)).replace(tzinfo=timezone_amsterdam) + + # Get transactions + transaction_candidates = Transaction.objects.filter(person=self.person, settlement=None) + transaction_pks = [t.get('id') for t in transaction_formset.cleaned_data if t.get('include', False)] + transactions = transaction_candidates.filter(id__in=transaction_pks) + + # Calculate the extra amount paid (transaction sum - amount paid) + total_transaction_price = sum(t.price for t in transactions) or Decimal("0.00") + extra_amount = paid_amount - total_transaction_price + + # Get description strings in the person's preferred language + with translation.override(self.person.preferred_language): + settlement_description = _("Personal tab settlement on {date} for {name}").format( + date=payment_date, name=self.person.incomplete_name() + ) + if paid_amount < 0: + # Negative payment, money goes from association to person + payment_description = _("Refund on {date} for {name}").format( + date=payment_date, name=self.person.incomplete_name() + ) + else: + # Positive (or zero) payment, money goes from person to association + payment_description = _("Manual payment on {date} for {name}").format( + date=payment_date, name=self.person.incomplete_name() + ) + + if extra_amount < 0: + # Personal tab balance will need to be used to settle these transactions (less paid than transaction sum) + balance_description = _("Debit from personal tab balance on {date} for {name}").format( + date=payment_date, name=self.person.incomplete_name() + ) + else: + # Personal tab balance will be added after settling these transactions (more paid than transaction sum) + balance_description = _("Credit to personal tab balance on {date} for {name}").format( + date=payment_date, name=self.person.incomplete_name() + ) + + settlement = ManualPaymentSettlement.create_for_transactions( + transactions=transactions, + payment_method=payment_method, + person=self.person, + settlement_description=settlement_description, + payment_description=payment_description, + leftover_balance_description=balance_description, + paid_amount=paid_amount, + payment_datetime=transaction_datetime, + created_by=self.request.person, + ) + + if settlement: + # Redirect to the settlement detail page. + return redirect(settlement) + else: + messages.error(self.request, _("No settlement was created because no transactions were selected and no amount was paid.")) + return redirect(reverse('personal_tab:dashboard', kwargs={'pk': self.person.pk, 'slug': self.person.slug})) + + @require_board def exam_cookie_credit(request, date_from=False, date_to=False): """Give an overview of exam cookie credits.""" @@ -1159,10 +1573,7 @@ def balance(request, dt_str=False): dt_url = _urlize(dt) - # Date the SEPA debt collection went into effect: 2013-10-31 00:00 CET - begin = datetime.datetime(2013, 10, 30, 23, 00, 00, tzinfo=tz.utc) - - all_transactions = Transaction.objects.filter(date__gte=begin, date__lt=dt) + all_transactions = Transaction.objects.filter(date__lt=dt) all_transactions_aggregated = all_transactions.aggregate(Sum('price')) all_transactions_sum = all_transactions_aggregated['price__sum'] @@ -1814,7 +2225,7 @@ def _get_person_context_contribution(instruction): """ person, context = _get_person_context(instruction) - membership = Membership.objects.get(contributiontransaction__debt_collection=instruction) + membership = Membership.objects.get(contributiontransaction__settlement=instruction) context['membership'] = { 'type': membership.type.name } diff --git a/amelie/settings/environ.py b/amelie/settings/environ.py index 8383b561..705ab77f 100644 --- a/amelie/settings/environ.py +++ b/amelie/settings/environ.py @@ -535,11 +535,11 @@ def custom_show_toolbar(request): # Maximum number of files for declarations PERSONAL_TAB_DECLARATION_MAX_FILE_AMOUNT = env.int("PERSONAL_TAB_DECLARATION_MAX_FILE_AMOUNT", default=PERSONAL_TAB_DECLARATION_MAX_FILE_AMOUNT) -# E-mail address for declarations -DECLARATION_EMAIL = env("AMELIE_DECLARATION_EMAIL", default=DECLARATION_EMAIL) +# E-mail addresses for declarations including committees with separate bookkeepings +DECLARATION_EMAIL = env("DECLARATION_EMAIL", default=DECLARATION_EMAIL) # E-mail address of the Treasurer -TREASURER_EMAIL = env("AMELIE_TREASURER_EMAIL", default=TREASURER_EMAIL) +TREASURER_EMAIL = env("TREASURER_EMAIL", default=TREASURER_EMAIL) ### # Amelie-specific settings diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index a702998c..812f179c 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -74,6 +74,13 @@ # The direct debit debtor ID of Inter-Actief that needs to be printed on the autorization forms DIRECT_DEBIT_DEBTOR_ID = 'NL81ZZZ400749470000' +# The ID of the personal_tab.PaymentMethod object representing an early termination (no payment needed). +MEMBERS_EARLY_TERMINATION_PAYMENT_METHOD_ID = 10 +# The ID of the personal_tab.PaymentMethod object representing a Forgiven payment (no payment needed). +MEMBERS_FORGIVEN_PAYMENT_METHOD_ID = 12 +# The ID of the personal_tab.PaymentMethod object representing an Internal settlement (no payment needed). +INTERNAL_SETTLEMENT_PAYMENT_METHOD_ID = 14 + # The LDAP host that is used to verify login attempts in the LDAP authentication module LDAP_HOST = 'hexia.ia.utwente.nl' @@ -450,9 +457,10 @@ # API documentation strings are formatted with markdown MODERNRPC_DOC_FORMAT = 'markdown' -# Settings regarding direct debits +# Settings regarding personal tab PERSONAL_TAB_MAXIMUM_ACTIVITY_PRICE = Decimal('30.00') # Maximum price of an activity PERSONAL_TAB_COMMITTEE_CAN_AUTHORIZE = True # Allow the committee to enroll people that pay with their authorization +PERSONAL_TAB_MINIMUM_BALANCE_REMINDER = Decimal('5.00') # Minimum balance above which people without authorization get monthly reminders to pay their open balance # Base path to the website. To be used when the django 'build_absolute_url()' does not work correctly. ABSOLUTE_PATH_TO_SITE = "https://staging.ia.utwente.nl" @@ -591,7 +599,15 @@ # Declaration Settings DECLARATION_EMAIL = 'Declarations ' +DECLARATION_EMAIL_COMMITTEE_OVERRIDE = { + "SocCie": "SocCie Declarations ", + "Kick-IT": "Kick-IT Declarations ", +} TREASURER_EMAIL = 'Penningmeester Inter-Actief ' +TREASURER_EMAIL_COMMITTEE_OVERRIDE = { + "SocCie": "Sociëteitscommissie ", + "Kick-IT": "Kick-IT ", +} PERSONAL_TAB_DECLARATION_MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB total, because email size limit is 25 MB PERSONAL_TAB_DECLARATION_MAX_FILE_AMOUNT = 10 diff --git a/amelie/style/static/css/compiled.css b/amelie/style/static/css/compiled.css index c94d4cbc..e20ca8d3 100644 --- a/amelie/style/static/css/compiled.css +++ b/amelie/style/static/css/compiled.css @@ -10310,6 +10310,9 @@ fieldset[disabled] .datepicker table tr td span.active.disabled:hover.focus { background-repeat: no-repeat; line-height: 1.5; } +.icon.scaled-icon { + background-size: contain; +} .icon[type="button"], .icon[type="submit"], .icon.looks-like-a-button { @@ -10325,6 +10328,9 @@ fieldset[disabled] .datepicker table tr td span.active.disabled:hover.focus { background-repeat: no-repeat; line-height: 1.5; } +.news-pinned.scaled-icon { + background-size: contain; +} .news-pinned[type="button"], .news-pinned[type="submit"], .news-pinned.looks-like-a-button { @@ -10564,6 +10570,39 @@ li.courses-soon { .icon-enroll { background: transparent url("/static/img/icons/calendar_add.png") no-repeat; } +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-center { + text-align: center; +} +.text-justify { + text-align: justify; +} +.text-nowrap { + white-space: nowrap; +} +.text-lowercase { + text-transform: lowercase; +} +.text-uppercase { + text-transform: uppercase; +} +.text-capitalize { + text-transform: capitalize; +} +.text-bold { + font-weight: bold; +} +.text-italics { + font-style: italic; +} +.text-underline { + text-decoration: underline; +} .hidden { display: none; } @@ -10710,6 +10749,9 @@ li.courses-soon { color: #FFFFFF; font-weight: bold; } +.ia th.secondary { + background: #2450A8; +} .ia .zebra thead tr th { color: #FFFFFF; background-color: #1d428a; @@ -10807,6 +10849,9 @@ li.courses-soon { color: #FFFFFF; font-weight: bold; } +.current th.secondary { + background: #099A2B; +} .current .zebra thead tr th { color: #FFFFFF; background-color: #077821; @@ -10904,6 +10949,9 @@ li.courses-soon { color: #FFFFFF; font-weight: bold; } +.edu th.secondary { + background: #DC332E; +} .edu .zebra thead tr th { color: #FFFFFF; background-color: #B8231F; @@ -11194,6 +11242,9 @@ a.looks-like-a-button.obscure { padding: 6px 6px 6px 28px !important; border: 2px solid; } +.ia_messages li.scaled-icon { + background-size: contain; +} .ia_messages li[type="button"], .ia_messages li[type="submit"], .ia_messages li.looks-like-a-button { @@ -11653,6 +11704,9 @@ p { margin: 10px 0; word-wrap: break-word; } +p.large-text { + font-size: 125%; +} p.serial { padding: 0 5px; display: inline-block; @@ -12298,6 +12352,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/email.png") no-repeat; } +.claudia-email.scaled-icon { + background-size: contain; +} .claudia-email[type="button"], .claudia-email[type="submit"], .claudia-email.looks-like-a-button { @@ -12312,6 +12369,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/email_go.png") no-repeat; } +.claudia-alias.scaled-icon { + background-size: contain; +} .claudia-alias[type="button"], .claudia-alias[type="submit"], .claudia-alias.looks-like-a-button { @@ -12326,6 +12386,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/vcard.png") no-repeat; } +.claudia-account.scaled-icon { + background-size: contain; +} .claudia-account[type="button"], .claudia-account[type="submit"], .claudia-account.looks-like-a-button { @@ -12340,6 +12403,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/exclamation.png") no-repeat; } +.claudia-error.scaled-icon { + background-size: contain; +} .claudia-error[type="button"], .claudia-error[type="submit"], .claudia-error.looks-like-a-button { @@ -12355,6 +12421,9 @@ table.totals tr:nth-last-child(2) td { background: transparent url("/static/img/icons/status_offline.png") no-repeat; color: gray !important; } +.claudia-nomapping.scaled-icon { + background-size: contain; +} .claudia-nomapping[type="button"], .claudia-nomapping[type="submit"], .claudia-nomapping.looks-like-a-button { @@ -12369,6 +12438,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/user.png") no-repeat; } +.claudia-AmeliePerson.scaled-icon { + background-size: contain; +} .claudia-AmeliePerson[type="button"], .claudia-AmeliePerson[type="submit"], .claudia-AmeliePerson.looks-like-a-button { @@ -12383,6 +12455,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/group.png") no-repeat; } +.claudia-AmelieGroup.scaled-icon { + background-size: contain; +} .claudia-AmelieGroup[type="button"], .claudia-AmelieGroup[type="submit"], .claudia-AmelieGroup.looks-like-a-button { @@ -12397,6 +12472,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/group_link.png") no-repeat; } +.claudia-AmelieDoGroup.scaled-icon { + background-size: contain; +} .claudia-AmelieDoGroup[type="button"], .claudia-AmelieDoGroup[type="submit"], .claudia-AmelieDoGroup.looks-like-a-button { @@ -12411,6 +12489,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/group_add.png") no-repeat; } +.claudia-ExtraGroup.scaled-icon { + background-size: contain; +} .claudia-ExtraGroup[type="button"], .claudia-ExtraGroup[type="submit"], .claudia-ExtraGroup.looks-like-a-button { @@ -12425,6 +12506,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/user_add.png") no-repeat; } +.claudia-ExtraPerson.scaled-icon { + background-size: contain; +} .claudia-ExtraPerson[type="button"], .claudia-ExtraPerson[type="submit"], .claudia-ExtraPerson.looks-like-a-button { @@ -12439,6 +12523,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/folder_user.png") no-repeat; } +.claudia-SharedDrive.scaled-icon { + background-size: contain; +} .claudia-SharedDrive[type="button"], .claudia-SharedDrive[type="submit"], .claudia-SharedDrive.looks-like-a-button { @@ -12453,6 +12540,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/email_go.png") no-repeat; } +.claudia-AliasGroup.scaled-icon { + background-size: contain; +} .claudia-AliasGroup[type="button"], .claudia-AliasGroup[type="submit"], .claudia-AliasGroup.looks-like-a-button { @@ -12467,6 +12557,9 @@ table.totals tr:nth-last-child(2) td { line-height: 1.5; background: transparent url("/static/img/icons/email.png") no-repeat; } +.claudia-Contact.scaled-icon { + background-size: contain; +} .claudia-Contact[type="button"], .claudia-Contact[type="submit"], .claudia-Contact.looks-like-a-button { diff --git a/amelie/style/static/less/htmltags/content.less b/amelie/style/static/less/htmltags/content.less index 0dbbe688..46b942e0 100644 --- a/amelie/style/static/less/htmltags/content.less +++ b/amelie/style/static/less/htmltags/content.less @@ -92,6 +92,10 @@ p { margin: 10px 0; word-wrap: break-word; + &.large-text { + font-size: 125%; + } + &.serial { padding: 0 5px; display: inline-block; diff --git a/amelie/style/static/less/util/icons.less b/amelie/style/static/less/util/icons.less index 6ff2ce14..68bc2ce8 100644 --- a/amelie/style/static/less/util/icons.less +++ b/amelie/style/static/less/util/icons.less @@ -11,6 +11,10 @@ background-repeat: no-repeat; line-height: 1.5; + &.scaled-icon { + background-size: contain; + } + &[type="button"], &[type="submit"], &.looks-like-a-button { padding-left: 26px; background-position-x: 4px; diff --git a/amelie/style/static/less/util/mixins.less b/amelie/style/static/less/util/mixins.less index 591c342f..818ac825 100644 --- a/amelie/style/static/less/util/mixins.less +++ b/amelie/style/static/less/util/mixins.less @@ -108,6 +108,10 @@ font-weight: bold; } + th.secondary { + background: @color_light; + } + .zebra { thead tr th { color: @white; diff --git a/amelie/style/static/less/util/text_styles.less b/amelie/style/static/less/util/text_styles.less new file mode 100644 index 00000000..cebeef9b --- /dev/null +++ b/amelie/style/static/less/util/text_styles.less @@ -0,0 +1,17 @@ + +// Alignment +.text-left { text-align: left; } +.text-right { text-align: right; } +.text-center { text-align: center; } +.text-justify { text-align: justify; } +.text-nowrap { white-space: nowrap; } + +// Transformation +.text-lowercase { text-transform: lowercase; } +.text-uppercase { text-transform: uppercase; } +.text-capitalize { text-transform: capitalize; } + +// Font styles +.text-bold { font-weight: bold; } +.text-italics { font-style: italic; } +.text-underline { text-decoration: underline; } diff --git a/amelie/style/static/less/utils.less b/amelie/style/static/less/utils.less index cedf1fc3..5c047464 100644 --- a/amelie/style/static/less/utils.less +++ b/amelie/style/static/less/utils.less @@ -3,6 +3,7 @@ @import "./util/datepicker"; @import "./util/timepicker"; @import "./util/icons"; +@import "./util/text_styles"; .hidden { display: none; diff --git a/amelie/test_translation.py b/amelie/test_translation.py index ba67205d..bd57ea4b 100644 --- a/amelie/test_translation.py +++ b/amelie/test_translation.py @@ -20,6 +20,7 @@ REGEX_MSGID = re.compile(r'(?P(#.*\n)+)msgid +(?P(".*"\n)+)') REGEX_IGNORED = [ re.compile(r'{% +blocktrans.*?%}.+?{% +endblocktrans +%}'), + re.compile(r'{% +blocktranslate.*?%}.+?{% +endblocktranslate +%}'), re.compile(r'{% +comment.*?%}.+?{% +endcomment +%}'), re.compile(r''), re.compile(r''), re.compile(r''), re.compile(r'{[{%#].+?[}%#]}'), re.compile(r']*?type="(?:input|submit|text)"value=(?:\'(?:\'[^>]*?>)?|"(?:"[^>]*?>)?)'), @@ -39,7 +40,8 @@ "\\bEUR\\b", "\\b-\\b", "\\bI\\.C\\.T\\.S\\.V\\.\\b", "\\bInter-\\b", "\\bActief\\b", "\\+53 489 3756", "contact@inter-actief\\.net", "https://ictsv\\.nl/wa", "https://ictsv\\.nl/discord", "@inter\\.actief", - "https://www\\.linkedin\\.com/company/i\\.c\\.t\\.s\\.v\\.-inter-actief/", "\\*", "#" + "https://www\\.linkedin\\.com/company/i\\.c\\.t\\.s\\.v\\.-inter-actief/", "\\*", "#", + "€", " ", "€ " ] diff --git a/amelie/tools/locale.py b/amelie/tools/locale.py new file mode 100644 index 00000000..0943f1c4 --- /dev/null +++ b/amelie/tools/locale.py @@ -0,0 +1,21 @@ +import locale +import threading +from contextlib import contextmanager + + +LOCALE_LOCK = threading.Lock() + + +@contextmanager +def setlocale(name): + """ + A Contextmanager to change the locale within the context to the given locale string. + This allows tools like strptime to parse time strings in other locales than the system locale. + e.g. "with setlocale('en_US'): ..." + """ + with LOCALE_LOCK: + saved = locale.setlocale(locale.LC_ALL) + try: + yield locale.setlocale(locale.LC_ALL, name) + finally: + locale.setlocale(locale.LC_ALL, saved) diff --git a/amelie/tools/mail.py b/amelie/tools/mail.py index 623f4d67..9afb42c9 100644 --- a/amelie/tools/mail.py +++ b/amelie/tools/mail.py @@ -50,6 +50,7 @@ def __init__(self, recipient, context=None, headers=None, ccs=None, bccs=None, a super(PersonRecipient, self).__init__(tos, context, headers, ccs, bccs, language, attachments) + class ActiveMemberRecipient(Recipient): def __init__(self, recipient, context=None, headers=None, ccs=None, bccs=None, attachments=None): """ diff --git a/amelie/tools/management/commands/www_generate_dummydata.py b/amelie/tools/management/commands/www_generate_dummydata.py index d9057af4..17315ae9 100644 --- a/amelie/tools/management/commands/www_generate_dummydata.py +++ b/amelie/tools/management/commands/www_generate_dummydata.py @@ -1,6 +1,7 @@ import datetime import getpass import io +import math import os import random import uuid @@ -274,6 +275,10 @@ def handle(self, *args, **options): # The Personal Tab default settings require that at least 3 discount periods exist baker.make('personal_tab.DiscountPeriod', _quantity=3) + # Create a few PaymentMethods for the Personal Tab + self.stdout.write("- 4 payment methods...") + payment_methods = baker.make('personal_tab.PaymentMethod', _quantity=4) + # The SEPA authorizations require that an AuthorizationType exists for Contribution and for other stuff if not AuthorizationType.objects.filter(active=True, contribution=True).exists(): AuthorizationType.objects.create(name_nl="Contributie", name_en="Contribution", @@ -309,48 +314,53 @@ def handle(self, *args, **options): # 10 Preferences self.stdout.write("- 10 preferences...") baker.make('members.Preference', _quantity=10) - # 2 PaymentTypes - self.stdout.write("- 2 payment types...") - baker.make('members.PaymentType', _quantity=2) # 80 Persons with Memberships and Students and StudyPeriods self.stdout.write("- 80 persons with memberships, students and study periods...") students = baker.make(PERSON_MODEL_STRING, user=None, _quantity=80) for student in students: - baker.make('members.Membership', + ms = baker.make('members.Membership', member=student, year=current_association_year(), ended=None) + for m in ms: + m.create_contribution_transaction() s = baker.make('members.Student', person=student) baker.make('members.StudyPeriod', student=s, begin=gen_date_before_today) # 10 Persons with expired memberships (old members) and Students and StudyPeriods self.stdout.write("- 10 persons with expired memberships (old members), students and study periods...") - students = baker.make(PERSON_MODEL_STRING, user=None, _quantity=10) - for student in students: - baker.make('members.Membership', + expired_students = baker.make(PERSON_MODEL_STRING, user=None, _quantity=10) + for student in expired_students: + ms = baker.make('members.Membership', member=student, year=current_association_year()-1, ended=None) + for m in ms: + m.create_contribution_transaction() s = baker.make('members.Student', person=student) baker.make('members.StudyPeriod', student=s, begin=gen_date_before_today) # 20 Persons with Memberships and Employees self.stdout.write("- 20 persons with memberships and employees...") employees = baker.make(PERSON_MODEL_STRING, user=None, _quantity=20) for employee in employees: - baker.make('members.Membership', + ms = baker.make('members.Membership', member=employee, year=current_association_year(), ended=None) + for m in ms: + m.create_contribution_transaction() baker.make('members.Employee', person=employee, end=None) # 50 Payments (half of students and half of employees) self.stdout.write("- 50 payments for half of the students and half of the employees...") random.shuffle(students) half_students = students[:40] + random.shuffle(expired_students) + half_expired_students = expired_students[:10] random.shuffle(employees) half_employees = employees[:10] - for person in half_students + half_employees: - baker.make('members.Payment', - membership=person.membership_set.first(), - amount=person.membership_set.first().type.price) + for person in half_students + half_expired_students + half_employees: + # Mark membership as paid + m: Membership = person.membership_set.first() + m.mark_as_paid(payment_method=random.choice(payment_methods)) # 2 CommitteeCategories self.stdout.write("- 2 committee categories...") baker.make('members.CommitteeCategory', _quantity=2) @@ -379,7 +389,7 @@ def handle(self, *args, **options): _quantity=20) # 20 activities with unlimited enrollments between three weeks ago and three weeks from now, # with between 0 and 10 enrollments - self.stdout.write("- 20 activities with unlimited enrollments and 0 to 10 enrollments, " + self.stdout.write("- 20 activities with unlimited enrollments and 0 to 10 enrollments (of which about half are paid), " "between 3 weeks ago and 3 weeks from now...") as_unlimited_enroll = baker.make(ACTIVITY_MODEL_STRING, begin=gen_datetime_between( @@ -403,10 +413,16 @@ def handle(self, *args, **options): for activity in as_unlimited_enroll: num_enrollments = random.randint(0, 10) if num_enrollments != 0: - baker.make('calendar.Participation', event=activity, _quantity=num_enrollments) + participations = baker.make('calendar.Participation', event=activity, _quantity=num_enrollments) + # Mark about half of the participations as paid + random.shuffle(participations) + half_participations = participations[:math.ceil(num_enrollments/2)] + for participation in half_participations: + # Mark participation as paid + participation.mark_as_paid(payment_method=random.choice(payment_methods)) # 20 activities with limited enrollments between three weeks ago and three weeks from now, - # with between 0 and 10 enrollments - self.stdout.write("- 20 activities with limited enrollments and 0 to 10 enrollments, " + # with between 0 and 10 enrollments (of which about half are paid) + self.stdout.write("- 20 activities with limited enrollments and 0 to 10 enrollments (of which about half are paid), " "between 3 weeks ago and 3 weeks from now...") as_limited_enroll = baker.make(ACTIVITY_MODEL_STRING, begin=gen_datetime_between( @@ -431,8 +447,16 @@ def handle(self, *args, **options): for activity in as_limited_enroll: num_enrollments = random.randint(0, 10) if num_enrollments != 0: - baker.make('calendar.Participation', event=activity, _quantity=num_enrollments) + participations = baker.make('calendar.Participation', event=activity, _quantity=num_enrollments) + # Mark about half of the participations as paid + random.shuffle(participations) + half_participations = participations[:math.ceil(num_enrollments/2)] + for participation in half_participations: + # Mark participation as paid + participation.mark_as_paid(payment_method=random.choice(payment_methods)) + # TODO: Enrollment options + # Add between 1 and 30 pictures to the 20 limited enrollment activities self.stdout.write("- Between 1 and 30 pictures for the 20 limited enrollment activities...") attachment_recipe = Recipe('files.Attachment', _create_files=True, file=gen_image_by_dimensions(800, 600), diff --git a/amelie/tools/mixins.py b/amelie/tools/mixins.py index f0115034..35f01e43 100644 --- a/amelie/tools/mixins.py +++ b/amelie/tools/mixins.py @@ -19,7 +19,7 @@ from amelie.members.models import Committee, DogroupGeneration from amelie.tools.logic import current_association_year -from amelie.tools.http import get_client_ips, is_allowed_ip, HttpJSONResponse +from amelie.tools.http import is_allowed_ip, HttpJSONResponse class PassesTestMixin(object): @@ -89,6 +89,12 @@ async def dispatch(self, request, *args, **kwargs): return await super(PassesTestAsyncMixin, self).dispatch(request, *args, **kwargs) +class RequireAjaxMixin(PassesTestMixin): + reason = _l('AJAX request required') + + def test_requirement(self, request): + return request.headers.get('x-requested-with') == 'XMLHttpRequest' + class RequirePersonMixin(PassesTestMixin): needs_login = True reason = _l('Access for (former) members only.') diff --git a/amelie/tools/templatetags/class_helpers.py b/amelie/tools/templatetags/class_helpers.py new file mode 100644 index 00000000..4ee8e33d --- /dev/null +++ b/amelie/tools/templatetags/class_helpers.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + + +@register.filter +def get_class(value): + return value.__class__.__name__ diff --git a/amelie/tools/tests.py b/amelie/tools/tests.py index 5b01ea1d..248c5390 100644 --- a/amelie/tools/tests.py +++ b/amelie/tools/tests.py @@ -124,6 +124,10 @@ def load_basic_data(self): username1 = 'testuser' username2 = 'testuser2' + # Membership type + membership_type = MembershipType(name_nl='Test membership', price=13.37) + membership_type.save() + data['password1'] = 'testuser13475' data['password2'] = 'testuser23475' @@ -135,6 +139,9 @@ def load_basic_data(self): account_name=data['user1'].username) data['person1'].save() + data['membership1'] = Membership(member=data['person1'], type=membership_type, year=current_association_year()) + data['membership1'].save() + data['user2'] = User(username=username2, first_name='Test2', last_name='Client', email='test2@example.com') data['user2'].set_password(data['password2']) data['user2'].save() @@ -143,6 +150,9 @@ def load_basic_data(self): account_name=data['user2'].username) data['person2'].save() + data['membership2'] = Membership(member=data['person2'], type=membership_type, year=current_association_year()) + data['membership2'].save() + # Committee data['committee1'] = Committee(name='Committee 1', abbreviation='Com1') data['committee1'].save() diff --git a/amelie/tools/views.py b/amelie/tools/views.py index 5933bcf2..1ccd9de9 100644 --- a/amelie/tools/views.py +++ b/amelie/tools/views.py @@ -1,6 +1,5 @@ import json import logging -import re from django.conf import settings from django.contrib import messages diff --git a/amelie/urls.py b/amelie/urls.py index 5079659f..b329c811 100644 --- a/amelie/urls.py +++ b/amelie/urls.py @@ -6,7 +6,6 @@ from django.contrib.auth.views import LogoutView from django.views.generic.base import RedirectView from django.views.static import serve -from health_check.views import HealthCheckView from amelie import views from amelie.activities.feeds import Activities @@ -55,7 +54,6 @@ path('videos/', include('amelie.videos.urls')), path('data_export/', include('amelie.data_export.urls')), path('publications/', include('amelie.publications.urls')), - path('participation/', include('amelie.calendar.participation_urls')), # API path('api/', include('amelie.api.urls')), diff --git a/amelie/views.py b/amelie/views.py index 1b8686bd..8d759033 100644 --- a/amelie/views.py +++ b/amelie/views.py @@ -32,8 +32,7 @@ from amelie.education.models import Complaint, EducationEvent from amelie.statistics.decorators import track_hits from amelie.tools.auth import get_user_info, unlink_totp, unlink_acount, unlink_passkey, register_totp, register_passkey -from amelie.tools.mixins import RequirePersonMixin, RequireSuperuserMixin, RequireBoardMixin, RequireActiveMemberMixin, \ - RequireSuperuserAsyncMixin +from amelie.tools.mixins import RequirePersonMixin, RequireSuperuserMixin, RequireBoardMixin, RequireSuperuserAsyncMixin from amelie.tools.buildinfo import get_build_info from amelie.tools.models import Profile from amelie.videos.models import BaseVideo diff --git a/locale/nl/LC_MESSAGES/django.mo b/locale/nl/LC_MESSAGES/django.mo index 3e84ab7b..0bfba633 100644 Binary files a/locale/nl/LC_MESSAGES/django.mo and b/locale/nl/LC_MESSAGES/django.mo differ diff --git a/locale/nl/LC_MESSAGES/django.po b/locale/nl/LC_MESSAGES/django.po index 8b33906f..c03e8dcf 100644 --- a/locale/nl/LC_MESSAGES/django.po +++ b/locale/nl/LC_MESSAGES/django.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-08 15:25+0200\n" -"PO-Revision-Date: 2026-08-08 13:42+0000\n" +"POT-Creation-Date: 2026-08-17 14:55+0200\n" +"PO-Revision-Date: 2026-08-17 14:58+0000\n" "Last-Translator: Kevin Alberts \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -11,7 +11,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 2026.8\n" +"X-Generator: Weblate 2026.8.1\n" "Used command: ./manage.py makemessages -e py,html,txt,mail\n" "X-Translated-Using: django-rosetta 0.9.9\n" @@ -27,12 +27,12 @@ msgstr "Slug van de pagina" msgid "Page content" msgstr "Inhoud van de pagina" -#: amelie/about/models.py:9 amelie/activities/models.py:25 -#: amelie/activities/models.py:481 -#: amelie/activities/templates/activity_enrollment_overview.html:79 -#: amelie/activities/templates/activity_enrollment_overview.html:83 -#: amelie/activities/templates/activity_enrollment_overview.html:231 -#: amelie/activities/templates/activity_enrollment_print.html:29 +#: amelie/about/models.py:9 amelie/activities/models.py:32 +#: amelie/activities/models.py:681 +#: amelie/activities/templates/activity_enrollment_print.html:35 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:131 +#: amelie/activities/templates/includes/activity_participants_table.html:11 +#: amelie/activities/templates/includes/activity_participants_table.html:15 #: amelie/claudia/templates/claudia/aliasgroup_list.html:21 #: amelie/claudia/templates/claudia/contact_list.html:20 #: amelie/claudia/templates/claudia/email_detail.html:16 @@ -56,11 +56,11 @@ msgstr "Inhoud van de pagina" #: amelie/claudia/templates/claudia/timeline_list.html:20 #: amelie/education/models.py:26 amelie/education/models.py:48 #: amelie/education/models.py:133 amelie/education/models.py:179 -#: amelie/members/models.py:44 amelie/members/models.py:65 -#: amelie/members/models.py:89 amelie/members/models.py:129 -#: amelie/members/models.py:226 amelie/members/models.py:256 -#: amelie/members/models.py:818 amelie/members/models.py:848 -#: amelie/members/models.py:1095 amelie/members/models.py:1118 +#: amelie/members/models.py:46 amelie/members/models.py:67 +#: amelie/members/models.py:91 amelie/members/models.py:131 +#: amelie/members/models.py:228 amelie/members/models.py:258 +#: amelie/members/models.py:845 amelie/members/models.py:1173 +#: amelie/members/models.py:1196 #: amelie/members/templates/committee_members.html:7 #: amelie/members/templates/includes/registration/personal_details.html:14 #: amelie/members/templates/includes/registration/personal_details_employee.html:14 @@ -68,20 +68,20 @@ msgstr "Inhoud van de pagina" #: amelie/members/templates/person_membership_sign.html:30 #: amelie/members/templates/preregistration_resend_confirm.html:29 #: amelie/members/templates/registration_check.html:34 -#: amelie/personal_tab/models.py:263 amelie/personal_tab/models.py:291 -#: amelie/personal_tab/models.py:328 amelie/personal_tab/statistics.py:266 +#: amelie/personal_tab/models.py:299 amelie/personal_tab/models.py:327 +#: amelie/personal_tab/models.py:364 amelie/personal_tab/statistics.py:270 #: amelie/personal_tab/templates/declaration/declaration_form.html:54 #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:15 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:22 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:31 #: amelie/room_duty/models.py:17 amelie/tools/pdf.py:103 #: amelie/tools/pdf.py:546 templates/health_check/partial_celery_tables.html:74 msgid "Name" msgstr "Naam" -#: amelie/about/models.py:10 amelie/activities/models.py:24 +#: amelie/about/models.py:10 amelie/activities/models.py:31 #: amelie/education/models.py:27 amelie/education/models.py:49 -#: amelie/members/models.py:90 amelie/members/models.py:819 -#: amelie/personal_tab/models.py:292 amelie/personal_tab/models.py:329 +#: amelie/members/models.py:92 amelie/members/models.py:846 +#: amelie/personal_tab/models.py:328 amelie/personal_tab/models.py:365 msgid "Name (en)" msgstr "Naam (en)" @@ -185,160 +185,193 @@ msgstr "IA Activiteiten" msgid "Inter-Actief's activities in the coming weeks" msgstr "De activiteiten van I.C.T.S.V. Inter-Actief in de komende tijd" -#: amelie/activities/forms.py:29 -#: amelie/activities/templates/activity_enrollment_overview.html:243 -#: amelie/activities/templates/activity_enrollment_print.html:36 -#: amelie/calendar/models.py:34 -msgid "Method of payment" -msgstr "Betaalwijze" - -#: amelie/activities/forms.py:42 -msgid "Place on bottom of waiting list" -msgstr "Plaats onderaan de wachtlijst" - -#: amelie/activities/forms.py:43 -msgid "Waiting list action" -msgstr "Wachtlijstactie" - -#: amelie/activities/forms.py:46 -msgid "Skip waiting list and enroll immediately" -msgstr "Sla de wachtlijst over en schrijf direct in" - -#: amelie/activities/forms.py:52 amelie/activities/models.py:482 -#: amelie/activities/templates/activity_enrollment_overview.html:183 -#: amelie/activities/templates/activity_enrollment_print.html:69 -#: amelie/members/models.py:821 amelie/members/models.py:1078 -#: amelie/personal_tab/forms.py:51 amelie/personal_tab/models.py:132 -#: amelie/personal_tab/models.py:295 -#: amelie/personal_tab/templates/authorization/view.html:139 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:70 +#: amelie/activities/forms.py:29 amelie/activities/models.py:682 +#: amelie/activities/templates/activity_enrollment_print.html:86 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:83 +#: amelie/members/models.py:848 amelie/personal_tab/forms.py:50 +#: amelie/personal_tab/models.py:137 amelie/personal_tab/models.py:331 +#: amelie/personal_tab/templates/authorization/view.html:144 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:53 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:44 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:93 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:18 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:27 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:124 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:75 #: amelie/personal_tab/templates/debt_collection/person_instructions.html:17 #: amelie/personal_tab/templates/debt_collection/process_batch.html:52 #: amelie/personal_tab/templates/debt_collection/view.html:95 -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:19 -#: amelie/personal_tab/templates/info/transaction_information.html:11 -#: amelie/personal_tab/templates/lists/activity_transactions.html:14 -#: amelie/personal_tab/templates/lists/alexia_transactions.html:12 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:14 -#: amelie/personal_tab/templates/lists/custom_transactions.html:12 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:12 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:11 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:12 -#: amelie/personal_tab/templates/lists/transactions.html:12 +#: amelie/personal_tab/templates/info/payments.html:23 +#: amelie/personal_tab/templates/info/payments.html:80 +#: amelie/personal_tab/templates/info/transaction_information.html:15 +#: amelie/personal_tab/templates/lists/activity_transactions.html:15 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:13 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:13 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:15 +#: amelie/personal_tab/templates/lists/custom_transactions.html:13 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:13 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:12 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:13 +#: amelie/personal_tab/templates/lists/payment_transactions.html:15 +#: amelie/personal_tab/templates/lists/transactions.html:13 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:13 #: amelie/personal_tab/templates/statistics/statistics.html:189 #: amelie/personal_tab/templates/statistics/statistics.html:244 #: amelie/personal_tab/templates/statistics/statistics.html:287 #: amelie/personal_tab/templates/statistics/statistics.html:323 -#: amelie/personal_tab/templates/statistics/statistics.html:377 -#: amelie/personal_tab/templates/statistics/statistics.html:416 -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:32 +#: amelie/personal_tab/templates/statistics/statistics.html:375 +#: amelie/personal_tab/templates/statistics/statistics.html:414 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:28 #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:18 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:26 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:35 #: amelie/personal_tab/templates/wrapped/wrapped.html:95 #: amelie/personal_tab/templates/wrapped/wrapped.html:195 #: amelie/personal_tab/templates/wrapped/wrapped.html:241 msgid "Price" msgstr "Prijs" -#: amelie/activities/forms.py:52 amelie/activities/models.py:67 -#: amelie/activities/models.py:280 amelie/activities/models.py:306 +#: amelie/activities/forms.py:29 amelie/activities/models.py:76 +#: amelie/activities/models.py:459 amelie/activities/models.py:491 msgid "Enrolled participants will be emailed when you change the price." msgstr "Ingeschreven deelnemers zullen een e-mail ontvangen als je de prijs aanpast." -#: amelie/activities/forms.py:97 +#: amelie/activities/forms.py:74 +msgid "Website enrolment must have a positive price. Either turn off the ability to enrol or increase the cost of the activity." +msgstr "Inschrijving via de website moet een positief bedrag kosten. Schakel ofwel de inschrijfoptie uit, of verhoog de prijs van de activiteit!" + +#: amelie/activities/forms.py:77 #, python-brace-format -msgid "Website enrolment has a maximum of {0} euro. Either turn off the ability to enrol or decrease the cost of the activity." -msgstr "Inschrijving via de website heeft een maximum van {0} euro. Of schakel de inschrijfoptie uit, of verlaag de prijs van de activiteit!" +msgid "Website enrollment has a maximum of {0} euro. Either turn off the ability to enrol or decrease the cost of the activity." +msgstr "Inschrijving via de website heeft een maximum van {0} euro. Schakel ofwel de inschrijfoptie uit, of verlaag de prijs van de activiteit!" -#: amelie/activities/forms.py:101 amelie/activities/models.py:113 +#: amelie/activities/forms.py:81 amelie/activities/models.py:103 msgid "There is no enrollment start date entered." msgstr "Er is geen inschrijfstartdatum opgegeven." -#: amelie/activities/forms.py:104 amelie/activities/models.py:116 +#: amelie/activities/forms.py:84 amelie/activities/models.py:106 msgid "There is no enrollment end date entered." msgstr "Er is geen inschrijfstopdatum opgegeven." -#: amelie/activities/forms.py:108 +#: amelie/activities/forms.py:88 msgid "Signup end must be after the signup start." msgstr "Einde van de inschrijving valt voor het begin." -#: amelie/activities/forms.py:189 +#: amelie/activities/forms.py:99 +msgid "Place on bottom of waiting list" +msgstr "Plaats onderaan de wachtlijst" + +#: amelie/activities/forms.py:100 +msgid "Waiting list action" +msgstr "Wachtlijstactie" + +#: amelie/activities/forms.py:103 +msgid "Skip waiting list and enroll immediately" +msgstr "Sla de wachtlijst over en schrijf direct in" + +#: amelie/activities/forms.py:134 +msgid "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." +msgstr "Het aantal huidige inschrijvingen voor deze optie zou hoger zijn dan het nieuwe maximumaantal. Schrijf a.u.b. eerst mensen uit, of pas het maximum voor deze optie aan." + +#: amelie/activities/forms.py:147 +msgid "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." +msgstr "Het geselecteerde aantal van (ten minste) een van de huidige inschrijvingen voor deze optie is hoger dan het nieuwe maximumaantal per persoon. Schrijf a.u.b. eerst mensen uit, pas hun inschrijving aan, of pas het maximum per persoon voor deze optie aan." + +#: amelie/activities/forms.py:207 msgid "Sorry, but the last spot has been claimed already!" msgstr "Sorry, maar de laatste plek is al vergeven!" -#: amelie/activities/forms.py:211 +#: amelie/activities/forms.py:229 +msgid "Sorry, but the amount must be a positive number!" +msgstr "Sorry, maar het bedrag moet positief zijn!" + +#: amelie/activities/forms.py:231 msgid "Sorry, but you cannot exceed the maximum amount per person!" msgstr "Sorry, maar je kunt niet over het aantal stuks per persoon!" -#: amelie/activities/forms.py:213 +#: amelie/activities/forms.py:245 +msgid "Sorry, but there are no spots left!" +msgstr "Sorry, maar er zijn geen plekken over!" + +#: amelie/activities/forms.py:247 msgid "Sorry, but there are not enough spots left!" msgstr "Sorry, maar er zijn niet genoeg plekken over!" -#: amelie/activities/forms.py:236 +#: amelie/activities/forms.py:361 amelie/members/forms.py:306 +#: amelie/personal_tab/forms.py:132 amelie/personal_tab/models.py:1043 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:43 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:17 +#: amelie/personal_tab/templates/info/payments.html:79 +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:29 +#: amelie/personal_tab/templates/lists/payment_transactions.html:13 +msgid "Payment method" +msgstr "Betaalmethode" + +#: amelie/activities/forms.py:375 msgid "Photographer" msgstr "Fotograaf" -#: amelie/activities/forms.py:237 amelie/members/models.py:310 -#: amelie/members/models.py:945 amelie/members/models.py:1319 +#: amelie/activities/forms.py:376 amelie/members/models.py:312 +#: amelie/members/models.py:956 amelie/members/models.py:1397 #: amelie/members/templates/members/datablock_html.mail:4 #: amelie/members/templates/members/datablock_plain.mail:3 msgid "First name" msgstr "Voornaam" -#: amelie/activities/forms.py:238 amelie/activities/forms.py:240 -#: amelie/activities/forms.py:242 +#: amelie/activities/forms.py:377 amelie/activities/forms.py:379 +#: amelie/activities/forms.py:381 msgid "Use this field if a non-member is the owner of these pictures" msgstr "Gebruik dit veld als een extern iemand deze foto's heeft gemaakt." -#: amelie/activities/forms.py:239 amelie/members/models.py:311 -#: amelie/members/models.py:946 amelie/members/models.py:1320 +#: amelie/activities/forms.py:378 amelie/members/models.py:313 +#: amelie/members/models.py:957 amelie/members/models.py:1398 #: amelie/members/templates/members/datablock_html.mail:5 #: amelie/members/templates/members/datablock_plain.mail:4 msgid "Last name pre-fix" msgstr "Tussenvoegsels" -#: amelie/activities/forms.py:241 amelie/members/models.py:312 -#: amelie/members/models.py:947 amelie/members/models.py:1321 +#: amelie/activities/forms.py:380 amelie/members/models.py:314 +#: amelie/members/models.py:958 amelie/members/models.py:1399 #: amelie/members/templates/members/datablock_html.mail:6 #: amelie/members/templates/members/datablock_plain.mail:5 msgid "Last name" msgstr "Achternaam" -#: amelie/activities/forms.py:243 amelie/activities/models.py:82 -#: amelie/activities/models.py:83 +#: amelie/activities/forms.py:382 amelie/activities/models.py:92 +#: amelie/activities/models.py:93 #: amelie/activities/templates/activity_list.html:4 #: amelie/activities/templates/activity_list.html:35 #: amelie/activities/templates/activity_list.html:115 #: amelie/activities/templates/activity_old.html:4 #: amelie/activities/templates/activity_old.html:34 -#: amelie/calendar/models.py:31 amelie/calendar/models.py:133 -#: amelie/calendar/models.py:134 +#: amelie/calendar/models.py:31 amelie/calendar/models.py:209 +#: amelie/calendar/models.py:210 #: amelie/companies/templates/companies/company_event_list.html:4 #: amelie/companies/templates/companies/company_event_old.html:34 -#: amelie/personal_tab/models.py:189 amelie/personal_tab/statistics.py:285 +#: amelie/personal_tab/models.py:225 amelie/personal_tab/statistics.py:289 #: amelie/personal_tab/templates/cookie_corner_transactions.html:42 -#: amelie/personal_tab/templates/lists/activity_transactions.html:11 -#: amelie/personal_tab/templates/lists/transactions_totals.html:22 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:64 +#: amelie/personal_tab/templates/info/transaction_information.html:62 +#: amelie/personal_tab/templates/lists/activity_transactions.html:12 +#: amelie/personal_tab/templates/lists/transactions_totals.html:23 #: amelie/personal_tab/templates/statistics/statistics.html:178 #: amelie/personal_tab/templates/statistics/statistics.html:186 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:14 #: amelie/weekmail/templates/weekmail/weekmail_mail_plain.mail:16 #: templates/basis.html:187 templates/basis.html:274 msgid "Activities" msgstr "Activiteiten" -#: amelie/activities/forms.py:244 amelie/calendar/models.py:116 +#: amelie/activities/forms.py:383 amelie/calendar/models.py:192 #: amelie/education/models.py:258 amelie/education/models.py:286 #: amelie/education/templates/education_event_overview.html:18 #: amelie/oauth/forms.py:11 amelie/videos/models.py:20 msgid "Public" msgstr "Publiek" -#: amelie/activities/forms.py:245 +#: amelie/activities/forms.py:384 msgid "Photos are also available without logging in" msgstr "Foto's zijn ook beschikbaar zonder in te loggen" -#: amelie/activities/forms.py:246 +#: amelie/activities/forms.py:385 #: amelie/activities/templates/activity_list.html:116 #: amelie/activities/templates/activity_old.html:35 #: amelie/activities/templates/gallery.html:4 @@ -356,152 +389,215 @@ msgstr "Foto's zijn ook beschikbaar zonder in te loggen" msgid "Photos" msgstr "Foto's" -#: amelie/activities/forms.py:261 +#: amelie/activities/forms.py:400 msgid "You must specify a photographer!" msgstr "Je moet een fotograaf opgeven!" -#: amelie/activities/forms.py:264 +#: amelie/activities/forms.py:403 msgid "You must specify a last name!" msgstr "Je moet een achternaam opgeven!" -#: amelie/activities/forms.py:267 +#: amelie/activities/forms.py:406 msgid "You must specify a first name!" msgstr "Je moet een voornaam opgeven!" -#: amelie/activities/forms.py:284 +#: amelie/activities/forms.py:423 #: amelie/activities/templates/activities/eventdesk/message_list.html:70 #: amelie/activities/templates/activities/eventdesk/message_list.html:102 msgid "Activity" msgstr "Activiteit" -#: amelie/activities/graphql.py:16 +#: amelie/activities/graphql.py:15 msgid "Has photos" msgstr "Heeft fotos" -#: amelie/activities/graphql.py:81 +#: amelie/activities/graphql.py:80 msgid "The absolute URL to an activity." msgstr "De absolute URL naar een activiteit." -#: amelie/activities/graphql.py:82 +#: amelie/activities/graphql.py:81 msgid "A URL to a random picture that was made at this activity." msgstr "Een URL naar een willekeurige foto die gemaakt is bij deze activiteit." -#: amelie/activities/graphql.py:83 +#: amelie/activities/graphql.py:82 msgid "A URL that points to the picture gallery for this activity." msgstr "Een URL die verwijst naar de fotogalerij van deze activiteit." -#: amelie/activities/graphql.py:84 +#: amelie/activities/graphql.py:83 msgid "A link to the ICS file for this activity." msgstr "Een link naar het ICS-bestand voor deze activiteit." -#: amelie/activities/graphql.py:85 +#: amelie/activities/graphql.py:84 msgid "Whether people can still enroll for this activity." msgstr "Of mensen zich nog kunnen inschrijven voor deze activiteit." -#: amelie/activities/graphql.py:86 +#: amelie/activities/graphql.py:85 msgid "Whether people can no longer enroll for this activity." msgstr "Of mensen zich niet meer kunnen inschrijven voor deze activiteit." -#: amelie/activities/graphql.py:87 +#: amelie/activities/graphql.py:86 msgid "Whether the person that is currently signed-in can edit this activity." msgstr "Of de huidig ingelogde persoon deze activiteit mag aanpassen." -#: amelie/activities/graphql.py:88 +#: amelie/activities/graphql.py:87 msgid "Whether this activity is full." msgstr "Of deze activiteit vol zit." -#: amelie/activities/graphql.py:89 +#: amelie/activities/graphql.py:88 msgid "Whether this activity is almost full (<= 10 places left)." msgstr "Of deze activiteit bijna vol zit (<= 10 plaatsen over)." -#: amelie/activities/graphql.py:90 +#: amelie/activities/graphql.py:89 msgid "If there are any options for enrollments." msgstr "Of er inschrijfopties zijn." -#: amelie/activities/graphql.py:91 +#: amelie/activities/graphql.py:90 msgid "If there are any costs associated with this activity." msgstr "Of er kosten zijn verbonden aan deze activiteit." -#: amelie/activities/graphql.py:158 +#: amelie/activities/graphql.py:157 msgid "Label name" msgstr "Labelnaam" -#: amelie/activities/graphql.py:159 +#: amelie/activities/graphql.py:158 msgid "Label explanation" msgstr "Labeluitleg" -#: amelie/activities/mail.py:64 amelie/activities/mail.py:66 -#: amelie/activities/mail.py:169 amelie/activities/mail.py:171 +#: amelie/activities/mail.py:63 amelie/activities/mail.py:65 +#: amelie/activities/mail.py:170 amelie/activities/mail.py:172 msgid "Inter-Activity" msgstr "Inter-Activiteit" -#: amelie/activities/models.py:26 +#: amelie/activities/models.py:33 msgid "Hexcolor without #" msgstr "Hex kleurcode (zonder #)" -#: amelie/activities/models.py:27 +#: amelie/activities/models.py:34 msgid "Icon from known icons" msgstr "Icoon uit bestaande iconen" -#: amelie/activities/models.py:28 +#: amelie/activities/models.py:35 msgid "Explanation on icon (en)" msgstr "Uitleg over icoon (en)" -#: amelie/activities/models.py:29 +#: amelie/activities/models.py:36 msgid "Explanation on icon" msgstr "Uitleg over icoon" -#: amelie/activities/models.py:30 +#: amelie/activities/models.py:37 #: amelie/companies/templates/companies/company_statistics.html:75 #: amelie/companies/templates/companies/company_statistics.html:105 msgid "Active?" msgstr "Actief?" -#: amelie/activities/models.py:57 +#: amelie/activities/models.py:64 msgid "enrollment" msgstr "inschrijving" -#: amelie/activities/models.py:58 +#: amelie/activities/models.py:65 msgid "enrollment start" msgstr "inschrijving start" -#: amelie/activities/models.py:58 +#: amelie/activities/models.py:65 msgid "If you want to add options, make sure your activity isn't open for enrollment right away" msgstr "Als je nog opties wil toevoegen, zorg ervoor dat de inschrijving dan niet meteen open is" -#: amelie/activities/models.py:59 +#: amelie/activities/models.py:66 msgid "enrollment end" msgstr "inschrijving stop" -#: amelie/activities/models.py:60 +#: amelie/activities/models.py:67 msgid "Hide participants" msgstr "Deelnemers verbergen" -#: amelie/activities/models.py:62 +#: amelie/activities/models.py:69 msgid "Lock waiting list" msgstr "Vergrendel wachtlijst" -#: amelie/activities/models.py:67 +#: amelie/activities/models.py:76 msgid "price" msgstr "prijs" -#: amelie/activities/models.py:68 +#: amelie/activities/models.py:78 msgid "can unenroll" msgstr "kan uitschrijven" -#: amelie/activities/models.py:70 +#: amelie/activities/models.py:80 msgid "icon" msgstr "icoon" -#: amelie/activities/models.py:70 +#: amelie/activities/models.py:80 msgid "Image of 175 by 275 pixels." msgstr "Afbeelding van 175 bij 275 pixels." -#: amelie/activities/models.py:72 +#: amelie/activities/models.py:82 msgid "Facebook event id, numerical value in the facebook event url." msgstr "Facebook event id, numerieke waarde in de facebook event url." -#: amelie/activities/models.py:219 +#: amelie/activities/models.py:243 +#, python-brace-format +msgid "Enrollment for {activity} is not possible." +msgstr "Inschrijven voor {activity} is niet mogelijk." + +#: amelie/activities/models.py:247 +#, python-brace-format +msgid "Enrollment for {activity} is not possible. You do not have an active membership at Inter-Actief" +msgstr "Inschrijven voor {activity} is niet mogelijk. Je hebt geen actief lidmaatschap bij Inter-Actief." + +#: amelie/activities/models.py:252 +#, python-brace-format +msgid "Enrollment for {activity} is only possible on the website of this activity." +msgstr "Inschrijven voor {activity} is alleen mogelijk op de website van deze activiteit." + +#: amelie/activities/models.py:258 +#, python-brace-format +msgid "The enrollment period for {activity} hasn't started yet or has already passed." +msgstr "De inschrijfperiode voor {activity} is nog niet begonnen of is al voorbij." + +#: amelie/activities/models.py:263 +#, python-brace-format +msgid "{person} is already enrolled for {activity}." +msgstr "{person} is al ingeschreven voor {activity}." + +#: amelie/activities/models.py:268 +#, python-brace-format +msgid "You are already enrolled for {activity}." +msgstr "Je bent al ingeschreven voor {activity}." + +#: amelie/activities/models.py:287 amelie/activities/models.py:307 +#, python-brace-format +msgid "Unenrollment for {activity} is not possible." +msgstr "Uitschrijven voor {activity} is niet mogelijk." + +#: amelie/activities/models.py:291 +#, python-brace-format +msgid "Unenrollment for {activity} is only possible on the website of this activity." +msgstr "Uitschrijven voor {activity} is alleen mogelijk op de website van deze activiteit." + +#: amelie/activities/models.py:299 +#, python-brace-format +msgid "{person} is not enrolled for {activity}." +msgstr "{person} is niet ingeschreven voor {activity}." + +#: amelie/activities/models.py:304 +#, python-brace-format +msgid "You are not enrolled for {activity}." +msgstr "Je bent niet ingeschreven voor {activity}." + +#: amelie/activities/models.py:310 +#, python-brace-format +msgid "The unenrollment term for {activity} has already passed." +msgstr "De uitschrijfperiode voor {activity} is al voorbij." + +#: amelie/activities/models.py:350 +msgid "Activity was cancelled" +msgstr "Activiteit is geannuleerd" + +#: amelie/activities/models.py:365 +msgid "Activity has already started, it cannot be canceled any more." +msgstr "Activiteit is al begonnen, en kan niet meer worden geannuleerd." + +#: amelie/activities/models.py:394 #: amelie/activities/templates/activities/enrollmentoption_list.html:41 #: amelie/activities/templates/activities/enrollmentoption_list.html:87 #: amelie/activities/templates/activities/enrollmentoption_list.html:135 @@ -517,65 +613,64 @@ msgstr "Facebook event id, numerieke waarde in de facebook event url." msgid "Title" msgstr "Titel" -#: amelie/activities/models.py:225 +#: amelie/activities/models.py:400 msgid "Enrollment option" msgstr "Inschrijfoptie" -#: amelie/activities/models.py:226 +#: amelie/activities/models.py:401 #: amelie/activities/templates/activities/enrollmentoption_list.html:5 -#: amelie/activities/templates/activity_enrollment_form.html:47 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:39 +#: amelie/activities/templates/activity_enrollment_form.html:99 +#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:19 msgid "Enrollment options" msgstr "Inschrijfopties" -#: amelie/activities/models.py:267 amelie/activities/models.py:494 +#: amelie/activities/models.py:446 amelie/activities/models.py:694 #: amelie/activities/templates/activities/enrollmentoption_list.html:136 #: amelie/activities/templates/activities/enrollmentoption_list.html:178 msgid "Required" msgstr "Verplicht" -#: amelie/activities/models.py:280 amelie/activities/models.py:306 -#: amelie/activities/models.py:345 +#: amelie/activities/models.py:459 amelie/activities/models.py:491 +#: amelie/activities/models.py:546 msgid "Price extra" msgstr "Prijs extra" -#: amelie/activities/models.py:281 amelie/activities/models.py:307 +#: amelie/activities/models.py:460 amelie/activities/models.py:492 msgid "Maximum limit of selections" msgstr "Limiet van selecties" -#: amelie/activities/models.py:281 amelie/activities/models.py:307 -#: amelie/activities/models.py:308 +#: amelie/activities/models.py:460 amelie/activities/models.py:492 +#: amelie/activities/models.py:493 msgid "Set as 0 for unlimited" msgstr "Houd op 0 voor onbeperkt" -#: amelie/activities/models.py:308 +#: amelie/activities/models.py:493 msgid "Maximum per person" msgstr "Maximum per persoon" -#: amelie/activities/models.py:358 +#: amelie/activities/models.py:559 msgid "Answer to enrollment option" msgstr "Inschrijfoptieantwoord" -#: amelie/activities/models.py:359 +#: amelie/activities/models.py:560 msgid "Anwers to enrollment option" msgstr "Inschrijfoptieantwoorden" -#: amelie/activities/models.py:407 amelie/activities/models.py:423 -#: amelie/activities/models.py:441 amelie/activities/models.py:456 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:47 +#: amelie/activities/models.py:607 amelie/activities/models.py:623 +#: amelie/activities/models.py:641 amelie/activities/models.py:656 msgid "Respons" msgstr "Antwoord" -#: amelie/activities/models.py:419 +#: amelie/activities/models.py:619 msgid "A response is required" msgstr "Antwoord is verplicht" -#: amelie/activities/models.py:428 +#: amelie/activities/models.py:628 #: amelie/activities/templates/activities/eventdesk/message_list.html:212 -#: amelie/activities/templates/activity_confirm_cancellation.html:21 +#: amelie/activities/templates/activity_confirm_cancellation.html:29 #: amelie/activities/templates/activity_confirm_delete.html:21 -#: amelie/activities/templates/activity_confirm_unenrollment.html:19 -#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:18 +#: amelie/activities/templates/activity_confirm_unenrollment.html:25 +#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:22 #: amelie/activities/templates/photo_upload_clear_confirm.html:25 #: amelie/calendar/templates/confirm_payment.html:19 #: amelie/claudia/templates/claudia/add_to_mapping_confirm.html:21 @@ -586,17 +681,17 @@ msgstr "Antwoord is verplicht" #: amelie/members/templates/members/datablock_html.mail:26 #: amelie/members/templates/preregistration_delete_confirm.html:17 #: amelie/personal_tab/templates/print/refund.html:96 -#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:31 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:42 +#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:36 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:56 #: amelie/weekmail/templates/weekmail/weekmailnewsarticle_confirm_delete.html:16 msgid "Yes" msgstr "Ja" -#: amelie/activities/models.py:428 +#: amelie/activities/models.py:628 #: amelie/activities/templates/activities/eventdesk/message_list.html:215 -#: amelie/activities/templates/activity_confirm_cancellation.html:22 +#: amelie/activities/templates/activity_confirm_cancellation.html:30 #: amelie/activities/templates/activity_confirm_delete.html:22 -#: amelie/activities/templates/activity_confirm_unenrollment.html:20 +#: amelie/activities/templates/activity_confirm_unenrollment.html:26 #: amelie/activities/templates/photo_upload_clear_confirm.html:26 #: amelie/calendar/templates/confirm_payment.html:20 #: amelie/claudia/templates/claudia/add_to_mapping_confirm.html:22 @@ -607,60 +702,63 @@ msgstr "Ja" #: amelie/members/templates/members/datablock_html.mail:26 #: amelie/members/templates/preregistration_delete_confirm.html:18 #: amelie/personal_tab/templates/print/refund.html:97 -#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:33 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:44 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:61 +#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:38 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:58 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:75 msgid "No" msgstr "Nee" -#: amelie/activities/models.py:461 amelie/activities/models.py:528 +#: amelie/activities/models.py:661 amelie/activities/models.py:728 msgid "(none)" msgstr "(geen)" -#: amelie/activities/models.py:480 amelie/activities/models.py:495 +#: amelie/activities/models.py:680 amelie/activities/models.py:695 msgid "Restaurant" msgstr "Restaurant" -#: amelie/activities/models.py:483 amelie/personal_tab/models.py:296 -#: amelie/personal_tab/models.py:330 amelie/room_duty/models.py:247 +#: amelie/activities/models.py:683 amelie/personal_tab/models.py:332 +#: amelie/personal_tab/models.py:366 amelie/room_duty/models.py:247 msgid "Available" msgstr "Beschikbaar" -#: amelie/activities/models.py:484 -#: amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html:39 +#: amelie/activities/models.py:684 +#: amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html:42 msgid "Allergens" msgstr "Allergiën" -#: amelie/activities/models.py:484 +#: amelie/activities/models.py:684 msgid "No information available" msgstr "Geen informatie beschikbaar" -#: amelie/activities/models.py:509 +#: amelie/activities/models.py:709 msgid "Enrollment optionquestion" msgstr "Inschrijfoptievraag" -#: amelie/activities/models.py:520 +#: amelie/activities/models.py:720 msgid "Dish cost" msgstr "Prijs voor gerecht" -#: amelie/activities/models.py:557 +#: amelie/activities/models.py:739 #: amelie/activities/templates/activities/eventdesk/message_list.html:51 #: amelie/personal_tab/templates/info/rfid_cards.html:15 msgid "Registered" msgstr "Aangemeld" -#: amelie/activities/models.py:558 +#: amelie/activities/models.py:740 #: amelie/activities/templates/activities/eventdesk/message_list.html:55 msgid "Accepted" msgstr "Geaccepteerd" -#: amelie/activities/models.py:559 amelie/activities/views.py:1615 -#: amelie/activities/views.py:1621 amelie/activities/views.py:1627 -#: amelie/members/models.py:295 +#: amelie/activities/models.py:741 amelie/activities/views.py:1646 +#: amelie/activities/views.py:1652 amelie/activities/views.py:1658 +#: amelie/members/models.py:297 #: amelie/members/templates/person_accounts.html:37 #: amelie/personal_tab/templates/info/rfid_cards.html:45 -#: amelie/personal_tab/templates/print/log_list.html:94 -#: amelie/personal_tab/templates/print/log_list.html:116 +#: amelie/personal_tab/templates/lists/payment_transactions.html:48 +#: amelie/personal_tab/templates/lists/payment_transactions.html:50 +#: amelie/personal_tab/templates/lists/payment_transactions.html:52 +#: amelie/personal_tab/templates/print/log_list.html:99 +#: amelie/personal_tab/templates/print/log_list.html:121 #: amelie/personal_tab/templates/print/refund.html:63 #: amelie/personal_tab/templates/print/refund.html:88 #: templates/health_check/partial_celery_tables.html:30 @@ -668,61 +766,103 @@ msgstr "Geaccepteerd" #: templates/health_check/partial_celery_tables.html:56 #: templates/health_check/partial_celery_tables.html:90 #: templates/health_check/partial_celery_tables.html:101 -#: templates/profile_overview.html:297 +#: templates/profile_overview.html:303 msgid "Unknown" msgstr "Onbekend" -#: amelie/activities/models.py:561 +#: amelie/activities/models.py:743 msgid "E-mail message ID" msgstr "E-mailbericht-ID" -#: amelie/activities/models.py:562 +#: amelie/activities/models.py:744 msgid "E-mail message timestamp" msgstr "Tijdstempel E-mailbericht" -#: amelie/activities/models.py:564 +#: amelie/activities/models.py:746 msgid "New state of the event registration" msgstr "Nieuwe status van de evenementaanmelding" -#: amelie/activities/models.py:566 +#: amelie/activities/models.py:748 msgid "Person that requested the registration as stated in the e-mail" msgstr "Persoon die de aanmelding heeft aangevraagd volgens de e-mail" -#: amelie/activities/models.py:568 +#: amelie/activities/models.py:750 msgid "Name of the event as stated in the e-mail" msgstr "Naam van het evenement volgens de e-mail" -#: amelie/activities/models.py:569 +#: amelie/activities/models.py:751 msgid "Start date/time of the event as stated in the e-mail" msgstr "Startdatum/tijd van het evenement volgens de e-mail" -#: amelie/activities/models.py:570 +#: amelie/activities/models.py:752 msgid "End date/time of the event as stated in the e-mail" msgstr "Einddatum/tijd van het evenement volgens de e-mail" -#: amelie/activities/models.py:571 +#: amelie/activities/models.py:753 msgid "Location of the event as stated in the e-mail" msgstr "Locatie van het evenement volgens de e-mail" -#: amelie/activities/models.py:572 +#: amelie/activities/models.py:754 msgid "Percentage certainty of match with activity." msgstr "Zekerheidspercentage van de match met de activiteit." -#: amelie/activities/models.py:610 +#: amelie/activities/models.py:792 msgid "n/a" msgstr "n.v.t." -#: amelie/activities/models.py:612 +#: amelie/activities/models.py:794 #, python-format, python-brace-format msgid "{0:.1f}% certain" msgstr "{0:.1f}% zeker" #: amelie/activities/templates/activities/activity_cancelled.mail:3 +#, python-format +msgid "Cancellation of %(activity)s" +msgstr "Annulering van %(activity)s" + +#: amelie/activities/templates/activities/activity_cancelled.mail:4 +#: amelie/claudia/templates/claudia/account_deleted.mail:5 +#, python-format +msgid "Dear %(name)s," +msgstr "Beste %(name)s," + +#: amelie/activities/templates/activities/activity_cancelled.mail:5 +#, python-format +msgid "" +"\n" +"You receive this email because you were enrolled for %(activity)s. This activity has been cancelled.\n" +msgstr "" +"\n" +"Je ontvangt deze e-mail omdat je bent ingeschreven voor %(activity)s. Deze activiteit is geannuleerd.\n" + +#: amelie/activities/templates/activities/activity_cancelled.mail:8 +#, python-format +msgid "The total costs were %(costs)s euro." +msgstr "De kosten waren totaal %(costs)s euro." + +#: amelie/activities/templates/activities/activity_cancelled.mail:9 +msgid "These costs will be refunded on your personal tab." +msgstr "Deze kosten zullen worden teruggeboekt op je streeplijstsaldo." + +#: amelie/activities/templates/activities/activity_cancelled.mail:10 +msgid "If you already paid for this activity, please come by the association room or contact the treasurer to re-collect your payment. If you had not paid yet you do not need to do anything." +msgstr "Als je al betaald had voor deze activiteit, kom dan langs de verenigingskamer of neem contact op met de penningmeester om je betaling terug te krijgen. Als je nog niet had betaald dan hoef je niks te doen." + +#: amelie/activities/templates/activities/activity_cancelled.mail:11 +#: amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail:7 +#: amelie/activities/templates/activities/activity_cancelled_treasurer.mail:13 +#: amelie/activities/templates/activities/activity_enrolled_on_waiting_list.mail:15 +msgid "" +"\n" +"Kind regards," +msgstr "" +"\n" +"Met vriendelijke groet," + #: amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail:3 msgid "Cancellation of " msgstr "Annulering van " -#: amelie/activities/templates/activities/activity_cancelled.mail:4 #: amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail:4 #: amelie/activities/templates/activities/activity_enrolled.mail:5 #: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:5 @@ -742,36 +882,12 @@ msgstr "Annulering van " #: amelie/members/templates/members/yeartransfer_ended_eniac.mail:6 #: amelie/members/templates/members/yeartransfer_ended_final.mail:6 #: amelie/members/templates/members/yeartransfer_prolonged.mail:6 +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:6 #: amelie/tools/templates/send_oauth_link_code.mail:6 #: amelie/weekmail/templates/weekmail/weekmail_mail_plain.mail:1 msgid "Dear" msgstr "Beste" -#: amelie/activities/templates/activities/activity_cancelled.mail:5 -#, python-format -msgid "" -"\n" -"You receive this email because you were enrolled for %(summary)s. This activity has been cancelled.\n" -msgstr "" -"\n" -"Je ontvangt deze e-mail omdat je bent ingeschreven voor %(summary)s. Deze activiteit is geannuleerd.\n" - -#: amelie/activities/templates/activities/activity_cancelled.mail:8 -#, python-format -msgid "The total costs were %(participation_costs)s euro. Your payment method was %(paymentmethod)s. These costs will be refunded on your personal tab in the case of a mandate or you can collect your cash payment at the association room in the case of a cash payment." -msgstr "De totale kosten waren %(participation_costs)s euro. Jouw betaalmethode was %(paymentmethod)s. Deze kosten krijg je teruggestort op je streeplijst als je via een machtiging hebt betaald. Als je contant betaald hebt, dan kan je bij de vereningskamer je geld ophalen." - -#: amelie/activities/templates/activities/activity_cancelled.mail:9 -#: amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail:7 -#: amelie/activities/templates/activities/activity_cancelled_treasurer.mail:13 -#: amelie/activities/templates/activities/activity_enrolled_on_waiting_list.mail:15 -msgid "" -"\n" -"Kind regards," -msgstr "" -"\n" -"Met vriendelijke groet," - #: amelie/activities/templates/activities/activity_cancelled_from_waiting_list.mail:5 #, python-format msgid "" @@ -809,39 +925,49 @@ msgstr "Je ontvangt deze e-mail omdat je bent ingeschreven voor %(summary)s. Dez #: amelie/activities/templates/activities/activity_enrolled.mail:9 #: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:9 #, python-format -msgid "The total costs are %(costs)s euro. Payment method: %(paymentmethod)s." -msgstr "De kosten zijn totaal %(costs)s euro. Betaalwijze: %(paymentmethod)s." +msgid "The total costs are %(costs)s euro." +msgstr "De kosten zijn totaal %(costs)s euro." -#: amelie/activities/templates/activities/activity_enrolled.mail:11 -#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:11 +#: amelie/activities/templates/activities/activity_enrolled.mail:10 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:10 +msgid "These costs will be debited from your bank account via your direct debit mandate." +msgstr "Deze kosten zullen worden afgeschreven van je bankrekening via je incassomachtiging." + +#: amelie/activities/templates/activities/activity_enrolled.mail:10 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:10 +msgid "These costs will need to be paid manually by you, either at the activity or at the board." +msgstr "Deze kosten zullen door jou handmatig betaald moeten worden, ofwel bij de activiteit of bij het bestuur." + +#: amelie/activities/templates/activities/activity_enrolled.mail:12 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:12 msgid "For this activity, the following enrollment options exist:" msgstr "Bij deze activiteit zijn de volgende inschrijfopties ingevuld:" -#: amelie/activities/templates/activities/activity_enrolled.mail:21 -#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:21 +#: amelie/activities/templates/activities/activity_enrolled.mail:22 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:22 #, python-format msgid "It's possible to edit your enrollment or unenroll until %(enrollment_end)s." msgstr "Tot %(enrollment_end)s is het mogelijk om je inschrijving te wijzigen of te annuleren." -#: amelie/activities/templates/activities/activity_enrolled.mail:23 -#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:23 +#: amelie/activities/templates/activities/activity_enrolled.mail:24 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:24 msgid "For this activity, you can only unenroll yourself by contacting the board." msgstr "Voor deze activiteit kun je jezelf alleen uitschrijven indien je contact met het bestuur opneemt." -#: amelie/activities/templates/activities/activity_enrolled.mail:25 +#: amelie/activities/templates/activities/activity_enrolled.mail:26 #: amelie/activities/templates/activities/activity_enrolled_on_waiting_list.mail:11 #, python-format msgid "You have been enrolled by %(enrolled_by)s. In case you did not approve this, get in touch with the board." msgstr "Je bent ingeschreven door %(enrolled_by)s. Indien je hier geen toestemming voor gegeven hebt, neem dan contact op met het bestuur." -#: amelie/activities/templates/activities/activity_enrolled.mail:27 -#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:25 +#: amelie/activities/templates/activities/activity_enrolled.mail:28 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:26 #: amelie/activities/templates/activities/activity_enrolled_on_waiting_list.mail:13 msgid "An invite is attached. You can import this in your agenda. In case you no longer want to receive invites through e-mail, edit your preferences on the website." msgstr "In de bijlage is een invite toegevoegd. Deze is te importeren in je agenda. Wil je geen invite meer ontvangen in de e-mail, wijzig dan je voorkeuren op de website." -#: amelie/activities/templates/activities/activity_enrolled.mail:29 -#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:27 +#: amelie/activities/templates/activities/activity_enrolled.mail:30 +#: amelie/activities/templates/activities/activity_enrolled_from_waiting_list.mail:28 msgid "" "See you then!\n" "\n" @@ -984,7 +1110,8 @@ msgstr "Opslaan en inschrijfopties toevoegen" #: amelie/activities/templates/activities/activity_form.html:104 #: amelie/activities/templates/activities/activity_form.html:107 #: amelie/activities/templates/activities/enrollmentoption_confirm_delete.html:21 -#: amelie/activities/templates/activity_enrollment_form.html:107 +#: amelie/activities/templates/activity_enrollment_form.html:194 +#: amelie/activities/templates/includes/activity_participation_create_payment.html:60 #: amelie/claudia/templates/accounts/mailing_alias.html:18 #: amelie/claudia/templates/claudia/extrapersonalalias_delete.html:21 #: amelie/companies/templates/companies/company_event_form.html:111 @@ -994,8 +1121,8 @@ msgstr "Opslaan en inschrijfopties toevoegen" #: amelie/members/templates/committee_edit_data.html:8 #: amelie/members/templates/committee_edit_members.html:73 #: amelie/members/templates/committee_edit_single_member.html:66 -#: amelie/members/templates/members/payment_confirm_delete.html:21 #: amelie/members/templates/person_activate_mandate.html:19 +#: amelie/members/templates/person_confirm_mandate.html:23 #: amelie/members/templates/person_delete_mandate.html:28 #: amelie/members/templates/person_edit_data.html:18 #: amelie/members/templates/person_edit_employee.html:9 @@ -1008,18 +1135,19 @@ msgstr "Opslaan en inschrijfopties toevoegen" #: amelie/members/templates/person_end_membership.html:21 #: amelie/members/templates/person_existing_mandate.html:17 #: amelie/members/templates/person_mandate_sign.html:48 +#: amelie/members/templates/person_membership_create_payment.html:60 #: amelie/members/templates/person_membership_sign.html:48 #: amelie/members/templates/person_new_mandate.html:11 #: amelie/members/templates/person_new_membership.html:11 -#: amelie/members/templates/person_payment.html:15 #: amelie/members/templates/preregistration_resend_confirm.html:42 #: amelie/personal_tab/templates/authorization/amendment_delete.html:16 #: amelie/personal_tab/templates/cookie_corner_article_form.html:38 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html:16 #: amelie/personal_tab/templates/debt_collection/instruction_reversal_delete.html:16 #: amelie/personal_tab/templates/pos/register_external_card.html:40 #: amelie/personal_tab/templates/pos/scan_external_card.html:27 #: amelie/personal_tab/templates/transactions/transaction_delete.html:16 -#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:46 +#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:55 #: amelie/room_duty/templates/room_duty/balcony_duty/delete.html:17 #: amelie/room_duty/templates/room_duty/pool/delete.html:38 #: amelie/room_duty/templates/room_duty/pool/form.html:17 @@ -1035,7 +1163,7 @@ msgstr "Annuleer" #: amelie/activities/templates/activities/activity_form.html:118 #: amelie/activities/templates/activity.html:182 #: amelie/activities/templates/activity_list.html:36 -#: amelie/personal_tab/models.py:191 +#: amelie/personal_tab/models.py:227 msgid "Enrollment" msgstr "Inschrijving" @@ -1140,12 +1268,12 @@ msgstr "" #: amelie/claudia/templates/claudia/extrapersonalalias_delete.html:19 #: amelie/companies/templates/companies/company_event.html:20 #: amelie/education/templates/education_event.html:19 -#: amelie/members/templates/members/payment_confirm_delete.html:19 #: amelie/members/templates/person_delete_mandate.html:31 #: amelie/members/templates/person_edit_employee.html:13 #: amelie/members/templates/person_mandate.html:85 #: amelie/personal_tab/templates/authorization/amendment_delete.html:21 -#: amelie/personal_tab/templates/authorization/view.html:98 +#: amelie/personal_tab/templates/authorization/view.html:103 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html:21 #: amelie/personal_tab/templates/debt_collection/instruction_reversal_delete.html:21 #: amelie/personal_tab/templates/info/rfid_cards.html:63 #: amelie/personal_tab/templates/transactions/transaction_delete.html:21 @@ -1166,7 +1294,7 @@ msgstr "" #: amelie/weekmail/templates/weekmail/weekmail_wizard.html:83 #: templates/oauth2_provider/authorized-token-delete.html:13 #: templates/oauth2_provider/includes/user_oauth_table.html:9 -#: templates/profile_overview.html:175 templates/profile_overview.html:186 +#: templates/profile_overview.html:181 templates/profile_overview.html:192 msgid "Delete" msgstr "Verwijder" @@ -1258,7 +1386,8 @@ msgstr "Plekken over" #: amelie/members/templates/person_mandate.html:84 #: amelie/members/templates/person_preferences.html:18 #: amelie/members/templates/person_study.html:38 -#: amelie/personal_tab/templates/authorization/view.html:97 +#: amelie/personal_tab/templates/authorization/view.html:102 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:25 #: amelie/personal_tab/templates/info/account_information.html:62 #: amelie/personal_tab/templates/transactions/transaction_form.html:32 #: amelie/room_duty/templates/room_duty/table/list.html:21 @@ -1340,22 +1469,29 @@ msgstr "Metadata" #: amelie/companies/templates/companies/company_statistics.html:137 #: amelie/education/templates/education_event_overview.html:15 #: amelie/education/templates/education_event_overview.html:65 -#: amelie/members/models.py:1076 #: amelie/members/templates/statistics/overview.html:15 -#: amelie/personal_tab/forms.py:35 amelie/personal_tab/forms.py:43 -#: amelie/personal_tab/forms.py:92 amelie/personal_tab/forms.py:109 -#: amelie/personal_tab/models.py:131 -#: amelie/personal_tab/templates/authorization/view.html:66 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:122 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:154 +#: amelie/personal_tab/forms.py:34 amelie/personal_tab/forms.py:42 +#: amelie/personal_tab/forms.py:90 amelie/personal_tab/forms.py:107 +#: amelie/personal_tab/models.py:136 +#: amelie/personal_tab/templates/authorization/view.html:71 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:40 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:91 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:15 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:122 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:127 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:159 #: amelie/personal_tab/templates/debt_collection/person_instructions.html:15 -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:17 -#: amelie/personal_tab/templates/info/transaction_information.html:3 -#: amelie/personal_tab/templates/lists/alexia_transactions.html:8 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:8 -#: amelie/personal_tab/templates/lists/custom_transactions.html:8 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:8 -#: amelie/personal_tab/templates/lists/transactions.html:8 +#: amelie/personal_tab/templates/info/payments.html:21 +#: amelie/personal_tab/templates/info/payments.html:77 +#: amelie/personal_tab/templates/info/transaction_information.html:7 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:9 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:9 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:9 +#: amelie/personal_tab/templates/lists/custom_transactions.html:9 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:9 +#: amelie/personal_tab/templates/lists/payment_transactions.html:10 +#: amelie/personal_tab/templates/lists/transactions.html:9 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:9 #: amelie/personal_tab/templates/statistics/statistics.html:184 #: amelie/personal_tab/templates/statistics/statistics.html:238 #: amelie/personal_tab/templates/statistics/statistics.html:319 @@ -1669,7 +1805,7 @@ msgstr "Begindatum/tijd" #: amelie/activities/templates/activities/eventdesk/message_list.html:103 #: amelie/data_export/templates/data_export/dataexport_detail.html:89 #: amelie/members/templates/registration_check.html:100 -#: amelie/personal_tab/forms.py:150 +#: amelie/personal_tab/forms.py:182 #: amelie/personal_tab/templates/debt_collection/process_batch.html:36 #: amelie/personal_tab/templates/debt_collection/view.html:80 #: amelie/personal_tab/templates/info/rfid_cards.html:14 @@ -1715,7 +1851,7 @@ msgstr "Start/eindtijd" #: amelie/activities/templates/activities/eventdesk/message_list.html:153 #: amelie/activities/templates/activities/eventdesk/message_list.html:194 -#: amelie/calendar/models.py:113 +#: amelie/calendar/models.py:189 msgid "Location" msgstr "Locatie" @@ -1799,7 +1935,7 @@ msgstr "Wijzig inschrijfopties" msgid "Undo event cancellation" msgstr "Maak annuleren van evenement ongedaan" -#: amelie/activities/templates/activity.html:45 amelie/calendar/models.py:124 +#: amelie/activities/templates/activity.html:45 amelie/calendar/models.py:200 msgid "Cancel event" msgstr "Annuleer evenement" @@ -1827,8 +1963,8 @@ msgid "Label" msgstr "Type" #: amelie/activities/templates/activity.html:96 -#: amelie/activities/templates/activity_enrollment_form.html:79 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:48 +#: amelie/activities/templates/activity_enrollment_form.html:138 +#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:28 msgid "Costs" msgstr "Kosten" @@ -1872,22 +2008,43 @@ msgstr "Print inschrijvingen" msgid "Data export statistics" msgstr "Data-exportstatistieken" +#: amelie/activities/templates/activity_confirm_cancellation.html:4 +#: amelie/activities/templates/activity_confirm_cancellation.html:8 +msgid "Confirmation undo cancel" +msgstr "Bevestig ongedaan maken van annulering" + #: amelie/activities/templates/activity_confirm_cancellation.html:4 #: amelie/activities/templates/activity_confirm_cancellation.html:8 msgid "Confirmation cancel" msgstr "Bevestig verwijdering" -#: amelie/activities/templates/activity_confirm_cancellation.html:11 +#: amelie/activities/templates/activity_confirm_cancellation.html:12 #, python-format msgid "" "\n" -" Are you sure you want to cancel %(obj)s?\n" -" " +" Are you sure you want to undo the cancelation of %(obj)s?\n" +" " msgstr "" "\n" -"Weet je zeker dat je %(obj)s wilt annuleren?" +" Weet je zeker dat je het annuleren van %(obj)s ongedaan wilt maken?\n" +" " #: amelie/activities/templates/activity_confirm_cancellation.html:16 +msgid "The event will be available again, but enrollments will stay closed and need to be manually re-opened." +msgstr "De activiteit zal weer beschikbaar zijn, maar de inschrijving zal dicht blijven en zal handmatig opnieuw geopend moeten worden." + +#: amelie/activities/templates/activity_confirm_cancellation.html:19 +#, python-format +msgid "" +"\n" +" Are you sure you want to cancel %(obj)s?\n" +" " +msgstr "" +"\n" +" Weet je zeker dat je %(obj)s wilt annuleren?\n" +" " + +#: amelie/activities/templates/activity_confirm_cancellation.html:23 msgid "All enrollments will be cancelled, the transactions will be undone, and all the participants will receive an email." msgstr "Alle inschrijvingen worden verwijderd, de transacties worden ongedaan gemaakt, en alle deelnemers krijgen hiervan een email." @@ -1924,130 +2081,287 @@ msgstr "Bevestiging uitschrijving" msgid "Confirmation unenrollment" msgstr "Bevestiging uitschrijving" -#: amelie/activities/templates/activity_confirm_unenrollment.html:11 +#: amelie/activities/templates/activity_confirm_unenrollment.html:12 #, python-format msgid "" "\n" -" Are you sure you want to unenroll %(person)s for %(obj)s?\n" -" " +" Are you sure you want to unenroll %(person)s for %(obj)s?\n" +" " msgstr "" "\n" -"Weet je zeker dat je %(person)s wil uitschrijven voor %(obj)s?" - -#: amelie/activities/templates/activity_enrollment_form.html:5 -#: amelie/activities/templates/activity_enrollment_form.html:16 -msgid "Enroll for" -msgstr "Inschrijven voor" +" Weet je zeker dat je %(person)s wil uitschrijven voor %(obj)s?\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:14 -msgid "enroll for" -msgstr "inschrijven voor" +#: amelie/activities/templates/activity_confirm_unenrollment.html:16 +#, python-format +msgid "" +"\n" +" Are you sure you want to unenroll for %(obj)s?\n" +" " +msgstr "" +"\n" +" Weet je zeker dat je je wilt uitschrijven voor %(obj)s?\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:23 +#: amelie/activities/templates/activity_enrollment_form.html:7 #, python-format msgid "" "\n" -"\t\t\t\t\t\t \tWith this form, you will enroll %(name)s for the activity.\n" -"\t\t\t\t\t\t" +" Update enrollment of %(person)s for %(summary)s\n" +" " msgstr "" "\n" -"Met dit formulier schrijf je %(name)s in voor de activiteit." +" Inschrijving van %(person)s voor %(summary)s aanpassen\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:27 +#: amelie/activities/templates/activity_enrollment_form.html:11 +#, python-format msgid "" "\n" -"\t\t\t\t\t\t \tClick \"Enroll\" below this form to confirm your enrollment.\n" -"\t\t\t\t\t\t" +" Update enrollment for %(summary)s\n" +" " msgstr "" "\n" -"Klik onderaan dit formulier op inschrijven om je inschrijving te bevestigen." +" Inschrijving voor %(summary)s aanpassen\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:33 -msgid "The enrollment fee is" -msgstr "De inschrijfkosten bedragen" +#: amelie/activities/templates/activity_enrollment_form.html:17 +#, python-format +msgid "" +"\n" +" Enroll %(person)s for %(summary)s\n" +" " +msgstr "" +"\n" +" %(person)s inschrijven voor %(summary)s\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:39 -msgid "Unfortunately, there are no more openings. You will be enrolled on the waiting list." -msgstr "Helaas, maar er is geen beschikbare plek meer. Je kunt je wel inschrijven op de wachtlijst." +#: amelie/activities/templates/activity_enrollment_form.html:21 +#, python-format +msgid "" +"\n" +" Enroll for %(summary)s\n" +" " +msgstr "" +"\n" +" Inschrijven voor %(summary)s\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:51 +#: amelie/activities/templates/activity_enrollment_form.html:35 +#, python-format msgid "" "\n" -" The activity has a few enrollment options. Please fill these out before you enroll.\n" -"\t\t\t\t\t\t Warning: some of these options may cost extra!\n" +" Update enrollment of %(person)s for %(summary)s\n" " " msgstr "" "\n" -"Bij deze activiteit horen enkele inschrijfopties. Vul deze in voordat je je inschrijft.\n" -"Let op: aan sommige opties kunnen extra kosten verbonden zijn!" +" Inschrijving van %(person)s voor %(summary)s aanpassen\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:84 +#: amelie/activities/templates/activity_enrollment_form.html:39 #, python-format msgid "" "\n" -" It is assumed that %(name)s has given their permission to be enrolled.\n" +" Update enrollment for %(summary)s\n" " " msgstr "" "\n" -"Er wordt vanuit gegaan dat %(name)s toestemming heeft gegeven om zich te laten inschrijven." +" Inschrijving voor %(summary)s aanpassen\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:93 +#: amelie/activities/templates/activity_enrollment_form.html:45 +#, python-format msgid "" "\n" -" By pressing the sign-up-button, I declare to be aware that any enrollment costs will be\n" -" taken from my bank account through direct debit by Inter-Actief in accordance with\n" -" the authorization I granted for consumptions and activities.\n" +" Enroll %(person)s for %(summary)s\n" " " msgstr "" "\n" -"Door op de knop inschrijven te klikken verklaar ik dat ik op de hoogte ben van het feit dat (eventuele)\n" -"inschrijfkosten door Inter-Actief van mijn rekening zullen worden afschreven conform de door\n" -"mij getekende machtiging voor consumpties en activiteiten." +" %(person)s inschrijven voor %(summary)s\n" +" " -#: amelie/activities/templates/activity_enrollment_form.html:102 -#: amelie/activities/templates/activity_enrollment_overview.html:189 -#: amelie/activities/templates/activity_enrollment_print.html:73 -#: amelie/members/templates/statistics/overview.html:44 -#: amelie/members/templates/statistics/overview.html:133 -#: amelie/members/templates/statistics/payments.html:19 -#: amelie/personal_tab/templates/balance/balance.html:48 -#: amelie/personal_tab/templates/balance/balance.html:72 -#: amelie/personal_tab/templates/balance/balance.html:103 -#: amelie/personal_tab/templates/balance/balance.html:129 -#: amelie/personal_tab/templates/lists/activity_transactions.html:56 -#: amelie/personal_tab/templates/lists/alexia_transactions.html:42 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:48 -#: amelie/personal_tab/templates/lists/custom_transactions.html:42 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:50 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:48 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:21 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:50 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:38 -#: amelie/personal_tab/templates/lists/transactions.html:44 -#: amelie/personal_tab/templates/lists/transactions_totals.html:25 -#: amelie/personal_tab/templates/lists/transactions_totals.html:56 +#: amelie/activities/templates/activity_enrollment_form.html:49 +#, python-format +msgid "" +"\n" +" Enroll for %(summary)s\n" +" " +msgstr "" +"\n" +" Inschrijven voor %(summary)s\n" +" " + +#: amelie/activities/templates/activity_enrollment_form.html:60 +#, python-format +msgid "" +"\n" +"\t\t\t\t\t\t \tWith this form, you will update the enrollment of %(name)s for the activity.\n" +"\t\t\t\t\t\t" +msgstr "" +"\n" +"\t\t\t\t\t\t \tMet dit formulier pas je de inschrijving voor de activiteit van %(name)s aan.\n" +"\t\t\t\t\t\t" + +#: amelie/activities/templates/activity_enrollment_form.html:64 +msgid "" +"\n" +"\t\t\t\t\t\t \tClick \"Update\" below this form to save the changes to your enrollment.\n" +"\t\t\t\t\t\t" +msgstr "" +"\n" +"\t\t\t\t\t\t \tKlik onderaan dit formulier op \"Aanpassen\" om de wijzigingen aan je inschrijving op te slaan.\n" +"\t\t\t\t\t\t" + +#: amelie/activities/templates/activity_enrollment_form.html:70 +#, python-format +msgid "" +"\n" +"\t\t\t\t\t\t \tWith this form, you will enroll %(name)s for the activity.\n" +"\t\t\t\t\t\t" +msgstr "" +"\n" +"Met dit formulier schrijf je %(name)s in voor de activiteit." + +#: amelie/activities/templates/activity_enrollment_form.html:74 +msgid "" +"\n" +"\t\t\t\t\t\t \tClick \"Enroll\" below this form to confirm your enrollment.\n" +"\t\t\t\t\t\t" +msgstr "" +"\n" +"Klik onderaan dit formulier op inschrijven om je inschrijving te bevestigen." + +#: amelie/activities/templates/activity_enrollment_form.html:81 +msgid "The enrollment fee is" +msgstr "De inschrijfkosten bedragen" + +#: amelie/activities/templates/activity_enrollment_form.html:88 +msgid "There are no more openings. The person will be enrolled on the waiting list unless you override this." +msgstr "Er is geen beschikbare plek meer. De persoon zal worden ingeschreven op de wachtlijst, tenzij je dit aanpast." + +#: amelie/activities/templates/activity_enrollment_form.html:90 +msgid "Unfortunately, there are no more openings. You will be enrolled on the waiting list." +msgstr "Helaas, maar er is geen beschikbare plek meer. Je kunt je wel inschrijven op de wachtlijst." + +#: amelie/activities/templates/activity_enrollment_form.html:103 +msgid "The activity has a few enrollment options. Please fill these out before you enroll." +msgstr "De activiteit heeft een aantal inschrijfopties. Vul deze in voordat je jezelf inschrijft." + +#: amelie/activities/templates/activity_enrollment_form.html:106 +msgid "Warning: some of these options may cost extra!" +msgstr "Let op: sommige van deze opties kunnen extra kosten!" + +#: amelie/activities/templates/activity_enrollment_form.html:144 +#, python-format +msgid "" +"\n" +" It is assumed that %(name)s has given their permission for their enrollment to be modified.\n" +" " +msgstr "" +"\n" +" Er wordt vanuit gegaan dat %(name)s toestemming heeft gegeven om hun inschrijving aan te passen.\n" +" " + +#: amelie/activities/templates/activity_enrollment_form.html:148 +#, python-format +msgid "" +"\n" +" It is assumed that %(name)s has given their permission to be enrolled.\n" +" " +msgstr "" +"\n" +" Er wordt vanuit gegaan dat %(name)s toestemming heeft gegeven om zich te laten inschrijven.\n" +" " + +#: amelie/activities/templates/activity_enrollment_form.html:159 +msgid "" +"\n" +" By pressing the sign-up-button, I declare to be aware that any enrollment costs will be\n" +" taken from my bank account through direct debit by Inter-Actief in accordance with\n" +" the authorization I granted for consumptions and activities.\n" +" " +msgstr "" +"\n" +"Door op de knop inschrijven te klikken verklaar ik dat ik op de hoogte ben van het feit dat (eventuele)\n" +"inschrijfkosten door Inter-Actief van mijn rekening zullen worden afschreven conform de door\n" +"mij getekende machtiging voor consumpties en activiteiten." + +#: amelie/activities/templates/activity_enrollment_form.html:168 +#: amelie/activities/templates/activity_enrollment_print.html:90 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:89 +#: amelie/activities/templates/includes/activity_participation_create_payment.html:45 +#: amelie/members/templates/person_membership_create_payment.html:45 +#: amelie/members/templates/statistics/overview.html:44 +#: amelie/members/templates/statistics/overview.html:133 +#: amelie/personal_tab/templates/balance/balance.html:57 +#: amelie/personal_tab/templates/balance/balance.html:90 +#: amelie/personal_tab/templates/balance/balance.html:128 +#: amelie/personal_tab/templates/balance/balance.html:161 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:143 +#: amelie/personal_tab/templates/lists/activity_transactions.html:64 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:50 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:50 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:56 +#: amelie/personal_tab/templates/lists/custom_transactions.html:50 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:60 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:60 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:22 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:51 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:46 +#: amelie/personal_tab/templates/lists/payment_transactions.html:70 +#: amelie/personal_tab/templates/lists/transactions.html:24 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:39 +#: amelie/personal_tab/templates/lists/transactions_totals.html:27 +#: amelie/personal_tab/templates/lists/transactions_totals.html:66 #: amelie/personal_tab/templates/pos/shop.html:107 #: amelie/personal_tab/templates/statistics/statistics.html:120 #: amelie/personal_tab/templates/statistics/statistics.html:161 #: amelie/personal_tab/templates/statistics/statistics.html:216 #: amelie/personal_tab/templates/statistics/statistics.html:264 #: amelie/personal_tab/templates/statistics/statistics.html:299 -#: amelie/personal_tab/templates/statistics/statistics.html:355 -#: amelie/personal_tab/templates/statistics/statistics.html:389 -#: amelie/personal_tab/templates/statistics/statistics.html:435 +#: amelie/personal_tab/templates/statistics/statistics.html:354 +#: amelie/personal_tab/templates/statistics/statistics.html:387 +#: amelie/personal_tab/templates/statistics/statistics.html:433 #: amelie/personal_tab/templates/wrapped/wrapped.html:209 #: amelie/room_duty/templates/room_duty/table/overview.html:141 #: amelie/room_duty/templates/room_duty/table/overview.html:175 msgid "Total" msgstr "Totaal" -#: amelie/activities/templates/activity_enrollment_form.html:106 +#: amelie/activities/templates/activity_enrollment_form.html:175 +#, python-format +msgid "" +"\n" +" The costs will be deducted from %(name)s's personal tab\n" +" and will be paid via their direct debit mandate.\n" +" " +msgstr "" +"\n" +" Deze kosten worden afgeschreven van %(name)s's streeplijst en zullen betaald worden via automatische incasso.\n" +" " + +#: amelie/activities/templates/activity_enrollment_form.html:182 +#, python-format +msgid "" +"\n" +" %(name)s has no direct debit mandate for activities.\n" +" The costs will be deducted from their personal tab balance and will\n" +" need to be paid manually, either at the activity or at the board.\n" +" " +msgstr "" +"\n" +" %(name)s heeft geen incassomachtiging voor activiteiten. \n" +"De kosten zullen van diens streeplijstsaldo afgeschreven worden \n" +"en moeten handmatig betaald worden, ofwel bij de activiteit of bij het bestuur.\n" +" " + +#: amelie/activities/templates/activity_enrollment_form.html:193 msgid "Update" msgstr "Aanpassen" -#: amelie/activities/templates/activity_enrollment_form.html:106 +#: amelie/activities/templates/activity_enrollment_form.html:193 #: amelie/activities/templates/activity_enrollment_person_search.html:30 -#: amelie/activities/templates/includes/activity_enrollment.html:169 +#: amelie/activities/templates/includes/activity_enrollment.html:166 msgid "Enroll" msgstr "Inschrijven" @@ -2074,241 +2388,161 @@ msgstr "" #: amelie/activities/templates/activity_enrollment_mandate.html:20 msgid "" "\n" -"\t\t\t\t \tUnfortunately, you cannot enroll for this activity, because it has an entrance fee, and you have\n" -" not signed a direct debit mandate for consumptions and activities. Without this authorization, we\n" -" can not withdraw money from your account. Please ask the board for a mandate form.\n" +"\t\t\t\t \tUnfortunately, you cannot enroll for this activity, because it has an entrance fee, or you have\n" +" selected a paid enrollment option. You do not have a signed direct debit mandate for\n" +" consumptions and activities. Without this authorization, we cannot withdraw money from\n" +" your account. Please ask the board for a mandate form.\n" "\t\t\t\t" msgstr "" "\n" -"Je kunt je helaas niet inschrijven voor deze activiteit omdat aan deze activiteit kosten verbonden\n" -"zijn en je geen machtiging getekend hebt inzake consumpties en activiteiten. Zonder deze machtiging\n" -"is het niet mogelijk om eventuele kosten af te schrijven. Vraag bij het bestuur om een\n" -"machtigingsformulier." +"\t\t\t\t \tJe kunt je helaas niet inschrijven voor deze activiteit omdat aan deze activiteit kosten verbonden zijn, \n" +"of je hebt een betaalde inschrijfoptie gekozen. Je hebt geen machtiging getekend voor consumpties en activiteiten. \n" +"Zonder deze machtiging is het niet mogelijk om eventuele kosten af te schrijven. Vraag bij het bestuur om een machtigingsformulier.\n" +"\t\t\t\t" -#: amelie/activities/templates/activity_enrollment_mandate.html:28 +#: amelie/activities/templates/activity_enrollment_mandate.html:29 msgid "" "\n" -"\t\t Would you prefer not to sign a direct debit mandate? Please contact the board or organizing committee\n" -" and pay in cash.\n" +"\t\t Would you prefer not to sign a direct debit mandate? Please contact the board or organizing\n" +" committee to see if it's possible to pay manually by cash or PIN for this activity.\n" "\t\t " msgstr "" "\n" -"\t\t Liever geen machtiging tekenen? Neem dan contact op met het bestuur of de organiserende commissie en betaal contant.\n" +"\t\t Liever geen machtiging tekenen? Neem dan contact op met het bestuur of de organiserende commissie om te kijken of het mogelijk is om contant of met PIN te betalen voor deze activiteit.\n" "\t\t " -#: amelie/activities/templates/activity_enrollment_overview.html:21 -msgid "For privacy reasons, this page only shows anonymized enrollment data. For more detailed information please export or print it." -msgstr "Op deze pagina vind je vanwege privacyoverwegingen alleen geanonimiseerde inschrijfgegevens. Voor meer gedetailleerde informatie moet je een export maken of de inschrijvingen printen." - -#: amelie/activities/templates/activity_enrollment_overview.html:27 -msgid "Number of total participants including waiting list" -msgstr "Aantal deelnemers inclusief wachtlijst: " - -#: amelie/activities/templates/activity_enrollment_overview.html:31 -msgid "Number of confirmed participants" -msgstr "Aantal bevestigde deelnemers: " - -#: amelie/activities/templates/activity_enrollment_overview.html:38 -msgid "Show all participants" -msgstr "Laat alle deelnemers zien" - -#: amelie/activities/templates/activity_enrollment_overview.html:40 -msgid "Only show underage participants" -msgstr "Laat alleen minderjarige deelnemers zien" - -#: amelie/activities/templates/activity_enrollment_overview.html:46 -msgid "Need a list of underage participants? Ask the board!" -msgstr "Heb je een lijst met minderjarige deelnemers nodig? Vraag het bestuur!" - -#: amelie/activities/templates/activity_enrollment_overview.html:55 -msgid "Your enrollments are visible to other members." -msgstr "Je inschrijvingen zijn zichtbaar voor andere leden." - -#: amelie/activities/templates/activity_enrollment_overview.html:57 -msgid "Your enrollments are anonymous." -msgstr "Je inschrijvingen zijn anoniem." - -#: amelie/activities/templates/activity_enrollment_overview.html:59 +#: amelie/activities/templates/activity_enrollment_person_search.html:10 +#, python-format msgid "" "\n" -" You can change this preference on your profile.\n" +" Enroll person for %(name)s\n" " " msgstr "" "\n" -" Je kunt deze voorkeur aanpassen op je profiel.\n" -" " +"Persoon inschrijven voor %(name)s" -#: amelie/activities/templates/activity_enrollment_overview.html:66 -msgid "You are currently only viewing underage participants!" -msgstr "Je bekijkt nu alleen minderjarige deelnemers!" +#: amelie/activities/templates/activity_enrollment_person_search.html:19 +#, python-format +msgid "" +"\n" +" Enroll person for %(name)s\n" +" " +msgstr "" +"\n" +"Persoon inschrijven voor %(name)s" -#: amelie/activities/templates/activity_enrollment_overview.html:76 -#: amelie/activities/templates/activity_enrollment_overview.html:228 -#: amelie/personal_tab/models.py:332 -msgid "Sequence" -msgstr "Volgorde" +#: amelie/activities/templates/activity_enrollment_print.html:19 +#, python-format +msgid "Enrollments for %(name)s (%(date)s)" +msgstr "Inschrijvingen voor %(name) (%(date)s)" -#: amelie/activities/templates/activity_enrollment_overview.html:92 -#: amelie/members/models.py:1077 -#: amelie/members/templates/person_membership.html:47 -msgid "Payment" -msgstr "Betaling" +#: amelie/activities/templates/activity_enrollment_print.html:20 +#, python-format +msgid "(printed on %(date)s)" +msgstr "(geprint op %(date)s)" -#: amelie/activities/templates/activity_enrollment_overview.html:113 -#: amelie/activities/templates/activity_enrollment_overview.html:115 -#: amelie/activities/templates/activity_enrollment_overview.html:122 -msgid "This participant turns 18 during the activity!" -msgstr "Deze deelnemer wordt 18 tijdens de activiteit!" +#: amelie/activities/templates/activity_enrollment_print.html:23 +msgid "This enrollment list contains personal data. Please do not leave this lying around somewhere and make sure that it is destroyed after use. If you find this list somewhere, please give it to the board of Inter-Actief." +msgstr "Deze inschrijflijst bevat persoonsgegevens. Laat deze a.u.b. niet slingeren en zorg dat deze naderhand wordt vernietigd. Mocht u deze lijst vinden, geef deze dan aan het bestuur van Inter-Actief." -#: amelie/activities/templates/activity_enrollment_overview.html:120 -#: amelie/activities/templates/activity_enrollment_overview.html:124 -#: amelie/activities/templates/gallery_photo.html:43 -#: amelie/blame/templates/blame/blame_changelog.html:45 -#: amelie/blame/templates/blame/blame_detail.html:37 -#: amelie/blame/templates/blame/model_overview.html:42 -msgid "Anonymous" -msgstr "Anoniem" +#: amelie/activities/templates/activity_enrollment_print.html:26 +msgid "Number of participants" +msgstr "Aantal deelnemers: " -#: amelie/activities/templates/activity_enrollment_overview.html:131 -msgid "By clicking here, you confirm to have received the full amount of money." -msgstr "Door hier te klikken, bevestig je het complete contante bedrag ontvangen te hebben." +#: amelie/activities/templates/activity_enrollment_print.html:40 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:143 +#: amelie/activities/templates/includes/activity_participants_table.html:24 +msgid "Payment" +msgstr "Betaling" -#: amelie/activities/templates/activity_enrollment_overview.html:131 -msgid "Register cash payment" -msgstr "Registreer contante betaling" +#: amelie/activities/templates/activity_enrollment_print.html:55 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:172 +#: amelie/activities/templates/includes/activity_participants_table.html:65 +#: amelie/activities/templates/includes/activity_participants_table.html:137 +msgid "N/A (free)" +msgstr "n.v.t. (gratis)" + +#: amelie/activities/templates/activity_enrollment_print.html:59 +#: amelie/activities/templates/includes/activity_participants_table.html:79 +#: amelie/activities/templates/includes/activity_participants_table.html:141 +#: amelie/activities/templates/includes/activity_participation_payments.html:25 +#: amelie/members/templates/person_membership.html:84 +#: amelie/members/templates/person_membership_payments.html:25 +msgid "Paid" +msgstr "Betaald" + +#: amelie/activities/templates/activity_enrollment_print.html:62 +#: amelie/activities/templates/activity_enrollment_print.html:64 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:175 +#: amelie/activities/templates/includes/activity_participants_table.html:83 +#: amelie/activities/templates/includes/activity_participants_table.html:85 +#: amelie/activities/templates/includes/activity_participants_table.html:144 +#: amelie/activities/templates/includes/activity_participants_table.html:146 +#: amelie/members/templates/person_membership.html:88 +#: amelie/members/templates/person_membership.html:90 +msgid "To be debited" +msgstr "Nog te incasseren" -#: amelie/activities/templates/activity_enrollment_overview.html:138 -#: amelie/activities/templates/activity_enrollment_overview.html:274 -#: amelie/activities/templates/includes/activity_enrollment.html:126 -msgid "Edit enrollment" -msgstr "Inschrijving aanpassen" +#: amelie/activities/templates/activity_enrollment_print.html:67 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:177 +#: amelie/members/templates/person_membership.html:127 +msgid "Needs manual payment" +msgstr "Handmatige betaling vereist" -#: amelie/activities/templates/activity_enrollment_overview.html:143 -#: amelie/activities/templates/activity_enrollment_overview.html:279 -#: amelie/activities/templates/activity_enrollment_print.html:53 -#: amelie/activities/templates/includes/activity_enrollment.html:124 -msgid "Deregister" -msgstr "Uitschrijven" +#: amelie/activities/templates/activity_enrollment_print.html:69 +#: amelie/activities/templates/includes/activity_participants_table.html:87 +#: amelie/activities/templates/includes/activity_participants_table.html:148 +#: amelie/activities/templates/includes/activity_participation_payments.html:27 +#: amelie/members/templates/person_membership.html:92 +#: amelie/members/templates/person_membership_payments.html:27 +msgid "Not paid yet" +msgstr "Nog niet betaald" -#: amelie/activities/templates/activity_enrollment_overview.html:164 +#: amelie/activities/templates/activity_enrollment_print.html:71 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:178 #, python-format -msgid "" -"\n" -" ...and %(count)s Anonymous member.\n" -" " -msgid_plural "" -"\n" -" ...and %(count)s Anonymous members.\n" -" " -msgstr[0] "" -"\n" -" ...en %(count)s Anoniem lid.\n" -" " -msgstr[1] "" -"\n" -" ...en %(count)s Anonieme leden.\n" -" " +msgid "Open amount: € %(costs)s" +msgstr "Open bedrag: € %(costs)s" -#: amelie/activities/templates/activity_enrollment_overview.html:180 -#: amelie/activities/templates/activity_enrollment_print.html:66 +#: amelie/activities/templates/activity_enrollment_print.html:83 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:80 msgid "Order totals" msgstr "Totalen bestellingen" -#: amelie/activities/templates/activity_enrollment_overview.html:183 -#: amelie/activities/templates/activity_enrollment_print.html:69 +#: amelie/activities/templates/activity_enrollment_print.html:86 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:83 msgid "Dish" msgstr "Gerecht" -#: amelie/activities/templates/activity_enrollment_overview.html:183 -#: amelie/activities/templates/activity_enrollment_print.html:69 +#: amelie/activities/templates/activity_enrollment_print.html:86 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:83 +#: amelie/activities/templates/includes/activity_participation_create_payment.html:18 +#: amelie/activities/templates/includes/activity_participation_payments.html:37 +#: amelie/members/templates/person_membership_create_payment.html:18 +#: amelie/members/templates/person_membership_payments.html:37 #: amelie/members/templates/statistics/overview.html:35 #: amelie/members/templates/statistics/overview.html:124 -#: amelie/personal_tab/forms.py:428 amelie/personal_tab/models.py:210 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:12 +#: amelie/personal_tab/forms.py:466 amelie/personal_tab/models.py:246 +#: amelie/personal_tab/templates/info/transaction_information.html:92 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:13 #: amelie/personal_tab/templates/pos/success.html:35 #: amelie/personal_tab/templates/statistics/statistics.html:57 #: amelie/personal_tab/templates/statistics/statistics.html:103 #: amelie/personal_tab/templates/statistics/statistics.html:144 #: amelie/personal_tab/templates/statistics/statistics.html:286 -#: amelie/personal_tab/templates/statistics/statistics.html:376 -#: amelie/personal_tab/templates/statistics/statistics.html:413 -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:31 -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:18 +#: amelie/personal_tab/templates/statistics/statistics.html:374 +#: amelie/personal_tab/templates/statistics/statistics.html:411 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:27 #: amelie/personal_tab/templates/wrapped/wrapped.html:97 #: amelie/tools/pdf.py:570 msgid "Amount" msgstr "Aantal" -#: amelie/activities/templates/activity_enrollment_overview.html:183 -#: amelie/activities/templates/activity_enrollment_print.html:69 +#: amelie/activities/templates/activity_enrollment_print.html:86 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:83 msgid "Total price" msgstr "Totale prijs" -#: amelie/activities/templates/activity_enrollment_overview.html:192 -msgid "Grand Total" -msgstr "Eindtotaal" - -#: amelie/activities/templates/activity_enrollment_overview.html:203 -#: amelie/activities/templates/activity_enrollment_print.html:81 -msgid "Enroll people" -msgstr "Personen inschrijven" - -#: amelie/activities/templates/activity_enrollment_overview.html:208 -#: amelie/activities/templates/activity_enrollment_print.html:87 -#: amelie/members/templates/includes/query/query_mailing.html:4 -#: amelie/members/templates/includes/query/query_mailing.html:66 -#: amelie/members/templates/includes/query/query_mailing.html:144 -#: amelie/personal_tab/templates/debt_collection/view.html:46 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:72 -msgid "Send mailing" -msgstr "Mailing versturen" - -#: amelie/activities/templates/activity_enrollment_overview.html:213 -#: amelie/activities/templates/activity_enrollment_print.html:92 -msgid "View transactions" -msgstr "Transacties bekijken" - -#: amelie/activities/templates/activity_enrollment_overview.html:220 -#, python-format -msgid "Waiting list (%(n)s members)" -msgstr "Wachtlijst (%(n)s leden)" - -#: amelie/activities/templates/activity_enrollment_overview.html:220 -msgid "Locked" -msgstr "Vergrendeld" - -#: amelie/activities/templates/activity_enrollment_overview.html:235 -msgid "Enrollment #" -msgstr "Inschrijving #" - -#: amelie/activities/templates/activity_enrollment_person_search.html:10 -#, python-format -msgid "" -"\n" -" Enroll person for %(name)s\n" -" " -msgstr "" -"\n" -"Persoon inschrijven voor %(name)s" - -#: amelie/activities/templates/activity_enrollment_person_search.html:19 -#, python-format -msgid "" -"\n" -" Enroll person for %(name)s\n" -" " -msgstr "" -"\n" -"Persoon inschrijven voor %(name)s" - -#: amelie/activities/templates/activity_enrollment_print.html:18 -msgid "This enrollment list contains personal data. Please do not leave this lying around somewhere and make sure that it is destroyed after use. If you find this list somewhere, please give it to the board of Inter-Actief." -msgstr "Deze inschrijflijst bevat persoonsgegevens. Laat deze a.u.b. niet slingeren en zorg dat deze naderhand wordt vernietigd. Mocht u deze lijst vinden, geef deze dan aan het bestuur van Inter-Actief." - -#: amelie/activities/templates/activity_enrollment_print.html:21 -msgid "Number of participants" -msgstr "Aantal deelnemers: " - #: amelie/activities/templates/activity_list.html:9 msgid "Activities you don't want to miss" msgstr "Activiteiten die je niet mag missen" @@ -2339,7 +2573,7 @@ msgstr "iCal-document met activiteit" #: amelie/activities/templates/activity_list.html:70 #: amelie/activities/templates/activity_list.html:71 #: amelie/activities/templates/activity_list.html:127 -#: amelie/calendar/models.py:118 +#: amelie/calendar/models.py:194 #: amelie/companies/templates/companies/company_event_list.html:36 #: amelie/companies/templates/companies/company_event_list.html:86 #: templates/frontpage.html:28 templates/frontpage.html:29 @@ -2377,12 +2611,12 @@ msgstr "Oude activiteiten" #: amelie/claudia/templates/claudia/mapping_list.html:10 #: amelie/claudia/templates/claudia/mapping_list.html:36 #: amelie/companies/templates/companies/company_event_old.html:15 -#: amelie/education/templates/summaries.html:15 amelie/members/forms.py:380 +#: amelie/education/templates/summaries.html:15 amelie/members/forms.py:383 #: amelie/members/templates/query.html:34 #: amelie/members/templates/query.html:54 #: amelie/members/templates/query.html:67 #: amelie/members/templates/registration_check.html:21 -#: amelie/personal_tab/forms.py:148 +#: amelie/personal_tab/forms.py:180 #: amelie/personal_tab/templates/authorization/list.html:52 msgid "Search" msgstr "Zoek" @@ -2398,33 +2632,34 @@ msgstr "" "\n" "Resultaten (%(amount)s activiteiten)" -#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:21 +#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:25 msgid "Additional costs:" msgstr "Meerprijs:" -#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:25 +#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:29 +#: amelie/personal_tab/templates/statistics/statistics.html:145 msgid "Discount" msgstr "Korting:" -#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:28 -#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:28 +#: amelie/activities/templates/forms/enrollmentoption_checkbox_answer.html:32 +#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:31 msgid "spot(s) left" msgstr "plek(ken) over" -#: amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html:17 -#: amelie/activities/templates/forms/enrollmentoption_question_answer.html:16 +#: amelie/activities/templates/forms/enrollmentoption_foodquestion_answer.html:10 +#: amelie/activities/templates/forms/enrollmentoption_question_answer.html:9 msgid "required" msgstr "verplicht" -#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:21 +#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:24 msgid "Additional costs per unit:" msgstr "Meerprijs per stuk:" -#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:25 +#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:28 msgid "Discount per unit" msgstr "Korting per stuk:" -#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:32 +#: amelie/activities/templates/forms/enrollmentoption_numeric_answer.html:35 msgid "The maximum per person is" msgstr "Het maximum per persoon is " @@ -2501,6 +2736,15 @@ msgstr "Alle fotos" msgid "Photographer:" msgstr "Fotograaf: " +#: amelie/activities/templates/gallery_photo.html:43 +#: amelie/activities/templates/includes/activity_participants_table.html:52 +#: amelie/activities/templates/includes/activity_participants_table.html:56 +#: amelie/blame/templates/blame/blame_changelog.html:45 +#: amelie/blame/templates/blame/blame_detail.html:37 +#: amelie/blame/templates/blame/model_overview.html:42 +msgid "Anonymous" +msgstr "Anoniem" + #: amelie/activities/templates/gallery_photo.html:46 msgid "If you want to have this photo removed, please contact the board" msgstr "Als je deze foto wilt laten verwijderen, neem dan contact op met het bestuur" @@ -2662,20 +2906,32 @@ msgid "You have enrolled yourself for this event." msgstr "Je hebt je ingeschreven voor deze activiteit." #: amelie/activities/templates/includes/activity_enrollment.html:111 -#: amelie/activities/templates/includes/activity_enrollment.html:193 +#: amelie/activities/templates/includes/activity_enrollment.html:190 msgid "You are enrolled on the waiting list for this event." msgstr "Je hebt je ingeschreven voor deze activiteit op de wachtlijst." #: amelie/activities/templates/includes/activity_enrollment.html:117 -#: amelie/activities/templates/includes/activity_enrollment.html:194 +#: amelie/activities/templates/includes/activity_enrollment.html:191 msgid "Your spot on the waiting list is" msgstr "Jouw plek op de wachtlijst is" -#: amelie/activities/templates/includes/activity_enrollment.html:124 +#: amelie/activities/templates/includes/activity_enrollment.html:123 msgid "Deregister from waiting list" msgstr "Uitschrijven van de wachtlijst" -#: amelie/activities/templates/includes/activity_enrollment.html:131 +#: amelie/activities/templates/includes/activity_enrollment.html:123 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:190 +#: amelie/activities/templates/includes/activity_participants_table.html:167 +msgid "Deregister" +msgstr "Uitschrijven" + +#: amelie/activities/templates/includes/activity_enrollment.html:126 +#: amelie/activities/templates/includes/activity_enrollment_overview.html:185 +#: amelie/activities/templates/includes/activity_participants_table.html:162 +msgid "Edit enrollment" +msgstr "Inschrijving aanpassen" + +#: amelie/activities/templates/includes/activity_enrollment.html:130 #, python-format msgid "" "\n" @@ -2685,11 +2941,11 @@ msgstr "" "\n" "Je kunt je inschrijving nog terugtrekken tot uiterlijk %(date)s om %(time)s." -#: amelie/activities/templates/includes/activity_enrollment.html:137 +#: amelie/activities/templates/includes/activity_enrollment.html:136 msgid "For this activity, you can only unenroll yourself at the board." msgstr "Voor deze activiteit kun je jezelf alleen uitschrijven indien je contact met het bestuur opneemt." -#: amelie/activities/templates/includes/activity_enrollment.html:142 +#: amelie/activities/templates/includes/activity_enrollment.html:141 msgid "" "\n" " You are not enrolled in this activity yet.\n" @@ -2698,7 +2954,7 @@ msgstr "" "\n" "Je doet nog niet mee met deze activiteit." -#: amelie/activities/templates/includes/activity_enrollment.html:150 +#: amelie/activities/templates/includes/activity_enrollment.html:148 msgid "" "\n" " It's not possible to unenroll yourself for this activity.\n" @@ -2707,7 +2963,7 @@ msgstr "" "\n" "Voor deze activiteit is het niet mogelijk jezelf uit te schrijven." -#: amelie/activities/templates/includes/activity_enrollment.html:158 +#: amelie/activities/templates/includes/activity_enrollment.html:156 msgid "" "\n" " You can no longer enroll, all placed have been filled.
    \n" @@ -2720,11 +2976,11 @@ msgstr "" "Wil je op de reservelijst? Dat is nog wel mogelijk!
    \n" "Wanneer je op de wachtlijst staat, word je vanzelf ingeschreven wanneer er plek is." -#: amelie/activities/templates/includes/activity_enrollment.html:169 +#: amelie/activities/templates/includes/activity_enrollment.html:166 msgid "Place on waiting list" msgstr "Plaats op wachtlijst" -#: amelie/activities/templates/includes/activity_enrollment.html:177 +#: amelie/activities/templates/includes/activity_enrollment.html:174 #, python-format msgid "" "\n" @@ -2734,49 +2990,363 @@ msgstr "" "\n" "Om je inschrijving te bekijken of te wijzigen moet je inloggen." -#: amelie/activities/templates/includes/activity_enrollment.html:186 +#: amelie/activities/templates/includes/activity_enrollment.html:183 msgid "You can only enroll for this activity on the website of this activity" msgstr "Je kan je voor deze activiteit alleen inschrijven via de website van deze activiteit" -#: amelie/activities/templates/includes/activity_enrollment.html:190 +#: amelie/activities/templates/includes/activity_enrollment.html:187 #: amelie/activities/templates/includes/activity_list_enrollment.html:5 msgid "You have enrolled for this event" msgstr "Je hebt je ingeschreven voor deze activiteit" -#: amelie/activities/templates/includes/activity_enrollment.html:197 +#: amelie/activities/templates/includes/activity_enrollment.html:194 msgid "Unfortunately, you cannot enroll for this activity anymore, as the enrollment period has ended." msgstr "Je kunt je helaas niet meer inschrijven voor deze activiteit, de inschrijftermijn is verlopen." -#: amelie/activities/templates/includes/activity_enrollment.html:201 +#: amelie/activities/templates/includes/activity_enrollment.html:198 +#, python-format +msgid "" +"\n" +" The enrollment for this activity has not been opened yet. You can enroll from %(enrollment_begin)s until %(enrollment_end)s.\n" +" " +msgstr "" +"\n" +"De inschrijving is helaas nog niet geopend voor deze activiteit. Van %(enrollment_begin)s tot %(enrollment_end)s kun je je inschrijven." + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:22 +msgid "For privacy reasons, this page only shows anonymized enrollment data. For more detailed information please export or print it." +msgstr "Op deze pagina vind je vanwege privacyoverwegingen alleen geanonimiseerde inschrijfgegevens. Voor meer gedetailleerde informatie moet je een export maken of de inschrijvingen printen." + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:28 +msgid "Number of total participants including waiting list" +msgstr "Aantal deelnemers inclusief wachtlijst: " + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:32 +msgid "Number of confirmed participants" +msgstr "Aantal bevestigde deelnemers: " + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:39 +msgid "Show all participants" +msgstr "Laat alle deelnemers zien" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:41 +msgid "Only show underage participants" +msgstr "Laat alleen minderjarige deelnemers zien" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:47 +msgid "Need a list of underage participants? Ask the board!" +msgstr "Heb je een lijst met minderjarige deelnemers nodig? Vraag het bestuur!" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:56 +msgid "Your enrollments are visible to other members." +msgstr "Je inschrijvingen zijn zichtbaar voor andere leden." + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:58 +msgid "Your enrollments are anonymous." +msgstr "Je inschrijvingen zijn anoniem." + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:60 +msgid "" +"\n" +" You can change this preference on your profile.\n" +" " +msgstr "" +"\n" +" Je kunt deze voorkeur aanpassen op je profiel.\n" +" " + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:67 +msgid "You are currently only viewing underage participants!" +msgstr "Je bekijkt nu alleen minderjarige deelnemers!" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:92 +msgid "Grand Total" +msgstr "Eindtotaal" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:103 +msgid "Enroll people" +msgstr "Personen inschrijven" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:108 +#: amelie/members/templates/includes/query/query_mailing.html:4 +#: amelie/members/templates/includes/query/query_mailing.html:66 +#: amelie/members/templates/includes/query/query_mailing.html:144 +#: amelie/personal_tab/templates/debt_collection/view.html:46 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:15 +msgid "Send mailing" +msgstr "Mailing versturen" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:113 +msgid "View transactions" +msgstr "Transacties bekijken" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:120 +#, python-format +msgid "Waiting list (%(n)s members)" +msgstr "Wachtlijst (%(n)s leden)" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:120 +msgid "Locked" +msgstr "Vergrendeld" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:128 +#: amelie/activities/templates/includes/activity_participants_table.html:8 +#: amelie/personal_tab/models.py:368 +msgid "Sequence" +msgstr "Volgorde" + +#: amelie/activities/templates/includes/activity_enrollment_overview.html:135 +msgid "Enrollment #" +msgstr "Inschrijving #" + +#: amelie/activities/templates/includes/activity_list_enrollment.html:7 +msgid "You are on the waiting list" +msgstr "Je staat op de wachtlijst" + +#: amelie/activities/templates/includes/activity_list_enrollment.html:10 +msgid "Subscription closed" +msgstr "Inschrijving gesloten" + +#: amelie/activities/templates/includes/activity_list_enrollment.html:12 +msgid "Enrollment is full" +msgstr "Inschrijving is vol" + +#: amelie/activities/templates/includes/activity_list_enrollment.html:14 +msgid "Enroll now!" +msgstr "Schrijf je nu in!" + +#: amelie/activities/templates/includes/activity_list_enrollment.html:16 +#, python-format +msgid "Subscription is open from %(date)s" +msgstr "Inschrijven kan vanaf %(date)s" + +#: amelie/activities/templates/includes/activity_participants_table.html:45 +#: amelie/activities/templates/includes/activity_participants_table.html:47 +#: amelie/activities/templates/includes/activity_participants_table.html:54 +msgid "This participant turns 18 during the activity!" +msgstr "Deze deelnemer wordt 18 tijdens de activiteit!" + +#: amelie/activities/templates/includes/activity_participants_table.html:69 +msgid "For some reason this free participation has unpaid payments! This should not happen." +msgstr "Om de een of andere reden heeft deze gratis inschrijving onbetaalde transacties! Dit zou niet mogen gebeuren." + +#: amelie/activities/templates/includes/activity_participants_table.html:71 +#: amelie/activities/templates/includes/activity_participants_table.html:91 +#: amelie/members/templates/person_membership.html:76 +#: amelie/members/templates/person_membership.html:96 +msgid "Inspect payments" +msgstr "Betalingen bekijken" + +#: amelie/activities/templates/includes/activity_participants_table.html:96 +#: amelie/members/templates/person_membership.html:101 +msgid "No transaction! Contact the WWW." +msgstr "Geen transactie! Neem contact op met de WWW." + +#: amelie/activities/templates/includes/activity_participants_table.html:100 +#: amelie/members/templates/person_membership.html:105 +#, python-format +msgid "" +"\n" +" Debited on %(date)s\n" +" " +msgstr "" +"\n" +" Afgeschreven op %(date)s\n" +" " + +#: amelie/activities/templates/includes/activity_participants_table.html:106 +#: amelie/members/templates/person_membership.html:111 +#, python-format +msgid "" +"\n" +" %(method)s on %(date)s\n" +" " +msgstr "" +"\n" +" (%(method)s op %(date)s)\n" +" " + +#: amelie/activities/templates/includes/activity_participants_table.html:112 +#: amelie/members/templates/person_membership.html:117 +msgid "Unknown payment! Contact the WWW." +msgstr "Onbekende betaling! Neem contact op met de WWW." + +#: amelie/activities/templates/includes/activity_participants_table.html:117 +#: amelie/members/templates/person_membership.html:122 +msgid "Previous attempt was reversed" +msgstr "Vorige poging is teruggeboekt" + +#: amelie/activities/templates/includes/activity_participants_table.html:121 +#: amelie/activities/templates/includes/activity_participants_table.html:151 +#, python-format +msgid "Needs manual payment (€ %(costs)s)" +msgstr "Handmatige betaling vereist (€ %(costs)s)" + +#: amelie/activities/templates/includes/activity_participants_table.html:128 +#: amelie/members/templates/person_membership.html:135 +msgid "Enter manual payment" +msgstr "Handmatige betaling invoeren" + +#: amelie/activities/templates/includes/activity_participants_table.html:188 +#, python-format +msgid "" +"\n" +" ...and %(count)s Anonymous member.\n" +" " +msgid_plural "" +"\n" +" ...and %(count)s Anonymous members.\n" +" " +msgstr[0] "" +"\n" +" ...en %(count)s Anoniem lid.\n" +" " +msgstr[1] "" +"\n" +" ...en %(count)s Anonieme leden.\n" +" " + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:5 +#, python-format +msgid "" +"\n" +" Create manual payment for %(participation)s\n" +" " +msgstr "" +"\n" +" Handmatige betaling aanmaken voor %(participation)s\n" +" " + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:10 +#: amelie/members/templates/person_membership_create_payment.html:10 +msgid "You are creating a manual payment that will cover the following transactions:" +msgstr "Je maakt een handmatige betaling aan voor de volgende transacties:" + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:16 +#: amelie/activities/templates/includes/activity_participation_payments.html:35 +#: amelie/blame/templates/blame/blame_changelog.html:25 +#: amelie/blame/templates/blame/blame_detail.html:29 +#: amelie/blame/templates/blame/model_overview.html:21 +#: amelie/members/templates/person_membership_create_payment.html:16 +#: amelie/members/templates/person_membership_payments.html:35 +#: amelie/personal_tab/templates/print/log_list.html:29 +#: amelie/personal_tab/templates/print/refund.html:14 amelie/tools/models.py:46 +#: amelie/tools/templates/data_export_statistics.html:26 +msgid "Timestamp" +msgstr "Tijdstempel" + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:17 +#: amelie/activities/templates/includes/activity_participation_payments.html:36 +#: amelie/calendar/models.py:185 +#: amelie/claudia/templates/claudia/aliasgroup_list.html:23 +#: amelie/claudia/templates/claudia/contact_list.html:22 +#: amelie/claudia/templates/claudia/email_detail.html:18 +#: amelie/claudia/templates/claudia/extragroup_list.html:24 +#: amelie/claudia/templates/claudia/extraperson_list.html:23 +#: amelie/claudia/templates/claudia/kanidm/group_detail.html:18 +#: amelie/claudia/templates/claudia/kanidm/group_list.html:26 +#: amelie/claudia/templates/claudia/kanidm/service_account_detail.html:22 +#: amelie/claudia/templates/claudia/kanidm/service_account_list.html:25 +#: amelie/claudia/templates/claudia/mapping_detail.html:102 +#: amelie/claudia/templates/claudia/mapping_detail.html:157 +#: amelie/claudia/templates/claudia/shareddrive_list.html:22 +#: amelie/companies/templates/companies/company_event_form.html:87 +#: amelie/education/templates/education_event_form.html:50 +#: amelie/members/models.py:847 +#: amelie/members/templates/person_membership_create_payment.html:17 +#: amelie/members/templates/person_membership_payments.html:36 +#: amelie/oauth/templates/oauth_access_request.mail:12 +#: amelie/oauth/templates/request_oauth_sent.html:23 +#: amelie/personal_tab/forms.py:51 amelie/personal_tab/forms.py:474 +#: amelie/personal_tab/models.py:139 +#: amelie/personal_tab/templates/authorization/view.html:142 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:49 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:42 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:92 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:16 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:123 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:71 +#: amelie/personal_tab/templates/debt_collection/list.html:22 +#: amelie/personal_tab/templates/debt_collection/person_instructions.html:16 +#: amelie/personal_tab/templates/debt_collection/view.html:21 +#: amelie/personal_tab/templates/info/payments.html:22 +#: amelie/personal_tab/templates/info/payments.html:78 +#: amelie/personal_tab/templates/info/transaction_information.html:11 +#: amelie/personal_tab/templates/lists/activity_transactions.html:13 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:12 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:12 +#: amelie/personal_tab/templates/lists/custom_transactions.html:12 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:12 +#: amelie/personal_tab/templates/lists/payment_transactions.html:14 +#: amelie/personal_tab/templates/lists/transactions.html:12 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:12 +#: amelie/personal_tab/templates/statistics/statistics.html:322 +#: amelie/personal_tab/templates/wrapped/wrapped.html:239 +#: amelie/tools/pdf.py:584 amelie/videos/templates/videos/ia_video_form.html:58 +#: amelie/videos/templates/videos/peertube_video_form.html:58 +#: amelie/videos/templates/videos/yt_video_form.html:58 +msgid "Description" +msgstr "Omschrijving" + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:28 +#: amelie/activities/templates/includes/activity_participation_payments.html:48 +#: amelie/members/templates/person_membership_create_payment.html:28 +#: amelie/members/templates/person_membership_payments.html:48 +msgid "Transaction details (opens in new tab)" +msgstr "Transactiedetails (opent nieuw tabblad)" + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:39 +#: amelie/members/templates/person_membership_create_payment.html:39 +msgid "No transactions found. This is probably wrong! Please contact the WWW." +msgstr "Geen transacties gevonden. Dit klopt waarschijnlijk niet! Neem contact op met de WWW." + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:52 +#: amelie/members/templates/person_membership_create_payment.html:52 +msgid "Please select the payment method that was used for the payment." +msgstr "Selecteer de betaalmethode die gebruikt is voor de betaling." + +#: amelie/activities/templates/includes/activity_participation_create_payment.html:63 +#: amelie/members/templates/person_membership_create_payment.html:63 +msgid "Create payment" +msgstr "Betaling toevoegen" + +#: amelie/activities/templates/includes/activity_participation_payments.html:8 +#: amelie/members/templates/person_membership_payments.html:8 +msgid "Payment created. Please check the transactions below for correctness." +msgstr "Betaling aangemaakt. Controleer onderstaande transacties op juistheid." + +#: amelie/activities/templates/includes/activity_participation_payments.html:14 +#: amelie/members/templates/person_membership_payments.html:14 +msgid "No unpaid transactions found. Please re-check and try again." +msgstr "Geen onbetaalde transacties gevonden. Controleer en probeer het opnieuw." + +#: amelie/activities/templates/includes/activity_participation_payments.html:20 #, python-format msgid "" "\n" -" The enrollment for this activity has not been opened yet. You can enroll from %(enrollment_begin)s until %(enrollment_end)s.\n" -" " +" Payments for %(participation)s\n" +" " msgstr "" "\n" -"De inschrijving is helaas nog niet geopend voor deze activiteit. Van %(enrollment_begin)s tot %(enrollment_end)s kun je je inschrijven." - -#: amelie/activities/templates/includes/activity_list_enrollment.html:7 -msgid "You are on the waiting list" -msgstr "Je staat op de wachtlijst" - -#: amelie/activities/templates/includes/activity_list_enrollment.html:10 -msgid "Subscription closed" -msgstr "Inschrijving gesloten" +" Betalingen voor %(participation)s\n" +" " -#: amelie/activities/templates/includes/activity_list_enrollment.html:12 -msgid "Enrollment is full" -msgstr "Inschrijving is vol" +#: amelie/activities/templates/includes/activity_participation_payments.html:38 +#: amelie/members/templates/person_membership_payments.html:38 +#: amelie/personal_tab/templates/info/transaction_information.html:19 +msgid "Settled in" +msgstr "Afgehandeld in" -#: amelie/activities/templates/includes/activity_list_enrollment.html:14 -msgid "Enroll now!" -msgstr "Schrijf je nu in!" +#: amelie/activities/templates/includes/activity_participation_payments.html:62 +msgid "No transactions found. If this is a paid activity, this is probably wrong! Please contact the WWW." +msgstr "Geen transacties gevonden! Als dit een betaalde activiteit is dan klopt dit waarschijnlijk niet! Neem dan contact op met de WWW." -#: amelie/activities/templates/includes/activity_list_enrollment.html:16 -#, python-format -msgid "Subscription is open from %(date)s" -msgstr "Inschrijven kan vanaf %(date)s" +#: amelie/activities/templates/includes/activity_participation_payments.html:70 +#: amelie/members/templates/person_mandate_retrieve_signed.html:18 +#: amelie/members/templates/person_membership_payments.html:70 +#: amelie/members/templates/person_membership_retrieve_signed.html:18 +msgid "Return" +msgstr "Terug" #: amelie/activities/templates/includes/gallery_paginator.html:5 #: amelie/activities/templates/includes/gallery_paginator.html:8 @@ -2920,232 +3490,225 @@ msgstr "Er zijn geen bestanden in de uploadmap" msgid "Upload pictures" msgstr "Foto's uploaden" -#: amelie/activities/utils.py:34 -#, python-brace-format -msgid "Enrollment for {activity} is not possible. You do not have an active membership at Inter-Actief" -msgstr "Inschrijven voor {activity} is niet mogelijk. Je hebt geen actief lidmaatschap bij Inter-Actief." - -#: amelie/activities/utils.py:43 amelie/activities/utils.py:101 -#: amelie/activities/utils.py:133 -#, python-brace-format -msgid "Unenrollment for {activity} is not possible." -msgstr "Uitschrijven voor {activity} is niet mogelijk." - -#: amelie/activities/utils.py:50 -#, python-brace-format -msgid "Enrollment for {activity} is only possible on the website of this activity." -msgstr "Inschrijven voor {activity} is alleen mogelijk op de website van deze activiteit." - -#: amelie/activities/utils.py:58 -#, python-brace-format -msgid "The enrollment period for {activity} hasn't started yet or has already passed." -msgstr "De inschrijfperiode voor {activity} is nog niet begonnen of is al voorbij." - -#: amelie/activities/utils.py:66 -#, python-brace-format -msgid "{person} is already enrolled for {activity}." -msgstr "{person} is al ingeschreven voor {activity}." - -#: amelie/activities/utils.py:72 -#, python-brace-format -msgid "You are already enrolled for {activity}." -msgstr "Je bent al ingeschreven voor {activity}." - -#: amelie/activities/utils.py:108 -#, python-brace-format -msgid "Unenrollment for {activity} is only possible on the website of this activity." -msgstr "Uitschrijven voor {activity} is alleen mogelijk op de website van deze activiteit." +#: amelie/activities/views.py:76 +msgid "Events of Inter-Actief" +msgstr "Activiteiten IA" -#: amelie/activities/utils.py:120 +#: amelie/activities/views.py:331 #, python-brace-format -msgid "{person} is not enrolled for {activity}." -msgstr "{person} is niet ingeschreven voor {activity}." +msgid "{obj} has been successfully re-opened. Please note that any previous enrollments are not added again and that enrollments will have to be manually re-opened." +msgstr "{obj} is succesvol heropend. Let erop dat vorige aanmeldingen niet opnieuw zijn toegevoegd en dat je inschrijvingen weer handmatig open moet zetten." -#: amelie/activities/utils.py:126 +#: amelie/activities/views.py:339 #, python-brace-format -msgid "You are not enrolled for {activity}." -msgstr "Je bent niet ingeschreven voor {activity}." +msgid "{obj} was cancelled successfully." +msgstr "{obj} is succesvol geannuleerd." -#: amelie/activities/utils.py:140 +#: amelie/activities/views.py:344 #, python-brace-format -msgid "The unenrollment term for {activity} has already passed." -msgstr "De uitschrijfperiode voor {activity} is al voorbij." +msgid "We were unable to cancel {obj}, since it has already started." +msgstr "Het annuleren van {obj} is niet meer mogelijk, omdat deze al is begonnen." -#: amelie/activities/utils.py:164 -#, python-brace-format -msgid "{person} has been promoted from the waiting list for {activity}." -msgstr "{person} is gepromoveerd van de wachtlijst naar bevestigde deelname voor {activity}." +#: amelie/activities/views.py:387 +msgid "You are not allowed to access this activity page for other people." +msgstr "Je hebt geen toegang tot deze activiteitenpagina voor andere personen." -#: amelie/activities/views.py:102 -msgid "Events of Inter-Actief" -msgstr "Activiteiten IA" +#: amelie/activities/views.py:390 +msgid "Not implemented on base enrollment view." +msgstr "Niet geimplementeerd voor de basis inschrijvingsview." -#: amelie/activities/views.py:285 amelie/activities/views.py:1083 -msgid "No photo" -msgstr "Geen foto" +#: amelie/activities/views.py:452 +msgid "You are not allowed to enroll other people for this activity." +msgstr "Je hebt geen toegang om andere personen in te schrijven voor deze activiteit." -#: amelie/activities/views.py:339 +#: amelie/activities/views.py:493 amelie/activities/views.py:690 #, python-brace-format -msgid "{obj} has been successfully re-opened. Please note that any previous enrollments are not added again and that enrollments will have to be manually re-opened." -msgstr "{obj} is succesvol heropend. Let erop dat vorige aanmeldingen niet opnieuw zijn toegevoegd en dat je inschrijvingen weer handmatig open moet zetten." +msgid "{person} is now enrolled for the waitinglist of {activity}." +msgstr "{person} is nu ingeschreven voor de wachtlijst van {activity}." -#: amelie/activities/views.py:346 +#: amelie/activities/views.py:495 amelie/activities/views.py:692 #, python-brace-format -msgid "We were unable to cancel {obj}, since it has already started." -msgstr "Het annuleren van {obj} is niet meer mogelijk, omdat deze al is begonnen." +msgid "{person} is now enrolled for {activity}." +msgstr "{person} is nu ingeschreven voor {activity}." -#: amelie/activities/views.py:370 +#: amelie/activities/views.py:499 amelie/activities/views.py:696 #, python-brace-format -msgid "{obj} was cancelled successfully." -msgstr "{obj} is succesvol geannuleerd." +msgid "You are now enrolled for the waitinglist of {activity}." +msgstr "Je bent nu ingeschreven op de wachtlijst voor {activity}." -#: amelie/activities/views.py:408 +#: amelie/activities/views.py:501 amelie/activities/views.py:698 #, python-brace-format msgid "You are now enrolled for {activity}." msgstr "Je bent nu ingeschreven voor {activity}." -#: amelie/activities/views.py:414 +#: amelie/activities/views.py:562 amelie/activities/views.py:731 +msgid "Your changes would make the costs of your participation negative! Unfortunately that's not allowed, so the change was blocked. Please contact the board or the organizing committee if you think this is a mistake." +msgstr "Je wijzigingen zouden de kosten van je inschrijving negatief maken! Helaas is dat niet toegestaan, dus de wijziging is geblokkeerd. Neem a.u.b. contact op met het bestuur of de organiserende commissie als je denkt dat dit een fout is." + +#: amelie/activities/views.py:598 +msgid "You are not allowed to unenroll other people for this activity." +msgstr "Je hebt geen toegang om andere personen uit te schrijven voor deze activiteit." + +#: amelie/activities/views.py:620 #, python-brace-format -msgid "You are now on the waiting list for {activity}." -msgstr "Je staat nu op de wachtlijst voor {activity}." +msgid "{person} is now unenrolled for {activity}." +msgstr "{person} is nu uitgeschreven voor {activity}." -#: amelie/activities/views.py:450 +#: amelie/activities/views.py:624 #, python-brace-format msgid "You are now unenrolled for {activity}." msgstr "Je bent nu uitgeschreven voor {activity}." -#: amelie/activities/views.py:579 +#: amelie/activities/views.py:631 #, python-brace-format -msgid "{person} is now unenrolled for {activity}." -msgstr "{person} is nu uitgeschreven voor {activity}." +msgid "{person} has been promoted from the waiting list for {activity}." +msgstr "{person} is gepromoveerd van de wachtlijst naar bevestigde deelname voor {activity}." -#: amelie/activities/views.py:770 -msgid "the waitinglist of" -msgstr "de wachtlijst voor" +#: amelie/activities/views.py:651 +msgid "You are not allowed to edit enrollments of other people for this activity." +msgstr "Je hebt geen toegang om inschrijvingen van andere personen aan te passen voor deze activiteit." -#: amelie/activities/views.py:774 -#, python-brace-format -msgid "{person} is now enrolled for {waiting_list} {activity}." -msgstr "{person} is nu ingeschreven voor {waiting_list} {activity}." +#: amelie/activities/views.py:710 +msgid "Participation not found while editing enrollment." +msgstr "Deelname niet gevonden tijdens aanpassen van inschrijving." -#: amelie/activities/views.py:781 -#, python-brace-format -msgid "You are now enrolled for {waiting_list} {activity}." -msgstr "Je bent nu ingeschreven voor {waiting_list} {activity}." +#: amelie/activities/views.py:757 amelie/activities/views.py:1045 +msgid "No photo" +msgstr "Geen foto" -#: amelie/activities/views.py:852 +#: amelie/activities/views.py:814 msgid "The photos are now being processed in the background.Photos will show up on the activity as they are processed, you can refresh the page to follow the progres." msgstr "De foto's worden nu in de achtergrond verwerkt. Foto's zullen per foto bij de activiteit verschijnen terwijl ze verwerkt worden, je kan de pagina verversen om de voortgang te volgen." -#: amelie/activities/views.py:871 amelie/activities/views.py:876 -#: amelie/activities/views.py:880 +#: amelie/activities/views.py:833 amelie/activities/views.py:838 +#: amelie/activities/views.py:842 msgid "This picture does not exist." msgstr "Deze foto bestaat niet." -#: amelie/activities/views.py:1098 +#: amelie/activities/views.py:1060 msgid "No participants" msgstr "Geen deelnemers" -#: amelie/activities/views.py:1151 +#: amelie/activities/views.py:1113 msgid "You do not have permissions to edit this activity" msgstr "Je mag deze activiteit niet bewerken" -#: amelie/activities/views.py:1154 +#: amelie/activities/views.py:1116 msgid "You cannot edit this activity, because it is cancelled" msgstr "Je kunt deze activiteit niet aanpassen, omdat hij is geannuleerd" -#: amelie/activities/views.py:1175 +#: amelie/activities/views.py:1137 msgid "You did not fill in an English summary/description." msgstr "Je hebt geen Engelse samenvatting/omschrijving ingevuld." -#: amelie/activities/views.py:1176 amelie/activities/views.py:1183 +#: amelie/activities/views.py:1138 amelie/activities/views.py:1145 msgid "edit event" msgstr "activiteit wijzigen" -#: amelie/activities/views.py:1182 +#: amelie/activities/views.py:1144 msgid "You did not fill in a Dutch summary/description." msgstr "Je hebt geen Nederlandse samenvatting/omschrijving ingevuld." -#: amelie/activities/views.py:1232 +#: amelie/activities/views.py:1194 +msgid "Editing the activity price to this value would make the minimum possible price for this activity negative! This is not allowed. Please modify the pricing and limits of the activity or the enrollment options first." +msgstr "Het aanpassen van de prijs van de activiteit zou ervoor zorgen dat de minimaal mogelijke prijs van deze activiteit negatief wordt! Dit is niet toegestaan. Pas a.u.b. eerst de prijs of limieten van de activiteit of de inschrijfopties aan." + +#: amelie/activities/views.py:1207 msgid "start date was changed" msgstr "startdatum is aangepast" -#: amelie/activities/views.py:1237 +#: amelie/activities/views.py:1213 msgid "price was changed" msgstr "prijs is aangepast" -#: amelie/activities/views.py:1247 +#: amelie/activities/views.py:1219 #, python-brace-format msgid "Activity '{activity}' was changed (reversal of old transaction) ({change_reasons})" msgstr "Activiteit '{activity}' is aangepast (terugboeken van oude transactie) ({change_reasons})" -#: amelie/activities/views.py:1259 +#: amelie/activities/views.py:1238 +msgid "Editing the activity in this way would make the costs of (at least) one of the current enrollments negative! This is not allowed. Please unenroll the affected people, or modify the pricing and limits of the activity or the enrollment options first." +msgstr "Het aanpassen van de activiteit op deze manier zou ervoor zorgen dat de kosten van (ten minste) een van de huidige inschrijvingen van deze activiteit negatief wordt! Dit is niet toegestaan. Schrijf de betrokken mensen eerst uit, of pas eerst de prijs of limieten van de activiteit of de inschrijfopties aan." + +#: amelie/activities/views.py:1243 #, python-brace-format msgid "Activity '{activity}' was changed (addition of new transaction) ({change_reasons})" msgstr "Activiteit '{activity}' is aangepast (toevoegen van nieuwe transactie) ({change_reasons})" -#: amelie/activities/views.py:1328 amelie/activities/views.py:1338 +#: amelie/activities/views.py:1318 +msgid "Adding this option would make the minimum possible price for this activity negative! This is not allowed. Please modify the pricing or limits of this or other enrollment options first." +msgstr "Het toevoegen van deze optie zou ervoor zorgen dat de minimaal mogelijke prijs van deze activiteit negatief wordt! Dit is niet toegestaan. Pas a.u.b. eerst de prijs of limieten van deze of andere inschrijfopties aan." + +#: amelie/activities/views.py:1332 amelie/activities/views.py:1342 msgid "This activity already has enrollments, therefore options can no longer be added!" msgstr "Deze activiteit heeft inschrijvingen, er kunnen geen opties toegevoegd worden!" -#: amelie/activities/views.py:1370 +#: amelie/activities/views.py:1374 +msgid "Editing this option in this way would make the minimum possible price for this activity negative! This is not allowed. Please modify the pricing or limits of this or other enrollment options first." +msgstr "Het aanpassen van deze optie op deze manier zou ervoor zorgen dat de minimaal mogelijke prijs van deze activiteit negatief wordt! Dit is niet toegestaan. Pas a.u.b. eerst de prijs of limieten van deze of andere inschrijfopties aan." + +#: amelie/activities/views.py:1384 msgid "enrollment option price was changed" msgstr "prijs van inschrijvingsoptie gewijzigd" -#: amelie/activities/views.py:1380 +#: amelie/activities/views.py:1390 #, python-brace-format msgid "An enrollment option for activity '{activity}' was changed (reversal of old transaction) ({change_reasons})" msgstr "Een inschrijfoptie voor activiteit '{activity}' is aangepast (terugboeken van de oude transactie) ({change_reasons})" -#: amelie/activities/views.py:1392 +#: amelie/activities/views.py:1409 +msgid "Editing this option in this way would make the costs of (at least) one of the current enrollments negative! This is not allowed. Please unenroll the affected people, or modify the pricing or limits of this or other enrollment options first." +msgstr "Het aanpassen van deze optie op deze manier zou ervoor zorgen dat de kosten van (ten minste) een van de huidige inschrijvingen van deze activiteit negatief wordt! Dit is niet toegestaan. Schrijf de betrokken mensen eerst uit, of pas eerst de prijs of limieten van deze of andere inschrijfopties aan." + +#: amelie/activities/views.py:1414 #, python-brace-format msgid "An enrollment option for activity '{activity}' was changed (addition of new transaction) ({change_reasons})" msgstr "Een inschrijfoptie voor activiteit '{activity}' is aangepast (toevoegen van nieuwe transactie) ({change_reasons})" -#: amelie/activities/views.py:1432 amelie/activities/views.py:1446 +#: amelie/activities/views.py:1460 amelie/activities/views.py:1474 msgid "This activity already has enrollments, therefore options cannot be deleted!" msgstr "Deze activiteit heeft inschrijvingen, er kunnen geen opties verwijderd worden!" -#: amelie/activities/views.py:1438 +#: amelie/activities/views.py:1466 #, python-format msgid "Enrollment options %(object)s has been deleted" msgstr "Inschrijfoptie %(object)s is verwijderd" -#: amelie/activities/views.py:1459 +#: amelie/activities/views.py:1487 msgid "Only accessible for the board and the organisers of this activity." msgstr "Uitsluitend toegankelijk voor het bestuur en de organisators van de activiteit." -#: amelie/activities/views.py:1465 amelie/activities/views.py:1480 +#: amelie/activities/views.py:1493 amelie/activities/views.py:1508 msgid "No activity found or invalid activity ID given." msgstr "Geen activiteit gevonden of je hebt een ongeldige activiteit-id meegegeven." -#: amelie/activities/views.py:1475 +#: amelie/activities/views.py:1503 msgid "Invalid activity ID" msgstr "Ongeldige activiteit-id." -#: amelie/activities/views.py:1496 amelie/personal_tab/views.py:1626 +#: amelie/activities/views.py:1524 amelie/personal_tab/views.py:2037 msgid "Could not create data export, because an unknown data-exporttype is provided." msgstr "Kon geen data-export maken, omdat er een onbekend data-exporttype is opegeven." -#: amelie/activities/views.py:1503 amelie/members/query_views.py:241 -#: amelie/personal_tab/views.py:1633 +#: amelie/activities/views.py:1531 amelie/members/query_views.py:241 +#: amelie/personal_tab/views.py:2044 msgid "Could not create data export, because something went wrong while saving information about the export." msgstr "Kon geen data-export maken, omdat er iets mis ging bij het opslaan van gegevens over de data-export." -#: amelie/activities/views.py:1581 amelie/activities/views.py:1591 -#: amelie/activities/views.py:1593 +#: amelie/activities/views.py:1612 amelie/activities/views.py:1622 +#: amelie/activities/views.py:1624 msgid "You are not allowed to edit this activity" msgstr "Je mag deze activiteit niet bewerken" -#: amelie/activities/views.py:1633 +#: amelie/activities/views.py:1664 msgid "Unknown error occurred." msgstr "Er is een onbekende fout opgetreden." -#: amelie/activities/views.py:1649 amelie/activities/views.py:1664 +#: amelie/activities/views.py:1680 amelie/activities/views.py:1695 msgid "Message is not encoded in UTF-8, Latin-1 or ASCII, giving up..." msgstr "Bericht is niet geëncodeerd in UTF-8, Latin-1 of ASCII, ik geef het op..." -#: amelie/activities/views.py:1670 +#: amelie/activities/views.py:1701 msgid "Could not retrieve this message from Google. {}" msgstr "Kon dit bericht niet ophalen bij Google. {}" @@ -3194,15 +3757,6 @@ msgstr "Object" msgid "Action" msgstr "Actie" -#: amelie/blame/templates/blame/blame_changelog.html:25 -#: amelie/blame/templates/blame/blame_detail.html:29 -#: amelie/blame/templates/blame/model_overview.html:21 -#: amelie/personal_tab/templates/print/log_list.html:29 -#: amelie/personal_tab/templates/print/refund.html:14 amelie/tools/models.py:46 -#: amelie/tools/templates/data_export_statistics.html:26 -msgid "Timestamp" -msgstr "Tijdstempel" - #: amelie/blame/templates/blame/blame_changelog.html:26 #: amelie/blame/templates/blame/blame_detail.html:62 #: amelie/blame/templates/blame/model_overview.html:22 @@ -3294,6 +3848,7 @@ msgid "Detail page for this change" msgstr "Detailpagina van deze wijziging" #: amelie/blame/templates/blame/overview.html:4 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:21 #: amelie/room_duty/templates/room_duty/table/overview.html:6 #: amelie/room_duty/templates/room_duty/table/overview.html:86 msgid "Overview" @@ -3323,48 +3878,42 @@ msgstr "Promotietekst voor deze activiteit, in de voorkeurstaal van de gebruiker msgid "A brief description of this activity (always in english)." msgstr "Een korte beschrijving van deze activiteit (altijd in het Engels)." -#: amelie/calendar/models.py:26 -msgid "No payment" -msgstr "Geen betaling" - -#: amelie/calendar/models.py:27 -msgid "Cash at the board/committee" -msgstr "Contant bij bestuur/commissie" +#: amelie/calendar/migrations/0009_remove_participation_cash_payment_made_and_more.py:170 +#, python-brace-format +msgid "Manual payment required for enrollment to {event} on {date} (migrated)" +msgstr "Handmatige betaling vereist voor inschrijving voor {event} op {date} (gemigreerd)" -#: amelie/calendar/models.py:28 amelie/members/query_forms.py:83 -#: amelie/members/templates/includes/query/query_mandate.html:4 -#: amelie/personal_tab/templates/authorization/view.html:4 -#: amelie/personal_tab/templates/authorization/view.html:8 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:36 -#: amelie/personal_tab/templates/debt_collection/process_batch.html:49 -#: amelie/personal_tab/templates/debt_collection/view.html:92 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:11 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:10 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:24 -msgid "Mandate" -msgstr "Machtiging" +#: amelie/calendar/migrations/0009_remove_participation_cash_payment_made_and_more.py:190 +#, python-brace-format +msgid "Manual payment for enrollment to {event} on {date} (migrated)" +msgstr "Handmatige betaling voor inschrijving voor {event} op {date} (gemigreerd)" #: amelie/calendar/models.py:30 amelie/data_export/models.py:31 -#: amelie/education/models.py:289 amelie/members/forms.py:375 -#: amelie/members/models.py:867 amelie/members/models.py:918 -#: amelie/members/models.py:1286 +#: amelie/education/models.py:289 amelie/members/forms.py:378 +#: amelie/members/models.py:878 amelie/members/models.py:929 +#: amelie/members/models.py:1364 #: amelie/members/templates/committee_edit_members.html:33 #: amelie/members/templates/committee_edit_single_member.html:28 -#: amelie/personal_tab/forms.py:139 amelie/personal_tab/forms.py:193 -#: amelie/personal_tab/models.py:133 amelie/personal_tab/models.py:380 +#: amelie/personal_tab/forms.py:171 amelie/personal_tab/forms.py:225 +#: amelie/personal_tab/models.py:138 amelie/personal_tab/models.py:416 #: amelie/personal_tab/templates/authorization/view.html:21 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:19 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:41 #: amelie/personal_tab/templates/debt_collection/instruction_view.html:23 #: amelie/personal_tab/templates/debt_collection/process_batch.html:48 #: amelie/personal_tab/templates/debt_collection/view.html:91 -#: amelie/personal_tab/templates/info/transaction_information.html:39 -#: amelie/personal_tab/templates/lists/activity_transactions.html:10 -#: amelie/personal_tab/templates/lists/alexia_transactions.html:10 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:10 -#: amelie/personal_tab/templates/lists/custom_transactions.html:10 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:9 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:9 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:10 -#: amelie/personal_tab/templates/lists/transactions.html:10 +#: amelie/personal_tab/templates/info/transaction_information.html:36 +#: amelie/personal_tab/templates/lists/activity_transactions.html:11 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:11 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:11 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:11 +#: amelie/personal_tab/templates/lists/custom_transactions.html:11 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:10 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:10 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:11 +#: amelie/personal_tab/templates/lists/payment_transactions.html:12 +#: amelie/personal_tab/templates/lists/transactions.html:11 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:11 #: amelie/personal_tab/templates/statistics/statistics.html:321 #: templates/profile_picture_verification.html:97 msgid "Person" @@ -3372,144 +3921,105 @@ msgstr "Persoon" #: amelie/calendar/models.py:32 amelie/education/models.py:254 #: amelie/education/models.py:290 amelie/education/templates/complaint.html:107 -#: amelie/members/models.py:1289 amelie/personal_tab/forms.py:118 -#: amelie/personal_tab/templates/authorization/view.html:70 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:138 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:29 +#: amelie/members/models.py:1367 amelie/personal_tab/forms.py:116 +#: amelie/personal_tab/templates/authorization/view.html:75 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:143 +#: amelie/personal_tab/templates/info/transaction_information.html:77 msgid "Remarks" msgstr "Opmerking" -#: amelie/calendar/models.py:35 -msgid "Cash payment made" -msgstr "Contante betaling voldaan" - -#: amelie/calendar/models.py:36 +#: amelie/calendar/models.py:33 msgid "On waiting list" msgstr "Op wachtlijst" -#: amelie/calendar/models.py:38 amelie/personal_tab/models.py:140 -#: amelie/personal_tab/templates/info/transaction_information.html:49 +#: amelie/calendar/models.py:35 amelie/personal_tab/models.py:145 +#: amelie/personal_tab/templates/info/transaction_information.html:53 msgid "Added on" msgstr "Toegevoegd op" -#: amelie/calendar/models.py:41 amelie/personal_tab/models.py:143 -#: amelie/personal_tab/templates/info/transaction_information.html:53 +#: amelie/calendar/models.py:38 amelie/personal_tab/models.py:148 +#: amelie/personal_tab/templates/info/transaction_information.html:57 msgid "Added by" msgstr "Toegevoegd door" -#: amelie/calendar/models.py:45 +#: amelie/calendar/models.py:42 msgid "Participation" msgstr "Deelname" -#: amelie/calendar/models.py:46 +#: amelie/calendar/models.py:43 msgid "Participations" msgstr "Deelnames" -#: amelie/calendar/models.py:52 amelie/data_export/exporters/amelie.py:137 +#: amelie/calendar/models.py:49 amelie/data_export/exporters/amelie.py:158 msgid "deleted activity" msgstr "verwijderde activiteit" -#: amelie/calendar/models.py:94 amelie/education/models.py:134 +#: amelie/calendar/models.py:136 +#, python-brace-format +msgid "Payment for enrollment to {event} on {date}" +msgstr "Betaling voor inschrijving voor {event} op {date}" + +#: amelie/calendar/models.py:170 amelie/education/models.py:134 #: amelie/room_duty/models.py:40 amelie/room_duty/models.py:109 #: amelie/room_duty/models.py:206 msgid "Starts" msgstr "Begint" -#: amelie/calendar/models.py:95 amelie/education/models.py:135 +#: amelie/calendar/models.py:171 amelie/education/models.py:135 #: amelie/room_duty/models.py:110 amelie/room_duty/models.py:207 msgid "Ends" msgstr "Eindigt" -#: amelie/calendar/models.py:96 +#: amelie/calendar/models.py:172 msgid "All day" msgstr "Hele dag" -#: amelie/calendar/models.py:98 amelie/education/models.py:253 +#: amelie/calendar/models.py:174 amelie/education/models.py:253 msgid "Summary" msgstr "Samenvatting" -#: amelie/calendar/models.py:99 +#: amelie/calendar/models.py:175 msgid "Summary (en)" msgstr "Samenvatting (en)" -#: amelie/calendar/models.py:101 amelie/calendar/models.py:104 +#: amelie/calendar/models.py:177 amelie/calendar/models.py:180 msgid "Short promotional message" msgstr "Korte promotietekst" -#: amelie/calendar/models.py:102 amelie/calendar/models.py:106 +#: amelie/calendar/models.py:178 amelie/calendar/models.py:182 msgid "This text can be used by the board for promotion, for example on our socials or in our weekmail. Let it be a teaser, so people would want to read your full activity description." msgstr "Deze tekst kan gebruikt worden door het bestuur voor promotie, bijvoorbeeld op onze social media kanalen of in onze weekmail. Laat het een teaser zijn, zodat mensen die geïnteresseerd zijn jouw volledige beschrijving willen lezen." -#: amelie/calendar/models.py:109 -#: amelie/claudia/templates/claudia/aliasgroup_list.html:23 -#: amelie/claudia/templates/claudia/contact_list.html:22 -#: amelie/claudia/templates/claudia/email_detail.html:18 -#: amelie/claudia/templates/claudia/extragroup_list.html:24 -#: amelie/claudia/templates/claudia/extraperson_list.html:23 -#: amelie/claudia/templates/claudia/kanidm/group_detail.html:18 -#: amelie/claudia/templates/claudia/kanidm/group_list.html:26 -#: amelie/claudia/templates/claudia/kanidm/service_account_detail.html:22 -#: amelie/claudia/templates/claudia/kanidm/service_account_list.html:25 -#: amelie/claudia/templates/claudia/mapping_detail.html:102 -#: amelie/claudia/templates/claudia/mapping_detail.html:157 -#: amelie/claudia/templates/claudia/shareddrive_list.html:22 -#: amelie/companies/templates/companies/company_event_form.html:87 -#: amelie/education/templates/education_event_form.html:50 -#: amelie/members/models.py:820 amelie/members/models.py:849 -#: amelie/oauth/templates/oauth_access_request.mail:12 -#: amelie/oauth/templates/request_oauth_sent.html:23 -#: amelie/personal_tab/forms.py:52 amelie/personal_tab/forms.py:436 -#: amelie/personal_tab/models.py:134 -#: amelie/personal_tab/templates/authorization/view.html:137 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:66 -#: amelie/personal_tab/templates/debt_collection/list.html:22 -#: amelie/personal_tab/templates/debt_collection/person_instructions.html:16 -#: amelie/personal_tab/templates/debt_collection/view.html:21 -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:18 -#: amelie/personal_tab/templates/info/transaction_information.html:7 -#: amelie/personal_tab/templates/lists/activity_transactions.html:12 -#: amelie/personal_tab/templates/lists/alexia_transactions.html:11 -#: amelie/personal_tab/templates/lists/custom_transactions.html:11 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:11 -#: amelie/personal_tab/templates/lists/transactions.html:11 -#: amelie/personal_tab/templates/statistics/statistics.html:322 -#: amelie/personal_tab/templates/wrapped/wrapped.html:239 -#: amelie/tools/pdf.py:584 amelie/videos/templates/videos/ia_video_form.html:58 -#: amelie/videos/templates/videos/peertube_video_form.html:58 -#: amelie/videos/templates/videos/yt_video_form.html:58 -msgid "Description" -msgstr "Omschrijving" - -#: amelie/calendar/models.py:110 +#: amelie/calendar/models.py:186 msgid "Description (en)" msgstr "Omschrijving (en)" -#: amelie/calendar/models.py:112 +#: amelie/calendar/models.py:188 msgid "Organizer" msgstr "Organisator" -#: amelie/calendar/models.py:115 +#: amelie/calendar/models.py:191 msgid "Participants" msgstr "Deelnemers" -#: amelie/calendar/models.py:117 amelie/files/models.py:149 -#: amelie/personal_tab/forms.py:441 amelie/tools/pdf.py:574 +#: amelie/calendar/models.py:193 amelie/files/models.py:149 +#: amelie/personal_tab/forms.py:479 amelie/tools/pdf.py:574 msgid "Attachments" msgstr "Bijlagen" -#: amelie/calendar/models.py:120 +#: amelie/calendar/models.py:196 msgid "Callback URL" msgstr "Callback URL" -#: amelie/calendar/models.py:121 +#: amelie/calendar/models.py:197 msgid "URL has to start with https://" msgstr "URL moet met https:// beginnen" -#: amelie/calendar/models.py:125 +#: amelie/calendar/models.py:201 msgid "Whether this event is cancelled, only cancel when needed. Participants will receive an email notification!" msgstr "Of dit evenement geannuleerd is, annuleer deze alleen wanneer nodig is. Deelnemers ontvangen hiervan een email!" -#: amelie/calendar/models.py:196 amelie/education/models.py:160 +#: amelie/calendar/models.py:272 amelie/education/models.py:160 #: amelie/room_duty/models.py:122 msgid "Start may not be after of simultaneous to end." msgstr "Begin kan niet na het einde voorkomen of gelijk zijn aan het einde." @@ -3532,14 +4042,6 @@ msgstr "" "\n" "Weet je zeker dat je een contante betaling van %(person)s voor %(event)s wil registreren?" -#: amelie/calendar/views.py:27 -msgid "Payment registered on {timezone.now().strftime('%d-%m-%Y')} by {request.user.person.incomplete_name()}" -msgstr "Betaling geregistreerd op {timezone.now().strftime('%d-%m-%Y')} door {request.user.person.incomplete_name()}" - -#: amelie/calendar/views.py:30 -msgid "Cash payment registered!" -msgstr "Contante betaling geregistreerd!" - #: amelie/claudia/account_views.py:61 amelie/claudia/account_views.py:74 msgid "Enter a correct username and password. Warning: the fields are case-sensitive." msgstr "Voer een correcte gebruikersnaam en wachtwoord in. Let op, de velden zijn hoofdletter-gevoelig." @@ -3672,7 +4174,7 @@ msgstr "Types" #: amelie/claudia/forms.py:27 amelie/claudia/forms.py:64 #: amelie/claudia/templates/claudia/extragroup_list.html:23 #: amelie/claudia/templates/claudia/mapping_detail.html:49 -#: amelie/members/models.py:348 +#: amelie/members/models.py:350 #: amelie/members/templates/includes/person_data_table.html:98 msgid "Account name" msgstr "Accountnaam" @@ -3726,7 +4228,7 @@ msgstr "De wachtwoordvelden zullen niet worden gebruikt, maar er zal een willeke msgid "This field is required." msgstr "Dit veld is verplicht." -#: amelie/claudia/models.py:33 amelie/members/models.py:1145 +#: amelie/claudia/models.py:33 amelie/members/models.py:1223 msgid "Create GitLab group" msgstr "GitLab groep aanmaken" @@ -3734,7 +4236,7 @@ msgstr "GitLab groep aanmaken" msgid "Members of this group get access to GitLab" msgstr "Leden van deze groep krijgen toegang tot GitLab" -#: amelie/claudia/models.py:35 amelie/members/models.py:1147 +#: amelie/claudia/models.py:35 amelie/members/models.py:1225 msgid "Create Matrix space" msgstr "Matrix-ruimte aanmaken" @@ -3748,7 +4250,7 @@ msgstr "Als een mail adres voor een extra groep is ingesteld, dan mag deze allee #: amelie/claudia/models.py:52 amelie/claudia/models.py:165 #: amelie/claudia/models.py:212 amelie/claudia/models.py:246 -#: amelie/members/models.py:183 amelie/members/models.py:1180 +#: amelie/members/models.py:185 amelie/members/models.py:1258 msgid "This email address is already in use by another mapping!" msgstr "Dit e-mailadres wordt al door een andere mapping gebruikt!" @@ -4046,11 +4548,6 @@ msgstr "Heb je nog vragen? Op https://wiki.ia.utwente.nl/ vind je onder het kopj msgid "Active member account at Inter-Actief" msgstr "Actieveledenaccount bij Inter-Actief" -#: amelie/claudia/templates/claudia/account_deleted.mail:5 -#, python-format -msgid "Dear %(name)s," -msgstr "Beste %(name)s," - #: amelie/claudia/templates/claudia/account_deleted.mail:7 msgid "" "According to the administration of Inter-Actief, you are no longer committee member and therefore should not have access to an active members account. This is the account with which you could log in to the computers and the Google Suite of Inter-Actief, your association membership will of course continue as normal.\n" @@ -4157,8 +4654,8 @@ msgstr "Aliasgroep aanmaken" #: amelie/claudia/templates/claudia/extraperson_list.html:21 #: amelie/claudia/templates/claudia/extrapersonalalias_list.html:29 #: amelie/claudia/templates/claudia/mapping_detail.html:26 -#: amelie/members/models.py:330 amelie/members/models.py:1130 -#: amelie/members/models.py:1329 amelie/members/query_forms.py:37 +#: amelie/members/models.py:332 amelie/members/models.py:1208 +#: amelie/members/models.py:1407 amelie/members/query_forms.py:37 #: amelie/members/templates/includes/committee_data.html:31 #: amelie/members/templates/includes/person_data_table.html:139 #: amelie/members/templates/includes/registration/data_overview_employee.html:14 @@ -4185,8 +4682,8 @@ msgstr "E-mailadres" #: amelie/companies/templates/companies/company_banners.html:64 #: amelie/companies/templates/companies/company_banners.html:100 #: amelie/companies/templates/companies/company_form.html:50 -#: amelie/members/models.py:102 amelie/members/models.py:824 -#: amelie/personal_tab/forms.py:135 +#: amelie/members/models.py:104 amelie/members/models.py:851 +#: amelie/personal_tab/forms.py:167 #: templates/health_check/partial_celery_tables.html:10 msgid "Active" msgstr "Actief" @@ -4242,9 +4739,9 @@ msgstr "Het e-mailadres %(email)s wordt gebruikt door de volgende mappings:" #: amelie/claudia/templates/claudia/mapping_list.html:50 #: amelie/claudia/templates/claudia/mapping_timeline.html:20 #: amelie/claudia/templates/claudia/timeline_list.html:19 -#: amelie/members/forms.py:296 amelie/members/models.py:68 -#: amelie/members/models.py:93 amelie/members/models.py:984 -#: amelie/personal_tab/forms.py:141 +#: amelie/members/forms.py:291 amelie/members/models.py:70 +#: amelie/members/models.py:95 amelie/members/models.py:995 +#: amelie/personal_tab/forms.py:173 msgid "Type" msgstr "Type" @@ -4281,8 +4778,8 @@ msgid "Create extra group" msgstr "Extra groep aanmaken" #: amelie/claudia/templates/claudia/extragroup_list.html:26 -#: amelie/members/models.py:149 amelie/members/models.py:901 -#: amelie/members/models.py:1361 +#: amelie/members/models.py:151 amelie/members/models.py:912 +#: amelie/members/models.py:1439 #: amelie/members/templates/includes/query/query_dogroup.html:4 #: amelie/members/templates/includes/registration/data_overview_freshmen.html:77 #: amelie/members/templates/includes/registration/study_freshmen.html:53 @@ -4633,7 +5130,7 @@ msgstr "Timeline" #: amelie/claudia/templates/claudia/mapping_detail.html:101 #: amelie/claudia/templates/claudia/mapping_detail.html:156 -#: amelie/members/models.py:331 amelie/members/models.py:1330 +#: amelie/members/models.py:333 amelie/members/models.py:1408 #: amelie/members/templates/includes/person_data_table.html:113 #: amelie/members/templates/includes/registration/contact_member.html:11 #: amelie/members/templates/includes/registration/data_overview_employee.html:25 @@ -4669,7 +5166,7 @@ msgstr "automatisch" #: amelie/claudia/templates/claudia/mapping_detail.html:167 #: amelie/claudia/templates/claudia/mapping_detail.html:168 #: amelie/claudia/templates/claudia/mapping_detail.html:188 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:78 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:83 msgid "yes" msgstr "ja" @@ -4715,7 +5212,7 @@ msgstr "Mapping:" #: amelie/claudia/templates/claudia/mapping_timeline.html:19 #: amelie/claudia/templates/claudia/timeline_list.html:17 -#: amelie/personal_tab/templates/lists/activity_transactions.html:8 +#: amelie/personal_tab/templates/lists/activity_transactions.html:9 msgid "Date/time" msgstr "Datum/tijd" @@ -4794,11 +5291,11 @@ msgstr "Company Corner bedrijf of naam/website verplicht." msgid "Activity's \"visible from\" date is later than its \"visible until\" date" msgstr "Activiteit zichtbaarheid \"vanaf\" datum is later dan de \"tot\" datum" -#: amelie/companies/forms.py:139 amelie/personal_tab/forms.py:182 +#: amelie/companies/forms.py:139 amelie/personal_tab/forms.py:214 msgid "Beginning:" msgstr "Begin:" -#: amelie/companies/forms.py:140 amelie/personal_tab/forms.py:183 +#: amelie/companies/forms.py:140 amelie/personal_tab/forms.py:215 msgid "End (till)" msgstr "Einde (tot):" @@ -4834,11 +5331,11 @@ msgstr "De absolute URL naar deze activiteit" msgid "Whether this event is visible" msgstr "Of deze activiteit zichtbaar is" -#: amelie/companies/models.py:18 amelie/personal_tab/models.py:430 +#: amelie/companies/models.py:18 amelie/personal_tab/models.py:466 msgid "name" msgstr "naam" -#: amelie/companies/models.py:22 amelie/personal_tab/models.py:433 +#: amelie/companies/models.py:22 amelie/personal_tab/models.py:469 msgid "name (en)" msgstr "naam (en)" @@ -4959,7 +5456,7 @@ msgstr "" #: amelie/companies/templates/companies/company_banners.html:17 #: amelie/companies/templates/companies/company_banners.html:53 #: amelie/companies/templates/companies/company_banners.html:89 -#: amelie/personal_tab/models.py:755 +#: amelie/personal_tab/models.py:791 msgid "New" msgstr "Nieuw" @@ -5067,7 +5564,7 @@ msgid "iCal all external events" msgstr "iCal alle externe activiteiten" #: amelie/companies/templates/companies/company_event_list.html:19 -#: amelie/members/models.py:850 +#: amelie/personal_tab/models.py:960 msgid "Visible" msgstr "Zichtbaar" @@ -5173,7 +5670,7 @@ msgstr "Selecteer een periode en klik op Statistieken." #: amelie/companies/templates/companies/company_statistics.html:23 #: amelie/personal_tab/templates/cookie_corner_overview.html:19 -#: amelie/personal_tab/templates/statistics/statistics.html:452 +#: amelie/personal_tab/templates/statistics/statistics.html:450 #: amelie/personal_tab/templates/statistics/statistics_form.html:21 #: amelie/room_duty/templates/room_duty/table/overview.html:153 #: amelie/statistics/templates/statistics.html:5 @@ -5569,8 +6066,8 @@ msgstr "Module" #: amelie/education/forms.py:51 amelie/education/forms.py:58 #: amelie/education/forms.py:94 amelie/education/models.py:221 #: amelie/education/templates/complaints.html:59 -#: amelie/education/templates/complaints.html:119 amelie/members/models.py:151 -#: amelie/members/models.py:897 +#: amelie/education/templates/complaints.html:119 amelie/members/models.py:153 +#: amelie/members/models.py:908 #: amelie/members/templates/includes/query/query_study.html:4 #: amelie/members/templates/members/datablock_html.mail:13 #: amelie/members/templates/members/datablock_plain.mail:12 @@ -5582,9 +6079,9 @@ msgid "Course" msgstr "Vak" #: amelie/education/forms.py:61 amelie/education/models.py:132 -#: amelie/education/models.py:265 amelie/members/models.py:985 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:18 -#: amelie/personal_tab/templates/lists/transactions_totals.html:18 +#: amelie/education/models.py:265 amelie/members/models.py:996 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:19 +#: amelie/personal_tab/templates/lists/transactions_totals.html:19 #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:17 #: amelie/personal_tab/templates/unpaid_memberships/overview.html:13 msgid "Year" @@ -5606,8 +6103,8 @@ msgstr "Docent" #: amelie/education/forms.py:95 #: amelie/education/templates/dea_nomination.html:25 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:162 -#: amelie/personal_tab/templates/transactions/reversal_transaction_detail.html:23 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:167 +#: amelie/personal_tab/templates/info/transaction_information.html:117 #: amelie/tools/templates/data_export_statistics.html:28 msgid "Reason" msgstr "Reden" @@ -5665,14 +6162,14 @@ msgstr "Inhoud van de pagina (gelokaliseerd voor gebruiker)" msgid "Category name (localized for user)" msgstr "Categorienaam (gelokaliseerd voor gebruiker)" -#: amelie/education/models.py:31 amelie/members/forms.py:630 -#: amelie/members/models.py:1123 amelie/personal_tab/models.py:293 -#: amelie/personal_tab/models.py:350 +#: amelie/education/models.py:31 amelie/members/forms.py:633 +#: amelie/members/models.py:1201 amelie/personal_tab/models.py:329 +#: amelie/personal_tab/models.py:386 #: amelie/personal_tab/templates/statistics/statistics.html:54 msgid "Category" msgstr "Categorie" -#: amelie/education/models.py:32 amelie/personal_tab/models.py:351 +#: amelie/education/models.py:32 amelie/personal_tab/models.py:387 msgid "Categories" msgstr "Categorieën" @@ -5733,7 +6230,7 @@ msgid "Modules" msgstr "Modules" #: amelie/education/models.py:222 amelie/education/templates/summaries.html:22 -#: amelie/members/models.py:227 +#: amelie/members/models.py:229 msgid "Courses" msgstr "Vakken" @@ -6580,7 +7077,7 @@ msgstr "Vakcode van de module" msgid "Education" msgstr "Onderwijs" -#: amelie/education/templates/overview.html:12 amelie/members/models.py:1134 +#: amelie/education/templates/overview.html:12 amelie/members/models.py:1212 #: templates/basis.html:272 msgid "Information" msgstr "Informatie" @@ -6785,8 +7282,13 @@ msgstr "" "Er zijn %(mail_count)s berichten verstuurd. Hiervan zijn er %(sent_count)s geaccepteerd\n" "en %(error_count)s niet geaccepteerd." -#: amelie/members/forms.py:36 amelie/members/models.py:322 -#: amelie/members/models.py:1323 +#: amelie/members/ajax_views.py:220 +#, python-brace-format +msgid "Early termination of membership {membership_type} ({begin_year}/{end_year})" +msgstr "Voortijdige beëindiging van lidmaatschap {membership_type} ({begin_year}/{end_year})" + +#: amelie/members/forms.py:36 amelie/members/models.py:324 +#: amelie/members/models.py:1401 #: amelie/members/templates/includes/person_data_table.html:82 #: amelie/members/templates/includes/registration/data_overview_employee.html:11 #: amelie/members/templates/includes/registration/data_overview_external.html:11 @@ -6800,8 +7302,8 @@ msgstr "" msgid "Gender" msgstr "Geslacht" -#: amelie/members/forms.py:37 amelie/members/models.py:324 -#: amelie/members/models.py:1325 +#: amelie/members/forms.py:37 amelie/members/models.py:326 +#: amelie/members/models.py:1403 #: amelie/members/templates/includes/person_data_table.html:88 #: amelie/members/templates/includes/registration/data_overview_employee.html:17 #: amelie/members/templates/includes/registration/data_overview_external.html:17 @@ -6815,15 +7317,15 @@ msgstr "Geslacht" msgid "Language of preference" msgstr "Voorkeurstaal" -#: amelie/members/forms.py:39 amelie/members/models.py:326 -#: amelie/members/models.py:1327 +#: amelie/members/forms.py:39 amelie/members/models.py:328 +#: amelie/members/models.py:1405 #: amelie/members/templates/includes/person_data_table.html:85 #: amelie/members/templates/includes/registration/personal_details.html:93 msgid "International student" msgstr "Internationale student" -#: amelie/members/forms.py:40 amelie/members/models.py:328 -#: amelie/members/models.py:1328 +#: amelie/members/forms.py:40 amelie/members/models.py:330 +#: amelie/members/models.py:1406 #: amelie/members/templates/includes/person_data_table.html:78 #: amelie/members/templates/includes/registration/data_overview_employee.html:8 #: amelie/members/templates/includes/registration/data_overview_external.html:8 @@ -6837,8 +7339,8 @@ msgstr "Internationale student" msgid "Birth date" msgstr "Geboortedatum" -#: amelie/members/forms.py:42 amelie/members/models.py:373 -#: amelie/members/models.py:1346 +#: amelie/members/forms.py:42 amelie/members/models.py:375 +#: amelie/members/models.py:1424 #: amelie/members/templates/includes/query/query_preference.html:4 #: amelie/members/templates/members/datablock_html.mail:24 #: amelie/members/templates/members/datablock_plain.mail:22 @@ -6887,122 +7389,122 @@ msgstr "Suggestie:" msgid "Related to" msgstr "Verbonden aan" -#: amelie/members/forms.py:304 +#: amelie/members/forms.py:299 msgid "Current association year" msgstr "Huidig verenigingsjaar" -#: amelie/members/forms.py:305 +#: amelie/members/forms.py:300 msgid "Upcoming association year" msgstr "Volgend verenigingsjaar" -#: amelie/members/forms.py:311 +#: amelie/members/forms.py:313 msgid "Does the member still have to pay the membership fee?" msgstr "Moet het lid de contributie nog wel betalen?" -#: amelie/members/forms.py:323 +#: amelie/members/forms.py:325 msgid "Immediately" msgstr "Per direct" -#: amelie/members/forms.py:325 +#: amelie/members/forms.py:327 msgid "At the end of the association year" msgstr "Aan het einde van het verenigingsjaar" -#: amelie/members/forms.py:328 +#: amelie/members/forms.py:331 msgid "Send the signature request" msgstr "Verstuur het ondertekeningsverzoek" -#: amelie/members/forms.py:334 +#: amelie/members/forms.py:337 msgid "Send a signature request" msgstr "Verstuur ondertekeningsverzoek" -#: amelie/members/forms.py:335 +#: amelie/members/forms.py:338 msgid "This will send an e-mail to the member, with a request to sign the mandate form via our signing tool." msgstr "Dit zal een e-mail naar het lid toe sturen met het verzoek om de machtiging te ondertekenen via onze ondertekeningstool." -#: amelie/members/forms.py:354 +#: amelie/members/forms.py:357 msgid "A mandate type is required." msgstr "Machtigingssoort is verplicht." -#: amelie/members/forms.py:357 amelie/members/forms.py:668 +#: amelie/members/forms.py:360 amelie/members/forms.py:671 msgid "An IBAN is required." msgstr "IBAN is verplicht." -#: amelie/members/forms.py:376 amelie/personal_tab/forms.py:193 +#: amelie/members/forms.py:379 amelie/personal_tab/forms.py:225 msgid "Choose a name from the suggestions." msgstr "Kies een naam uit de suggesties." -#: amelie/members/forms.py:437 +#: amelie/members/forms.py:440 msgid "If we can use your parent(s)/guardian(s) data, then you need to enter at least their e-mail address!" msgstr "Als we de gegevens van je ouder(s)/verzorger(s) mogen gebruiken dan moet je in ieder geval het e-mailadres invullen!" -#: amelie/members/forms.py:442 +#: amelie/members/forms.py:445 msgid "If we can not use your parent(s)/guardian(s) data, then you do not need to enter any data!" msgstr "Als we de gegevens van je ouder(s)/verzorger(s) niet mogen gebruiken dan moet je ook geen gegevens invullen!" -#: amelie/members/forms.py:469 +#: amelie/members/forms.py:472 msgid "A study has been chosen, but no cohort was specified. Enter the missing data to continue." msgstr "Er is wel een studie gekozen, maar geen generatie. Vul een generatie in om verder te gaan." -#: amelie/members/forms.py:476 +#: amelie/members/forms.py:479 msgid "A cohort has been chosen, but no study was specified. Enter at least one study to continue." msgstr "Er is wel een generatie gekozen, maar geen studie. Vul een studie in om verder te gaan." -#: amelie/members/forms.py:483 amelie/members/forms.py:505 +#: amelie/members/forms.py:486 amelie/members/forms.py:508 msgid "A student with this student number already exists." msgstr "Student met dit studentnummer bestaat al." -#: amelie/members/forms.py:485 amelie/members/forms.py:507 +#: amelie/members/forms.py:488 amelie/members/forms.py:510 msgid "This student number is already pre-enrolled. A board member can activate your account." msgstr "Dit studentnummer is al vooringeschreven. Een bestuurder kan je account activeren." -#: amelie/members/forms.py:516 +#: amelie/members/forms.py:519 msgid "An employee with this employee number already exists." msgstr "Medewerker met dit medewerkersnummer bestaat al." -#: amelie/members/forms.py:541 amelie/members/query_forms.py:85 +#: amelie/members/forms.py:544 amelie/members/query_forms.py:85 #: amelie/members/templates/includes/registration/authorizations.html:53 #: amelie/members/templates/includes/registration/data_overview_employee.html:62 #: amelie/members/templates/includes/registration/data_overview_external.html:62 #: amelie/members/templates/includes/registration/data_overview_freshmen.html:94 #: amelie/members/templates/includes/registration/data_overview_general.html:71 -#: amelie/personal_tab/forms.py:112 amelie/personal_tab/forms.py:143 -#: amelie/personal_tab/forms.py:417 amelie/personal_tab/forms.py:422 -#: amelie/personal_tab/models.py:503 amelie/personal_tab/models.py:1028 -#: amelie/personal_tab/templates/authorization/view.html:33 +#: amelie/personal_tab/forms.py:110 amelie/personal_tab/forms.py:175 +#: amelie/personal_tab/forms.py:455 amelie/personal_tab/forms.py:460 +#: amelie/personal_tab/models.py:539 amelie/personal_tab/models.py:1440 +#: amelie/personal_tab/templates/authorization/view.html:38 #: amelie/tools/pdf.py:412 amelie/tools/pdf.py:550 msgid "IBAN" msgstr "IBAN" -#: amelie/members/forms.py:542 +#: amelie/members/forms.py:545 #: amelie/members/templates/includes/registration/authorizations.html:66 #: amelie/members/templates/includes/registration/data_overview_employee.html:65 #: amelie/members/templates/includes/registration/data_overview_external.html:65 #: amelie/members/templates/includes/registration/data_overview_freshmen.html:97 #: amelie/members/templates/includes/registration/data_overview_general.html:74 -#: amelie/personal_tab/forms.py:115 amelie/personal_tab/forms.py:144 -#: amelie/personal_tab/models.py:506 -#: amelie/personal_tab/templates/authorization/view.html:37 +#: amelie/personal_tab/forms.py:113 amelie/personal_tab/forms.py:176 +#: amelie/personal_tab/models.py:542 amelie/personal_tab/models.py:1329 +#: amelie/personal_tab/templates/authorization/view.html:42 #: amelie/tools/pdf.py:414 msgid "BIC" msgstr "BIC" -#: amelie/members/forms.py:583 +#: amelie/members/forms.py:586 msgid "By last name" msgstr "Op achternaam" -#: amelie/members/forms.py:584 +#: amelie/members/forms.py:587 msgid "By first name" msgstr "Op voornaam" -#: amelie/members/forms.py:585 +#: amelie/members/forms.py:588 msgid "By student number" msgstr "Op studentnummer" -#: amelie/members/forms.py:586 +#: amelie/members/forms.py:589 msgid "By order of pre-enrollment" msgstr "Op volgorde van voorinschrijving" -#: amelie/members/forms.py:596 amelie/members/models.py:1288 +#: amelie/members/forms.py:599 amelie/members/models.py:1366 #: amelie/members/templates/committee_edit_members.html:34 #: amelie/members/templates/committee_edit_single_member.html:29 #: amelie/members/templates/committee_members.html:8 @@ -7011,13 +7513,13 @@ msgstr "Op volgorde van voorinschrijving" msgid "Position" msgstr "Functie" -#: amelie/members/forms.py:632 +#: amelie/members/forms.py:635 #: amelie/members/templates/includes/committee_data.html:20 msgid "Parent committee" msgstr "Oudercommissie" -#: amelie/members/forms.py:651 amelie/members/models.py:868 -#: amelie/members/models.py:1357 +#: amelie/members/forms.py:654 amelie/members/models.py:879 +#: amelie/members/models.py:1435 #: amelie/members/templates/includes/registration/data_overview_freshmen.html:74 #: amelie/members/templates/includes/registration/data_overview_general.html:51 #: amelie/members/templates/includes/registration/study_freshmen.html:39 @@ -7029,34 +7531,34 @@ msgstr "Oudercommissie" msgid "Student number" msgstr "Studentnummer" -#: amelie/members/forms.py:659 +#: amelie/members/forms.py:662 msgid "There is no student with this student number." msgstr "Student met dit studentnummer bestaat niet." -#: amelie/members/forms.py:672 +#: amelie/members/forms.py:675 msgid "Please enter your own bank account number!" msgstr "Vul alstublieft uw eigen rekeningnummer in!" -#: amelie/members/forms.py:676 +#: amelie/members/forms.py:679 msgid "BIC has to be entered for foreign bankaccounts." msgstr "BIC moet ingevuld worden met een buitenlandse rekening." -#: amelie/members/forms.py:682 +#: amelie/members/forms.py:683 msgid "BIC could not be generated, please enter it yourself." msgstr "Kon BIC niet automatisch bepalen, vul deze alsjeblieft zelf in." -#: amelie/members/forms.py:685 +#: amelie/members/forms.py:687 amelie/members/forms.py:720 +msgid "The bank associated with your BIC does not accept SEPA Direct Debits." +msgstr "De bank die bij de ingevulde BIC hoort accepteert geen SEPA-incasso's." + +#: amelie/members/forms.py:690 msgid "The BIC country does not match the IBAN country." msgstr "Het land in de BIC komt niet overeen met het land in het IBAN." -#: amelie/members/forms.py:690 amelie/members/forms.py:696 +#: amelie/members/forms.py:695 amelie/members/forms.py:701 msgid "The BIC bank does not match the IBAN bank." msgstr "De bank in de BIC komt niet overeen met de bank in het IBAN." -#: amelie/members/forms.py:715 -msgid "BIC is not from a SEPA country" -msgstr "BIC komt niet uit een SEPA land" - #: amelie/members/graphql.py:25 msgid "Person name" msgstr "Naam van persoon" @@ -7081,217 +7583,239 @@ msgstr "Commissie-informatie (gelokaliseerd voor gebruiker)" msgid "Committees in this category" msgstr "Commissies in deze categorie" -#: amelie/members/models.py:45 amelie/members/models.py:66 -#: amelie/members/models.py:91 amelie/members/models.py:1119 +#: amelie/members/migrations/0024_delete_payment_delete_paymenttype.py:162 +#: amelie/members/models.py:1074 +#, python-brace-format +msgid "Contribution {membership_type} ({begin_year}/{end_year})" +msgstr "Contributie {membership_type} ({begin_year}/{end_year})" + +#: amelie/members/migrations/0024_delete_payment_delete_paymenttype.py:202 +#, python-brace-format +msgid "Internal settlement for reversed contribution {membership_type} ({begin_year}/{end_year}) (migrated)" +msgstr "Interne verrekening voor teruggeboekte contributie {membership_type} ({begin_year}/{end_year}) (gemigreerd)" + +#: amelie/members/migrations/0024_delete_payment_delete_paymenttype.py:234 +#, python-brace-format +msgid "Manual payment for contribution {membership_type} ({begin_year}/{end_year}) (migrated)" +msgstr "Handmatige betaling voor contributie {membership_type} ({begin_year}/{end_year}) (gemigreerd)" + +#: amelie/members/migrations/0024_delete_payment_delete_paymenttype.py:266 +#, python-brace-format +msgid "Pre-SEPA payment for contribution {membership_type} ({begin_year}/{end_year}) (migrated)" +msgstr "Pre-SEPA-betaling voor contributie {membership_type} ({begin_year}/{end_year}) (gemigreerd)" + +#: amelie/members/models.py:47 amelie/members/models.py:68 +#: amelie/members/models.py:93 amelie/members/models.py:1197 msgid "Abbreviation" msgstr "Afkorting" -#: amelie/members/models.py:49 +#: amelie/members/models.py:51 msgid "faculty" msgstr "faculteit" -#: amelie/members/models.py:50 +#: amelie/members/models.py:52 msgid "faculties" msgstr "faculteiten" -#: amelie/members/models.py:62 +#: amelie/members/models.py:64 msgid "Chair" msgstr "Leerstoel" -#: amelie/members/models.py:63 templates/health_check/index.html:32 +#: amelie/members/models.py:65 templates/health_check/index.html:32 msgid "Service" msgstr "Dienst" -#: amelie/members/models.py:67 +#: amelie/members/models.py:69 msgid "Faculty" msgstr "Faculteit" -#: amelie/members/models.py:72 +#: amelie/members/models.py:74 msgid "department" msgstr "afdeling" -#: amelie/members/models.py:73 +#: amelie/members/models.py:75 msgid "departments" msgstr "afdelingen" -#: amelie/members/models.py:85 +#: amelie/members/models.py:87 msgid "Bachelor of Science" msgstr "Bachelor of Science" -#: amelie/members/models.py:86 +#: amelie/members/models.py:88 msgid "Master of Science" msgstr "Master of Science" -#: amelie/members/models.py:87 +#: amelie/members/models.py:89 msgid "Engineer" msgstr "Ingenieur" -#: amelie/members/models.py:92 +#: amelie/members/models.py:94 msgid "Faculties" msgstr "Faculteiten" -#: amelie/members/models.py:94 +#: amelie/members/models.py:96 msgid "Course length" msgstr "Studielengte" -#: amelie/members/models.py:97 +#: amelie/members/models.py:99 msgid "Primary study" msgstr "Hoofdstudie" -#: amelie/members/models.py:98 +#: amelie/members/models.py:100 msgid "Indicates that this study is one of our primary studies" msgstr "Geeft aan dat deze studie een van onze primaire studies is" -#: amelie/members/models.py:103 +#: amelie/members/models.py:105 msgid "Make a study inactive when the study is not given any more on the University of Twente" msgstr "Maak een studie inactief als de studie niet meer gegeven wordt op de Universiteit Twente" -#: amelie/members/models.py:117 +#: amelie/members/models.py:119 msgid "study" msgstr "studie" -#: amelie/members/models.py:118 +#: amelie/members/models.py:120 msgid "studies" msgstr "studies" -#: amelie/members/models.py:130 +#: amelie/members/models.py:132 msgid "Color" msgstr "Kleur" -#: amelie/members/models.py:130 +#: amelie/members/models.py:132 msgid "What is the color of this dogroup?" msgstr "Wat is de kleur van deze doegroep?" -#: amelie/members/models.py:135 +#: amelie/members/models.py:137 msgid "do-group" msgstr "doegroep" -#: amelie/members/models.py:136 +#: amelie/members/models.py:138 msgid "do-groups" msgstr "doegroepen" -#: amelie/members/models.py:148 +#: amelie/members/models.py:150 msgid "Generation" msgstr "Generatie" -#: amelie/members/models.py:150 +#: amelie/members/models.py:152 msgid "Introduction parents" msgstr "Doegroepouders" -#: amelie/members/models.py:152 +#: amelie/members/models.py:154 msgid "Mailalias" msgstr "Mailalias" -#: amelie/members/models.py:153 +#: amelie/members/models.py:155 msgid "Generation color" msgstr "Generatiekleur" -#: amelie/members/models.py:154 +#: amelie/members/models.py:156 msgid "Similar to the dogroup color, however this value can be set in order to override the color for just this generation" msgstr "Vergelijkbaar met de kleur van een doegroep, maar als deze is ingevuld overschrijft het de originele doegroep kleurwaarde" -#: amelie/members/models.py:158 +#: amelie/members/models.py:160 msgid "do-group generation" msgstr "doegroepgeneratie" -#: amelie/members/models.py:159 +#: amelie/members/models.py:161 msgid "do-group generations" msgstr "doegroepgeneraties" -#: amelie/members/models.py:172 +#: amelie/members/models.py:174 msgid "The mail alias for a dogroup generation may only point to an Inter-Actief server." msgstr "Het email alias voor een doegroep mag alleen maar verwijzen naar een Inter-Actief server." -#: amelie/members/models.py:231 +#: amelie/members/models.py:233 msgid "association" msgstr "vereniging" -#: amelie/members/models.py:232 +#: amelie/members/models.py:234 msgid "associations" msgstr "verenigingen" -#: amelie/members/models.py:242 +#: amelie/members/models.py:244 msgid "Category of preference" msgstr "Voorkeurscategorie" -#: amelie/members/models.py:245 +#: amelie/members/models.py:247 msgid "category of preference" msgstr "voorkeurscategorie" -#: amelie/members/models.py:246 +#: amelie/members/models.py:248 msgid "categories of preference" msgstr "voorkeurscategorieën" -#: amelie/members/models.py:257 +#: amelie/members/models.py:259 msgid "Preference" msgstr "Voorkeur" -#: amelie/members/models.py:258 +#: amelie/members/models.py:260 msgid "Preference(s)" msgstr "Voorkeur(en)" -#: amelie/members/models.py:260 +#: amelie/members/models.py:262 msgid "Standard" msgstr "Standaard" -#: amelie/members/models.py:261 +#: amelie/members/models.py:263 msgid "Adjustable by the user in profile" msgstr "Instelbaar door gebruiker in profiel" -#: amelie/members/models.py:262 +#: amelie/members/models.py:264 msgid "Adjustable by the user on login" msgstr "Instelbaar door gebruiker bij inloggen" -#: amelie/members/models.py:263 amelie/members/templates/person_mandate.html:82 +#: amelie/members/models.py:265 amelie/members/templates/person_mandate.html:82 #: amelie/room_duty/templates/room_duty/table/overview.html:90 msgid "Print" msgstr "Print" -#: amelie/members/models.py:276 amelie/members/models.py:277 +#: amelie/members/models.py:278 amelie/members/models.py:279 msgid "preference" msgstr "voorkeur" -#: amelie/members/models.py:296 +#: amelie/members/models.py:298 msgid "Man" msgstr "Man" -#: amelie/members/models.py:297 +#: amelie/members/models.py:299 msgid "Woman" msgstr "Vrouw" -#: amelie/members/models.py:298 -#: amelie/personal_tab/templates/cookie_corner_transactions.html:48 -#: amelie/personal_tab/templates/lists/transactions_totals.html:24 +#: amelie/members/models.py:300 +#: amelie/personal_tab/templates/cookie_corner_transactions.html:51 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:73 +#: amelie/personal_tab/templates/lists/transactions_totals.html:26 #: amelie/personal_tab/templates/statistics/statistics.html:313 #: templates/frontpage.html:139 msgid "Other" msgstr "Overig" -#: amelie/members/models.py:301 +#: amelie/members/models.py:303 msgid "Yes, I am an international student." msgstr "Ja, ik ben een internationale student." -#: amelie/members/models.py:302 +#: amelie/members/models.py:304 msgid "No, I am not an international student." msgstr "Nee, ik ben geen internationale student." -#: amelie/members/models.py:303 +#: amelie/members/models.py:305 msgid "I would rather not say if I'm an international student or not." msgstr "Ik wil liever niet zeggen of ik een internationale student ben of niet." -#: amelie/members/models.py:306 +#: amelie/members/models.py:308 msgid "No preference" msgstr "Geen voorkeur" -#: amelie/members/models.py:307 +#: amelie/members/models.py:309 msgid "Bash" msgstr "Bash" -#: amelie/members/models.py:308 +#: amelie/members/models.py:310 msgid "Z shell" msgstr "Z shell" -#: amelie/members/models.py:313 amelie/members/models.py:1322 +#: amelie/members/models.py:315 amelie/members/models.py:1400 #: amelie/members/templates/includes/registration/personal_details.html:29 #: amelie/members/templates/includes/registration/personal_details_employee.html:29 #: amelie/members/templates/members/datablock_html.mail:3 @@ -7299,21 +7823,21 @@ msgstr "Z shell" msgid "Initials" msgstr "Initialen" -#: amelie/members/models.py:317 +#: amelie/members/models.py:319 #: amelie/members/templates/includes/query/query_results.html:61 msgid "Photo" msgstr "Foto" -#: amelie/members/models.py:318 +#: amelie/members/models.py:320 msgid "Unverified photo" msgstr "Onbeoordeelde foto" -#: amelie/members/models.py:320 +#: amelie/members/models.py:322 #: amelie/members/templates/includes/query/query_results.html:64 msgid "Notes" msgstr "Notities" -#: amelie/members/models.py:332 amelie/members/models.py:1331 +#: amelie/members/models.py:334 amelie/members/models.py:1409 #: amelie/members/templates/includes/registration/contact_member.html:24 #: amelie/members/templates/includes/registration/data_overview_employee.html:28 #: amelie/members/templates/includes/registration/data_overview_external.html:31 @@ -7326,7 +7850,7 @@ msgstr "Notities" msgid "Postal code" msgstr "Postcode" -#: amelie/members/models.py:333 amelie/members/models.py:1332 +#: amelie/members/models.py:335 amelie/members/models.py:1410 #: amelie/members/templates/includes/registration/contact_member.html:37 #: amelie/members/templates/includes/registration/data_overview_employee.html:31 #: amelie/members/templates/includes/registration/data_overview_external.html:34 @@ -7339,7 +7863,7 @@ msgstr "Postcode" msgid "City" msgstr "Plaats" -#: amelie/members/models.py:334 amelie/members/models.py:1333 +#: amelie/members/models.py:336 amelie/members/models.py:1411 #: amelie/members/templates/includes/registration/contact_member.html:50 #: amelie/members/templates/includes/registration/data_overview_employee.html:34 #: amelie/members/templates/includes/registration/data_overview_external.html:37 @@ -7352,7 +7876,7 @@ msgstr "Plaats" msgid "Country" msgstr "Land" -#: amelie/members/models.py:335 amelie/members/models.py:1334 +#: amelie/members/models.py:337 amelie/members/models.py:1412 #: amelie/members/query_forms.py:36 #: amelie/members/templates/members/datablock_html.mail:20 #: amelie/members/templates/members/datablock_plain.mail:19 @@ -7360,357 +7884,343 @@ msgstr "Land" msgid "Phonenumber" msgstr "Telefoonnummer" -#: amelie/members/models.py:337 amelie/members/models.py:1336 +#: amelie/members/models.py:339 amelie/members/models.py:1414 #: amelie/members/templates/includes/person_data_table.html:164 #: amelie/members/templates/includes/registration/contact_parents.html:25 msgid "E-mail address of parent(s)/guardian(s)" msgstr "E-mailadres van ouder(s)/verzorger(s)" -#: amelie/members/models.py:338 amelie/members/models.py:1337 +#: amelie/members/models.py:340 amelie/members/models.py:1415 #: amelie/members/templates/includes/person_data_table.html:147 #: amelie/members/templates/includes/registration/contact_parents.html:38 #: amelie/tools/pdf.py:131 templates/profile_edit.html:57 msgid "Address of parent(s)/guardian(s)" msgstr "Adres van ouder(s)/verzorger(s)" -#: amelie/members/models.py:340 amelie/members/models.py:1339 +#: amelie/members/models.py:342 amelie/members/models.py:1417 #: amelie/members/templates/includes/registration/contact_parents.html:51 msgid "Postal code of parent(s)/guardian(s)" msgstr "Postcode van ouder(s)/verzorger(s)" -#: amelie/members/models.py:342 amelie/members/models.py:1341 +#: amelie/members/models.py:344 amelie/members/models.py:1419 #: amelie/members/templates/includes/registration/contact_parents.html:64 #: amelie/tools/pdf.py:133 msgid "Residence of parent(s)/guardian(s)" msgstr "Woonplaats van ouder(s)/verzorger(s)" -#: amelie/members/models.py:344 amelie/members/models.py:1343 +#: amelie/members/models.py:346 amelie/members/models.py:1421 #: amelie/members/templates/includes/registration/contact_parents.html:77 #: amelie/tools/pdf.py:135 msgid "Country of parent(s)/guardian(s)" msgstr "Land van ouder(s)/verzorger(s)" -#: amelie/members/models.py:345 amelie/members/models.py:1344 +#: amelie/members/models.py:347 amelie/members/models.py:1422 #: amelie/members/templates/includes/registration/contact_parents.html:16 #: templates/profile_edit.html:64 msgid "My parents' address details may be used for the parents day." msgstr "De adresgegevens van mijn ouders mogen gebruikt worden voor de ouderdag." -#: amelie/members/models.py:349 +#: amelie/members/models.py:351 msgid "You can only enter ^[a-z]*$." msgstr "Je mag alleen ^[a-z]*$ invullen." -#: amelie/members/models.py:349 amelie/members/models.py:355 -#: amelie/members/models.py:1120 +#: amelie/members/models.py:351 amelie/members/models.py:357 +#: amelie/members/models.py:1198 msgid "Invalid account name" msgstr "Ongeldige accountnaam" -#: amelie/members/models.py:354 +#: amelie/members/models.py:356 msgid "UT external account name" msgstr "UT Externen-accountnaam" -#: amelie/members/models.py:355 +#: amelie/members/models.py:357 #, python-brace-format msgid "You can only enter ^x[0-9]{7}$." msgstr "Je mag alleen ^x[0-9]{7}$ invullen." -#: amelie/members/models.py:362 +#: amelie/members/models.py:364 msgid "Minecraft username" msgstr "Minecraft gebruikersnaam" -#: amelie/members/models.py:367 +#: amelie/members/models.py:369 msgid "Minecraft UUID" msgstr "Minecraft UUID" -#: amelie/members/models.py:369 +#: amelie/members/models.py:371 msgid "Unix shell" msgstr "UNIX shell" -#: amelie/members/models.py:370 +#: amelie/members/models.py:372 msgid "Is web master" msgstr "Is webmaster" -#: amelie/members/models.py:371 +#: amelie/members/models.py:373 msgid "Has signed NDA" msgstr "Heeft getekende NDA" -#: amelie/members/models.py:374 +#: amelie/members/models.py:376 msgid "User" msgstr "Gebruiker" -#: amelie/members/models.py:377 +#: amelie/members/models.py:379 msgid "Password reset code" msgstr "Wachtwoordherstelcode" -#: amelie/members/models.py:379 +#: amelie/members/models.py:381 msgid "Password reset code expiry" msgstr "Verloopdatum wachtwoordherstelcode" -#: amelie/members/models.py:382 +#: amelie/members/models.py:384 msgid "Sudo password reset code" msgstr "Sudo-wachtwoordherstelcode" -#: amelie/members/models.py:384 +#: amelie/members/models.py:386 msgid "Sudo password reset code expiry" msgstr "Verloopdatum sudowachtwoordherstelcode" -#: amelie/members/models.py:391 amelie/personal_tab/models.py:93 -#: amelie/personal_tab/models.py:500 amelie/personal_tab/models.py:1019 +#: amelie/members/models.py:393 amelie/personal_tab/models.py:98 +#: amelie/personal_tab/models.py:536 amelie/personal_tab/models.py:1040 +#: amelie/personal_tab/models.py:1431 msgid "person" msgstr "persoon" -#: amelie/members/models.py:392 +#: amelie/members/models.py:394 msgid "people" msgstr "personen" -#: amelie/members/models.py:723 +#: amelie/members/models.py:628 +msgid "Invalid transaction type" +msgstr "Ongeldige transactiesoort" + +#: amelie/members/models.py:750 msgid "Your email address is not to point to an Inter-Actief server. If you have a Google Apps account please use firstname.lastname@gapps.inter-actief.nl as an alternative." msgstr "Je E-mailadres mag niet naar een Inter-Actief server verwijzen. Als je een Google Apps account hebt kan je voornaam.achternaam@gapps.inter-actief.nl gebruiken." -#: amelie/members/models.py:769 +#: amelie/members/models.py:796 msgid "Documenso ID was not given. Can't do anything." msgstr "Documenso-ID is niet opgegeven. Ik kan hier niks doen." -#: amelie/members/models.py:822 +#: amelie/members/models.py:849 msgid "Needs verification" msgstr "Moet geverifiëerd worden" -#: amelie/members/models.py:838 +#: amelie/members/models.py:865 msgid "membership type" msgstr "lidmaatschapstype" -#: amelie/members/models.py:839 +#: amelie/members/models.py:866 msgid "membership types" msgstr "lidmaatschapstypes" -#: amelie/members/models.py:854 -msgid "way of payment" -msgstr "betalingswijze" - -#: amelie/members/models.py:855 -msgid "ways of payment" -msgstr "betalingswijzen" - -#: amelie/members/models.py:872 +#: amelie/members/models.py:883 msgid "student" msgstr "student" -#: amelie/members/models.py:873 +#: amelie/members/models.py:884 msgid "students" msgstr "studenten" -#: amelie/members/models.py:896 +#: amelie/members/models.py:907 msgid "Student" msgstr "Student" -#: amelie/members/models.py:898 +#: amelie/members/models.py:909 #: amelie/room_duty/templates/room_duty/table/fill.html:19 #: amelie/room_duty/templates/room_duty/table/overview.html:100 msgid "Begin" msgstr "Begin" -#: amelie/members/models.py:899 amelie/members/models.py:921 +#: amelie/members/models.py:910 amelie/members/models.py:932 #: amelie/room_duty/templates/room_duty/table/fill.html:20 #: amelie/room_duty/templates/room_duty/table/overview.html:101 msgid "End" msgstr "Eind" -#: amelie/members/models.py:900 +#: amelie/members/models.py:911 msgid "Has graduated" msgstr "Is afgestudeerd" -#: amelie/members/models.py:905 +#: amelie/members/models.py:916 msgid "study period" msgstr "studieperiode" -#: amelie/members/models.py:906 +#: amelie/members/models.py:917 msgid "study periods" msgstr "studieperiodes" -#: amelie/members/models.py:919 +#: amelie/members/models.py:930 #: amelie/members/templates/includes/registration/data_overview_employee.html:45 #: amelie/members/templates/includes/registration/employee_general.html:10 #: amelie/tools/pdf.py:141 amelie/tools/pdf.py:405 msgid "Employee number" msgstr "Medewerkernummer" -#: amelie/members/models.py:925 +#: amelie/members/models.py:936 msgid "employee" msgstr "medewerker" -#: amelie/members/models.py:926 +#: amelie/members/models.py:937 msgid "employees" msgstr "medewerkers" -#: amelie/members/models.py:958 +#: amelie/members/models.py:969 msgid "External photographers should at least have a first- and lastname." msgstr "Een externe fotograaf moet op zijn minst een voor- en achternaam hebben." -#: amelie/members/models.py:983 -#: amelie/personal_tab/templates/balance/balance.html:60 -#: amelie/personal_tab/templates/balance/balance.html:115 +#: amelie/members/models.py:994 +#: amelie/personal_tab/templates/balance/balance.html:69 +#: amelie/personal_tab/templates/balance/balance.html:140 msgid "Member" msgstr "Lid" -#: amelie/members/models.py:986 +#: amelie/members/models.py:997 msgid "Ended preliminary" msgstr "Voortijdig beëindigd" -#: amelie/members/models.py:987 +#: amelie/members/models.py:998 msgid "Verified on" msgstr "Geverifiëerd op" -#: amelie/members/models.py:990 amelie/members/models.py:1365 -#: amelie/personal_tab/models.py:522 +#: amelie/members/models.py:1001 amelie/members/models.py:1443 +#: amelie/personal_tab/models.py:558 msgid "Documenso ID" msgstr "Documenso-ID" -#: amelie/members/models.py:991 amelie/personal_tab/models.py:523 +#: amelie/members/models.py:1002 amelie/personal_tab/models.py:559 msgid "Signed document" msgstr "Getekend document" -#: amelie/members/models.py:995 +#: amelie/members/models.py:1006 msgid "membership" msgstr "lidmaatschap" -#: amelie/members/models.py:996 +#: amelie/members/models.py:1007 msgid "memberships" msgstr "lidmaatschappen" -#: amelie/members/models.py:1054 amelie/members/models.py:1510 -#: amelie/personal_tab/models.py:636 -msgid "Documenso ID is not set. No documents are due to be signed." -msgstr "Documenso-ID is niet ingesteld. Er zijn geen documenten wachtend op ondertekening." - -#: amelie/members/models.py:1080 -#: amelie/members/templates/includes/query/query_membership.html:3 -#: amelie/personal_tab/models.py:250 amelie/tools/pdf.py:73 -#: amelie/tools/pdf.py:155 templates/basis.html:210 -#: templates/profile_overview.html:45 -msgid "Membership" -msgstr "Lidmaatschap" - -#: amelie/members/models.py:1084 -msgid "payment" -msgstr "betaling" +#: amelie/members/models.py:1100 +#, python-brace-format +msgid "Payment for contribution {membership_type} ({begin_year}/{end_year})" +msgstr "Betaling voor contributie {membership_type} ({begin_year}/{end_year})" -#: amelie/members/models.py:1085 -msgid "payments" -msgstr "betalingen" +#: amelie/members/models.py:1153 amelie/members/models.py:1589 +#: amelie/personal_tab/models.py:672 +msgid "Documenso ID is not set. No documents are due to be signed." +msgstr "Documenso-ID is niet ingesteld. Er zijn geen documenten wachtend op ondertekening." -#: amelie/members/models.py:1100 +#: amelie/members/models.py:1178 msgid "committee category" msgstr "commissiecategorie" -#: amelie/members/models.py:1101 +#: amelie/members/models.py:1179 msgid "committee categories" msgstr "commissiecategorieën" -#: amelie/members/models.py:1120 +#: amelie/members/models.py:1198 msgid "You can only enter ^[a-zA-Z0-9.-]*$." msgstr "Je mag alleen ^[a-zA-Z0-9.-]*$ invullen." -#: amelie/members/models.py:1125 +#: amelie/members/models.py:1203 msgid "Parent committees" msgstr "Oudercommissies" -#: amelie/members/models.py:1131 amelie/members/models.py:1290 +#: amelie/members/models.py:1209 amelie/members/models.py:1368 msgid "Started on" msgstr "Begonnen op" -#: amelie/members/models.py:1132 amelie/members/models.py:1291 +#: amelie/members/models.py:1210 amelie/members/models.py:1369 msgid "Ended on" msgstr "Beëindigd op" -#: amelie/members/models.py:1133 +#: amelie/members/models.py:1211 #: amelie/members/templates/includes/committee_data.html:36 msgid "Web site" msgstr "Website" -#: amelie/members/models.py:1135 +#: amelie/members/models.py:1213 msgid "Information (en)" msgstr "Informatie (en)" -#: amelie/members/models.py:1137 +#: amelie/members/models.py:1215 msgid "E-mail address is private" msgstr "E-mailadres is privé" -#: amelie/members/models.py:1139 +#: amelie/members/models.py:1217 msgid "The e-mail address of this committee is not displayed on the website" msgstr "Het e-mailadres van deze commissie wordt niet op de website getoond" -#: amelie/members/models.py:1141 +#: amelie/members/models.py:1219 msgid "Is board" msgstr "Is bestuur" -#: amelie/members/models.py:1143 +#: amelie/members/models.py:1221 msgid "Members of this committee are granted board authorities on this web site" msgstr "Leden van deze commissie krijgen bestuursrecht op de website" -#: amelie/members/models.py:1146 +#: amelie/members/models.py:1224 msgid "Members of this committee get access to GitLab" msgstr "Leden van deze commissie krijgen toegang tot GitLab" -#: amelie/members/models.py:1148 +#: amelie/members/models.py:1226 msgid "Members of this committee get a private Matrix space" msgstr "Leden van deze commissie krijgen toegang tot een eigen Matrix-ruimte" -#: amelie/members/models.py:1156 amelie/personal_tab/models.py:35 -#: amelie/personal_tab/models.py:265 +#: amelie/members/models.py:1234 amelie/personal_tab/models.py:40 +#: amelie/personal_tab/models.py:301 msgid "ledger account" msgstr "grootboekrekeningnummer" -#: amelie/members/models.py:1160 amelie/personal_tab/models.py:1022 +#: amelie/members/models.py:1238 amelie/personal_tab/models.py:1434 msgid "committee" msgstr "commissie" -#: amelie/members/models.py:1161 +#: amelie/members/models.py:1239 #: amelie/members/templates/members/new_member.mail:22 msgid "committees" msgstr "commissies" -#: amelie/members/models.py:1170 +#: amelie/members/models.py:1248 msgid "The email address for a committee may only point to an Inter-Actief server." msgstr "Het e-mailadres van een commissie mag alleen maar verwijzen naar een Inter-Actief server." -#: amelie/members/models.py:1287 +#: amelie/members/models.py:1365 #: amelie/members/templates/includes/person_function_table.html:7 #: amelie/members/templates/person_edit_functions.html:16 #: amelie/members/templates/statistics/overview.html:123 -#: amelie/personal_tab/forms.py:221 amelie/personal_tab/forms.py:405 +#: amelie/personal_tab/forms.py:253 amelie/personal_tab/forms.py:443 #: amelie/statistics/templates/statistics.html:58 amelie/tools/pdf.py:548 msgid "Committee" msgstr "Commissie" -#: amelie/members/models.py:1295 +#: amelie/members/models.py:1373 msgid "position" msgstr "functie" -#: amelie/members/models.py:1296 +#: amelie/members/models.py:1374 msgid "functions" msgstr "functies" -#: amelie/members/models.py:1305 amelie/members/models.py:1309 +#: amelie/members/models.py:1383 amelie/members/models.py:1387 msgid "Person is already a member of this committee." msgstr "Persoon zit al in deze commissie." -#: amelie/members/models.py:1349 +#: amelie/members/models.py:1427 msgid "Authorizations" msgstr "Machtigingen" -#: amelie/members/models.py:1352 +#: amelie/members/models.py:1430 msgid "Chosen membership type" msgstr "Gekozen lidmaatschapstype" -#: amelie/members/models.py:1354 +#: amelie/members/models.py:1432 msgid "Membership start year" msgstr "Startjaar lidmaatschap" -#: amelie/members/models.py:1359 +#: amelie/members/models.py:1437 msgid "Studies" msgstr "Studies" -#: amelie/members/models.py:1360 +#: amelie/members/models.py:1438 msgid "Study start date" msgstr "Begindatum studie" @@ -7794,6 +8304,19 @@ msgstr "Heeft een doegroep" msgid "Has no dogroup" msgstr "Heeft geen doegroep" +#: amelie/members/query_forms.py:83 +#: amelie/members/templates/includes/query/query_mandate.html:4 +#: amelie/personal_tab/templates/authorization/view.html:4 +#: amelie/personal_tab/templates/authorization/view.html:8 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:41 +#: amelie/personal_tab/templates/debt_collection/process_batch.html:49 +#: amelie/personal_tab/templates/debt_collection/view.html:92 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:12 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:11 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:33 +msgid "Mandate" +msgstr "Machtiging" + #: amelie/members/query_forms.py:84 msgid "Mandate number" msgstr "Machtigingnummer" @@ -7826,43 +8349,43 @@ msgstr "Ongeldig IBAN." msgid "Invalid characters in name." msgstr "Ongeldige karakters in naam." -#: amelie/members/query_forms.py:383 +#: amelie/members/query_forms.py:385 msgid "Sender's name" msgstr "Naam afzender" -#: amelie/members/query_forms.py:384 +#: amelie/members/query_forms.py:386 msgid "Sender's e-mail" msgstr "Afzender e-mail" -#: amelie/members/query_forms.py:385 +#: amelie/members/query_forms.py:387 msgid "CC" msgstr "CC" -#: amelie/members/query_forms.py:386 +#: amelie/members/query_forms.py:388 msgid "BCC" msgstr "BCC" -#: amelie/members/query_forms.py:392 +#: amelie/members/query_forms.py:394 msgid "Message (Dutch)" msgstr "Bericht (Nederlands)" -#: amelie/members/query_forms.py:394 +#: amelie/members/query_forms.py:396 msgid "Message (English)" msgstr "Bericht (Engels)" -#: amelie/members/query_forms.py:401 +#: amelie/members/query_forms.py:403 msgid "Invalid subject" msgstr "Ongeldig onderwerp" -#: amelie/members/query_forms.py:409 +#: amelie/members/query_forms.py:411 msgid "Invalid template" msgstr "Ongeldig template" -#: amelie/members/query_forms.py:496 +#: amelie/members/query_forms.py:498 msgid "Include people on the waiting list" msgstr "Ook mensen op de wachtlijst" -#: amelie/members/query_views.py:155 amelie/personal_tab/views.py:2006 +#: amelie/members/query_views.py:155 amelie/personal_tab/views.py:2417 msgid "The mails are now being sent one by one. This happens in a background process and might take a while." msgstr "De mails worden nu een voor een verstuurd. Dit gebeurt in een achtergrondproces en kan even duren." @@ -7994,9 +8517,9 @@ msgstr "" #: amelie/members/templates/person_edit_functions.html:18 #: amelie/members/templates/person_edit_study.html:18 #: amelie/members/templates/person_mandate.html:42 -#: amelie/personal_tab/forms.py:145 +#: amelie/personal_tab/forms.py:177 #: amelie/personal_tab/templates/authorization/list.html:70 -#: amelie/personal_tab/templates/authorization/view.html:45 +#: amelie/personal_tab/templates/authorization/view.html:50 #: amelie/personal_tab/templates/info/account_information.html:16 #: amelie/personal_tab/templates/lists/authorization_select.html:13 #: amelie/statistics/models.py:6 @@ -8009,9 +8532,9 @@ msgstr "Begindatum" #: amelie/members/templates/person_edit_functions.html:19 #: amelie/members/templates/person_edit_study.html:19 #: amelie/members/templates/person_mandate.html:43 -#: amelie/personal_tab/forms.py:146 +#: amelie/personal_tab/forms.py:178 #: amelie/personal_tab/templates/authorization/list.html:71 -#: amelie/personal_tab/templates/authorization/view.html:49 +#: amelie/personal_tab/templates/authorization/view.html:54 #: amelie/personal_tab/templates/info/account_information.html:17 #: amelie/statistics/models.py:7 msgid "Ends on" @@ -8050,7 +8573,7 @@ msgstr "Lid sinds" #: amelie/members/templates/committees.html:11 #: amelie/members/templates/person.html:54 #: amelie/members/templates/statistics/overview.html:90 -#: templates/basis.html:209 templates/profile_overview.html:144 +#: templates/basis.html:209 templates/profile_overview.html:150 msgid "Committees" msgstr "Commissies" @@ -8315,6 +8838,14 @@ msgstr "EN:" msgid "Content:" msgstr "Inhoud:" +#: amelie/members/templates/includes/query/query_membership.html:3 +#: amelie/personal_tab/models.py:286 +#: amelie/personal_tab/templates/info/transaction_information.html:101 +#: amelie/tools/pdf.py:73 amelie/tools/pdf.py:155 templates/basis.html:210 +#: templates/profile_overview.html:45 +msgid "Membership" +msgstr "Lidmaatschap" + #: amelie/members/templates/includes/query/query_name.html:3 msgid "Basic data" msgstr "Basisgegevens" @@ -8401,7 +8932,7 @@ msgstr "Contactgegevens" #: amelie/members/templates/includes/registration/authorizations.html:4 #: amelie/members/templates/person.html:26 #: amelie/members/templates/registration_check.html:73 -#: amelie/personal_tab/forms.py:173 +#: amelie/personal_tab/forms.py:205 #: amelie/personal_tab/templates/authorization/list.html:4 #: amelie/personal_tab/templates/authorization/list.html:58 #: amelie/personal_tab/templates/cookie_corner_overview.html:21 @@ -8454,8 +8985,8 @@ msgid "Your IBAN bank account number. For example \"NL18ABNA0484869868\". msgstr "Je IBAN-bankrekeningnummer. Bijvoorbeeld \"NL18ABNA0484869868\"." #: amelie/members/templates/includes/registration/authorizations.html:71 -msgid "Your BIC-code. Not required for Dutch bank accounts. For example \"ABNANL2A\". Find your BIC by clicking here (opens in new tab)." -msgstr "Je BIC-code. Niet vereist voor Nederlandse rekeningnummers. Bijvoorbeeld \"ABNANL2A\". Vind je BIC door hier te klikken (opent in nieuwe tab)." +msgid "Your BIC-code. Not required for Dutch bank accounts. For example \"ABNANL2A\". Find your BIC by clicking here (opens in new tab)." +msgstr "Je BIC-code. Niet vereist voor Nederlandse rekeningnummers. Bijvoorbeeld \"ABNANL2A\". Vind je BIC door hier te klikken (opent nieuw tabblad)." #: amelie/members/templates/includes/registration/contact_member.html:6 msgid "Where can we reach you?" @@ -8548,7 +9079,7 @@ msgstr "Medewerker- en Lidmaatschapsgegevens" #: amelie/members/templates/includes/registration/membership_freshmen.html:25 #: amelie/members/templates/includes/registration/membership_general.html:33 #: amelie/members/templates/registration_check.html:59 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:10 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:11 #: amelie/personal_tab/templates/statistics/statistics.html:285 msgid "Membership type" msgstr "Lidmaatschapstype" @@ -8614,7 +9145,7 @@ msgstr "Studie- en Lidmaatschapsgegevens" #: amelie/members/templates/includes/registration/study_freshmen.html:10 #: amelie/members/templates/includes/registration/study_general.html:10 #: amelie/members/templates/person_study.html:9 -#: templates/profile_overview.html:111 +#: templates/profile_overview.html:117 msgid "Study/studies" msgstr "Studie(s)" @@ -9108,21 +9639,6 @@ msgstr "" "\n" "Het bestuur van " -#: amelie/members/templates/members/payment_confirm_delete.html:5 -#: amelie/members/templates/members/payment_confirm_delete.html:10 -msgid "Remove payment" -msgstr "Betaling verwijderen" - -#: amelie/members/templates/members/payment_confirm_delete.html:14 -#, python-format -msgid "" -"\n" -" Are you sure you want to remove the payment %(object)s?\n" -" " -msgstr "" -"\n" -"Weet je zeker dat je betaling %(object)s wilt verwijderen?" - #: amelie/members/templates/members/preregistration_welcome.mail:4 #, python-format msgid "[Inter-Actief] Welcome to Inter-/Actief/, %(name)s, please sign your membership documents!" @@ -9224,6 +9740,7 @@ msgstr "Je kan zelf zien of je studie al is geverifiëerd door naar je profiel o #: amelie/members/templates/members/verification_reminder.mail:14 #: amelie/members/templates/members/yeartransfer_ended.mail:14 +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:17 msgid "If you have any questions you can contact us by replying to this email." msgstr "Als je nog vragen hebt, kun je natuurlijk altijd reageren op deze mail." @@ -9325,7 +9842,7 @@ msgstr "De opdracht wordt uitgevoerd..." #: amelie/members/templates/person.html:35 #: amelie/members/templates/person_study.html:8 -#: templates/profile_overview.html:101 templates/profile_overview.html:110 +#: templates/profile_overview.html:107 templates/profile_overview.html:116 msgid "Student data" msgstr "Studentgegevens" @@ -9334,7 +9851,7 @@ msgstr "Studentgegevens" msgid "Employee data" msgstr "Medewerkergegevens" -#: amelie/members/templates/person.html:76 templates/profile_overview.html:245 +#: amelie/members/templates/person.html:76 templates/profile_overview.html:251 msgid "User accounts and login providers" msgstr "Gebruikersaccounts en inlogproviders" @@ -9364,28 +9881,27 @@ msgstr "De volgende gebruikersaccount(s) zijn gevonden die aan deze persoon gere #: amelie/members/templates/person_accounts.html:8 #: amelie/members/templates/person_accounts.html:44 -#: amelie/personal_tab/templates/register/register_card_index.html:19 -#: templates/profile_overview.html:256 templates/profile_overview.html:304 +#: templates/profile_overview.html:262 templates/profile_overview.html:310 msgid "Username" msgstr "Gebruikersnaam" #: amelie/members/templates/person_accounts.html:9 -#: templates/profile_overview.html:257 +#: templates/profile_overview.html:263 msgid "Account type" msgstr "Accounttype" #: amelie/members/templates/person_accounts.html:10 -#: templates/profile_overview.html:258 +#: templates/profile_overview.html:264 msgid "Linked social accounts" msgstr "Gekoppelde sociale accounts" #: amelie/members/templates/person_accounts.html:17 -#: templates/profile_overview.html:265 +#: templates/profile_overview.html:271 msgid "Inter-Actief Active Members account" msgstr "Inter-Actief-actieveledenaccount" #: amelie/members/templates/person_accounts.html:20 -#: templates/profile_overview.html:267 +#: templates/profile_overview.html:273 msgid "TOTP devices:" msgstr "TOTP-apparaten:" @@ -9394,27 +9910,27 @@ msgid "No TOTP devices" msgstr "Geen TOTP-apparaten" #: amelie/members/templates/person_accounts.html:33 -#: templates/profile_overview.html:293 +#: templates/profile_overview.html:299 msgid "Integration account" msgstr "Integratie-account" #: amelie/members/templates/person_accounts.html:35 -#: templates/profile_overview.html:295 +#: templates/profile_overview.html:301 msgid "University of Twente account" msgstr "Universiteit Twente account" #: amelie/members/templates/person_accounts.html:43 -#: templates/profile_overview.html:303 +#: templates/profile_overview.html:309 msgid "Provider" msgstr "Dienst" #: amelie/members/templates/person_accounts.html:52 -#: templates/profile_overview.html:318 +#: templates/profile_overview.html:324 msgid "No linked accounts" msgstr "Geen gekoppelde accounts" #: amelie/members/templates/person_accounts.html:59 -#: templates/profile_overview.html:325 +#: templates/profile_overview.html:331 msgid "No user accounts could be retrieved" msgstr "Er konden geen accounts opgehaald worden" @@ -9461,6 +9977,38 @@ msgstr "De persoonsgegevens van %(name)s zijn geanonimiseerd." msgid "Return to this person." msgstr "Terug naar deze persoon." +#: amelie/members/templates/person_confirm_mandate.html:3 +msgid "Confirm BIC for mandate" +msgstr "Bevestig BIC voor machtiging" + +#: amelie/members/templates/person_confirm_mandate.html:12 +#, python-format +msgid "" +"\n" +" The BIC for this mandate is on the list of known BICs that do not accept SEPA Direct Debits.\n" +" Are you sure you want to add a mandate with the following IBAN: %(iban)s and BIC: %(bic)s?\n" +" " +msgstr "" +"\n" +" De BIC van deze machtiging staat op de lijst van bij ons bekende BICs van banken die geen SEPA Incasso's ondersteunen.\n" +"Weet je zeker dat je een machtiging met het volgende IBAN: %(iban)s en BIC: %(bic)s wilt toevoegen?\n" +" " + +#: amelie/members/templates/person_confirm_mandate.html:17 +msgid "" +"\n" +" Only do this if you are sure that this bank accepts SEPA Direct Debits.\n" +" " +msgstr "" +"\n" +" Doe dit alleen als je zeker weet dat deze bank SEPA Direct Debits ondersteunt.\n" +" " + +#: amelie/members/templates/person_confirm_mandate.html:26 +#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:57 +msgid "Confirm" +msgstr "Bevestigen" + #: amelie/members/templates/person_delete.html:5 msgid "Delete account" msgstr "Verwijder account" @@ -9568,7 +10116,7 @@ msgstr "" #: amelie/members/templates/person_edit_mandate.html:27 #: amelie/members/templates/person_new_mandate.html:8 -#: amelie/personal_tab/forms.py:115 +#: amelie/personal_tab/forms.py:113 msgid "BIC is not required for a Dutch IBAN" msgstr "BIC is niet vereist bij een Nederlands IBAN." @@ -9584,6 +10132,7 @@ msgstr "Nieuwe studie toevoegen" #: amelie/members/templates/person_new_mandate.html:14 #: amelie/members/templates/person_new_membership.html:14 #: amelie/members/templates/registration_check.html:120 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:25 #: amelie/personal_tab/templates/exam_cookie_credit/add.html:22 #: amelie/personal_tab/templates/transactions/transaction_form.html:32 #: amelie/room_duty/templates/room_duty/pool/list.html:12 @@ -9690,9 +10239,9 @@ msgid "Add mandate" msgstr "Voeg machtiging toe" #: amelie/members/templates/person_mandate.html:37 -#: amelie/personal_tab/forms.py:140 +#: amelie/personal_tab/forms.py:172 #: amelie/personal_tab/templates/authorization/list.html:66 -#: amelie/personal_tab/templates/authorization/view.html:134 +#: amelie/personal_tab/templates/authorization/view.html:139 #: amelie/personal_tab/templates/debt_collection/process_batch.html:47 #: amelie/personal_tab/templates/debt_collection/view.html:90 #: amelie/personal_tab/templates/info/account_information.html:12 @@ -9702,7 +10251,7 @@ msgstr "Kenmerk" #: amelie/members/templates/person_mandate.html:38 #: amelie/members/templates/person_membership.html:46 -#: amelie/personal_tab/forms.py:152 +#: amelie/personal_tab/forms.py:184 #: amelie/personal_tab/templates/authorization/list.html:67 #: amelie/personal_tab/templates/authorization/view.html:17 #: amelie/personal_tab/templates/info/account_information.html:13 @@ -9717,8 +10266,8 @@ msgid "IBAN/BIC" msgstr "IBAN/BIC" #: amelie/members/templates/person_mandate.html:40 -#: amelie/personal_tab/forms.py:142 -#: amelie/personal_tab/templates/authorization/view.html:41 +#: amelie/personal_tab/forms.py:174 +#: amelie/personal_tab/templates/authorization/view.html:46 #: amelie/personal_tab/templates/info/account_information.html:15 #: amelie/tools/pdf.py:393 msgid "Account holder" @@ -9729,9 +10278,9 @@ msgid "Signed" msgstr "Getekend" #: amelie/members/templates/person_mandate.html:71 -#: amelie/personal_tab/templates/authorization/list.html:105 +#: amelie/personal_tab/templates/authorization/list.html:110 #: amelie/personal_tab/templates/info/account_information.html:42 -#: amelie/personal_tab/templates/lists/authorization_select.html:46 +#: amelie/personal_tab/templates/lists/authorization_select.html:51 msgid "before September 2013" msgstr "voor september 2013" @@ -9756,10 +10305,10 @@ msgid "View signature" msgstr "Handtekening tonen" #: amelie/members/templates/person_mandate.html:94 -#: amelie/members/templates/person_membership.html:96 +#: amelie/members/templates/person_membership.html:149 #: amelie/members/templates/preregistration_resend_confirm.html:4 #: amelie/members/templates/preregistration_resend_confirm.html:8 -#: amelie/members/templates/preregistration_status.html:76 +#: amelie/members/templates/preregistration_status.html:84 msgid "Re-send digital signature request" msgstr "Digitaal ondertekeningsverzoek opnieuw versturen" @@ -9769,7 +10318,7 @@ msgstr "Handtekening opnieuw aanvragen" #: amelie/members/templates/person_mandate.html:96 #: amelie/members/templates/person_mandate_sign.html:3 -#: amelie/members/templates/person_membership.html:98 +#: amelie/members/templates/person_membership.html:151 #: amelie/members/templates/person_membership_sign.html:3 msgid "Send digital signature request" msgstr "Verstuur digitaal ondertekeningsverzoek" @@ -9787,8 +10336,8 @@ msgid "View in sign tool" msgstr "Tonen in ondertekeningstool" #: amelie/members/templates/person_mandate.html:102 -#: amelie/members/templates/person_membership.html:104 -#: amelie/members/templates/preregistration_status.html:82 +#: amelie/members/templates/person_membership.html:157 +#: amelie/members/templates/preregistration_status.html:90 msgid "Try to retrieve signed file from Documenso" msgstr "Probeer het ondertekende document op te halen uit Documenso" @@ -9818,11 +10367,6 @@ msgstr "Het volgende document is succesvol opgehaald uit Dcouemso en opgeslagen msgid "Could not retrieve any document from Documenso. The error received was:" msgstr "Kon geen documenten ophalen van Documenso. De ontvangen foutmelding is:" -#: amelie/members/templates/person_mandate_retrieve_signed.html:18 -#: amelie/members/templates/person_membership_retrieve_signed.html:18 -msgid "Return" -msgstr "Terug" - #: amelie/members/templates/person_mandate_sign.html:7 msgid "" "\n" @@ -9879,7 +10423,7 @@ msgstr "" "%(obj)s is geen lid." #: amelie/members/templates/person_membership.html:21 -#: amelie/members/templates/person_membership.html:126 +#: amelie/members/templates/person_membership.html:179 msgid "Add membership" msgstr "Voeg lidmaatschap toe" @@ -9911,39 +10455,69 @@ msgstr "%(obj)s is niet lid." msgid "Course year" msgstr "Collegejaar" +#: amelie/members/templates/person_membership.html:47 +msgid "Paid?" +msgstr "Betaald?" + #: amelie/members/templates/person_membership.html:63 #, python-format msgid "Ended as of %(ended)s" msgstr "Beëindigd per %(ended)s" -#: amelie/members/templates/person_membership.html:75 -msgid "Delete payment" -msgstr "Verwijder betaling" +#: amelie/members/templates/person_membership.html:72 +msgid "N/A" +msgstr "n.v.t." -#: amelie/members/templates/person_membership.html:84 -msgid "Process payment" -msgstr "Voer betaling in" +#: amelie/members/templates/person_membership.html:75 +msgid "For some reason this free membership has payments! This should not happen." +msgstr "Om de een of andere reden heeft dit gratis lidmaatschap betalingen! Dit zou niet mogen gebeuren." -#: amelie/members/templates/person_membership.html:90 +#: amelie/members/templates/person_membership.html:143 msgid "Download PDF form" msgstr "Download PDF-formulier" -#: amelie/members/templates/person_membership.html:92 +#: amelie/members/templates/person_membership.html:145 msgid "View signed member form" msgstr "Ondertekend lidmaatschapsformulier tonen" -#: amelie/members/templates/person_membership.html:103 +#: amelie/members/templates/person_membership.html:156 msgid "A signature request for this membership is in progress. Click to open the request in Documenso." msgstr "Een ondertekeningsverzoek voor deze machtiging is in behandeling. Klik om het verzoek te openen in Documenso." -#: amelie/members/templates/person_membership.html:107 +#: amelie/members/templates/person_membership.html:160 msgid "This membership was created before we introduced digital signatures. Its signed copy may be in the physical folders." msgstr "Dit lidmaatschap is aangemaakt voordat we digitale handtekeningen hebben geintroduceerd. Het ondertekende exemplaar bevindt zich mogelijk in de fysieke mappen." -#: amelie/members/templates/person_membership.html:122 +#: amelie/members/templates/person_membership.html:175 msgid "Terminate membership" msgstr "Einde lidmaatschap" +#: amelie/members/templates/person_membership_create_payment.html:5 +#, python-format +msgid "" +"\n" +" Create manual payment for %(membership)s of %(person)s\n" +" " +msgstr "" +"\n" +" Handmatige betaling aanmaken voor %(membership)s van %(person)s\n" +" " + +#: amelie/members/templates/person_membership_payments.html:20 +#, python-format +msgid "" +"\n" +" Payments for %(membership)s %(year_1)s/%(year_2)s\n" +" " +msgstr "" +"\n" +" Betalingen voor %(membership)s %(year_1)s/%(year_2)s\n" +" " + +#: amelie/members/templates/person_membership_payments.html:62 +msgid "No transactions found. If this is a paid membership, this is probably wrong! Please contact the WWW." +msgstr "Geen transacties gevonden. Als dit een betaald lidmaatschap is dan klopt dit waarschijnlijk niet! Neem dan contact op met de WWW." + #: amelie/members/templates/person_membership_retrieve_signed.html:6 msgid "The following document was successfully retrieved from Documenso and saved to the membership:" msgstr "Het volgende document is succesvol opgehaald uit Dcouemso en opgeslagen in het lidmaatschap:" @@ -9990,14 +10564,6 @@ msgstr "" msgid "Add a membership" msgstr "Lidmaatschap toevoegen" -#: amelie/members/templates/person_payment.html:3 -msgid "Execute payment" -msgstr "Betaling invoeren" - -#: amelie/members/templates/person_payment.html:18 -msgid "Input" -msgstr "Invoeren" - #: amelie/members/templates/person_registration_form_complete.html:5 msgid "Enrollment successful" msgstr "Inschrijving geslaagd" @@ -10060,17 +10626,17 @@ msgid "Pre-enroll new freshman member" msgstr "Nieuw eerstejaarslid voorinschrijven" #: amelie/members/templates/person_study.html:10 -#: templates/profile_overview.html:112 +#: templates/profile_overview.html:118 msgid "Period(s)" msgstr "Periode(s)" #: amelie/members/templates/person_study.html:19 -#: templates/profile_overview.html:121 +#: templates/profile_overview.html:127 msgid "No doegroep registered" msgstr "Geen doegroep geregistreerd" #: amelie/members/templates/person_study.html:21 templates/basis.html:181 -#: templates/profile_overview.html:123 +#: templates/profile_overview.html:129 msgid "Current" msgstr "Actueel" @@ -10114,7 +10680,7 @@ msgid "Print all forms" msgstr "Print alle formulieren" #: amelie/members/templates/preregistration_print_all.html:8 -#: amelie/members/templates/preregistration_status.html:40 +#: amelie/members/templates/preregistration_status.html:48 msgid "Print all membership and authorization forms" msgstr "Print alle lidmaatschaps- en machtigingsformulieren" @@ -10140,8 +10706,6 @@ msgstr "Print die formulieren!" #: amelie/members/templates/preregistration_print_all.html:45 #: amelie/personal_tab/templates/pos/shop.html:13 -#: amelie/personal_tab/templates/register/register_card_error.html:14 -#: amelie/personal_tab/templates/register/register_card_scan.html:54 msgid "Go back" msgstr "Ga terug" @@ -10234,62 +10798,74 @@ msgstr "Dit zijn de huidige voorinschrinvingen die nog niet zijn geactiveerd, pe msgid "If the member has signed their membership and authorization forms, the membership and any authorizations should automatically get activated and it will disappear from this list." msgstr "Als het lid hun lidmaatschaps- en machtigingsformulieren heeft ondertekend, dan zal het lidmaatschap en de eventuele machtigingen automatisch worden geactiveerd, en zal het lid verdwijnen uit deze lijst." -#: amelie/members/templates/preregistration_status.html:27 +#: amelie/members/templates/preregistration_status.html:30 +#, python-format +msgid "" +"\n" +" To view already activated memberships from this academic year, check the current enrollment status instead.\n" +" " +msgstr "" +"\n" +" Om lidmaatschappen uit dit academisch jaar te bekijken die al geactiveerd zijn, bekijk je de huidige inschrijvingsstatus.\n" +" " + +#: amelie/members/templates/preregistration_status.html:35 msgid "You can (re-)print the forms for that member by clicking the \"Print\" () button." msgstr "Je kunt de formulieren van een lid (opnieuw) printen door op de \"Print\" () knop te drukken." -#: amelie/members/templates/preregistration_status.html:29 +#: amelie/members/templates/preregistration_status.html:37 msgid "Additionally, as a board member, you can view the state of the signature requests (), and attempt re-download of the signed forms () or even re-send them () if it failed for whatever reason." msgstr "Daarnaast kun je als bestuurslid de status van ondertekeningsverzoeken zien (), en proberen de ondertekende documenten opnieuw op te halen () of deze zelfs opnieuw te versturen naar het lid () als dit voor een of andere reden is mislukt." -#: amelie/members/templates/preregistration_status.html:32 +#: amelie/members/templates/preregistration_status.html:40 msgid "Click the \"Delete\" () button to delete the pre-enrollment." msgstr "Klik op de \"Verwijder\" () knop om de voorinschrijving te verwijderen." -#: amelie/members/templates/preregistration_status.html:37 +#: amelie/members/templates/preregistration_status.html:45 msgid "Pre-enroll member" msgstr "Lid voorinschrijven" -#: amelie/members/templates/preregistration_status.html:45 +#: amelie/members/templates/preregistration_status.html:53 msgid "Disable deletions" msgstr "Verwijderknoppen uitschakelen" -#: amelie/members/templates/preregistration_status.html:51 +#: amelie/members/templates/preregistration_status.html:59 msgid "Enable deletions" msgstr "Verwijder-knoppen activeren" -#: amelie/members/templates/preregistration_status.html:65 +#: amelie/members/templates/preregistration_status.html:73 #, python-format msgid "%(len)s enrollment(s)" msgstr "%(len)s inschrijving(en)" -#: amelie/members/templates/preregistration_status.html:73 +#: amelie/members/templates/preregistration_status.html:81 msgid "Print membership forms (opens in new tab)" -msgstr "Lidmaatschapsformulieren printen (opent in nieuwe tab)" +msgstr "Lidmaatschapsformulieren printen (opent nieuw tabblad)" -#: amelie/members/templates/preregistration_status.html:80 +#: amelie/members/templates/preregistration_status.html:88 msgid "A signature request for this membership is in progress. Click to open the request in Documenso" msgstr "Een ondertekeningsverzoek voor dit lidmaatschap is in behandeling. Klik om het verzoek te openen in Documenso." -#: amelie/members/templates/preregistration_status.html:87 +#: amelie/members/templates/preregistration_status.html:95 msgid "Manually activate. Should not be necessary and will not include any digital signature files" msgstr "Handmatig activeren. Zou niet nodig moeten zijn en er zullen geen digitaal getekende bestanden worden toegevoegd" -#: amelie/members/templates/preregistration_status.html:90 +#: amelie/members/templates/preregistration_status.html:98 msgid "Delete this pre-enrollment" msgstr "Verwijder deze voorinschrijving" -#: amelie/members/templates/preregistration_status.html:94 +#: amelie/members/templates/preregistration_status.html:102 +#: amelie/members/templates/registration_status.html:41 msgid "No members" msgstr "Geen leden" -#: amelie/members/templates/preregistration_status.html:103 +#: amelie/members/templates/preregistration_status.html:111 msgid "No pre-enrollments" msgstr "Geen voorinschrijvingen" -#: amelie/members/templates/preregistration_status.html:106 -msgid "There are currently no pre-enrollments." -msgstr "Er zijn momenteel geen voorinschrijvingen." +#: amelie/members/templates/preregistration_status.html:114 +msgid "There are currently no pending pre-enrollments." +msgstr "Er zijn momenteel geen openstaande voorinschrijvingen." #: amelie/members/templates/query.html:5 amelie/members/templates/query.html:63 msgid "Search for members" @@ -10347,7 +10923,7 @@ msgid "Card" msgstr "Kaart" #: amelie/members/templates/registration_check.html:108 -#: amelie/personal_tab/models.py:382 +#: amelie/personal_tab/models.py:418 #: amelie/personal_tab/templates/info/rfid_cards.html:31 msgid "Activated" msgstr "Geactiveerd" @@ -10362,6 +10938,39 @@ msgstr "Niet geactiveerd" msgid "You didn't register any rfid cards." msgstr "Er zijn geen rfid-kaarten geregistreerd." +#: amelie/members/templates/registration_status.html:5 +#: amelie/members/templates/registration_status.html:11 +msgid "Enrollment status" +msgstr "inschrijvingsstatus" + +#: amelie/members/templates/registration_status.html:14 +msgid "These are the new members this association year, per do-group. These memberships are already activated." +msgstr "Dit zijn de nieuwe leden in dit verenigingsjaar, per doegroep. Deze lidmaatschappen zijn al geactiveerd." + +#: amelie/members/templates/registration_status.html:18 +#, python-format +msgid "" +"\n" +" To view unactivated memberships, check the current pre-enrollment status instead.\n" +" " +msgstr "" +"\n" +" Om lidmaatschappen te bekijken die nog niet zijn geactiveerd, bekijk je de huidige voorinschrijvingsstatus.\n" +" " + +#: amelie/members/templates/registration_status.html:31 +#, python-format +msgid "%(len)s member(s)" +msgstr "%(len)s lid/leden" + +#: amelie/members/templates/registration_status.html:50 +msgid "No enrollments" +msgstr "Geen inschrijvingen" + +#: amelie/members/templates/registration_status.html:53 +msgid "There have not been any enrollments this year yet." +msgstr "Er zijn nog geen inschrijvingen geweest in dit jaar." + #: amelie/members/templates/statistics/overview.html:5 msgid "Member statistics" msgstr "Leden statistieken" @@ -10457,46 +11066,32 @@ msgstr "BIT" msgid "Members per committee" msgstr "Leden per Commissie" -#: amelie/members/templates/statistics/payments.html:5 -msgid "Payment statistics" -msgstr "Betalingen statistieken" - -#: amelie/members/templates/statistics/payments.html:11 -msgid "Payments" -msgstr "Betalingen" - -#: amelie/members/templates/statistics/payments.html:29 -#: amelie/personal_tab/statistics.py:291 -#: amelie/personal_tab/templates/wrapped/wrapped.html:129 -msgid "Totals" -msgstr "Totalen" - -#: amelie/members/views.py:254 +#: amelie/members/views.py:253 msgid "6 or more" msgstr "6 of meer" -#: amelie/members/views.py:271 +#: amelie/members/views.py:270 msgid "total" msgstr "Totaal" -#: amelie/members/views.py:348 +#: amelie/members/views.py:327 msgid "This person still has an active (unterminated) membership." msgstr "Deze persoon heeft nog een actief (niet beeindigd) lidmaatschap." -#: amelie/members/views.py:352 +#: amelie/members/views.py:331 #, python-brace-format msgid "This person has not paid one of their memberships ({year}/{next_year} {type})." msgstr "Deze persoon heeft een lidmaatschap nog niet betaald ({year}/{next_year} {type})." -#: amelie/members/views.py:361 +#: amelie/members/views.py:337 msgid "This person still has an outstanding balance on their personal tab." msgstr "Deze persoon heeft nog een openstaand streeplijstsaldo." -#: amelie/members/views.py:364 +#: amelie/members/views.py:340 msgid "This person isn't enrolled for one or more future activities." msgstr "Deze persoon staat ingeschreven voor een of meer toekomstige activiteit(en)." -#: amelie/members/views.py:379 +#: amelie/members/views.py:355 msgid "" "This person cannot be anonymized because of the following reasons:\n" "{}" @@ -10509,19 +11104,6 @@ msgstr "" msgid "OAuth link code and instructions were sent to {name}." msgstr "OAuth link code en instructies zijn verzonden naar {name}." -#: amelie/members/views.py:1675 -msgid "You are not allowed to manually delete the payments of free memberships." -msgstr "Je mag van gratis lidmaatschappen de betaling niet handmatig verwijderen." - -#: amelie/members/views.py:1678 -msgid "You are not allowed to delete payments that are already (being) debited." -msgstr "Je mag geen betalingen verwijderen die al geïncasseerd zijn/worden." - -#: amelie/members/views.py:1682 -#, python-format -msgid "Payment %(object)s has been removed" -msgstr "Betaling %(object)s is verwijderd" - #: amelie/narrowcasting/templates/index.html:8 msgid "Narrow Casting" msgstr "Narrow Casting" @@ -10840,747 +11422,946 @@ msgstr "Je hebt de volgende informatie ingevoerd" msgid "Application Name" msgstr "Applicatienaam" -#: amelie/personal_tab/debt_collection.py:99 +#: amelie/personal_tab/debt_collection.py:92 #, python-brace-format msgid "Contribution Inter-Actief {membership_year} {membership_type} {name} Questions? Email treasurer@inter-actief.net" msgstr "Contributie Inter-Actief {membership_year} {membership_type} {name} Voor vragen bel 053-489 3756" -#: amelie/personal_tab/debt_collection.py:158 +#: amelie/personal_tab/debt_collection.py:149 #, python-brace-format msgid "Direct withdrawal of contribution {date}" msgstr "Incasso contributie {date}" -#: amelie/personal_tab/debt_collection.py:159 -#, python-brace-format -msgid "Contribution {membership_type} ({begin_year}/{end_year})" -msgstr "Contributie {membership_type} ({begin_year}/{end_year})" - -#: amelie/personal_tab/debt_collection.py:222 +#: amelie/personal_tab/debt_collection.py:205 #, python-brace-format msgid "Personal tab Inter-Actief till {date} {name} Questions? Email treasurer@inter-actief.net" msgstr "Streeplijst Inter-Actief tot {date} {name} Vragen? E-mail treasurer@inter-actief.net" -#: amelie/personal_tab/debt_collection.py:287 +#: amelie/personal_tab/debt_collection.py:269 #, python-brace-format msgid "Direct withdrawal personal tab {date}" msgstr "Incasso streeplijst {date}" -#: amelie/personal_tab/debt_collection.py:315 +#: amelie/personal_tab/debt_collection.py:297 #, python-brace-format msgid "Reversal of direct withdrawal {date}" msgstr "Terugboeking incasso {date}" -#: amelie/personal_tab/debt_collection.py:327 +#: amelie/personal_tab/debt_collection.py:306 #, python-brace-format msgid "Reversal contribution {membership_type} ({start_year}/{end_year})" msgstr "Terugboeking contributie {membership_type} ({start_year}/{end_year})" -#: amelie/personal_tab/forms.py:56 +#: amelie/personal_tab/forms.py:55 msgid "Description for within Inter-Actief" msgstr "Omschrijving voor Inter-Actief" -#: amelie/personal_tab/forms.py:57 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:62 +#: amelie/personal_tab/forms.py:56 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:67 #: amelie/personal_tab/templates/debt_collection/process_batch.html:20 #: amelie/personal_tab/templates/debt_collection/view.html:60 msgid "Date of execution" msgstr "Uitvoerdatum" -#: amelie/personal_tab/forms.py:58 +#: amelie/personal_tab/forms.py:57 #: amelie/personal_tab/templates/balance/balance.html:29 #: amelie/personal_tab/templates/cookie_corner_transactions.html:39 -#: amelie/personal_tab/templates/lists/transactions_totals.html:21 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:61 +#: amelie/personal_tab/templates/lists/transactions_totals.html:22 #: amelie/statistics/templates/statistics.html:79 #: amelie/statistics/templates/statistics_year.html:78 templates/basis.html:305 msgid "Personal tab" msgstr "Streeplijst" -#: amelie/personal_tab/forms.py:59 +#: amelie/personal_tab/forms.py:58 msgid "Transactions until" msgstr "Transacties tot" -#: amelie/personal_tab/forms.py:60 +#: amelie/personal_tab/forms.py:59 #: amelie/personal_tab/templates/statistics/statistics.html:279 msgid "Membership fee" msgstr "Contributie" -#: amelie/personal_tab/forms.py:61 +#: amelie/personal_tab/forms.py:60 msgid "Membership fee years" msgstr "Contributiejaren" -#: amelie/personal_tab/forms.py:77 +#: amelie/personal_tab/forms.py:75 msgid "Date of execution is too soon" msgstr "Uitvoerdatum is te vroeg" -#: amelie/personal_tab/forms.py:79 +#: amelie/personal_tab/forms.py:77 msgid "Date of execution cannot be during the weekend" msgstr "Uitvoerdatum kan niet in het weekend vallen" -#: amelie/personal_tab/forms.py:87 +#: amelie/personal_tab/forms.py:85 msgid "Please select at least one year for the contribution debt collection." msgstr "Selecteer ten minste 1 jaar voor de incassopoging voor contributie." -#: amelie/personal_tab/forms.py:119 amelie/personal_tab/models.py:687 +#: amelie/personal_tab/forms.py:117 amelie/personal_tab/models.py:723 msgid "Short description of the reason for the amendment" msgstr "Korte omschrijving reden van amendement" +#: amelie/personal_tab/forms.py:131 +msgid "Payment date" +msgstr "Betaaldatum" + #: amelie/personal_tab/forms.py:134 +msgid "Paid amount" +msgstr "Betaald bedrag" + +#: amelie/personal_tab/forms.py:135 +msgid "The amount that was paid in this settlement. Can be different from the amount that needs to be paid, can even be negative (if money was paid out by the association). Useful if, say, the transactions add up to 9 euro, you can enter a payment of 10 euro, which leaves 1 euro extra as balance on the person's personal tab." +msgstr "Het bedrag dat is betaald in deze schikking. Kan anders zijn dan het te betalen bedrag, kan zelfs negatief zijn (als de vereniging geld heeft betaald). Handig als, zeg, de transacties totaal 9 euro zijn, kan je een betaling van 10 euro invoeren zodat er 1 euro extra saldo op de streeplijst van de persoon terecht komt." + +#: amelie/personal_tab/forms.py:166 msgid "Not signed" msgstr "Niet getekend" -#: amelie/personal_tab/forms.py:136 +#: amelie/personal_tab/forms.py:168 msgid "Ended" msgstr "Beëindigd" -#: amelie/personal_tab/forms.py:154 +#: amelie/personal_tab/forms.py:186 msgid "Sort by" msgstr "Sorteer op" -#: amelie/personal_tab/forms.py:155 +#: amelie/personal_tab/forms.py:187 msgid "Sort in reverse" msgstr "Sorteer omgekeerd" -#: amelie/personal_tab/forms.py:184 +#: amelie/personal_tab/forms.py:216 msgid "Tables:" msgstr "Tabellen:" -#: amelie/personal_tab/forms.py:192 +#: amelie/personal_tab/forms.py:224 msgid "Find person" msgstr "Persoon zoeken" -#: amelie/personal_tab/forms.py:202 +#: amelie/personal_tab/forms.py:234 msgid "-- No printers available --" msgstr "-- Geen printers beschikbaar --" -#: amelie/personal_tab/forms.py:208 +#: amelie/personal_tab/forms.py:240 msgid "PDF Document" msgstr "PDF-document" -#: amelie/personal_tab/forms.py:209 +#: amelie/personal_tab/forms.py:241 msgid "Select a PDF document to print" msgstr "Selecteer een PDF-document om te printen" -#: amelie/personal_tab/forms.py:213 +#: amelie/personal_tab/forms.py:245 msgid "Printer" msgstr "Printer" -#: amelie/personal_tab/forms.py:215 +#: amelie/personal_tab/forms.py:247 msgid "Select the printer to use for printing" msgstr "Selecteer de printer die je wilt gebruiken om te printen" -#: amelie/personal_tab/forms.py:219 +#: amelie/personal_tab/forms.py:251 msgid "-- Pay via personal tab --" msgstr "-- Betalen via streeplijst --" -#: amelie/personal_tab/forms.py:222 +#: amelie/personal_tab/forms.py:254 msgid "Select a committee for free printing, or leave blank to pay for printing" msgstr "Selecteer een commissie om gratis te printen, of laat leeg om te betalen voor het printen" -#: amelie/personal_tab/forms.py:226 +#: amelie/personal_tab/forms.py:258 msgid "Copies" msgstr "Kopieën" -#: amelie/personal_tab/forms.py:227 +#: amelie/personal_tab/forms.py:259 msgid "Number of copies to print" msgstr "Aantal kopieën om te printen" -#: amelie/personal_tab/forms.py:233 +#: amelie/personal_tab/forms.py:265 msgid "Dual-sided printing" msgstr "Dubbelzijdig printen" -#: amelie/personal_tab/forms.py:234 +#: amelie/personal_tab/forms.py:266 msgid "Print both sides of the page. Leave unchecked for single-sided printing." msgstr "Print aan beide zijden van de pagina. Laat uitgevinkt voor enkelzijdig printen." -#: amelie/personal_tab/forms.py:247 +#: amelie/personal_tab/forms.py:279 #: amelie/personal_tab/templates/print/form.html:173 msgid "Please select a PDF file." msgstr "Selecteer a.u.b. een PDF-bestand." -#: amelie/personal_tab/forms.py:251 +#: amelie/personal_tab/forms.py:283 msgid "Only PDF files are allowed." msgstr "Alleen PDF-bestanden zijn toegestaan." -#: amelie/personal_tab/forms.py:256 +#: amelie/personal_tab/forms.py:288 msgid "File size cannot exceed 50MB." msgstr "Bestand mag maximaal 50MB groot zijn." -#: amelie/personal_tab/forms.py:267 +#: amelie/personal_tab/forms.py:299 msgid "You are not a member of the committee you want to print for." msgstr "Je bent geen lid van de commissie waarvoor je probeert te printen." -#: amelie/personal_tab/forms.py:271 +#: amelie/personal_tab/forms.py:303 msgid "You do not have an active consumption mandate or are not a member anymore, so we cannot add the costs for this print to your personal tab. Contact the board for help." msgstr "Je hebt geen actieve streeplijstmachtiging of je bent geen lid meer, dus we kunnen de kosten van deze printopdracht niet van je streeplijst afschrijven. Neem contact op met het bestuur voor hulp." -#: amelie/personal_tab/forms.py:331 +#: amelie/personal_tab/forms.py:363 msgid "Printing article not found. Please contact the WWW committee." msgstr "Printartikel niet gevonden. Neem a.u.b. contact op met de WWW-commissie." -#: amelie/personal_tab/forms.py:382 +#: amelie/personal_tab/forms.py:414 msgid "My committees" msgstr "Mijn commissies" -#: amelie/personal_tab/forms.py:386 +#: amelie/personal_tab/forms.py:418 msgid "Other committees" msgstr "Andere commissies" -#: amelie/personal_tab/forms.py:403 +#: amelie/personal_tab/forms.py:441 msgid "-- No Committee --" msgstr "-- Geen commissie --" -#: amelie/personal_tab/forms.py:406 +#: amelie/personal_tab/forms.py:444 msgid "Select a committee for which this declaration is, or leave blank and specify in the description." msgstr "Selecteer een commissie waar deze declaratie voor is, of laat het leeg en leg uit waar het voor is in de omschrijving." -#: amelie/personal_tab/forms.py:411 amelie/tools/pdf.py:568 +#: amelie/personal_tab/forms.py:449 amelie/tools/pdf.py:568 msgid "Payment Method" msgstr "Betalingsmethode" -#: amelie/personal_tab/forms.py:412 +#: amelie/personal_tab/forms.py:450 msgid "Select how you paid for the expense." msgstr "Selecteer hoe je hebt betaald voor de uitgave." -#: amelie/personal_tab/forms.py:418 +#: amelie/personal_tab/forms.py:456 msgid "Select your IBAN for payment." msgstr "Selecteer je IBAN voor betaling." -#: amelie/personal_tab/forms.py:423 +#: amelie/personal_tab/forms.py:461 msgid "Fill in your IBAN if it is not listed above." msgstr "Vul je IBAN in als deze niet hierboven staat." -#: amelie/personal_tab/forms.py:429 +#: amelie/personal_tab/forms.py:467 msgid "Enter the amount of your declaration in euro's." msgstr "Vul het bedrag in van je declaratie, in euros." -#: amelie/personal_tab/forms.py:437 +#: amelie/personal_tab/forms.py:475 msgid "Enter the description for your declaration." msgstr "Vul een omschrijving in voor je declaratie." -#: amelie/personal_tab/forms.py:443 +#: amelie/personal_tab/forms.py:481 msgid "Select documents to attach to the declaration." msgstr "Selecteer documenten om toe te voegen aan de declaratie." -#: amelie/personal_tab/forms.py:459 +#: amelie/personal_tab/forms.py:485 +msgid "Inter-Actief" +msgstr "Inter-Actief" + +#: amelie/personal_tab/forms.py:487 +msgid "Bookkeeping" +msgstr "Boekhouding" + +#: amelie/personal_tab/forms.py:488 +msgid "" +"Select the bookkeeping where the declaration should be sent.\n" +"If you do not know what this means, do not change it." +msgstr "" +"Kies de boekhouding waar de declaratie naartoe gestuurd moet worden.\n" +"Als je niet weet wat dit betekent, verander het dan niet." + +#: amelie/personal_tab/forms.py:505 msgid "-- Other --" msgstr "-- Anders --" -#: amelie/personal_tab/forms.py:469 +#: amelie/personal_tab/forms.py:514 #, python-brace-format msgid "You can upload a maximum of {max_files} files." msgstr "Je kunt maximaal {max_files} bestanden uploaden." -#: amelie/personal_tab/forms.py:475 +#: amelie/personal_tab/forms.py:520 #, python-brace-format msgid "The total file size cannot exceed {max_size} MB." msgstr "De totale bestandsgrootte mag maximaal %(max_size)s MB zijn." -#: amelie/personal_tab/forms.py:491 +#: amelie/personal_tab/forms.py:536 msgid "Please select either an IBAN from the list or enter a custom IBAN, not both." msgstr "Selecteer ofwel een IBAN uit de lijst, of vul er zelf een in, niet beide." -#: amelie/personal_tab/forms.py:497 +#: amelie/personal_tab/forms.py:542 msgid "Please select an IBAN from the list or enter a custom IBAN." msgstr "Selecteer een IBAN uit de lijst, of vul zelf een IBAN in." -#: amelie/personal_tab/forms.py:502 +#: amelie/personal_tab/forms.py:547 msgid "The amount cannot be zero." msgstr "Het bedrag kan niet nul zijn." -#: amelie/personal_tab/forms.py:507 +#: amelie/personal_tab/forms.py:552 msgid "The amount must be a positive number." msgstr "Het bedrag moet positief zijn." -#: amelie/personal_tab/forms.py:512 +#: amelie/personal_tab/forms.py:557 msgid "Please provide a description for your declaration." msgstr "Vul een omschrijving in voor je declaratie." -#: amelie/personal_tab/models.py:29 amelie/personal_tab/models.py:716 +#: amelie/personal_tab/migrations/0015_personaltabsettlement_and_more.py:175 +#, python-brace-format +msgid "Settlement for transactions handled before SEPA debits were introduced - {person_name}" +msgstr "Schikking voor transacties afgehandeld voor introductie SEPA-machtigingen - {person_name}" + +#: amelie/personal_tab/models.py:34 amelie/personal_tab/models.py:752 msgid "begin" msgstr "begin" -#: amelie/personal_tab/models.py:30 amelie/personal_tab/models.py:719 +#: amelie/personal_tab/models.py:35 amelie/personal_tab/models.py:755 msgid "end" msgstr "eind" -#: amelie/personal_tab/models.py:31 amelie/personal_tab/models.py:94 -#: amelie/personal_tab/models.py:710 amelie/personal_tab/models.py:833 +#: amelie/personal_tab/models.py:36 amelie/personal_tab/models.py:99 +#: amelie/personal_tab/models.py:746 amelie/personal_tab/models.py:854 #: amelie/publications/models.py:19 msgid "description" msgstr "omschrijving" -#: amelie/personal_tab/models.py:32 +#: amelie/personal_tab/models.py:37 msgid "description (en)" msgstr "omschrijving (en)" -#: amelie/personal_tab/models.py:34 +#: amelie/personal_tab/models.py:39 msgid "articles" msgstr "artikelen" -#: amelie/personal_tab/models.py:36 +#: amelie/personal_tab/models.py:41 msgid "balance account number" msgstr "balansrekeningnummer" -#: amelie/personal_tab/models.py:52 amelie/personal_tab/models.py:60 -#: amelie/personal_tab/models.py:90 +#: amelie/personal_tab/models.py:57 amelie/personal_tab/models.py:65 +#: amelie/personal_tab/models.py:95 msgid "discount offer" msgstr "kortingsactie" -#: amelie/personal_tab/models.py:53 +#: amelie/personal_tab/models.py:58 msgid "discount offers" msgstr "kortingsacties" -#: amelie/personal_tab/models.py:57 amelie/personal_tab/models.py:92 -#: amelie/personal_tab/models.py:836 +#: amelie/personal_tab/models.py:62 amelie/personal_tab/models.py:97 +#: amelie/personal_tab/models.py:857 msgid "amount" msgstr "bedrag" -#: amelie/personal_tab/models.py:58 amelie/personal_tab/models.py:91 -#: amelie/personal_tab/models.py:674 +#: amelie/personal_tab/models.py:63 amelie/personal_tab/models.py:96 +#: amelie/personal_tab/models.py:710 msgid "date" msgstr "datum" -#: amelie/personal_tab/models.py:67 amelie/personal_tab/models.py:95 -#: amelie/personal_tab/models.py:135 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:13 +#: amelie/personal_tab/models.py:72 amelie/personal_tab/models.py:100 +#: amelie/personal_tab/models.py:140 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:14 #: amelie/personal_tab/templates/pos/success.html:37 #: amelie/personal_tab/templates/statistics/statistics.html:58 #: amelie/personal_tab/templates/statistics/statistics.html:104 -#: amelie/personal_tab/templates/statistics/statistics.html:145 msgid "discount" msgstr "korting" -#: amelie/personal_tab/models.py:68 +#: amelie/personal_tab/models.py:73 msgid "discounts" msgstr "kortingen" -#: amelie/personal_tab/models.py:97 +#: amelie/personal_tab/models.py:102 msgid "added on" msgstr "toegevoegd op" -#: amelie/personal_tab/models.py:100 +#: amelie/personal_tab/models.py:105 msgid "added by" msgstr "toegevoegd door" -#: amelie/personal_tab/models.py:104 +#: amelie/personal_tab/models.py:109 msgid "Discount balance" -msgstr "Kortingstegoeden" +msgstr "Kortingstegoed" -#: amelie/personal_tab/models.py:105 +#: amelie/personal_tab/models.py:110 msgid "discount balances" msgstr "kortingstegoeden" -#: amelie/personal_tab/models.py:137 -#: amelie/personal_tab/templates/info/transaction_information.html:15 -msgid "Direct withdrawal" -msgstr "Incasso" +#: amelie/personal_tab/models.py:142 +msgid "Settlement" +msgstr "Schikking" -#: amelie/personal_tab/models.py:147 +#: amelie/personal_tab/models.py:152 #: amelie/personal_tab/templates/print/refund.html:48 msgid "Transaction" msgstr "Transactie" -#: amelie/personal_tab/models.py:148 -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:33 +#: amelie/personal_tab/models.py:153 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:41 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:93 #: amelie/personal_tab/templates/cookie_corner_transactions.html:4 #: amelie/personal_tab/templates/cookie_corner_transactions.html:9 #: amelie/personal_tab/templates/cookie_corner_transactions.html:28 #: amelie/personal_tab/templates/cookie_corner_transactions_form.html:4 #: amelie/personal_tab/templates/cookie_corner_transactions_form.html:9 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:172 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:177 #: amelie/personal_tab/templates/statistics/statistics.html:187 #: amelie/personal_tab/templates/transactions/activity_transactions.html:4 #: amelie/personal_tab/templates/transactions/activity_transactions.html:9 msgid "Transactions" msgstr "Transacties" -#: amelie/personal_tab/models.py:192 -#: amelie/personal_tab/templates/lists/activity_transactions.html:13 +#: amelie/personal_tab/models.py:164 amelie/personal_tab/views.py:1008 +msgid "Activity Transaction" +msgstr "Activiteittransactie" + +#: amelie/personal_tab/models.py:166 amelie/personal_tab/views.py:1009 +msgid "Alexia Transaction" +msgstr "Alexiatransactie" + +#: amelie/personal_tab/models.py:168 amelie/personal_tab/views.py:1010 +msgid "Contribution Transaction" +msgstr "Contributietransactie" + +#: amelie/personal_tab/models.py:170 amelie/personal_tab/views.py:1007 +msgid "Cookie Corner Transaction" +msgstr "Cookie Corner transactie" + +#: amelie/personal_tab/models.py:172 amelie/personal_tab/views.py:1011 +msgid "Custom Transaction" +msgstr "Overige transactie" + +#: amelie/personal_tab/models.py:174 amelie/personal_tab/views.py:1015 +msgid "Debt Collection Transaction" +msgstr "Incassotransactie" + +#: amelie/personal_tab/models.py:176 amelie/personal_tab/views.py:1016 +msgid "Reversal Transaction" +msgstr "Terugboekingstransactie" + +#: amelie/personal_tab/models.py:178 amelie/personal_tab/views.py:1018 +msgid "Extra Balance Transaction" +msgstr "Extra saldotransactie" + +#: amelie/personal_tab/models.py:180 amelie/personal_tab/views.py:1017 +msgid "Manual Payment Transaction" +msgstr "Handmatige betalingstransactie" + +#: amelie/personal_tab/models.py:228 +#: amelie/personal_tab/templates/lists/activity_transactions.html:14 msgid "More sign up options" msgstr "Met inschrijfopties" -#: amelie/personal_tab/models.py:209 amelie/personal_tab/models.py:311 +#: amelie/personal_tab/models.py:245 amelie/personal_tab/models.py:347 #: amelie/personal_tab/templates/cookie_corner_price_list.html:21 #: amelie/personal_tab/templates/cookie_corner_price_list.html:64 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:11 +#: amelie/personal_tab/templates/info/transaction_information.html:88 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:12 #: amelie/personal_tab/templates/statistics/statistics.html:55 #: amelie/personal_tab/templates/statistics/statistics.html:143 -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:14 msgid "Article" msgstr "Artikel" -#: amelie/personal_tab/models.py:234 +#: amelie/personal_tab/models.py:270 msgid "Transaction id" msgstr "Transactie ID" -#: amelie/personal_tab/models.py:271 amelie/personal_tab/models.py:294 +#: amelie/personal_tab/models.py:307 amelie/personal_tab/models.py:330 #: amelie/personal_tab/templates/statistics/statistics.html:56 #: amelie/personal_tab/templates/statistics/statistics.html:102 msgid "General ledger account" msgstr "Grootboekrekening" -#: amelie/personal_tab/models.py:272 +#: amelie/personal_tab/models.py:308 msgid "General ledger accounts" msgstr "Grootboekrekeningen" -#: amelie/personal_tab/models.py:297 amelie/personal_tab/models.py:331 +#: amelie/personal_tab/models.py:333 amelie/personal_tab/models.py:367 msgid "Image" msgstr "Afbeelding" -#: amelie/personal_tab/models.py:298 +#: amelie/personal_tab/models.py:334 #: amelie/personal_tab/templates/cookie_corner_price_list.html:23 #: amelie/personal_tab/templates/cookie_corner_price_list.html:66 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:15 -#: amelie/personal_tab/templates/lists/transactions.html:13 -#: amelie/personal_tab/templates/lists/transactions_totals.html:27 +#: amelie/personal_tab/templates/info/transaction_information.html:96 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:16 +#: amelie/personal_tab/templates/lists/transactions.html:14 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:14 +#: amelie/personal_tab/templates/lists/transactions_totals.html:31 #: amelie/personal_tab/templates/pos/success.html:40 -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:22 msgid "kCal" msgstr "kCal" -#: amelie/personal_tab/models.py:312 +#: amelie/personal_tab/models.py:348 msgid "Articles" msgstr "Artikelen" -#: amelie/personal_tab/models.py:333 +#: amelie/personal_tab/models.py:369 msgid "Show the calculator in the Point of Sale instead of the products" msgstr "Laat de calculator zien in de Point of Sale in plaats van de producten" -#: amelie/personal_tab/models.py:334 +#: amelie/personal_tab/models.py:370 msgid "The first active article in the category will be used for calculations." msgstr "Het eerste actieve artikel in de categorie wordt gebruikt voor berekeningen." -#: amelie/personal_tab/models.py:367 +#: amelie/personal_tab/models.py:403 msgid "Mifare Light" msgstr "Mifare Light" -#: amelie/personal_tab/models.py:368 +#: amelie/personal_tab/models.py:404 msgid "Mifare 1K (Student card/Building card)" msgstr "Mifare 1K (Collegekaart/Gebouwpas)" -#: amelie/personal_tab/models.py:369 +#: amelie/personal_tab/models.py:405 msgid "Mifare 4K (Public transit card)" msgstr "Mifare 4K (OV Chipkaart)" -#: amelie/personal_tab/models.py:370 +#: amelie/personal_tab/models.py:406 msgid "Mifare DESFire" msgstr "Mifare DESFire" -#: amelie/personal_tab/models.py:371 +#: amelie/personal_tab/models.py:407 msgid "Mifare Ultralight (Student card/IA keychain)" msgstr "Mifare Ultralight (Collegekaart/IA-sleutelhanger)" -#: amelie/personal_tab/models.py:372 +#: amelie/personal_tab/models.py:408 msgid "JCOP30" msgstr "JCOP30" -#: amelie/personal_tab/models.py:373 +#: amelie/personal_tab/models.py:409 msgid "Shanghai Transport" msgstr "Shanghai Transport" -#: amelie/personal_tab/models.py:374 +#: amelie/personal_tab/models.py:410 msgid "MPCOS Combi" msgstr "MPCOS Combi" -#: amelie/personal_tab/models.py:375 +#: amelie/personal_tab/models.py:411 msgid "ISO Type B, Calypso" msgstr "ISO Type B, Calypso" -#: amelie/personal_tab/models.py:376 +#: amelie/personal_tab/models.py:412 msgid "ASK CTS256B, Type B" msgstr "ASK CTS256B, Type B" -#: amelie/personal_tab/models.py:377 +#: amelie/personal_tab/models.py:413 msgid "ASK CTS521B, Type B" msgstr "ASK CTS521B, Type B" -#: amelie/personal_tab/models.py:381 +#: amelie/personal_tab/models.py:417 msgid "Rfid code" msgstr "RFID-code" -#: amelie/personal_tab/models.py:383 +#: amelie/personal_tab/models.py:419 msgid "Date last used" msgstr "Datum laatst gebruikt" -#: amelie/personal_tab/models.py:384 +#: amelie/personal_tab/models.py:420 msgid "Creation date" msgstr "Aanmaakdatum" -#: amelie/personal_tab/models.py:388 +#: amelie/personal_tab/models.py:424 msgid "RFID card" msgstr "RFID-kaart" -#: amelie/personal_tab/models.py:389 +#: amelie/personal_tab/models.py:425 #: amelie/personal_tab/templates/info/rfid_cards.html:4 msgid "RFID cards" msgstr "Rfid-kaarten" -#: amelie/personal_tab/models.py:423 +#: amelie/personal_tab/models.py:459 msgid "Only alphanumerical signs are allowed" msgstr "Alleen alfanumerieke tekens toegestaan" -#: amelie/personal_tab/models.py:436 +#: amelie/personal_tab/models.py:472 msgid "text" msgstr "tekst" -#: amelie/personal_tab/models.py:439 +#: amelie/personal_tab/models.py:475 msgid "text (en)" msgstr "tekst (en)" -#: amelie/personal_tab/models.py:442 +#: amelie/personal_tab/models.py:478 msgid "active" msgstr "actief" -#: amelie/personal_tab/models.py:445 +#: amelie/personal_tab/models.py:481 msgid "membership fee" msgstr "contributie" -#: amelie/personal_tab/models.py:448 +#: amelie/personal_tab/models.py:484 msgid "consumptions" msgstr "consumpties" -#: amelie/personal_tab/models.py:451 +#: amelie/personal_tab/models.py:487 msgid "activities" msgstr "activiteiten" -#: amelie/personal_tab/models.py:454 +#: amelie/personal_tab/models.py:490 msgid "other payments" msgstr "overige betalingen" -#: amelie/personal_tab/models.py:479 +#: amelie/personal_tab/models.py:515 msgid "type of mandate" msgstr "machtigingsoort" -#: amelie/personal_tab/models.py:480 +#: amelie/personal_tab/models.py:516 msgid "types of mandate" msgstr "machtigingsoorten" -#: amelie/personal_tab/models.py:497 +#: amelie/personal_tab/models.py:533 msgid "type" msgstr "type" -#: amelie/personal_tab/models.py:509 +#: amelie/personal_tab/models.py:545 msgid "account holder" msgstr "Rekeninghouder" -#: amelie/personal_tab/models.py:513 +#: amelie/personal_tab/models.py:549 msgid "startdate" msgstr "begindatum" -#: amelie/personal_tab/models.py:516 +#: amelie/personal_tab/models.py:552 msgid "enddate" msgstr "einddatum" -#: amelie/personal_tab/models.py:519 +#: amelie/personal_tab/models.py:555 msgid "has been signed" msgstr "is getekend" -#: amelie/personal_tab/models.py:529 amelie/personal_tab/models.py:671 -#: amelie/personal_tab/models.py:827 +#: amelie/personal_tab/models.py:565 amelie/personal_tab/models.py:707 +#: amelie/personal_tab/models.py:915 msgid "mandate" msgstr "machtiging" -#: amelie/personal_tab/models.py:530 +#: amelie/personal_tab/models.py:566 msgid "mandates" msgstr "machtigingen" -#: amelie/personal_tab/models.py:677 +#: amelie/personal_tab/models.py:713 msgid "previous IBAN" msgstr "vorige IBAN" -#: amelie/personal_tab/models.py:680 +#: amelie/personal_tab/models.py:716 msgid "previous BIC" msgstr "vorige BIC" -#: amelie/personal_tab/models.py:683 +#: amelie/personal_tab/models.py:719 msgid "other bank" msgstr "andere bank" -#: amelie/personal_tab/models.py:686 +#: amelie/personal_tab/models.py:722 msgid "remark" msgstr "opmerking" -#: amelie/personal_tab/models.py:690 +#: amelie/personal_tab/models.py:726 #, python-format msgid "Amendment of %(authorization)s on %(date)s" msgstr "Amendement van %(authorization)s op %(date)s" -#: amelie/personal_tab/models.py:695 amelie/personal_tab/models.py:840 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:33 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:31 +#: amelie/personal_tab/models.py:731 amelie/personal_tab/models.py:922 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:43 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:41 msgid "amendment" msgstr "amendement" -#: amelie/personal_tab/models.py:696 +#: amelie/personal_tab/models.py:732 msgid "amendments" msgstr "amendementen" -#: amelie/personal_tab/models.py:713 +#: amelie/personal_tab/models.py:749 msgid "created on" msgstr "aangemaakt op" -#: amelie/personal_tab/models.py:742 +#: amelie/personal_tab/models.py:778 msgid "Direct withdrawal-task" msgstr "Incasso-opdracht" -#: amelie/personal_tab/models.py:743 +#: amelie/personal_tab/models.py:779 #: amelie/personal_tab/templates/debt_collection/list.html:4 #: amelie/personal_tab/templates/debt_collection/list.html:8 msgid "Direct withdrawal-tasks" msgstr "Incasso-opdrachten" -#: amelie/personal_tab/models.py:756 +#: amelie/personal_tab/models.py:792 msgid "Processed" msgstr "Verwerkt" -#: amelie/personal_tab/models.py:757 +#: amelie/personal_tab/models.py:793 msgid "Declined" msgstr "Geweigerd" -#: amelie/personal_tab/models.py:758 +#: amelie/personal_tab/models.py:794 msgid "Cancelled" msgstr "Geannuleerd" -#: amelie/personal_tab/models.py:762 +#: amelie/personal_tab/models.py:798 #: amelie/personal_tab/templates/debt_collection/new.html:49 #: amelie/personal_tab/templates/debt_collection/new.html:105 #: amelie/personal_tab/templates/debt_collection/new.html:114 msgid "First collection" msgstr "Eerste incasso" -#: amelie/personal_tab/models.py:763 +#: amelie/personal_tab/models.py:799 msgid "Continuation of direct withdrawal" msgstr "Vervolgincasso" -#: amelie/personal_tab/models.py:764 +#: amelie/personal_tab/models.py:800 msgid "Last collection" msgstr "Laatste incasso" -#: amelie/personal_tab/models.py:765 +#: amelie/personal_tab/models.py:801 msgid "One-time direct withdrawal" msgstr "Eenmalige incasso" -#: amelie/personal_tab/models.py:772 +#: amelie/personal_tab/models.py:808 msgid "assignment" msgstr "opdracht" -#: amelie/personal_tab/models.py:775 +#: amelie/personal_tab/models.py:811 msgid "date of execution" msgstr "uitvoerdatum" -#: amelie/personal_tab/models.py:778 +#: amelie/personal_tab/models.py:814 msgid "sequence type" msgstr "sequence type" -#: amelie/personal_tab/models.py:781 +#: amelie/personal_tab/models.py:817 msgid "status" msgstr "status" -#: amelie/personal_tab/models.py:804 +#: amelie/personal_tab/models.py:840 msgid "direct withdrawal-batch" msgstr "incasso-batch" -#: amelie/personal_tab/models.py:805 +#: amelie/personal_tab/models.py:841 msgid "direct withdrawal-batches" msgstr "incasso-batches" -#: amelie/personal_tab/models.py:823 +#: amelie/personal_tab/models.py:892 +msgid "personal tab settlement" +msgstr "Streeplijstschikking" + +#: amelie/personal_tab/models.py:893 +msgid "personal tab settlements" +msgstr "Streeplijstschikkingen" + +#: amelie/personal_tab/models.py:911 msgid "batch" msgstr "batch" -#: amelie/personal_tab/models.py:830 -#: amelie/personal_tab/templates/authorization/view.html:136 +#: amelie/personal_tab/models.py:918 +#: amelie/personal_tab/templates/authorization/view.html:141 #: amelie/personal_tab/templates/debt_collection/instruction_view.html:18 #: amelie/personal_tab/templates/debt_collection/process_batch.html:50 #: amelie/personal_tab/templates/debt_collection/view.html:93 msgid "end-to-end-id" msgstr "End-to-end-id" -#: amelie/personal_tab/models.py:857 +#: amelie/personal_tab/models.py:948 msgid "direct withdrawal-instruction" msgstr "incasso-instructie" -#: amelie/personal_tab/models.py:858 -msgid "direct withdrawal-instructions" -msgstr "incasso-instructies" +#: amelie/personal_tab/models.py:949 +msgid "direct withdrawal-instructions" +msgstr "incasso-instructies" + +#: amelie/personal_tab/models.py:956 +msgid "Name (NL)" +msgstr "Naam (NL)" + +#: amelie/personal_tab/models.py:957 +msgid "Name (EN)" +msgstr "Naam (EN)" + +#: amelie/personal_tab/models.py:958 +msgid "Description (NL)" +msgstr "Omschrijving (NL)" + +#: amelie/personal_tab/models.py:959 +msgid "Description (EN)" +msgstr "Omschrijving (EN)" + +#: amelie/personal_tab/models.py:960 +msgid "This payment method will be visible when creating a manual payment directly in someone's personal tab." +msgstr "Deze betaalmethode zal zichtbaar zijn tijdens het maken van een handmatige betaling direct in iemand's streeplijst." + +#: amelie/personal_tab/models.py:961 +msgid "Visible for memberships" +msgstr "Zichtbaar voor lidmaatschappen" + +#: amelie/personal_tab/models.py:961 +msgid "This payment method will be visible when privileged members enter a manual payment for a membership." +msgstr "Deze betaalmethode zal zichtbaar zijn als bevoegde leden een handmatige betaling voor een lidmaatschap invoeren." + +#: amelie/personal_tab/models.py:962 +msgid "Visible for activities" +msgstr "Zichtbaar voor activiteiten" + +#: amelie/personal_tab/models.py:962 +msgid "This payment method will be visible when privileged members enter a manual payment for an activity." +msgstr "Deze betaalmethode zal zichtbaar zijn als bevoegde leden een handmatige betaling voor een activiteit invoeren." + +#: amelie/personal_tab/models.py:963 +msgid "Icon name for frontend" +msgstr "Naam van icoon voor de frontend" + +#: amelie/personal_tab/models.py:967 +msgid "payment method" +msgstr "betaalmethode" + +#: amelie/personal_tab/models.py:968 +msgid "payment methods" +msgstr "betaalmethodes" + +#: amelie/personal_tab/models.py:995 +msgid "Frontend Icon" +msgstr "Icoon in frontend" + +#: amelie/personal_tab/models.py:1037 +msgid "payment date" +msgstr "betaaldatum" + +#: amelie/personal_tab/models.py:1046 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:58 +msgid "Created by" +msgstr "Aangemaakt door" + +#: amelie/personal_tab/models.py:1057 +msgid "manual payment settlement" +msgstr "schikking voor handmatige betaling" + +#: amelie/personal_tab/models.py:1058 +msgid "manual payment settlements" +msgstr "schikkingen voor handmatige betaling" + +#: amelie/personal_tab/models.py:1092 +msgid "Manual settlements can only be deleted in the same association year as they were paid in." +msgstr "Handmatige schikkingen kunnen alleen worden verwijderd in hetzelfde verenigingsjaar waarin ze zijn betaald." + +#: amelie/personal_tab/models.py:1107 +msgid "The extra balance that was created in this settlement was already used elsewhere." +msgstr "Het extra saldo dat in deze schikking is aangemaakt is al ergens anders voor gebruikt." + +#: amelie/personal_tab/models.py:1112 +#, python-brace-format +msgid "The total sum of the transactions ({transactions_sum}) does not inversely equal the linked manual payment's amount ({manual_amount})." +msgstr "De totaalsom van de transacties ({transactions_sum}) is niet omgekeerd hetzelfde als het bedrag van de gekoppelde handmatige betaling ({manual_amount})." + +#: amelie/personal_tab/models.py:1117 +#, python-brace-format +msgid "The settlement's amount ({settlement_amount}) does not inversely equal the manual payment's amount and the extra balance ({manual_amount} + {extra_balance} = {total})." +msgstr "Het bedrag van de schikking ({settlement_amount}) is niet omgekeerd hetzelfde als het bedrag van de handmatige betaling en het extra saldo ({manual_amount} + {extra_balance} = {total})." + +#: amelie/personal_tab/models.py:1170 +#, python-brace-format +msgid "Deposit to personal tab balance on {date} for {name}" +msgstr "Bijschrijving op streeplijstsaldo op {date} voor {name}" + +#: amelie/personal_tab/models.py:1174 +#, python-brace-format +msgid "Refund of personal tab balance on {date} for {name}" +msgstr "Terugbetaling van streeplijstsaldo op {date} voor {name}" + +#: amelie/personal_tab/models.py:1178 +#, python-brace-format +msgid "Leftover balance after settlement on {date} for {name}" +msgstr "Overgebleven saldo na schikking op {date} voor {name}" -#: amelie/personal_tab/models.py:903 +#: amelie/personal_tab/models.py:1268 msgid "instruction" msgstr "instructie" -#: amelie/personal_tab/models.py:906 +#: amelie/personal_tab/models.py:1271 msgid "Processing date" msgstr "Verwerkingsdatum" -#: amelie/personal_tab/models.py:907 +#: amelie/personal_tab/models.py:1272 msgid "Fill out processing date, not rent date. See Rabobank Internet Banking." msgstr "Vul hier de verwerkingsdatum in, niet de rentedatum. Zie Rabobank Internetbankieren." -#: amelie/personal_tab/models.py:910 +#: amelie/personal_tab/models.py:1275 msgid "pre-settlement" msgstr "pre-settlement" -#: amelie/personal_tab/models.py:911 +#: amelie/personal_tab/models.py:1276 msgid "A debit reversal is pre-settlement when the withdrawal happens before the crediting of the direct withdrawal has taken place." msgstr "Een terugboeking is pre-settlement als de afboeking gebeurt voordat de bijschrijving van de incasso plaatsvindt." -#: amelie/personal_tab/models.py:915 +#: amelie/personal_tab/models.py:1280 msgid "reason" msgstr "reden" -#: amelie/personal_tab/models.py:918 +#: amelie/personal_tab/models.py:1283 #, python-format msgid "Debit reversal %s" msgstr "Terugboeking %s" -#: amelie/personal_tab/models.py:922 amelie/personal_tab/models.py:946 +#: amelie/personal_tab/models.py:1291 amelie/personal_tab/models.py:1315 msgid "debit reversal" msgstr "terugboeking" -#: amelie/personal_tab/models.py:923 +#: amelie/personal_tab/models.py:1292 msgid "reversal" msgstr "terugboeking" -#: amelie/personal_tab/models.py:962 +#: amelie/personal_tab/models.py:1330 +msgid "Date added" +msgstr "Datum toegevoegd" + +#: amelie/personal_tab/models.py:1331 +msgid "First reversal" +msgstr "Eerste terugboeking" + +#: amelie/personal_tab/models.py:1332 +msgid "Last reversal" +msgstr "Laatste terugboeking" + +#: amelie/personal_tab/models.py:1360 +msgid "known bad BIC" +msgstr "bekende foute BIC" + +#: amelie/personal_tab/models.py:1361 +msgid "known bad BICs" +msgstr "bekende foute BICs" + +#: amelie/personal_tab/models.py:1374 #: amelie/personal_tab/templates/print/log_list.html:30 #: amelie/personal_tab/templates/print/refund.html:26 msgid "Actor" msgstr "Acteur" -#: amelie/personal_tab/models.py:974 +#: amelie/personal_tab/models.py:1386 msgid "Document name" msgstr "Documentnaam" -#: amelie/personal_tab/models.py:977 +#: amelie/personal_tab/models.py:1389 msgid "Printer Job ID" msgstr "Printer Opdracht-ID" -#: amelie/personal_tab/models.py:980 +#: amelie/personal_tab/models.py:1392 msgid "Page count" msgstr "Aantal pagina's" -#: amelie/personal_tab/models.py:993 +#: amelie/personal_tab/models.py:1405 msgid "print log entry" msgstr "printlogboekvermelding" -#: amelie/personal_tab/models.py:994 +#: amelie/personal_tab/models.py:1406 msgid "print log entries" msgstr "printlogboekvermeldingen" -#: amelie/personal_tab/models.py:997 +#: amelie/personal_tab/models.py:1409 #, python-format msgid "%(actor)s printed %(pages)s page(s) %(for_str)s" msgstr "%(actor)s heeft %(pages)s pagina(s) geprint %(for_str)s" -#: amelie/personal_tab/models.py:1001 +#: amelie/personal_tab/models.py:1413 #, python-format msgid "for %(committee)s" msgstr "voor %(committee)s" -#: amelie/personal_tab/models.py:1003 +#: amelie/personal_tab/models.py:1415 msgid "for personal use" msgstr "voor persoonlijk gebruik" +#: amelie/personal_tab/models.py:1456 +msgid "bookkeeping" +msgstr "boekhouding" + #: amelie/personal_tab/pos_models.py:21 msgid "Login token" msgstr "Logintoken" @@ -11595,6 +12376,8 @@ msgstr "Uitsluiten toegankelijk voor de streeplijst." #: amelie/personal_tab/pos_views.py:83 amelie/personal_tab/pos_views.py:593 #: amelie/personal_tab/pos_views.py:604 +#: amelie/personal_tab/register_views.py:60 +#: amelie/personal_tab/register_views.py:66 msgid "Something went wrong, please try again later." msgstr "Er ging iets fout, probeer het later opnieuw." @@ -11603,10 +12386,12 @@ msgid "No cards were scanned, please try again." msgstr "Er zijn geen kaarten gescand, probeer het opnieuw." #: amelie/personal_tab/pos_views.py:108 amelie/personal_tab/pos_views.py:227 +#: amelie/personal_tab/register_views.py:85 msgid "You do not have a valid mandate or are not a member anymore. Contact the board for help." msgstr "Je hebt geen geldige machtiging of bent geen lid meer. Neem contact op met het bestuur voor hulp." #: amelie/personal_tab/pos_views.py:111 +#: amelie/personal_tab/register_views.py:88 msgid "RFID-cards from multiple people recognised. Please scan one card at a time." msgstr "RFID-kaarten van meerdere personen herkend. Scan alsjeblieft maar 1 kaart tegelijkertijd." @@ -11623,26 +12408,32 @@ msgid "Invalid token type requested!" msgstr "Ongeldig token type opgevraagd!" #: amelie/personal_tab/pos_views.py:174 +#: amelie/personal_tab/register_views.py:119 msgid "No card was scanned, please try again." msgstr "Er is geen kaart gescand, probeer het opnieuw." #: amelie/personal_tab/pos_views.py:181 +#: amelie/personal_tab/register_views.py:126 msgid "Something went wrong while scanning the RFID card. Please try again." msgstr "Er ging iets mis bij het scannen van de RFID-kaart. Probeer het opnieuw." #: amelie/personal_tab/pos_views.py:188 amelie/personal_tab/pos_views.py:617 +#: amelie/personal_tab/register_views.py:133 msgid "This card is already registered." msgstr "Deze kaart is al geregistreerd." #: amelie/personal_tab/pos_views.py:190 +#: amelie/personal_tab/register_views.py:135 msgid "This card has already been registered, but has not been activated on the website. Please contact the board." msgstr "Deze kaart is al geregistreerd, maar nog niet geactiveerd via de website. Neem contact op met het bestuur." #: amelie/personal_tab/pos_views.py:210 +#: amelie/personal_tab/register_views.py:155 msgid "This user is inactive. Please contact the board." msgstr "Deze gebruiker is inactief. Neem contact op met het bestuur." -#: amelie/personal_tab/pos_views.py:214 amelie/personal_tab/register.py:33 +#: amelie/personal_tab/pos_views.py:214 +#: amelie/personal_tab/register_views.py:159 msgid "You are (not yet/ no longer) a member of Inter-Actief. Ask the board for a membership form." msgstr "Je bent (nog) geen lid (meer) van Inter-Actief. Vraag het bestuur om een lidmaatschapsformulier." @@ -11651,14 +12442,18 @@ msgid "Card successfully registered. Please scan the card again to log in." msgstr "Kaart succesvol geregistreerd. Scan de kaart opnieuw om in te loggen." #: amelie/personal_tab/pos_views.py:265 +#: amelie/personal_tab/register_views.py:191 msgid "Invalid login token, please try again!" msgstr "Ongeldig login token, probeer het opnieuw!" #: amelie/personal_tab/pos_views.py:278 amelie/personal_tab/pos_views.py:281 +#: amelie/personal_tab/register_views.py:204 +#: amelie/personal_tab/register_views.py:207 msgid "Invalid token, please try again!" msgstr "Ongeldig token, probeer het opnieuw!" #: amelie/personal_tab/pos_views.py:285 +#: amelie/personal_tab/register_views.py:211 msgid "This token was already used before, please try again!" msgstr "Dit token is al een keer eerder gebruikt, probeer het opnieuw!" @@ -11667,6 +12462,7 @@ msgid "Cookie Corner login verified successfully!" msgstr "Koekenkast login succesvol geverifiëerd!" #: amelie/personal_tab/pos_views.py:293 +#: amelie/personal_tab/register_views.py:217 msgid "New card registration verified successfully!" msgstr "Nieuwe kaartregistratie succesvol geverifiëerd!" @@ -11675,11 +12471,13 @@ msgid "Unknown token verified successfully!" msgstr "Onbekend token succesvol geverifiëerd!" #: amelie/personal_tab/pos_views.py:299 +#: amelie/personal_tab/register_views.py:221 msgid "An unknown error occurred, please try again!" msgstr "Er is een onbekende fout opgetreden, probeer het opnieuw!" #: amelie/personal_tab/pos_views.py:311 -msgid "The login code is invalid or expired, please try again!." +#: amelie/personal_tab/register_views.py:233 +msgid "The login code is invalid or expired, please try again!" msgstr "Deze logincode is ongeldig of verlopen, probeer het opnieuw!" #: amelie/personal_tab/pos_views.py:354 @@ -11746,90 +12544,86 @@ msgstr "Kaart succesvol geregistreerd. Je bent uitgelogd." msgid "Multiple unknown RFID-cards detected. Please scan one card at a time. You have been logged out." msgstr "Meerdere onbekende RFID-kaarten gescand. Scan alsjeblieft 1 kaart tegelijkertijd. Je bent uitgelogd." -#: amelie/personal_tab/print_views.py:75 +#: amelie/personal_tab/print_views.py:77 #, python-brace-format msgid "Document '{name}' with {pages} page(s) was queued for printing successfully." msgstr "Document '{name}' met {pages} pagina(s) is succesvol in de printwachtrij gezet." -#: amelie/personal_tab/print_views.py:81 +#: amelie/personal_tab/print_views.py:83 #, python-brace-format msgid "Error while submitting print to the printer. No prints were registered. Error: {ex}" msgstr "Fout tijdens het versturen van de printopdracht naar de printer. Er zijn geen prints geregistreerd. Error: {ex}" -#: amelie/personal_tab/print_views.py:101 +#: amelie/personal_tab/print_views.py:103 #, python-brace-format msgid "Refund for print, transaction #{tid}" msgstr "Terugbetaling voor print, transactie #{tid}" -#: amelie/personal_tab/print_views.py:106 +#: amelie/personal_tab/print_views.py:128 #, python-brace-format msgid "Print by {person} was refunded successfully." msgstr "Print van {person} is succesvol terugbetaald." -#: amelie/personal_tab/register.py:22 -msgid "Username and password are obligatory" -msgstr "Gebruikersnaam en wachtwoord zijn verplicht." - -#: amelie/personal_tab/register.py:29 -msgid "Username and/or password incorrect." -msgstr "Gebruikersnaam en/of wachtwoord is onjuist." - -#: amelie/personal_tab/register.py:76 -msgid "No cards are scanned." -msgstr "Er zijn geen kaarten gescand." - -#: amelie/personal_tab/register.py:85 -msgid "This RFID card is already registered." -msgstr "Deze RFID-kaart is al geregistreerd." +#: amelie/personal_tab/register_views.py:81 +msgid "This card has already been registered." +msgstr "Deze kaart is al geregistreerd." -#: amelie/personal_tab/register.py:91 -msgid "Card is registered." -msgstr "Kaart is geregistreerd." +#: amelie/personal_tab/register_views.py:165 +msgid "Card successfully registered." +msgstr "Kaart succesvol geregistreerd." -#: amelie/personal_tab/statistics.py:276 +#: amelie/personal_tab/statistics.py:280 msgid "{} ledger statistics" msgstr "{} grootboekstatistieken" -#: amelie/personal_tab/statistics.py:283 +#: amelie/personal_tab/statistics.py:287 #: amelie/personal_tab/templates/statistics/statistics.html:48 #: amelie/personal_tab/templates/statistics/statistics_form.html:4 #: amelie/personal_tab/templates/statistics/statistics_form.html:9 msgid "Personal tab statistics" msgstr "Streeplijststatistieken" -#: amelie/personal_tab/statistics.py:284 +#: amelie/personal_tab/statistics.py:288 #: amelie/personal_tab/templates/balance/balance_form.html:4 #: amelie/personal_tab/templates/balance/balance_form.html:9 #: amelie/personal_tab/templates/statistics/statistics.html:96 msgid "Personal tab balance" -msgstr "Streeplijst totalen" +msgstr "Streeplijstsaldo" -#: amelie/personal_tab/statistics.py:286 +#: amelie/personal_tab/statistics.py:290 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:81 #: amelie/personal_tab/templates/statistics/statistics.html:231 msgid "Alexia transactions" msgstr "Alexiatransacties" -#: amelie/personal_tab/statistics.py:287 +#: amelie/personal_tab/statistics.py:291 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:78 msgid "Contribution transactions" msgstr "Contributietransacties" -#: amelie/personal_tab/statistics.py:288 -#: amelie/personal_tab/templates/statistics/statistics.html:403 +#: amelie/personal_tab/statistics.py:292 +#: amelie/personal_tab/templates/statistics/statistics.html:401 msgid "Discount offers" msgstr "Kortingsacties" -#: amelie/personal_tab/statistics.py:289 -#: amelie/personal_tab/templates/statistics/statistics.html:369 +#: amelie/personal_tab/statistics.py:293 +#: amelie/personal_tab/templates/statistics/statistics.html:367 msgid "Discount balances" msgstr "Kortingstegoeden" -#: amelie/personal_tab/statistics.py:290 +#: amelie/personal_tab/statistics.py:294 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:82 msgid "Custom transactions" msgstr "Overige transacties" +#: amelie/personal_tab/statistics.py:295 +#: amelie/personal_tab/templates/wrapped/wrapped.html:129 +msgid "Totals" +msgstr "Totalen" + #: amelie/personal_tab/templates/authorization/amendment.html:4 #: amelie/personal_tab/templates/authorization/amendment.html:12 -#: amelie/personal_tab/templates/authorization/view.html:113 +#: amelie/personal_tab/templates/authorization/view.html:118 msgid "Add amendment" msgstr "Amendement invoeren" @@ -11914,9 +12708,39 @@ msgstr "Machtigingen zoeken" msgid "Person/account holder" msgstr "Persoon/rekeninghouder" -#: amelie/personal_tab/templates/authorization/list.html:103 +#: amelie/personal_tab/templates/authorization/list.html:92 +#: amelie/personal_tab/templates/authorization/view.html:28 +#: amelie/personal_tab/templates/balance/balance.html:48 +#: amelie/personal_tab/templates/balance/balance.html:81 +#: amelie/personal_tab/templates/balance/balance.html:119 +#: amelie/personal_tab/templates/balance/balance.html:152 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:26 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:58 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:30 +#: amelie/personal_tab/templates/debt_collection/process_batch.html:69 +#: amelie/personal_tab/templates/debt_collection/view.html:112 +#: amelie/personal_tab/templates/info/transaction_information.html:43 +#: amelie/personal_tab/templates/lists/activity_transactions.html:30 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:28 +#: amelie/personal_tab/templates/lists/authorization_select.html:39 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:28 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:31 +#: amelie/personal_tab/templates/lists/custom_transactions.html:28 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:30 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:29 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:28 +#: amelie/personal_tab/templates/lists/payment_transactions.html:31 +#: amelie/personal_tab/templates/lists/transaction_row.html:15 +#: amelie/personal_tab/templates/print/log_list.html:56 +#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:29 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:49 +#, python-format +msgid "Go to %(name)s's personal tab" +msgstr "Ga naar %(name)'s streeplijst." + +#: amelie/personal_tab/templates/authorization/list.html:108 #: amelie/personal_tab/templates/info/account_information.html:40 -#: amelie/personal_tab/templates/lists/authorization_select.html:44 +#: amelie/personal_tab/templates/lists/authorization_select.html:49 msgid "(not yet) active" msgstr "(nog) niet actief" @@ -11939,68 +12763,68 @@ msgstr "Machtigingen beëindigen" msgid "Mandate reference" msgstr "Machtigingskenmerk" -#: amelie/personal_tab/templates/authorization/view.html:54 +#: amelie/personal_tab/templates/authorization/view.html:59 msgid "print" msgstr "afdrukken" -#: amelie/personal_tab/templates/authorization/view.html:59 +#: amelie/personal_tab/templates/authorization/view.html:64 msgid "Amendments" msgstr "Amendementen" -#: amelie/personal_tab/templates/authorization/view.html:67 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:126 +#: amelie/personal_tab/templates/authorization/view.html:72 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:131 msgid "Previous IBAN" msgstr "Vorige IBAN" -#: amelie/personal_tab/templates/authorization/view.html:68 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:130 +#: amelie/personal_tab/templates/authorization/view.html:73 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:135 msgid "Previous BIC" msgstr "Vorige BIC" -#: amelie/personal_tab/templates/authorization/view.html:69 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:134 +#: amelie/personal_tab/templates/authorization/view.html:74 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:139 msgid "Other bank" msgstr "Andere bank" -#: amelie/personal_tab/templates/authorization/view.html:71 +#: amelie/personal_tab/templates/authorization/view.html:76 msgid "Instruction" msgstr "Instructie" -#: amelie/personal_tab/templates/authorization/view.html:110 +#: amelie/personal_tab/templates/authorization/view.html:115 msgid "No amendments have been found." msgstr "Er zijn geen amendementen gevonden." -#: amelie/personal_tab/templates/authorization/view.html:116 +#: amelie/personal_tab/templates/authorization/view.html:121 msgid "Current amendment has not yet been sent to the bank, so a new one cannot be added." msgstr "Het huidige amendement is nog niet naar de bank verstuurd, dus er kan nog geen nieuwe toegevoegd worden." -#: amelie/personal_tab/templates/authorization/view.html:118 +#: amelie/personal_tab/templates/authorization/view.html:123 msgid "This mandate is not signed, so a new amendment cannot be added." msgstr "Deze machtiging is niet ondertekend, dus er kan geen amendement aan worden toegevoegd." -#: amelie/personal_tab/templates/authorization/view.html:120 +#: amelie/personal_tab/templates/authorization/view.html:125 msgid "This mandate is terminated, so a new amendment cannot be added." msgstr "Deze machtiging is beëindigd, dus er kan geen amendenemt aan worden toegevoegd." -#: amelie/personal_tab/templates/authorization/view.html:127 +#: amelie/personal_tab/templates/authorization/view.html:132 msgid "Instructions" msgstr "Instructies" -#: amelie/personal_tab/templates/authorization/view.html:135 +#: amelie/personal_tab/templates/authorization/view.html:140 msgid "Task/batch" msgstr "Opdracht/batch" -#: amelie/personal_tab/templates/authorization/view.html:138 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:75 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:117 +#: amelie/personal_tab/templates/authorization/view.html:143 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:80 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:122 #: amelie/personal_tab/templates/debt_collection/process_batch.html:51 #: amelie/personal_tab/templates/debt_collection/view.html:94 msgid "Amendment" msgstr "Amendement" -#: amelie/personal_tab/templates/authorization/view.html:172 -#: amelie/personal_tab/templates/debt_collection/process_batch.html:95 -#: amelie/personal_tab/templates/debt_collection/view.html:138 +#: amelie/personal_tab/templates/authorization/view.html:177 +#: amelie/personal_tab/templates/debt_collection/process_batch.html:100 +#: amelie/personal_tab/templates/debt_collection/view.html:143 msgid "No instructions have been found." msgstr "Er zijn geen instructies gevonden." @@ -12022,21 +12846,22 @@ msgid "Total costs" msgstr "Totaal bedrag" #: amelie/personal_tab/templates/balance/balance.html:20 -#: amelie/personal_tab/templates/balance/balance.html:82 +#: amelie/personal_tab/templates/balance/balance.html:100 msgid "Exam credits" msgstr "Tentamenkoekentegoed" #: amelie/personal_tab/templates/balance/balance.html:36 -#: amelie/personal_tab/templates/balance/balance.html:89 +#: amelie/personal_tab/templates/balance/balance.html:107 msgid "Former Member" msgstr "Voormalige leden" #: amelie/personal_tab/templates/balance/balance.html:37 -#: amelie/personal_tab/templates/balance/balance.html:61 -#: amelie/personal_tab/templates/balance/balance.html:90 -#: amelie/personal_tab/templates/balance/balance.html:116 +#: amelie/personal_tab/templates/balance/balance.html:70 +#: amelie/personal_tab/templates/balance/balance.html:108 +#: amelie/personal_tab/templates/balance/balance.html:141 #: amelie/personal_tab/templates/balance/balance_form.html:20 #: amelie/personal_tab/templates/cookie_corner_overview.html:20 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:50 msgid "Balance" msgstr "Saldo" @@ -12078,12 +12903,28 @@ msgstr "Streeplijst van %(person)s" #: amelie/personal_tab/templates/cookie_corner_dashboard.html:13 #, python-format -msgid "Your current balance is € %(curr_balance)s. Including future transactions that will be € %(all_balance)s." +msgid "" +"\n" +" Your current balance is € %(curr_balance)s. Including future transactions that will be € %(all_balance)s.\n" +" " +msgstr "" +"\n" +" Je huidige streeplijstsaldo is € %(curr_balance)s. Met toekomstige transacties meegerekend is dat € %(all_balance)s.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:17 +msgid "" +"\n" +" If you signed a direct debit mandate, the balance is usually settled by withdrawing it from your bank account periodically.\n" +" If you do not have a mandate, you can settle any outstanding balance with a manual payment. To arrange such a payment, please contact the board.\n" +" " msgstr "" -"Je huidige streeplijstsaldo is € %(curr_balance)s,\n" -" met toekomstige transacties meegerekend is dat € %(all_balance)s." +"\n" +" Als je een incassomachtiging hebt getekend, dan wordt het saldo normaliter verrekend door deze periodiek te incasseren van je bankrekening. \n" +"Als je geen machtiging hebt getekend, dan kan je het openstaande saldo handmatig betalen. Om een betaling te doen neem je contact op met het bestuur.\n" +" " -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:16 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:24 #, python-format msgid "" "\n" @@ -12093,7 +12934,7 @@ msgstr "" "\n" "Er wordt op %(date)s € %(amount)s geincasseerd van je rekening." -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:23 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:31 #, python-format msgid "" "\n" @@ -12104,13 +12945,13 @@ msgstr "" " Omdat je tentamens hebt ingeleverd, mag je nog voor € %(credit)s aan consumpties kopen.\n" " " -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:38 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:46 #: amelie/personal_tab/templates/cookie_corner_transactions.html:37 #: amelie/personal_tab/templates/exam_cookie_credit/overview.html:26 msgid "No transactions have been found" msgstr "Er zijn geen transacties gevonden." -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:41 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:49 #, python-format msgid "" "\n" @@ -12120,35 +12961,116 @@ msgstr "" "\n" "Laatste %(amount)s toegevoegde transacties." -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:49 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:57 #: amelie/personal_tab/templates/exam_cookie_credit/overview.html:45 msgid "Previous transactions" msgstr "Eerdere transacties" -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:52 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:60 +#: amelie/personal_tab/templates/cookie_corner_overview.html:30 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:54 +msgid "Unpaid transactions" +msgstr "Onbetaalde transacties" + +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:63 msgid "History of exam credit" msgstr "Geschiedenis tentamentegoed" -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:55 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:66 #: amelie/personal_tab/templates/wrapped/wrapped.html:4 #: amelie/personal_tab/templates/wrapped/wrapped_no_transactions.html:4 msgid "Cookie Corner Wrapped" msgstr "Cookie Corner Wrapped" -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:63 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:72 +msgid "Board tasks:" +msgstr "Bestuurstaken:" + +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:74 msgid "Add transaction to personal tab" msgstr "Streeplijsttransactie toevoegen" -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:66 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:77 msgid "Add other transaction" msgstr "Overige transactie toevoegen" -#: amelie/personal_tab/templates/cookie_corner_dashboard.html:69 +#: amelie/personal_tab/templates/cookie_corner_dashboard.html:80 #: amelie/personal_tab/templates/exam_cookie_credit/add.html:5 #: amelie/personal_tab/templates/exam_cookie_credit/overview.html:54 msgid "Add credit for exams" msgstr "Tentamenkoekentegoed toevoegen" +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:4 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:8 +msgid "Manual Payment Settlement" +msgstr "Schikking voor Handmatige Betaling" + +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:14 +msgid "Reference of manual payment" +msgstr "Referentie van handmatige betaling" + +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:37 +msgid "Date of payment" +msgstr "Betaaldatum" + +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:41 +msgid "Method of payment" +msgstr "Betaalwijze" + +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:79 +#: amelie/personal_tab/templates/cookie_corner_manual_payment_settlement_view.html:84 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html:4 +msgid "Delete manual payment" +msgstr "Verwijder handmatige betaling" + +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:4 +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:8 +#, python-format +msgid "Manual payments - Page %(page)s/%(total)s" +msgstr "Handmatige betalingen - Pagina %(page)s/%(total)s" + +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:12 +msgid "" +"\n" +" These are payments that people made manually directly to the association, for example, by cash or by PIN.\n" +" A manual payment can pay for transactions that were made against their personal tab, or it can add\n" +" a negative amount to their personal tab to create a credit they can use for activities.\n" +" " +msgstr "" +"\n" +" Dit zijn betalingen die mensen handmatig hebben gedaan direct aan de vereniging, bijvoorbeeld contant of met PIN. \n" +"Een handmatige betaling kan betalen voor transacties die tegen de streeplijst van een persoon zijn gedaan, of het kan \n" +"een negatief bedrag toevoegen aan de streeplijst om zo een krediet te maken die gebruikt kan worden voor activiteiten.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:19 +msgid "" +"\n" +" Click on a payment description to view details about which transactions the manual payment paid for,\n" +" and if there was any extra money deposited.\n" +" " +msgstr "" +"\n" +" Klik op een betalingsbeschrijving om details te bekijken over voor welke transacties de handmatige betaling heeft betaald, en of er extra geld is bijgeschreven.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:27 +msgid "" +"\n" +" To create a manual payment, go to the person's personal tab overview\n" +" and click on the \"Add manual payment\" button in the relevant block.\n" +" " +msgstr "" +"\n" +" Om een handmatige betaling aan te maken, ga je naar het streeplijstoverzicht van de persoon \n" +"en klik je op de knop \"Handmatige betaling toevoegen\" in het relevante blok.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_manual_payments_list.html:78 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:42 +msgid "No manual payments have been found." +msgstr "Er zijn geen handmatige betalingen gevonden." + #: amelie/personal_tab/templates/cookie_corner_overview.html:13 msgid "Personal overview" msgstr "Persoonlijk overzicht" @@ -12174,9 +13096,126 @@ msgid "Direct withdrawal-tasks (SEPA)" msgstr "Incasso-opdrachten (SEPA)" #: amelie/personal_tab/templates/cookie_corner_overview.html:28 +#: amelie/personal_tab/templates/info/payments.html:59 +msgid "Manual payments" +msgstr "Handmatige betalingen" + +#: amelie/personal_tab/templates/cookie_corner_overview.html:29 msgid "Unpaid memberships" msgstr "Onbetaalde lidmaatschappen" +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html:8 +#, python-format +msgid "Delete manual payment '%(object)s'" +msgstr "Verwijder handmatige betaling '%(object)s'" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_confirm_delete.html:11 +#: amelie/personal_tab/templates/transactions/transaction_delete.html:11 +#, python-format +msgid "" +"\n" +" Are you sure you want to delete '%(object)s' for %(person)s?\n" +" " +msgstr "" +"\n" +"Weet je zeker dat je '%(object)s' van %(person)s wilt verwijderen?" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:4 +#, python-format +msgid "Create manual payment for %(person)s" +msgstr "Handmatige betaling toevoegen voor %(person)s" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:9 +#, python-format +msgid "New manual payment for %(person)s" +msgstr "Nieuwe handmatige betaling voor %(person)s" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:14 +#: amelie/personal_tab/templates/transactions/transaction_form.html:20 +msgid "You are now adding a transaction to a personal tab" +msgstr "Je voegt nu een streeplijsttransactie toe" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:16 +#: amelie/personal_tab/templates/transactions/transaction_form.html:22 +msgid "You are now adding a transaction in the category \"Other\"" +msgstr "Je voegt nu een overige transactie toe" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:32 +msgid "Total of selected" +msgstr "Totaal van geselecteerd" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:33 +msgid "Amount being paid" +msgstr "Bedrag dat wordt betaald" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:34 +msgid "Personal tab balance before payment" +msgstr "Streeplijstsaldo voor betaling" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:35 +msgid "Personal tab balance after payment" +msgstr "Streeplijstsaldo na betaling" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:38 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:40 +msgid "Today" +msgstr "Vandaag" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:39 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:41 +msgid "Including future" +msgstr "Inclusief toekomstig" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:56 +msgid "" +"\n" +" Please select the transactions that will be marked as paid with this settlement.\n" +" " +msgstr "" +"\n" +" Selecteer a.u.b. de transacties die als betaald zullen worden gemarkeerd met deze schikking.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:59 +msgid "" +"\n" +" Rows marked in green are transactions that have a date in the future.\n" +" " +msgstr "" +"\n" +" Rijen gemarkeerd met groen zijn transacties met een datum in de toekomst.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:66 +msgid "There were errors while submitting the form." +msgstr "Er zijn fouten gevonden tijdens het opsturen van het formulier." + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:76 +msgid "For more errors, see below." +msgstr "Zie hieronder voor meer fouten." + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:79 +msgid "See below for the errors." +msgstr "Zie de fouten hieronder" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:87 +msgid "Include?" +msgstr "Meenemen?" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:90 +msgid "#ID" +msgstr "#ID" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payment_create.html:121 +msgid "No unpaid transactions found!" +msgstr "Geen onbetaalde transacties gevonden!" + +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:4 +#: amelie/personal_tab/templates/cookie_corner_person_manual_payments.html:8 +#, python-format +msgid "Manual payments for %(person)s" +msgstr "Handmatige betalingen voor %(person)s" + #: amelie/personal_tab/templates/cookie_corner_price_list.html:22 #: amelie/personal_tab/templates/cookie_corner_price_list.html:65 msgid "Price (€)" @@ -12191,40 +13230,249 @@ msgstr "kCal/€" msgid "Inactive articles" msgstr "Inactieve artikelen" -#: amelie/personal_tab/templates/cookie_corner_price_list.html:82 -msgid "never" -msgstr "nooit" +#: amelie/personal_tab/templates/cookie_corner_price_list.html:82 +msgid "never" +msgstr "nooit" + +#: amelie/personal_tab/templates/cookie_corner_price_list.html:83 +#, python-format +msgid "" +"\n" +" (last used: %(last_used_date)s)\n" +" " +msgstr "" +"\n" +" (laatst gebruikt: %(last_used_date)s)\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_transactions.html:21 +#: amelie/personal_tab/templates/cookie_corner_transactions_form.html:21 +#: amelie/personal_tab/templates/exam_cookie_credit/overview.html:21 +msgid "Show overview" +msgstr "Overzicht tonen" + +#: amelie/personal_tab/templates/cookie_corner_transactions.html:31 +#, python-format +msgid "from %(date_from)s till %(date_to)s" +msgstr "van %(date_from)s tot %(date_to)s" + +#: amelie/personal_tab/templates/cookie_corner_transactions.html:45 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:67 +#: amelie/personal_tab/templates/lists/transactions_totals.html:24 +msgid "Alexia" +msgstr "Alexia" + +#: amelie/personal_tab/templates/cookie_corner_transactions.html:48 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:70 +#: amelie/personal_tab/templates/lists/transactions_totals.html:25 +msgid "Contribution" +msgstr "Contributie" + +#: amelie/personal_tab/templates/cookie_corner_transactions.html:54 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:45 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:76 +#: amelie/personal_tab/templates/info/payments.html:4 +#: amelie/personal_tab/templates/lists/transactions_totals.html:29 +msgid "Payments" +msgstr "Betalingen" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:5 +msgid "Unpaid Transactions" +msgstr "Onbetaalde transacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:9 +#, python-format +msgid "" +"\n" +" Unpaid Transactions for %(name)s\n" +" " +msgstr "" +"\n" +" Onbetaalde Transacties voor %(name)s\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:15 +#, python-format +msgid "" +"\n" +" Your current balance is € %(curr_balance)s. Including future transactions that will be € %(all_balance)s.\n" +" " +msgstr "" +"\n" +" Je huidige streeplijstsaldo is € %(curr_balance)s. Met toekomstige transacties meegerekend is dat € %(all_balance)s.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:18 +msgid "Back to overview" +msgstr "Terug naar overzicht" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:26 +#: amelie/personal_tab/templates/info/transaction_information.html:3 +msgid "Transaction type" +msgstr "Transactiesoort" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:39 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:30 +#: amelie/personal_tab/templates/pos/success.html:39 +msgid "Subtotal" +msgstr "Subtotaal" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:57 +msgid "Transactions per type" +msgstr "Transacties per soort" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions.html:59 +msgid "No unpaid transactions have been found" +msgstr "Er zijn geen onbetaalde transacties gevonden" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:6 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:10 +#, python-format +msgid "Unpaid transactions - Page %(page)s/%(total)s" +msgstr "Onbetaalde transacties - Pagina %(page)s/%(total)s" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:12 +msgid "These are people with transactions that have not been paid for." +msgstr "Dit zijn mensen met transacties die nog niet betaald zijn." + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:16 +#, python-format +msgid "" +"\n" +" Only people without a mandate of type '%(filtered_type)s' are shown.\n" +" " +msgstr "" +"\n" +" Alleen mensen zonder een machtiging van soort '%(filtered_type)s' worden getoond.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:20 +msgid "Only people without a mandate are shown." +msgstr "Alleen mensen zonder machtiging worden getoond." + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:26 +msgid "" +"\n" +" Only people with a mandate of type 'filtered_type' are shown.\n" +" " +msgstr "" +"\n" +" Alleen mensen met een machtiging van soort 'filtered_type' worden getoond.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:30 +msgid "Only people with a mandate are shown." +msgstr "Alleen mensen met een machtiging worden getoond." + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:35 +#, python-format +msgid "" +"\n" +" Only transactions of type '%(filter_transactions)s' are shown.\n" +" " +msgstr "" +"\n" +" Alleen transacties van soort '%(filter_transactions)s' worden getoond.\n" +" " + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:40 +msgid "Each person can be expanded to view details about the individual transactions." +msgstr "Elke persoon kan worden uitgeklapt om details over de individuele transacties te bekijken." + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:44 +msgid "Total number of people" +msgstr "Totaal aantal personen" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:48 +msgid "Total number of transactions" +msgstr "Totaal aantal transacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:52 +msgid "Total sum of unpaid costs" +msgstr "Totaalbedrag onbetaalde kosten" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:57 +msgid "Filter options" +msgstr "Filteropties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:60 +msgid "Only people with:" +msgstr "Alleen mensen met:" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:62 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:70 +msgid "Contribution mandate" +msgstr "Contributiemachtiging" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:63 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:71 +msgid "Consumption mandate" +msgstr "Consumptiemachtiging" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:64 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:72 +msgid "Any mandate" +msgstr "Elk soort machtiging" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:68 +msgid "Only people without:" +msgstr "Alleen mensen zonder:" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:76 +msgid "Only transactions of type:" +msgstr "Alleen transacties van soort:" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:79 +msgid "Activity transactions" +msgstr "Activiteittransacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:80 +msgid "Cookie corner transactions" +msgstr "Cookie corner transacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:83 +msgid "Reversal transactions" +msgstr "Terugboekingstransacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:88 +msgid "Clear filters" +msgstr "Filters weghalen" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:91 +msgid "Toggle expand/collapse all" +msgstr "Alles uit-/samenvouwen" -#: amelie/personal_tab/templates/cookie_corner_price_list.html:83 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:103 #, python-format msgid "" "\n" -" (last used: %(last_used_date)s)\n" -" " +" %(name)s - (%(num_transactions)s) - € %(total_cost)s\n" +" " msgstr "" "\n" -" (laatst gebruikt: %(last_used_date)s)\n" -" " - -#: amelie/personal_tab/templates/cookie_corner_transactions.html:21 -#: amelie/personal_tab/templates/cookie_corner_transactions_form.html:21 -#: amelie/personal_tab/templates/exam_cookie_credit/overview.html:21 -msgid "Show overview" -msgstr "Overzicht tonen" +" %(name)s - (%(num_transactions)s) - € %(total_cost)s\n" +" " -#: amelie/personal_tab/templates/cookie_corner_transactions.html:31 +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:112 #, python-format -msgid "from %(date_from)s till %(date_to)s" -msgstr "van %(date_from)s tot %(date_to)s" +msgid "Go to %(name)s's personal tab (opens new tab)" +msgstr "Ga naar %(name)s's streeplijst (opent nieuw tabblad)" -#: amelie/personal_tab/templates/cookie_corner_transactions.html:45 -#: amelie/personal_tab/templates/lists/transactions_totals.html:23 -msgid "Alexia" -msgstr "Alexia" +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:115 +msgid "Create settlement (opens new tab)" +msgstr "Schikking toevoegen (opent nieuw tabblad)" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:155 +msgid "No unpaid transactions" +msgstr "Geen onbetaalde transacties" + +#: amelie/personal_tab/templates/cookie_corner_unpaid_transactions_list.html:157 +msgid "There are no people with unpaid transactions! Woah! 🎉🎉🎉" +msgstr "Er zijn geen mensen met onbetaalde transacties! Wow! 🎉🎉🎉" #: amelie/personal_tab/templates/debt_collection/instruction_reversal.html:4 #: amelie/personal_tab/templates/debt_collection/instruction_reversal.html:8 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:96 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:101 msgid "Insert reversal" msgstr "Terugboeking invoeren" @@ -12240,7 +13488,7 @@ msgstr "" #: amelie/personal_tab/templates/debt_collection/instruction_reversal_delete.html:4 #: amelie/personal_tab/templates/debt_collection/instruction_reversal_delete.html:9 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:107 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:112 msgid "Delete reversal" msgstr "Terugboeking verwijderen" @@ -12264,31 +13512,31 @@ msgstr "Incasso-instructie" msgid "Reference of direct withdrawal" msgstr "Incassoreferentie" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:49 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:54 #: amelie/personal_tab/templates/debt_collection/process_batch.html:4 #: amelie/personal_tab/templates/debt_collection/view.html:4 #: amelie/personal_tab/templates/debt_collection/view.html:8 msgid "direct withdrawal-task" msgstr "incasso-opdracht" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:57 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:62 msgid "Direct withdrawal-batch" msgstr "Incasso-batch" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:80 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:85 msgid "no" msgstr "nee" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:86 -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:149 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:91 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:154 msgid "Debit reversal" msgstr "Terugboeking" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:102 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:107 msgid "Edit reversal" msgstr "Terugboeking wijzigen" -#: amelie/personal_tab/templates/debt_collection/instruction_view.html:158 +#: amelie/personal_tab/templates/debt_collection/instruction_view.html:163 msgid "Pre-settlement" msgstr "Pre-settlement" @@ -12466,17 +13714,18 @@ msgstr "Totaal gestorneerd" msgid "Export direct withdrawal-file" msgstr "Incassobestand exporteren" -#: amelie/personal_tab/templates/debt_collection/view.html:144 +#: amelie/personal_tab/templates/debt_collection/view.html:149 msgid "No batches" msgstr "Geen batches" -#: amelie/personal_tab/templates/debt_collection/view.html:146 +#: amelie/personal_tab/templates/debt_collection/view.html:151 msgid "No batches have been found." msgstr "Er zijn geen batches gevonden." #: amelie/personal_tab/templates/declaration/declaration.mail:3 -msgid "Declaration submitted via Amélie" -msgstr "Declaratie verstuurd via Amélie" +#, python-format +msgid "Declaration submitted via Amélie by %(name)s on %(date)s" +msgstr "Declaratie verstuurd via Amélie door %(name)s op %(date)s" #: amelie/personal_tab/templates/declaration/declaration.mail:24 msgid " I.C.T.S.V. Inter-Actief" @@ -12596,24 +13845,24 @@ msgstr "De bestandsnaam van de/het opgestuurde bestand(en);" msgid "Submission timestamp;" msgstr "Tijdstempel van indiening;" -#: amelie/personal_tab/templates/declaration/declaration_form.html:139 +#: amelie/personal_tab/templates/declaration/declaration_form.html:151 #: templates/basis.html:314 msgid "Submit declaration" msgstr "Declaratie versturen" -#: amelie/personal_tab/templates/declaration/declaration_form.html:173 +#: amelie/personal_tab/templates/declaration/declaration_form.html:185 msgid "You can upload a maximum of" msgstr "Upload maximaal" -#: amelie/personal_tab/templates/declaration/declaration_form.html:173 +#: amelie/personal_tab/templates/declaration/declaration_form.html:185 msgid "files" msgstr "bestanden" -#: amelie/personal_tab/templates/declaration/declaration_form.html:185 +#: amelie/personal_tab/templates/declaration/declaration_form.html:197 msgid "Total file size cannot exceed" msgstr "Totale bestandsgrootte moet kleiner zijn dan" -#: amelie/personal_tab/templates/declaration/declaration_form.html:205 +#: amelie/personal_tab/templates/declaration/declaration_form.html:217 #: amelie/personal_tab/templates/print/form.html:197 msgid "Selected:" msgstr "Geselecteerd:" @@ -12630,7 +13879,7 @@ msgstr "" #: amelie/personal_tab/templates/exam_cookie_credit/overview.html:30 msgid "Balance transaction" -msgstr "Tegoedtransacties" +msgstr "Saldotransactie" #: amelie/personal_tab/templates/exam_cookie_credit/overview.html:33 msgid "Usage transactions" @@ -12654,27 +13903,65 @@ msgstr "Voor het wijzigen of beëindigen van je machtiging kun je contact opneme msgid "You have not yet signed a direct debit mandate. You can ask the board for one." msgstr "Je hebt nog geen machtiging ingevuld. Je kunt het bestuur om een machtiging vragen." -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:4 +#: amelie/personal_tab/templates/info/payments.html:9 msgid "Direct debits" msgstr "Incasso's" -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:9 -#, python-format +#: amelie/personal_tab/templates/info/payments.html:11 msgid "" "\n" -" Last %(amount)s executed or planned direct debits.\n" -" " -msgstr "" +" Your most recently executed or planned direct debit.\n" +" " +msgid_plural "" +"\n" +" Your most recently executed or planned direct debits.\n" +" " +msgstr[0] "" +"\n" +" Je meest recent uitgevoerde of geplande incasso.\n" +" " +msgstr[1] "" "\n" -"Laatste %(amount)s uitgevoerde of geplande incasso's." +" Je meest recent uitgevoerde of geplande incasso's.\n" +" " + +#: amelie/personal_tab/templates/info/payments.html:42 +msgid "There are no direct debits executed or planned yet." +msgstr "Er zijn nog geen incasso's geweest of gepland." -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:41 +#: amelie/personal_tab/templates/info/payments.html:52 msgid "View all direct debits." msgstr "Alle incasso's bekijken" -#: amelie/personal_tab/templates/info/debt_collection_instructions.html:45 -msgid "There are no direct debits executed or planned yet." -msgstr "Er zijn nog geen incasso's geweest of gepland." +#: amelie/personal_tab/templates/info/payments.html:62 +msgid "Add manual payment" +msgstr "Handmatige betaling toevoegen" + +#: amelie/personal_tab/templates/info/payments.html:67 +msgid "" +"\n" +" Your most recent manual payment.\n" +" " +msgid_plural "" +"\n" +" Your most recent manual payments.\n" +" " +msgstr[0] "" +"\n" +" Je meest recente handmatige betaling.\n" +" " +msgstr[1] "" +"\n" +" Je meest recente handmatige betalingen.\n" +" " + +#: amelie/personal_tab/templates/info/payments.html:100 +msgid "There are no manual payments yet." +msgstr "Er zijn nog geen handmatige betalingen." + +#: amelie/personal_tab/templates/info/payments.html:110 +msgid "View all manual payments." +msgstr "Alle handmatige betalingen laten zien." #: amelie/personal_tab/templates/info/rfid_cards.html:13 msgid "Type of card" @@ -12692,97 +13979,215 @@ msgstr "Voor September 2019" msgid "Deactivate" msgstr "Deactiveren" -#: amelie/personal_tab/templates/info/transaction_information.html:29 -#: amelie/personal_tab/templates/statistics/statistics.html:375 -#: amelie/personal_tab/templates/statistics/statistics.html:410 +#: amelie/personal_tab/templates/info/transaction_information.html:26 +#: amelie/personal_tab/templates/statistics/statistics.html:373 +#: amelie/personal_tab/templates/statistics/statistics.html:408 msgid "Discount offer" msgstr "Kortingsactie" -#: amelie/personal_tab/templates/info/transaction_information.html:33 +#: amelie/personal_tab/templates/info/transaction_information.html:30 msgid "Discount amount" msgstr "Kortingsbedrag" -#: amelie/personal_tab/templates/lists/activity_transactions.html:9 -msgid "Added" -msgstr "Toegevoegd" - -#: amelie/personal_tab/templates/lists/activity_transactions.html:41 +#: amelie/personal_tab/templates/info/transaction_information.html:71 +#: amelie/personal_tab/templates/lists/activity_transactions.html:49 #: amelie/personal_tab/templates/statistics/statistics.html:205 -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:23 msgid "Deleted event" msgstr "Verwijderd event" -#: amelie/personal_tab/templates/lists/activity_transactions.html:64 +#: amelie/personal_tab/templates/info/transaction_information.html:83 +msgid "Transaction number Alexia" +msgstr "Transactienummer Alexia" + +#: amelie/personal_tab/templates/info/transaction_information.html:108 +msgid "Reversed collection" +msgstr "Teruggeboekte incasso" + +#: amelie/personal_tab/templates/info/transaction_information.html:122 +msgid "Originated in" +msgstr "Ontstaan in" + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:3 +msgid "Show settlement details (opens in new tab)" +msgstr "Schikkingsdetails laten zien (opent nieuw tabblad)" + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:9 +#, python-format +msgid "" +"\n" +" Direct debit on %(date)s\n" +" " +msgstr "" +"\n" +" Incasso op %(date)s\n" +" " + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:17 +#, python-format +msgid "" +"\n" +" Reversal on %(date)s\n" +" " +msgstr "" +"\n" +" Terugboeking op %(date)s\n" +" " + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:24 +#, python-format +msgid "" +"\n" +" Manual payment on %(date)s\n" +" " +msgstr "" +"\n" +" Handmatige betaling op %(date)s\n" +" " + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:35 +msgid "UNKNOWN payment!" +msgstr "ONBEKENDE betaling!" + +#: amelie/personal_tab/templates/info/transaction_settlement_info.html:40 +msgid "Unpaid!" +msgstr "Niet betaald!" + +#: amelie/personal_tab/templates/lists/activity_transactions.html:10 +msgid "Added" +msgstr "Toegevoegd" + +#: amelie/personal_tab/templates/lists/activity_transactions.html:72 msgid "No activity transactions." msgstr "Geen activiteittransacties." -#: amelie/personal_tab/templates/lists/alexia_transactions.html:9 -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:9 -#: amelie/personal_tab/templates/lists/custom_transactions.html:9 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:9 -#: amelie/personal_tab/templates/lists/transactions.html:9 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:10 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:10 +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:10 +#: amelie/personal_tab/templates/lists/custom_transactions.html:10 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:10 +#: amelie/personal_tab/templates/lists/payment_transactions.html:11 +#: amelie/personal_tab/templates/lists/transactions.html:10 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:10 #: amelie/personal_tab/templates/statistics/statistics.html:185 #: amelie/personal_tab/templates/statistics/statistics.html:320 msgid "Time" msgstr "Tijd" -#: amelie/personal_tab/templates/lists/alexia_transactions.html:50 +#: amelie/personal_tab/templates/lists/alexia_transactions.html:58 msgid "No Alexia transactions." msgstr "Geen Alexia-transacties." -#: amelie/personal_tab/templates/lists/authorization_select.html:55 +#: amelie/personal_tab/templates/lists/authorization_select.html:60 msgid "No mandates have been found." msgstr "Er zijn geen machtigingen gevonden." -#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:57 +#: amelie/personal_tab/templates/lists/contribution_transactions.html:58 +msgid "No contribution transactions." +msgstr "Geen contributietransacties." + +#: amelie/personal_tab/templates/lists/cookie_corner_transactions.html:65 msgid "No transactions on personal tab." msgstr "Geen streeplijsttransacties." -#: amelie/personal_tab/templates/lists/custom_transactions.html:50 +#: amelie/personal_tab/templates/lists/custom_transactions.html:58 msgid "No other transactions." msgstr "Geen overige transacties." -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:8 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:8 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:9 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:9 msgid "Collect" msgstr "Incasseren" -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:33 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:31 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:43 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:41 msgctxt "amendment abbr" msgid "A " msgstr "A " -#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:60 -#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:57 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_contribution.html:70 +#: amelie/personal_tab/templates/lists/debt_collection_proposal_cookie_corner.html:69 msgid "There are no collections in this category." msgstr "Er zijn geen incasso's in deze categorie." -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:10 -#: amelie/personal_tab/templates/lists/transactions_totals.html:10 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:11 +#: amelie/personal_tab/templates/lists/transactions_totals.html:11 msgid "Month" msgstr "Maand" -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:14 -#: amelie/personal_tab/templates/lists/transactions_totals.html:14 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:15 +#: amelie/personal_tab/templates/lists/transactions_totals.html:15 msgid "Day" msgstr "Dag" -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:22 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:23 msgid "Credit" msgstr "Tegoed" -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:23 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:24 msgid "Usage" msgstr "Verbruik" -#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:59 -#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:46 -#: amelie/personal_tab/templates/lists/transactions.html:53 -#: amelie/personal_tab/templates/lists/transactions_totals.html:67 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_totals.html:60 +#: amelie/personal_tab/templates/lists/exam_cookie_credit_transactions.html:54 +#: amelie/personal_tab/templates/lists/transactions.html:33 +#: amelie/personal_tab/templates/lists/transactions_categorized.html:48 +#: amelie/personal_tab/templates/lists/transactions_totals.html:79 msgid "No transactions" msgstr "Geen transacties" +#: amelie/personal_tab/templates/lists/payment_transactions.html:44 +msgid "Direct debit" +msgstr "Incasso" + +#: amelie/personal_tab/templates/lists/payment_transactions.html:46 +msgid "Reversal of direct debit" +msgstr "Terugboeking incasso" + +#: amelie/personal_tab/templates/lists/payment_transactions.html:78 +msgid "No payments." +msgstr "Geen betalingen." + +#: amelie/personal_tab/templates/lists/transactions_totals.html:51 +msgid "This date is in the future" +msgstr "Deze datum is in de toekomst" + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:4 +msgid "[Inter-Actief] Reminder of open balance on your personal tab" +msgstr "[Inter-Actief] Herinnering openstaand streeplijstsaldo" + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:8 +msgid "This e-mail serves as a reminder that you have an open balance on your personal tab that needs to be paid." +msgstr "Deze e-mail dient als herinnering dat je nog een open saldo hebt op je streeplijst, die nog betaald moet worden." + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:10 +msgid "Because you do not have an active, signed mandate for consumptions, we cannot collect this money from your bank automatically, so you will need to pay manually. Please visit the association room or contact the board to pay the balance by cash, bank transfer or PIN." +msgstr "Omdat je geen actieve, ondertekende machtiging hebt voor consumpties, kunnen we niet automatisch geld van je bankrenening incasseren, en zul je handmatig moeten betalen. Kom een keer langs in de verenigingskamer of neem contact op met het bestuur om je saldo contant, via bankoverschrijving of met PIN te betalen." + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:12 +#, python-format +msgid "Your open balance is %(costs)s euro. This consists of %(num_unpaid)s transactions." +msgstr "Je openstaande saldo is %(costs)s euro. Dit bestaat uit %(num_unpaid)s transacties." + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:14 +#, python-format +msgid "To view more details about the balance and the unpaid transactions, you can view your unpaid transaction overview." +msgstr "Om meer details te zien over je saldo en de onbetaalde transacties, kan je je eigen onbetaalde transactieoverzicht bekijken." + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:15 +#, python-format +msgid "To view more details about the balance and the unpaid transactions, you can view your unpaid transaction overview at %(link)s" +msgstr "Om meer details te zien over je saldo en de onbetaalde transacties, kan je je eigen onbetaalde transactieoverzicht bekijken op %(link)s" + +#: amelie/personal_tab/templates/personal_tab/balance_reminder.mail:19 +msgid "" +"Kind regards,\n" +"\n" +"The treasurer of Inter-Actief" +msgstr "" +"Met vriendelijke groet,\n" +"\n" +"De penningmeester van Inter-Actief" + #: amelie/personal_tab/templates/pos/base.html:6 msgid "Inter-/Actief/ Cookie Corner Point of Sale" msgstr "Inter-/Actief/ Cookie Corner Point of Sale" @@ -12799,7 +14204,7 @@ msgstr "Of tik hier om in te loggen" #: amelie/personal_tab/templates/pos/register_external_card.html:19 #: amelie/personal_tab/templates/pos/scan_external_card.html:13 #: amelie/personal_tab/templates/pos/shop.html:9 -#: amelie/personal_tab/templates/pos/show_qr.html:13 +#: amelie/personal_tab/templates/pos/show_qr.html:19 #: amelie/personal_tab/templates/pos/success.html:19 #: amelie/personal_tab/templates/pos/verify.html:8 msgid "THIS COOKIE CORNER IS RUNNING IN BETA MODE. PLEASE DO NOT BUY ANYTHING." @@ -12858,7 +14263,8 @@ msgstr "Enschede" #: amelie/personal_tab/templates/pos/register_external_card.html:16 #: amelie/personal_tab/templates/pos/scan_external_card.html:10 -#: amelie/personal_tab/templates/pos/show_qr.html:10 +#: amelie/personal_tab/templates/pos/show_qr.html:11 +#: amelie/personal_tab/templates/pos/show_qr.html:15 #: amelie/personal_tab/templates/pos/success.html:16 msgid "Return to home" msgstr "Terug naar hoofdpagina" @@ -12935,30 +14341,29 @@ msgstr "Winkelmandje" msgid "And {amount} more articles..." msgstr "En nog {amount} andere artikelen..." -#: amelie/personal_tab/templates/pos/show_qr.html:19 -#: amelie/personal_tab/templates/register/register_card_index.html:28 -#: templates/basis.html:324 templates/login.html:4 templates/login.html:47 +#: amelie/personal_tab/templates/pos/show_qr.html:25 templates/basis.html:324 +#: templates/login.html:4 templates/login.html:47 msgid "Log in" msgstr "Inloggen" -#: amelie/personal_tab/templates/pos/show_qr.html:21 +#: amelie/personal_tab/templates/pos/show_qr.html:27 msgid "Register new RFID card" msgstr "Registreer nieuwe RFID-kaart" -#: amelie/personal_tab/templates/pos/show_qr.html:23 +#: amelie/personal_tab/templates/pos/show_qr.html:29 #: amelie/personal_tab/templates/pos/verify.html:13 msgid "Error" msgstr "Fout" -#: amelie/personal_tab/templates/pos/show_qr.html:28 +#: amelie/personal_tab/templates/pos/show_qr.html:34 msgid "Scan the QR code below to log in." msgstr "Scan de QR code hieronder om in te loggen." -#: amelie/personal_tab/templates/pos/show_qr.html:33 +#: amelie/personal_tab/templates/pos/show_qr.html:39 msgid "Scan the QR code below to register your new card." msgstr "Scan de QR code hieronder om je kaart te registreren." -#: amelie/personal_tab/templates/pos/show_qr.html:38 +#: amelie/personal_tab/templates/pos/show_qr.html:44 msgid "Something went wrong. Please go back to the main page." msgstr "Er ging iets fout. Ga alsjebleift terug naar de hoofdpagina." @@ -12967,7 +14372,7 @@ msgid "Success!" msgstr "Succes!" #: amelie/personal_tab/templates/pos/success.html:33 -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:30 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:26 msgid "Product" msgstr "Product" @@ -12975,10 +14380,6 @@ msgstr "Product" msgid "Price per piece" msgstr "Stukprijs" -#: amelie/personal_tab/templates/pos/success.html:39 -msgid "Subtotal" -msgstr "Subtotaal" - #: amelie/personal_tab/templates/pos/success.html:58 msgid "Total:" msgstr "Totaal:" @@ -13179,60 +14580,60 @@ msgstr "" " %(time)s geleden\n" " " -#: amelie/personal_tab/templates/print/log_list.html:58 +#: amelie/personal_tab/templates/print/log_list.html:63 #, python-format msgid "Job %(job_id)s" msgstr "Opdracht %(job_id)s" -#: amelie/personal_tab/templates/print/log_list.html:61 +#: amelie/personal_tab/templates/print/log_list.html:66 msgid "No job id!" msgstr "Geen opdracht-id!" -#: amelie/personal_tab/templates/print/log_list.html:77 +#: amelie/personal_tab/templates/print/log_list.html:82 #: amelie/personal_tab/templates/print/refund.html:50 msgid "View transaction" msgstr "Transactie bekijken" -#: amelie/personal_tab/templates/print/log_list.html:81 +#: amelie/personal_tab/templates/print/log_list.html:86 #: amelie/personal_tab/templates/print/refund.html:54 #, python-format msgid "(€ %(price)s)" msgstr "(€ %(price)s)" -#: amelie/personal_tab/templates/print/log_list.html:84 +#: amelie/personal_tab/templates/print/log_list.html:89 msgid "Refund this print job" msgstr "Betaal deze printopdracht terug" -#: amelie/personal_tab/templates/print/log_list.html:86 +#: amelie/personal_tab/templates/print/log_list.html:91 msgid "Refund" msgstr "Terugbetalen" -#: amelie/personal_tab/templates/print/log_list.html:102 +#: amelie/personal_tab/templates/print/log_list.html:107 #: amelie/personal_tab/templates/print/refund.html:74 msgid "Chrome" msgstr "Chrome" -#: amelie/personal_tab/templates/print/log_list.html:104 +#: amelie/personal_tab/templates/print/log_list.html:109 #: amelie/personal_tab/templates/print/refund.html:76 msgid "Firefox" msgstr "Firefox" -#: amelie/personal_tab/templates/print/log_list.html:106 +#: amelie/personal_tab/templates/print/log_list.html:111 #: amelie/personal_tab/templates/print/refund.html:78 msgid "Safari" msgstr "Safari" -#: amelie/personal_tab/templates/print/log_list.html:108 +#: amelie/personal_tab/templates/print/log_list.html:113 #: amelie/personal_tab/templates/print/refund.html:80 msgid "Edge" msgstr "Edge" -#: amelie/personal_tab/templates/print/log_list.html:110 +#: amelie/personal_tab/templates/print/log_list.html:115 #: amelie/personal_tab/templates/print/refund.html:82 msgid "Browser" msgstr "Browser" -#: amelie/personal_tab/templates/print/log_list.html:124 +#: amelie/personal_tab/templates/print/log_list.html:129 msgid "No print log entries found." msgstr "Geen printlogboekvermeldingen gevonden." @@ -13263,70 +14664,48 @@ msgstr "" msgid "Document Name" msgstr "Documentnaam" -#: amelie/personal_tab/templates/register/register_card_error.html:4 -#: amelie/personal_tab/templates/register/register_card_error.html:8 -msgid "Register an RFID card - Error" -msgstr "Registreer een RFID-kaart - Error" - -#: amelie/personal_tab/templates/register/register_card_error.html:10 -msgid "Something went wrong while registering your RFID card" -msgstr "Er ging iets fout bij het registreren van je RFID-kaart" - #: amelie/personal_tab/templates/register/register_card_index.html:4 -#: amelie/personal_tab/templates/register/register_card_index.html:8 -#: amelie/personal_tab/templates/register/register_card_scan.html:4 -#: amelie/personal_tab/templates/register/register_card_scan.html:44 +#: amelie/personal_tab/templates/register/register_card_index.html:51 msgid "Register an RFID card" msgstr "Registreer een RFID-kaart" -#: amelie/personal_tab/templates/register/register_card_index.html:10 -msgid "Log in to register a card" -msgstr "Log in om een kaart te registreren" - -#: amelie/personal_tab/templates/register/register_card_index.html:12 -msgid "You can log on with your student account (for example: s0123456) or your Inter-Actief account." -msgstr "Je kunt inloggen met je studentaccount (bijvoorbeeld s0123456) of je Inter-Actief-account." - -#: amelie/personal_tab/templates/register/register_card_index.html:23 -msgid "Password" -msgstr "Wachtwoord" - -#: amelie/personal_tab/templates/register/register_card_scan.html:11 +#: amelie/personal_tab/templates/register/register_card_index.html:11 msgid "Please scan your card now..." msgstr "Scan een kaart om te registreren..." -#: amelie/personal_tab/templates/register/register_card_scan.html:15 +#: amelie/personal_tab/templates/register/register_card_index.html:15 msgid "Connection with NFC-reader failed." msgstr "Verbinden met NFC-reader mislukt." -#: amelie/personal_tab/templates/register/register_card_scan.html:26 +#: amelie/personal_tab/templates/register/register_card_index.html:25 msgid "Scanned card, please wait..." msgstr "Kaart gescand, een moment geduld aub..." -#: amelie/personal_tab/templates/register/register_card_scan.html:30 +#: amelie/personal_tab/templates/register/register_card_index.html:29 msgid "Card type is unknown. Please use normal RFID cards like your college card or public transport card." msgstr "Kaarttype is onbekend. Probeer een normale RFID kaart te scannen zoals je collegekaart of OV-chipkaart." -#: amelie/personal_tab/templates/register/register_card_scan.html:32 +#: amelie/personal_tab/templates/register/register_card_index.html:31 msgid "Error, could not scan card." msgstr "Error, kon kaart niet scannen." -#: amelie/personal_tab/templates/register/register_card_scan.html:46 +#: amelie/personal_tab/templates/register/register_card_index.html:41 +msgid "Received card, please wait..." +msgstr "Kaart ontvangen, een moment geduld aub..." + +#: amelie/personal_tab/templates/register/register_card_index.html:53 msgid "Connecting to card reader..." msgstr "Verbinden met kaartlezer..." -#: amelie/personal_tab/templates/register/register_card_success.html:4 -#: amelie/personal_tab/templates/register/register_card_success.html:13 -msgid "Register an RFID card - Success" -msgstr "Registreer een RFID-kaart - Succes" - -#: amelie/personal_tab/templates/register/register_card_success.html:17 -msgid "You will be brought back to the registration screen in 5 seconds..." -msgstr "Je wordt over 5 seconden teruggebracht naar het registratiescherm..." - -#: amelie/personal_tab/templates/register/register_card_success.html:19 -msgid "Go back now" -msgstr "Ga nu terug" +#: amelie/personal_tab/templates/register/register_card_index.html:60 +msgid "" +"\n" +" By registering your card, you can use it to make purchases in the Cookie Corner or at our drinks.\n" +" " +msgstr "" +"\n" +" Door je kaart te registreren kan je deze gebruikem om aankopen te doen in de Cookie Corner of op onze borrels.\n" +" " #: amelie/personal_tab/templates/statistics/statistics.html:23 msgid "End date (till)" @@ -13340,7 +14719,7 @@ msgstr "Inschrijvingen" msgid "Drink" msgstr "Borrel" -#: amelie/personal_tab/templates/statistics/statistics.html:455 +#: amelie/personal_tab/templates/statistics/statistics.html:453 msgid "There are statistics to be shown." msgstr "Er zijn statistieken om te laten zien." @@ -13358,60 +14737,46 @@ msgstr "Er zijn statistieken om te laten zien." msgid "Transaction information" msgstr "Transactiegegevens" -#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:46 +#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:26 msgid "Question" msgstr "Vraag" -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:14 -msgid "Transaction number Alexia" -msgstr "Transactienummer Alexia" +#: amelie/personal_tab/templates/transactions/activity_transaction_detail.html:27 +msgid "Response" +msgstr "Antwoord" -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:22 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:18 msgid "Purchases" msgstr "Aankopen" -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:44 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:40 msgid "No purchases." msgstr "Geen aankopen." -#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:51 +#: amelie/personal_tab/templates/transactions/alexia_transaction_detail.html:47 msgid "It was not possible to retrieve the transaction details." msgstr "Het was niet mogelijk de transactiedetails op te halen." -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:28 +#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:16 #: amelie/personal_tab/templates/transactions/transaction_detail.html:16 msgid "Edit transaction" msgstr "Transactie wijzigen" -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:29 +#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:17 #: amelie/personal_tab/templates/transactions/transaction_detail.html:17 msgid "Delete transaction" msgstr "Verwijder transactie" -#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:31 +#: amelie/personal_tab/templates/transactions/cookie_corner_transaction_detail.html:19 #: amelie/personal_tab/templates/transactions/transaction_detail.html:19 msgid "This transaction is not editable as it has already been debited." msgstr "De transactie is niet aan te passen omdat deze al geïncasseerd is." -#: amelie/personal_tab/templates/transactions/reversal_transaction_detail.html:14 -msgid "Reversed collection" -msgstr "Teruggeboekte incasso" - #: amelie/personal_tab/templates/transactions/transaction_delete.html:8 #, python-format msgid "Delete transaction '%(object)s'" msgstr "Verwijder transactie '%(object)s'" -#: amelie/personal_tab/templates/transactions/transaction_delete.html:11 -#, python-format -msgid "" -"\n" -" Are you sure you want to delete '%(object)s' for %(person)s?\n" -" " -msgstr "" -"\n" -"Weet je zeker dat je '%(object)s' van %(person)s wilt verwijderen?" - #: amelie/personal_tab/templates/transactions/transaction_form.html:5 #: amelie/personal_tab/templates/transactions/transaction_form.html:13 msgid "Add transaction" @@ -13422,14 +14787,6 @@ msgstr "Transactie toevoegen" msgid "Edit transaction '%(object)s'" msgstr "Wijzig transactie '%(object)s'" -#: amelie/personal_tab/templates/transactions/transaction_form.html:20 -msgid "You are now adding a transaction to a personal tab" -msgstr "Je voegt nu een streeplijsttransactie toe" - -#: amelie/personal_tab/templates/transactions/transaction_form.html:22 -msgid "You are now adding a transaction in the category \"Other\"" -msgstr "Je voegt nu een overige transactie toe" - #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:4 #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:9 msgid "Forgive Unpaid Memberships" @@ -13440,14 +14797,10 @@ msgid "You are about to forgive the membership fee of the following persons:" msgstr "Je staat op het punt de contributie kwijt te schelden voor de volgende personen:" #: amelie/personal_tab/templates/unpaid_memberships/forgive.html:16 -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:23 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:32 msgid "Current Member?" msgstr "Huidig lid?" -#: amelie/personal_tab/templates/unpaid_memberships/forgive.html:48 -msgid "Confirm" -msgstr "Bevestigen" - #: amelie/personal_tab/templates/unpaid_memberships/overview.html:4 #: amelie/personal_tab/templates/unpaid_memberships/overview.html:8 #: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:4 @@ -13463,14 +14816,26 @@ msgstr "Klik op een jaar om te zien wiens lidmaatschappen nog onbetaald zijn." msgid "Price Total" msgstr "Totaalprijs" -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:25 -msgid "Debit Refused?" -msgstr "Incasso geweigerd?" +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:12 +msgid "Use these buttons to send a mailing or forgive the membership fee of the selected members." +msgstr "Gebruik deze knoppen om een mailing te versturen of om de contributie kwijt te schelden voor de geselecteerde personen." -#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:73 +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:16 msgid "Forgive membership fee" msgstr "Contributie kwijtschelden" +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:34 +msgid "Debit Refused?" +msgstr "Incasso geweigerd?" + +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:94 +msgid "No unpaid memberships" +msgstr "Geen onbetaalde lidmaatschappen" + +#: amelie/personal_tab/templates/unpaid_memberships/year_overview.html:96 +msgid "There are no unpaid memberships in this year! Woah! 🎉🎉🎉" +msgstr "Er zijn geen onbetaalde lidmaatschappen in dit jaar! Wow! 🎉🎉🎉" + #: amelie/personal_tab/templates/wrapped/wrapped.html:4 #: amelie/personal_tab/templates/wrapped/wrapped.html:10 msgid " - Global statistics" @@ -13652,114 +15017,175 @@ msgstr "" msgid "Look back on a different year?" msgstr "Een ander jaar terugkijken?" -#: amelie/personal_tab/transactions.py:58 +#: amelie/personal_tab/transactions.py:74 +#, python-brace-format +msgid "Settlement of previous costs for {activity}" +msgstr "Schikking van vorige kosten van {activity}" + +#: amelie/personal_tab/transactions.py:105 #, python-brace-format msgid "Edited enrollment for {activity} (addition of updated costs)" msgstr "Inchrijving voor {activity} aangepast (toevoegen bijgewerkte kosten)" -#: amelie/personal_tab/transactions.py:60 +#: amelie/personal_tab/transactions.py:109 #, python-brace-format msgid "Enrolled for {activity}" msgstr "Inschrijven {activity}" -#: amelie/personal_tab/transactions.py:70 +#: amelie/personal_tab/transactions.py:121 #, python-brace-format msgid "Edited enrollment for {activity} (reversal of old costs)" msgstr "Inchrijving voor {activity} aangepast (terugboeken oude kosten)" -#: amelie/personal_tab/transactions.py:72 +#: amelie/personal_tab/transactions.py:125 #, python-brace-format msgid "Unenrolled for {activity}" msgstr "Uitschrijven {activity}" -#: amelie/personal_tab/transactions.py:115 -#: amelie/personal_tab/transactions.py:215 +#: amelie/personal_tab/transactions.py:168 +#: amelie/personal_tab/transactions.py:269 #, python-brace-format msgid "Sale {category}::{article}" msgstr "Verkoop {category}::{article}" -#: amelie/personal_tab/views.py:543 amelie/personal_tab/views.py:1016 -#: amelie/personal_tab/views.py:1047 amelie/personal_tab/views.py:1157 +#: amelie/personal_tab/views.py:560 amelie/personal_tab/views.py:1430 +#: amelie/personal_tab/views.py:1461 amelie/personal_tab/views.py:1571 msgid "Invalid date`" msgstr "Ongeldige datum" -#: amelie/personal_tab/views.py:591 amelie/personal_tab/views.py:599 -#: amelie/personal_tab/views.py:629 amelie/personal_tab/views.py:638 +#: amelie/personal_tab/views.py:610 amelie/personal_tab/views.py:618 +#: amelie/personal_tab/views.py:674 amelie/personal_tab/views.py:683 msgid "No members were selected." msgstr "Er zijn geen leden geselecteerd." -#: amelie/personal_tab/views.py:615 -msgid "The membership fees have been forgiven." -msgstr "De contributie is kwijtgescholden." +#: amelie/personal_tab/views.py:643 +#, python-brace-format +msgid "Forgiven membership costs for {membership_type} ({begin_year}/{end_year})" +msgstr "Kwijtgescholden lidmaatschapskosten voor {membership_type} ({begin_year}/{end_year})" + +#: amelie/personal_tab/views.py:660 +#, python-brace-format +msgid "The membership fees for {amount} memberships have been forgiven." +msgstr "De contributie voor {amount} lidmaatschappen is kwijtgescholden." -#: amelie/personal_tab/views.py:721 amelie/personal_tab/views.py:1912 +#: amelie/personal_tab/views.py:766 amelie/personal_tab/views.py:2323 msgid "The e-mails are now being sent one by one. This happens in a background process, so it may take a while." msgstr "De e-mails worden nu een voor een verstuurd. Dit gebeurt in een achtergrondproces en kan even duren." -#: amelie/personal_tab/views.py:830 +#: amelie/personal_tab/views.py:874 msgid "Transaction not editable." msgstr "Transactie niet te wijzigen" -#: amelie/personal_tab/views.py:855 +#: amelie/personal_tab/views.py:899 #, python-brace-format msgid "The transaction '{transaction}' has been successfully deleted." msgstr "De transactie '{transaction}' is met success verwijderd." -#: amelie/personal_tab/views.py:862 +#: amelie/personal_tab/views.py:906 msgid "Transaction not deletable." msgstr "Transactie niet te verwijderen" -#: amelie/personal_tab/views.py:950 amelie/personal_tab/views.py:1114 +#: amelie/personal_tab/views.py:992 amelie/personal_tab/views.py:1528 msgid "Invalid date" msgstr "Ongeldige datum" -#: amelie/personal_tab/views.py:1111 +#: amelie/personal_tab/views.py:1203 +msgid "Personal tab transactions in this settlement" +msgstr "Streeplijsttransacties in deze schikking" + +#: amelie/personal_tab/views.py:1211 +msgid "Payment for the transactions above" +msgstr "Betaling voor bovenstaande transacties" + +#: amelie/personal_tab/views.py:1219 +msgid "Residual balance debited from personal tab" +msgstr "Overgebleven saldo afgeschreven van streeplijst" + +#: amelie/personal_tab/views.py:1221 +msgid "Residual balance credited to personal tab" +msgstr "Overgebleven saldo bijgeschreven op streeplijst" + +#: amelie/personal_tab/views.py:1281 +#, python-brace-format +msgid "Manual payment '{payment}' of '{person}' deleted." +msgstr "Handmatige betaling '{payment}' van '{person}' is verwijderd." + +#: amelie/personal_tab/views.py:1363 +#, python-brace-format +msgid "Personal tab settlement on {date} for {name}" +msgstr "Streeplijstschikking op {date} voor {name}" + +#: amelie/personal_tab/views.py:1368 +#, python-brace-format +msgid "Refund on {date} for {name}" +msgstr "Terugbetaling op {date} voor {name}" + +#: amelie/personal_tab/views.py:1373 +#, python-brace-format +msgid "Manual payment on {date} for {name}" +msgstr "Handmatige betaling op {date} voor {name}" + +#: amelie/personal_tab/views.py:1379 +#, python-brace-format +msgid "Debit from personal tab balance on {date} for {name}" +msgstr "Afschrijving van streeplijstsaldo op {date} voor {name}" + +#: amelie/personal_tab/views.py:1384 +#, python-brace-format +msgid "Credit to personal tab balance on {date} for {name}" +msgstr "Tegoed op streeplijstsaldo op {date} voor {name}" + +#: amelie/personal_tab/views.py:1404 +msgid "No settlement was created because no transactions were selected and no amount was paid." +msgstr "Er is geen schikking aangemaakt omdat er geen transacties zijn geselecteerd, en er is geen bedrag betaald." + +#: amelie/personal_tab/views.py:1525 msgid "Invalid statistics list" msgstr "Ongeldige statistiekenlijst" -#: amelie/personal_tab/views.py:1346 +#: amelie/personal_tab/views.py:1757 msgid "End inactive mandates." msgstr "Inactieve machtigingen beëindigd." -#: amelie/personal_tab/views.py:1401 +#: amelie/personal_tab/views.py:1812 msgid "This mandate is terminated, so an amendment cannot be made" msgstr "Deze machtiging is beëindigd, dus er kan geen amendenemt worden aangemaakt" -#: amelie/personal_tab/views.py:1404 +#: amelie/personal_tab/views.py:1815 msgid "This mandate is not signed, so an amendment cannot be made" msgstr "Deze machtiging is niet getekend, dus er kan geen amendenemt worden aangemaakt" -#: amelie/personal_tab/views.py:1407 +#: amelie/personal_tab/views.py:1818 msgid "This mandate already has an ongoing amendment" msgstr "Deze machtiging heeft al een amendement in behandeling" -#: amelie/personal_tab/views.py:1434 amelie/personal_tab/views.py:1470 +#: amelie/personal_tab/views.py:1845 amelie/personal_tab/views.py:1881 msgid "This amendment does not belong to this authorization" msgstr "Dit amendement hoort niet bij deze machtiging" -#: amelie/personal_tab/views.py:1437 amelie/personal_tab/views.py:1473 +#: amelie/personal_tab/views.py:1848 amelie/personal_tab/views.py:1884 msgid "This amendment is not editable" msgstr "Dit amendement kan niet worden aangepast" -#: amelie/personal_tab/views.py:1673 +#: amelie/personal_tab/views.py:2084 msgid "Reversal already exists" msgstr "Terugboeking bestaat al" -#: amelie/personal_tab/views.py:1699 amelie/personal_tab/views.py:1731 +#: amelie/personal_tab/views.py:2110 amelie/personal_tab/views.py:2142 msgid "Reversal does not exist" msgstr "Terugboeking bestaat niet" -#: amelie/personal_tab/views.py:1888 amelie/personal_tab/views.py:1982 +#: amelie/personal_tab/views.py:2299 amelie/personal_tab/views.py:2393 #: amelie/room_duty/templates/room_duty/pool/persons_change.html:52 #: amelie/room_duty/templates/room_duty/table/change_persons.html:52 msgid "Found nobody." msgstr "Geen personen gevonden." -#: amelie/personal_tab/views.py:2319 +#: amelie/personal_tab/views.py:2730 msgid "Declaration was submitted successfully." msgstr "Declaratie is succesvol verstuurd." -#: amelie/personal_tab/views.py:2324 +#: amelie/personal_tab/views.py:2735 #, python-brace-format msgid "Error while submitting declaration: {ex}" msgstr "Fout bij het versturen van de declaratie: {ex}" @@ -14248,27 +15674,27 @@ msgstr "Dutch" msgid "Engels" msgstr "English" -#: amelie/settings/generic.py:121 +#: amelie/settings/generic.py:128 msgid "Dutch" msgstr "Nederlands" -#: amelie/settings/generic.py:122 +#: amelie/settings/generic.py:129 msgid "English" msgstr "Engels" -#: amelie/settings/generic.py:854 +#: amelie/settings/generic.py:870 msgid "Access to your name, date of birth, student number, mandate status and committee status." msgstr "Toegang tot je naam, geboortedatum, studentnummer, machtiging- en commissiestatus" -#: amelie/settings/generic.py:855 +#: amelie/settings/generic.py:871 msgid "Access to enrollments for activities and (un)enrolling you for activities." msgstr "Toegang tot inschrijvingen voor activiteiten en het in- en uitschrijven voor activiteiten" -#: amelie/settings/generic.py:856 +#: amelie/settings/generic.py:872 msgid "Access to transactions, direct debit transactions, mandates and RFID-cards." msgstr "Toegang tot transacties, incasso's, machtigingen en RFID-kaarten" -#: amelie/settings/generic.py:857 +#: amelie/settings/generic.py:873 msgid "Access to complaints and sending or supporting complaints in your name." msgstr "Toegang tot onderwijsklachten en het indienen of steunen van onderwijsklachten" @@ -14448,7 +15874,7 @@ msgstr "[START] {}" msgid "[END] {}" msgstr "[EIND] {}" -#: amelie/tools/decorators.py:42 +#: amelie/tools/decorators.py:42 amelie/tools/mixins.py:93 msgid "AJAX request required" msgstr "AJAX-request vereist" @@ -14456,26 +15882,26 @@ msgstr "AJAX-request vereist" msgid "Access for board members only." msgstr "Uitsluitend toegankelijk voor het bestuur." -#: amelie/tools/decorators.py:54 amelie/tools/mixins.py:110 +#: amelie/tools/decorators.py:54 amelie/tools/mixins.py:116 msgid "Access for the Educational Committee only." msgstr "Uitsluitend toegankelijk voor de onderwijscommissie." #: amelie/tools/decorators.py:60 amelie/tools/decorators.py:66 -#: amelie/tools/mixins.py:118 +#: amelie/tools/mixins.py:124 msgid "Only for members with an active membership." msgstr "Alleen voor leden met een actief lidmaatschap." -#: amelie/tools/decorators.py:72 amelie/tools/mixins.py:134 +#: amelie/tools/decorators.py:72 amelie/tools/mixins.py:140 msgid "Access for active members only." msgstr "Uitsluitend toegankelijk voor actieve leden." -#: amelie/tools/decorators.py:77 amelie/tools/mixins.py:153 -#: amelie/tools/mixins.py:161 +#: amelie/tools/decorators.py:77 amelie/tools/mixins.py:159 +#: amelie/tools/mixins.py:167 msgid "Only accessible by superusers." msgstr "Uitsluitend toegankelijk voor superusers." -#: amelie/tools/decorators.py:88 amelie/tools/mixins.py:172 -#: amelie/tools/mixins.py:188 amelie/tools/mixins.py:204 +#: amelie/tools/decorators.py:88 amelie/tools/mixins.py:178 +#: amelie/tools/mixins.py:194 amelie/tools/mixins.py:210 msgid "Access for members of the committee only." msgstr "Uitsluitend toegankelijk voor commissieleden." @@ -14644,31 +16070,31 @@ msgstr "De URL naar de gegenereerde afbeelding voor deze CAPTCHA" msgid "The response to the CAPTCHA challenge" msgstr "Het antwoord op de CAPTCHA challenge" -#: amelie/tools/mixins.py:94 +#: amelie/tools/mixins.py:100 msgid "Access for (former) members only." msgstr "Uitsluitend toegankelijk voor (oud-)leden." -#: amelie/tools/mixins.py:102 +#: amelie/tools/mixins.py:108 msgid "Access for boardmembers only." msgstr "Uitsluitend toegankelijk voor het bestuur." -#: amelie/tools/mixins.py:126 +#: amelie/tools/mixins.py:132 msgid "Only for members with a signed and active personal tab authorization." msgstr "Alleen voor mensen met een getekende en geactiveerde streeplijstmachtiging." -#: amelie/tools/mixins.py:142 +#: amelie/tools/mixins.py:148 msgid "Only for active members, or members with a signed and active personal tab authorization." msgstr "Alleen voor actieve leden, of voor mensen met een getekende en geactiveerde streeplijstmachtiging." -#: amelie/tools/mixins.py:232 +#: amelie/tools/mixins.py:238 msgid "Access for do-group parents only." msgstr "Uitsluitend toegankelijk voor doegroepouders." -#: amelie/tools/mixins.py:254 +#: amelie/tools/mixins.py:260 msgid "Access for allowlisted IP address only." msgstr "Uitsluitend toegankelijk voor bekende IP-adressen." -#: amelie/tools/mixins.py:278 +#: amelie/tools/mixins.py:284 msgid "Access for the cookie-corner computer only." msgstr "Uitsluitend toegankelijk voor de streeplijstcomputer." @@ -15181,12 +16607,12 @@ msgstr "Het is niet gelukt om een verbinding met Streaming.IA te maken! Neem con msgid "Could not connect to Video.IA! Please contact the WWW committee if this problem persists." msgstr "Het is niet gelukt om een verbinding met Video.IA te maken! Neem contact op met de WWW commissie als dit probleem blijft aanhouden." -#: amelie/views.py:313 +#: amelie/views.py:312 #, python-format msgid "CSRF-check failed. Reason: %s" msgstr "CSRF-controle mislukt. Reden: %s" -#: amelie/views.py:315 +#: amelie/views.py:314 msgid "CSRF-check failed." msgstr "CSRF-controle mislukt." @@ -16192,17 +17618,28 @@ msgstr "" "\n" "Je lidmaatschapstype is %(type)s." -#: templates/profile_overview.html:63 +#: templates/profile_overview.html:62 +msgid "" +"\n" +" Your membership type does not require payment.\n" +" " +msgstr "" +"\n" +" Je lidmaatschapstype heeft geen betaling nodig.\n" +" " + +#: templates/profile_overview.html:68 #, python-format msgid "" "\n" -" Your membership fees have been paid through %(payment_type)s on %(date)s.\n" -" " +" Your membership fees have been paid through %(method)s on %(date)s.\n" +" " msgstr "" "\n" -"Je lidmaatschap is betaald via %(payment_type)s op %(date)s." +" Je lidmaatschap is betaald via %(method)s op %(date)s.\n" +" " -#: templates/profile_overview.html:69 +#: templates/profile_overview.html:75 msgid "" "\n" " Your membership fee for this year has not yet been paid.\n" @@ -16212,7 +17649,7 @@ msgstr "" "\n" "Je lidmaatschap voor dit jaar is nog niet betaald. Indien je een machtiging hebt gegeven, dan wordt dit automatisch geregeld." -#: templates/profile_overview.html:76 +#: templates/profile_overview.html:82 msgid "" "\n" " Your student status has been verified, or we do not need it for your membership type.\n" @@ -16222,7 +17659,7 @@ msgstr "" " Je studentenstatus is geverifiëerd of we hebben het niet nodig voor jouw lidmaatschapstype.\n" " " -#: templates/profile_overview.html:80 +#: templates/profile_overview.html:86 msgid "" "\n" " Your student status has not been verified, while we do need it for your membership type.\n" @@ -16238,7 +17675,7 @@ msgstr "" "Je kunt contact opnemen met het bestuur om je lidmaatschap vanaf 1 juli om te zetten in een ander type lidmaatschap.\n" " " -#: templates/profile_overview.html:91 +#: templates/profile_overview.html:97 msgid "" "\n" " You have no active membership.\n" @@ -16247,7 +17684,7 @@ msgstr "" "\n" "Je hebt geen actief lidmaatschap." -#: templates/profile_overview.html:148 +#: templates/profile_overview.html:154 msgid "" "\n" " Below you can find an overview of your activism at Inter-Actief.\n" @@ -16258,51 +17695,51 @@ msgstr "" "Hieronder staat een overzicht van je activisme bij Inter-Actief. Wil je actief\n" "worden? Neem dan contact op met de intern." -#: templates/profile_overview.html:160 +#: templates/profile_overview.html:166 msgid "Inter-Actief server access" msgstr "Toegang tot Inter-Actief servers" -#: templates/profile_overview.html:164 +#: templates/profile_overview.html:170 msgid "You are an active member! As an active member you have access to (some of) the Inter-Actief servers." msgstr "Je bent een actief lid! Als actief lid heb je toegang tot (sommige) servers van Inter-Actief." -#: templates/profile_overview.html:168 +#: templates/profile_overview.html:174 msgid "Logging in to the servers is only possible with an SSH key. You can modify your public keys here." msgstr "Inloggen op de servers is alleen mogelijk via SSH-sleutels. Je kan je publieke sleutels hier aanpassen." -#: templates/profile_overview.html:173 templates/profile_overview.html:204 +#: templates/profile_overview.html:179 templates/profile_overview.html:210 msgid "Identifier" msgstr "Identificatie" -#: templates/profile_overview.html:174 +#: templates/profile_overview.html:180 msgid "Public key" msgstr "Publieke sleutel" -#: templates/profile_overview.html:191 +#: templates/profile_overview.html:197 msgid "You have no active SSH public keys." msgstr "Je hebt geen actieve publieke SSH-sleutels." -#: templates/profile_overview.html:201 +#: templates/profile_overview.html:207 msgid "Add new SSH key" msgstr "Nieuwe SSH-key toevoegen" -#: templates/profile_overview.html:206 +#: templates/profile_overview.html:212 msgid "An identifier for the key (max 25 letters or numbers)." msgstr "Een identificatie voor de key (max 25 letters of cijfers)." -#: templates/profile_overview.html:210 +#: templates/profile_overview.html:216 msgid "ssh-ed25519 AAA....." msgstr "ssh-ed25519 AAA....." -#: templates/profile_overview.html:212 +#: templates/profile_overview.html:218 msgid "The SSH public key string." msgstr "De publieke SSH-sleutelreeks." -#: templates/profile_overview.html:215 +#: templates/profile_overview.html:221 msgid "Add new key" msgstr "Nieuwe key toevoegen" -#: templates/profile_overview.html:222 +#: templates/profile_overview.html:228 msgid "" "\n" " If you are in a committee or group that grants you \"sudo\" rights on a server, you will\n" @@ -16316,60 +17753,60 @@ msgstr "" "Je kan op deze knop drukken om dit wachtwoord te resetten, dan zal er een resetlink naar je gemaild worden.\n" " " -#: templates/profile_overview.html:230 +#: templates/profile_overview.html:236 msgid "Reset sudo password" msgstr "Sudo-wachtwoord resetten" -#: templates/profile_overview.html:236 +#: templates/profile_overview.html:242 msgid "You are an active member, but you have no account name! Please contact the board and ask them to add an active members account for you." msgstr "Je bent actief lid, maar je hebt geen accountnaam! Neem contact op met het bestuur zodat ze voor jou een actieveledenaccount aanmaken." -#: templates/profile_overview.html:249 +#: templates/profile_overview.html:255 msgid "The following user account(s) have been found that are related to you." msgstr "De volgende gebruikersaccount(s) zijn gevonden die aan u gerelateerd zijn." -#: templates/profile_overview.html:252 +#: templates/profile_overview.html:258 msgid "You can link another account by logging out, and logging back in with the Google, LinkedIn or GitHub account you want to link." msgstr "U kunt een ander account koppelen door uit te loggen, en weer in te loggen met het Google-, LinkedIn- of GitHub-account dat u wilt koppelen." -#: templates/profile_overview.html:270 templates/profile_overview.html:281 -#: templates/profile_overview.html:312 +#: templates/profile_overview.html:276 templates/profile_overview.html:287 +#: templates/profile_overview.html:318 msgid "unlink" msgstr "ontkoppelen" -#: templates/profile_overview.html:273 +#: templates/profile_overview.html:279 msgid "No TOTP devices registered" msgstr "Geen TOTP-apparaten geregistreerd" -#: templates/profile_overview.html:278 +#: templates/profile_overview.html:284 msgid "Passkeys" msgstr "Passkeys" -#: templates/profile_overview.html:284 +#: templates/profile_overview.html:290 msgid "No passkeys registered" msgstr "Geen passkeys geregistreerd" -#: templates/profile_overview.html:289 +#: templates/profile_overview.html:295 msgid "To register a new TOTP device or passkey, press one of the buttons below. You will receive an e-mail with a link to register the new device." msgstr "Klik op de knoppen hieronder om een nieuw TOTP-apparaat of een nieuwe passkey te registreren. Je krijgt dan een e-mail met een link om het nieuwe apparaat te registreren." -#: templates/profile_overview.html:290 +#: templates/profile_overview.html:296 msgid "Register new TOTP device" msgstr "Registreer nieuw TOTP-apparaat" -#: templates/profile_overview.html:291 +#: templates/profile_overview.html:297 msgid "Register new passkey" msgstr "Registreer nieuwe passkey" -#: templates/profile_overview.html:314 +#: templates/profile_overview.html:320 msgid "Cannot unlink" msgstr "Kan niet ontkoppelen" -#: templates/profile_overview.html:333 +#: templates/profile_overview.html:339 msgid "oAuth2 Apps" msgstr "oAuth2 Apps" -#: templates/profile_overview.html:337 +#: templates/profile_overview.html:343 msgid "" "\n" " Below you can find an overview of the oAuth2 apps you have given permissions to use or edit your personal information.\n" diff --git a/pyproject.toml b/pyproject.toml index 28a9651a..2ae1af7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "django-localflavor>=5.0,<5.1", "django-extensions>=3.2.1,<4.2", "django-formtools>=2.4,<2.8", + "django-betterforms>=3.0.0,<3.1", "django-auditlog>=3.4.1,<3.5", "django-oauth-toolkit>=1.7.1,<3.4", "django-cors-headers>=3.13.0,<4.10", @@ -42,7 +43,7 @@ dependencies = [ "django-environ>=0.13.0,<0.15", # Django Modern RPC -- For our JSONRPC API - "django-modern-rpc>=2.0.0,<2.1", + "django-modern-rpc>=2.1.0,<2.2", # WYSIWYG Editor for Django "django-ckeditor>=6.7.3", @@ -64,14 +65,14 @@ dependencies = [ "nh3>=0.3.3,<0.4", # Other random dependencies, to be documented, see issue #556 - "httplib2>=0.20.4,<0.32", + "httplib2>=0.20.4,<0.33", "icalendar>=7.0.3,<7.3", "Markdown>=3.10.2,<3.11", "mysqlclient>=2.2.8", # Pillow requires libjpeg-dev zlib1g-dev (on Debian) "Pillow>=12.1.1,<12.4", - "python-gitlab>=8.1.0,<8.5", + "python-gitlab>=8.1.0,<8.6", "python-dateutil>=2.9.0,<2.10", # python-ldap requires libldap-dev libsasl2-dev (on Debian) to build @@ -100,24 +101,24 @@ dependencies = [ # Google API Client "google-api-python-client>=2.192.0,<3", "google-auth>=2.49.1,<2.57", - "pyOpenSSL>=26.0.0,<26.4.0", + "pyOpenSSL>=26.0.0,<26.5.0", # Development - "django-debug-toolbar>=3.7,<7.1", + "django-debug-toolbar>=3.7,<7.2", "django-rosetta>=0.10.3,<0.11", - "docutils>=0.19,<0.23", + "docutils>=0.19,<0.24", "pyinotify", - "model_bakery>=1.23.3,<1.24", + "model_bakery>=1.23.3,<1.25", "django-silk>=5.5.0,<5.6", # Single Sign On - OIDC Client "mozilla-django-oidc>=5.0.2,<5.1", # Sentry error logging - "sentry-sdk>=2.54.0,<2.65", + "sentry-sdk>=2.54.0,<2.69", # Health checks for Django - "django-health-check[psutil,celery,rabbitmq,redis]>=4.0,<4.5", + "django-health-check[psutil,celery,rabbitmq,redis]>=4.0,<4.6", # Allows for CAPTCHA support in forms "django-simple-captcha", diff --git a/templates/profile_overview.html b/templates/profile_overview.html index 875ac4b4..c3050271 100644 --- a/templates/profile_overview.html +++ b/templates/profile_overview.html @@ -58,11 +58,17 @@

    {% trans 'Membership' %}

    {% endblocktrans %}

    - {% if membership.is_paid %} + {% if membership.is_free %} +

    {% blocktrans %} + Your membership type does not require payment. + {% endblocktrans %}

    + {% elif membership.is_paid %} {% else %}