diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 86f946d..81ac23c 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -8,8 +8,12 @@ from django.utils.translation import gettext_lazy as _ from django.shortcuts import render from django.conf import settings +from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin + +from rules.contrib.admin import ObjectPermissionsModelAdmin + from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ - StatementUnSubmitted + StatementUnSubmitted, BillOnStatementProxy @admin.register(Ledger) @@ -17,8 +21,8 @@ class LedgerAdmin(admin.ModelAdmin): search_fields = ('name', ) -class BillOnStatementInline(admin.TabularInline): - model = Bill +class BillOnStatementInline(CommonAdminInlineMixin, admin.TabularInline): + model = BillOnStatementProxy extra = 0 sortable_options = [] fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] @@ -26,16 +30,11 @@ class BillOnStatementInline(admin.TabularInline): TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} } - def get_readonly_fields(self, request, obj=None): - if obj is not None and obj.submitted: - return self.fields - return super(BillOnStatementInline, self).get_readonly_fields(request, obj) - @admin.register(StatementUnSubmitted) -class StatementUnSubmittedAdmin(admin.ModelAdmin): +class StatementUnSubmittedAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'submitted'] - list_display = ['__str__', 'excursion'] + list_display = ['__str__', 'excursion', 'created_by'] inlines = [BillOnStatementInline] def save_model(self, request, obj, form, change): @@ -43,16 +42,6 @@ class StatementUnSubmittedAdmin(admin.ModelAdmin): obj.created_by = request.user.member super().save_model(request, obj, form, change) - def get_queryset(self, request): - queryset = super().get_queryset(request) - if request.user.has_perm('members.may_list_all_statements'): - return queryset - - if not hasattr(request.user, 'member'): - return Member.objects.none() - - return queryset.filter(Q(created_by=request.user.member) | Q(excursion__jugendleiter=request.user.member)) - def get_readonly_fields(self, request, obj=None): readonly_fields = ['submitted', 'excursion'] if obj is not None and obj.submitted: @@ -109,7 +98,7 @@ class TransactionOnSubmittedStatementInline(admin.TabularInline): class BillOnSubmittedStatementInline(BillOnStatementInline): - model = Bill + model = BillOnStatementProxy extra = 0 sortable_options = [] fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] diff --git a/jdav_web/finance/migrations/0002_alter_permissions.py b/jdav_web/finance/migrations/0002_alter_permissions.py new file mode 100644 index 0000000..b1a80d6 --- /dev/null +++ b/jdav_web/finance/migrations/0002_alter_permissions.py @@ -0,0 +1,49 @@ +# Generated by Django 4.0.1 on 2023-04-03 20:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + #replaces = [('finance', '0002_billonexcursionproxy_billonstatementproxy_and_more'), ('finance', '0003_alter_statementunsubmitted_options'), ('finance', '0004_alter_billonexcursionproxy_options'), ('finance', '0005_alter_billonstatementproxy_options'), ('finance', '0006_alter_statementsubmitted_options'), ('finance', '0007_alter_billonexcursionproxy_options_and_more')] + + dependencies = [ + ('finance', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='statementsubmitted', + options={'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'}, + ), + migrations.CreateModel( + name='BillOnExcursionProxy', + fields=[ + ], + options={ + 'verbose_name': 'Bill', + 'verbose_name_plural': 'Bills', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('finance.bill',), + ), + migrations.CreateModel( + name='BillOnStatementProxy', + fields=[ + ], + options={ + 'verbose_name': 'Bill', + 'verbose_name_plural': 'Bills', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('finance.bill',), + ), + migrations.AlterModelOptions( + name='statementunsubmitted', + options={'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'}, + ), + ] diff --git a/jdav_web/finance/migrations/0003_alter_bill_options_and_more.py b/jdav_web/finance/migrations/0003_alter_bill_options_and_more.py new file mode 100644 index 0000000..e7f04b4 --- /dev/null +++ b/jdav_web/finance/migrations/0003_alter_bill_options_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0.1 on 2023-04-04 12:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0002_alter_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='bill', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, + ), + migrations.AlterModelOptions( + name='billonexcursionproxy', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, + ), + migrations.AlterModelOptions( + name='billonstatementproxy', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Bill', 'verbose_name_plural': 'Bills'}, + ), + migrations.AlterModelOptions( + name='statement', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')], 'verbose_name': 'Statement', 'verbose_name_plural': 'Statements'}, + ), + migrations.AlterModelOptions( + name='statementconfirmed', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('may_manage_confirmed_statements', 'Can view and manage confirmed statements.')], 'verbose_name': 'Paid statement', 'verbose_name_plural': 'Paid statements'}, + ), + migrations.AlterModelOptions( + name='statementsubmitted', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': [('process_statementsubmitted', 'Can manage submitted statements.')], 'verbose_name': 'Submitted statement', 'verbose_name_plural': 'Submitted statements'}, + ), + migrations.AlterModelOptions( + name='statementunsubmitted', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Statement in preparation', 'verbose_name_plural': 'Statements in preparation'}, + ), + ] diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index fbb8ad3..d66d12b 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -2,11 +2,16 @@ import math from itertools import groupby from decimal import Decimal, ROUND_HALF_DOWN from django.utils import timezone +from .rules import is_creator, not_submitted, leads_excursion +from members.rules import is_leader, statement_not_submitted from django.db import models from django.utils.translation import gettext_lazy as _ from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE from django.conf import settings +import rules +from contrib.models import CommonModel +from contrib.rules import has_global_perm # Create your models here. @@ -35,7 +40,7 @@ class StatementManager(models.Manager): return super().get_queryset().filter(submitted=False, confirmed=False) -class Statement(models.Model): +class Statement(CommonModel): MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, VALID = 0, 1, 2 short_description = models.CharField(verbose_name=_('Short description'), @@ -71,10 +76,20 @@ class Statement(models.Model): on_delete=models.SET_NULL, related_name='confirmed_statements') - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('Statement') verbose_name_plural = _('Statements') - permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')] + permissions = [ + ('may_edit_submitted_statements', 'Is allowed to edit submitted statements') + ] + rules_permissions = { + # this is suboptimal, but Statement is only ever used as an inline on Freizeit + # so we check for excursion permissions + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader & statement_not_submitted, + 'delete_obj': is_leader & statement_not_submitted, + } def __str__(self): if self.excursion is not None: @@ -306,10 +321,16 @@ class StatementUnSubmittedManager(models.Manager): class StatementUnSubmitted(Statement): objects = StatementUnSubmittedManager() - class Meta: + class Meta(CommonModel.Meta): proxy = True verbose_name = _('Statement in preparation') verbose_name_plural = _('Statements in preparation') + rules_permissions = { + 'add_obj': rules.is_staff, + 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'), + 'change_obj': is_creator | leads_excursion, + 'delete_obj': is_creator | leads_excursion, + } class StatementSubmittedManager(models.Manager): @@ -320,11 +341,13 @@ class StatementSubmittedManager(models.Manager): class StatementSubmitted(Statement): objects = StatementSubmittedManager() - class Meta: + class Meta(CommonModel.Meta): proxy = True verbose_name = _('Submitted statement') verbose_name_plural = _('Submitted statements') - permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),) + permissions = [ + ('process_statementsubmitted', 'Can manage submitted statements.'), + ] class StatementConfirmedManager(models.Manager): @@ -335,14 +358,16 @@ class StatementConfirmedManager(models.Manager): class StatementConfirmed(Statement): objects = StatementConfirmedManager() - class Meta: + class Meta(CommonModel.Meta): proxy = True verbose_name = _('Paid statement') verbose_name_plural = _('Paid statements') - permissions = (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),) + permissions = [ + ('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'), + ] -class Bill(models.Model): +class Bill(CommonModel): statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) short_description = models.CharField(verbose_name=_('Short description'), max_length=30) explanation = models.TextField(verbose_name=_('Explanation'), blank=True) @@ -363,9 +388,37 @@ class Bill(models.Model): pretty_amount.admin_order_field = 'amount' pretty_amount.short_description = _('Amount') - class Meta: + class Meta(CommonModel.Meta): + verbose_name = _('Bill') + verbose_name_plural = _('Bills') + + +class BillOnExcursionProxy(Bill): + class Meta(CommonModel.Meta): + proxy = True + verbose_name = _('Bill') + verbose_name_plural = _('Bills') + rules_permissions = { + 'add_obj': leads_excursion & not_submitted, + 'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'), + 'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted, + 'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted, + } + + +class BillOnStatementProxy(Bill): + class Meta(CommonModel.Meta): + proxy = True verbose_name = _('Bill') verbose_name_plural = _('Bills') + rules_permissions = { + 'add_obj': (is_creator | leads_excursion) & not_submitted, + 'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'), + 'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy')) + & (not_submitted | has_global_perm('finance.process_statementsubmitted')), + 'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy')) + & not_submitted, + } class Transaction(models.Model): diff --git a/jdav_web/finance/rules.py b/jdav_web/finance/rules.py new file mode 100644 index 0000000..bb7e955 --- /dev/null +++ b/jdav_web/finance/rules.py @@ -0,0 +1,36 @@ +from members.models import Freizeit +from contrib.rules import memberize_user +from rules import predicate +from members.rules import _is_leader + + +@predicate +@memberize_user +def is_creator(self, statement): + assert statement is not None + return statement.created_by == self + + +@predicate +@memberize_user +def not_submitted(self, statement): + assert statement is not None + if isinstance(statement, Freizeit): + if hasattr(statement, 'statement'): + return not statement.statement.submitted + else: + return True + return not statement.submitted + + +@predicate +@memberize_user +def leads_excursion(self, statement): + assert statement is not None + if isinstance(statement, Freizeit): + return _is_leader(self, statement) + if not hasattr(statement, 'excursion'): + return False + if statement.excursion is None: + return False + return _is_leader(self, statement.excursion) diff --git a/jdav_web/mailer/admin.py b/jdav_web/mailer/admin.py index f25cddb..1f0ef25 100644 --- a/jdav_web/mailer/admin.py +++ b/jdav_web/mailer/admin.py @@ -7,12 +7,15 @@ from django import forms #from easy_select2 import apply_select2 import json +from rules.contrib.admin import ObjectPermissionsModelAdmin + from .models import Message, Attachment, MessageForm, EmailAddress, EmailAddressForm from .mailutils import NOT_SENT, PARTLY_SENT from members.models import Member +from contrib.admin import CommonAdminMixin, CommonAdminInlineMixin -class AttachmentInline(admin.TabularInline): +class AttachmentInline(CommonAdminInlineMixin, admin.TabularInline): model = Attachment extra = 0 @@ -27,8 +30,9 @@ class EmailAddressAdmin(admin.ModelAdmin): form = EmailAddressForm -class MessageAdmin(admin.ModelAdmin): +class MessageAdmin(CommonAdminMixin, ObjectPermissionsModelAdmin): """Message creation view""" + exclude = ('created_by',) list_display = ('subject', 'get_recipients', 'sent') search_fields = ('subject',) change_form_template = "mailer/change_form.html" @@ -42,6 +46,11 @@ class MessageAdmin(admin.ModelAdmin): form = MessageForm filter_horizontal = ('to_members','reply_to') + def save_model(self, request, obj, form, change): + if not change and hasattr(request.user, 'member'): + obj.created_by = request.user.member + super().save_model(request, obj, form, change) + def send_message(self, request, queryset): if request.POST.get('confirmed'): for msg in queryset: diff --git a/jdav_web/mailer/migrations/0002_message_created_by.py b/jdav_web/mailer/migrations/0002_message_created_by.py new file mode 100644 index 0000000..1a2d565 --- /dev/null +++ b/jdav_web/mailer/migrations/0002_message_created_by.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.1 on 2023-04-02 12:06 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0006_rename_permissions'), + ('mailer', '0001_initial_squashed_0006_auto_20210924_1155'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_messages', to='members.member', verbose_name='Created by'), + ), + ] diff --git a/jdav_web/mailer/migrations/0003_alter_message_options.py b/jdav_web/mailer/migrations/0003_alter_message_options.py new file mode 100644 index 0000000..2b142bd --- /dev/null +++ b/jdav_web/mailer/migrations/0003_alter_message_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.1 on 2023-04-04 12:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailer', '0002_message_created_by'), + ] + + operations = [ + migrations.AlterModelOptions( + name='message', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('submit_mails', 'Can submit mails'),), 'verbose_name': 'message', 'verbose_name_plural': 'messages'}, + ), + ] diff --git a/jdav_web/mailer/models.py b/jdav_web/mailer/models.py index eabbd67..6965c7c 100644 --- a/jdav_web/mailer/models.py +++ b/jdav_web/mailer/models.py @@ -9,6 +9,9 @@ from jdav_web.celery import app from django.core.validators import RegexValidator from django.conf import settings +from contrib.models import CommonModel +from .rules import is_creator + import os @@ -59,7 +62,7 @@ class EmailAddressForm(forms.ModelForm): # Create your models here. -class Message(models.Model): +class Message(CommonModel): """Represents a message that can be sent to some members""" subject = models.CharField(_('subject'), max_length=50) content = models.TextField(_('content')) @@ -88,6 +91,11 @@ class Message(models.Model): blank=True, related_name='reply_to_email_addr') sent = models.BooleanField(_('sent'), default=False) + created_by = models.ForeignKey('members.Member', verbose_name=_('Created by'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='created_messages') def __str__(self): return self.subject @@ -175,12 +183,17 @@ class Message(models.Model): self.save() return success - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('message') verbose_name_plural = _('messages') permissions = ( ("submit_mails", _("Can submit mails")), ) + rules_permissions = { + "view_obj": is_creator, + "change_obj": is_creator, + "delete_obj": is_creator, + } class MessageForm(forms.ModelForm): @@ -203,7 +216,7 @@ class MessageForm(forms.ModelForm): raise ValidationError(_('At least one reply-to recipient is required. ' 'Use the info mail if you really want no reply-to recipient.')) -class Attachment(models.Model): +class Attachment(CommonModel): """Represents an attachment to an email""" msg = models.ForeignKey(Message, on_delete=models.CASCADE) # file (not naming it file because of builtin) @@ -218,3 +231,10 @@ class Attachment(models.Model): class Meta: verbose_name = _('attachment') verbose_name_plural = _('attachments') + rules_permissions = { + "add_obj": is_creator, + "view_obj": is_creator, + "change_obj": is_creator, + "delete_obj": is_creator, + } + diff --git a/jdav_web/mailer/rules.py b/jdav_web/mailer/rules.py new file mode 100644 index 0000000..a1c6db1 --- /dev/null +++ b/jdav_web/mailer/rules.py @@ -0,0 +1,10 @@ +from contrib.rules import memberize_user +from rules import predicate + +@predicate +@memberize_user +def is_creator(self, message): + if message is None: + return False + + return message.created_by == self diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index 4964921..acab31f 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -28,6 +28,8 @@ from django.shortcuts import render from django.core.exceptions import PermissionDenied from .pdf import render_tex +from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin + import nested_admin from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, Klettertreff, @@ -35,7 +37,7 @@ from .models import (Member, Group, Freizeit, MemberNoteList, NewMemberOnList, K PermissionGroup, MemberTraining, TrainingCategory, KlettertreffAttendee, ActivityCategory, annotate_activity_score, RegistrationPassword, MemberUnconfirmedProxy) -from finance.models import Statement, Bill +from finance.models import Statement, BillOnExcursionProxy from mailer.mailutils import send as send_mail, get_echo_link from django.conf import settings #from easy_select2 import apply_select2 @@ -57,7 +59,7 @@ class FilteredMemberFieldMixin: elif not hasattr(request.user, 'member'): field.queryset = Member.objects.none() else: - field.queryset = request.user.member.filter_queryset_by_permissions() + field.queryset = request.user.member.filter_queryset_by_permissions(model=Member) return field def formfield_for_manytomany(self, db_field, request=None, **kwargs): @@ -75,7 +77,7 @@ class FilteredMemberFieldMixin: elif not hasattr(request.user, 'member'): field.queryset = Member.objects.none() else: - field.queryset = request.user.member.filter_queryset_by_permissions() + field.queryset = request.user.member.filter_queryset_by_permissions(model=Member) return field @@ -91,7 +93,7 @@ class PermissionOnMemberInline(admin.StackedInline): can_delete = False -class TrainingOnMemberInline(admin.TabularInline): +class TrainingOnMemberInline(CommonAdminInlineMixin, admin.TabularInline): model = MemberTraining formfield_overrides = { TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} @@ -145,7 +147,7 @@ class RegistrationFilter(admin.SimpleListFilter): # Register your models here. -class MemberAdmin(admin.ModelAdmin): +class MemberAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['prename', 'lastname', 'email', 'email_parents', 'cc_email_parents', 'street', 'plz', 'town', 'address_extra', 'country', 'nationality', 'phone_number_private', 'phone_number_mobile', @@ -156,7 +158,8 @@ class MemberAdmin(admin.ModelAdmin): 'medication', 'tetanus_vaccination', 'photos_may_be_taken', 'legal_guardians', 'good_conduct_certificate_presented_date', 'good_conduct_certificate_presentation_needed', 'iban', 'has_key', 'has_free_ticket_gym', 'gets_newsletter', 'registered', 'registration_form', - 'active', 'echoed', 'join_date', 'leave_date', 'comments', 'technical_comments'] + 'active', 'echoed', 'join_date', 'leave_date', 'comments', 'technical_comments', + 'user'] list_display = ('name_text_or_link', 'birth_date', 'age', 'get_group', 'gets_newsletter', 'registered', 'active', 'echoed', 'comments', 'activity_score') search_fields = ('prename', 'lastname', 'email') @@ -174,60 +177,13 @@ class MemberAdmin(admin.ModelAdmin): sensitive_fields = ['iban', 'registration_form', 'comments'] - def has_view_permission(self, request, obj=None): - user = request.user - if request.user.has_perm('members.may_view_everyone'): - return True - - if not hasattr(user, 'member'): - return False - - if obj is None: - return True - return request.user.member.may_view(obj) - - def has_change_permission(self, request, obj=None): - user = request.user - if request.user.has_perm('members.may_change_everyone'): - return True - - if not hasattr(user, 'member'): - return False - - if obj is None: - return True - return request.user.member.may_change(obj) - - def has_delete_permission(self, request, obj=None): - user = request.user - if request.user.has_perm('members.may_delete_everyone'): - return True - - if not hasattr(user, 'member'): - return False - - if obj is None: - return True - return request.user.member.may_delete(obj) - - def get_fields(self, request, obj=None): - if request.user.has_perm('members.may_set_auth_user'): - if 'user' not in self.fields: - self.fields.append('user') - else: - if 'user' in self.fields: - self.fields.remove('user') - return super(MemberAdmin, self).get_fields(request, obj) + field_permissions = { + 'user': 'members.may_set_auth_user', + 'group': 'members.may_change_group' + } def get_queryset(self, request): queryset = super().get_queryset(request) - if request.user.has_perm('members.may_list_everyone'): - return annotate_activity_score(queryset.prefetch_related('group')) - - if not hasattr(request.user, 'member'): - return Member.objects.none() - - queryset = request.user.member.filter_queryset_by_permissions(queryset, annotate=True) return annotate_activity_score(queryset.prefetch_related('group')) def change_view(self, request, object_id, form_url="", extra_context=None): @@ -242,11 +198,6 @@ class MemberAdmin(admin.ModelAdmin): extra_context=extra_context) except Member.DoesNotExist: return super().change_view(request, object_id) - except PermissionDenied: - member = Member.objects.get(pk=object_id) - messages.error(request, - _("You are not allowed to view %(name)s.") % {'name': member.name}) - return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) def send_mail_to(self, request, queryset): member_pks = [m.pk for m in queryset] @@ -297,7 +248,7 @@ class MemberAdmin(admin.ModelAdmin): class MemberUnconfirmedAdmin(admin.ModelAdmin): fields = ['prename', 'lastname', 'email', 'email_parents', 'cc_email_parents', 'street', 'plz', - 'town', 'phone_number', 'phone_number_parents', 'birth_date', 'group', + 'town', 'phone_number_mobile', 'phone_number_private','phone_number_parents', 'birth_date', 'group', 'registered', 'registration_form', 'active', 'comments'] list_display = ('name', 'birth_date', 'age', 'get_group', 'confirmed_mail', 'confirmed_mail_parents') search_fields = ('prename', 'lastname', 'email') @@ -380,7 +331,7 @@ class WaiterInviteForm(forms.Form): label=_('Group')) -class MemberWaitingListAdmin(admin.ModelAdmin): +class MemberWaitingListAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['prename', 'lastname', 'email', 'email_parents', 'birth_date', 'comments', 'invited_for_group'] list_display = ('name', 'birth_date', 'age', 'confirmed_mail', 'confirmed_mail_parents', 'waiting_confirmed') @@ -501,7 +452,7 @@ class GroupAdminForm(forms.ModelForm): self.fields['leiters'].queryset = Member.objects.filter(group__name='Jugendleiter') -class GroupAdmin(admin.ModelAdmin): +class GroupAdmin(CommonAdminMixin, admin.ModelAdmin): fields = ['name', 'year_from', 'year_to', 'leiters'] form = GroupAdminForm list_display = ('name', 'year_from', 'year_to') @@ -529,13 +480,13 @@ class FreizeitAdminForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(FreizeitAdminForm, self).__init__(*args, **kwargs) - q = self.fields['jugendleiter'].queryset - self.fields['jugendleiter'].queryset = q.filter(group__name='Jugendleiter') - #self.fields['add_member'].queryset = Member.objects.filter(prename__startswith='F') + if 'jugendleiter' in self.fields: + q = self.fields['jugendleiter'].queryset + self.fields['jugendleiter'].queryset = q.filter(group__name='Jugendleiter') -class BillOnStatementInline(FilteredMemberFieldMixin, admin.TabularInline): - model = Bill +class BillOnExcursionInline(FilteredMemberFieldMixin, CommonAdminInlineMixin, admin.TabularInline): + model = BillOnExcursionProxy extra = 0 sortable_options = [] fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] @@ -543,31 +494,16 @@ class BillOnStatementInline(FilteredMemberFieldMixin, admin.TabularInline): TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} } - def get_readonly_fields(self, request, obj=None): - if obj is not None and obj.submitted: - return self.fields - return super(BillOnStatementInline, self).get_readonly_fields(request, obj) - -class StatementOnListInline(nested_admin.NestedStackedInline): +class StatementOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline): model = Statement extra = 1 sortable_options = [] fields = ['night_cost'] - inlines = [BillOnStatementInline] - - def get_readonly_fields(self, request, obj=None): - if obj is not None and hasattr(obj, 'statement') and obj.statement.submitted: - return self.fields - return super(StatementOnListInline, self).get_readonly_fields(request, obj) - - def has_delete_permission(self, request, obj=None): - if obj is not None and hasattr(obj, 'statement') and obj.statement.submitted: - return False - return True + inlines = [BillOnExcursionInline] -class InterventionOnLJPInline(admin.TabularInline): +class InterventionOnLJPInline(CommonAdminInlineMixin, admin.TabularInline): model = Intervention extra = 0 sortable_options = [] @@ -576,14 +512,14 @@ class InterventionOnLJPInline(admin.TabularInline): } -class LJPOnListInline(nested_admin.NestedStackedInline): +class LJPOnListInline(CommonAdminInlineMixin, nested_admin.NestedStackedInline): model = LJPProposal extra = 1 sortable_options = [] inlines = [InterventionOnLJPInline] -class MemberOnListInline(FilteredMemberFieldMixin, GenericTabularInline): +class MemberOnListInline(FilteredMemberFieldMixin, CommonAdminInlineMixin, GenericTabularInline): model = NewMemberOnList extra = 0 formfield_overrides = { @@ -669,8 +605,8 @@ class MemberNoteListAdmin(admin.ModelAdmin): generate_summary.short_description = "PDF Übersicht erstellen" -class FreizeitAdmin(FilteredMemberFieldMixin, nested_admin.NestedModelAdmin): - inlines = [MemberOnListInline, LJPOnListInline, StatementOnListInline] +class FreizeitAdmin(CommonAdminMixin, nested_admin.NestedModelAdmin): + #inlines = [MemberOnListInline, LJPOnListInline, StatementOnListInline] form = FreizeitAdminForm list_display = ['__str__', 'date'] search_fields = ('name',) @@ -682,6 +618,12 @@ class FreizeitAdmin(FilteredMemberFieldMixin, nested_admin.NestedModelAdmin): # ForeignKey: {'widget': apply_select2(forms.Select)} #} + def get_inlines(self, request, obj=None): + if obj: + return [MemberOnListInline, LJPOnListInline, StatementOnListInline] + else: + return [MemberOnListInline] + class Media: css = {'all': ('admin/css/tabular_hide_original.css',)} @@ -689,23 +631,11 @@ class FreizeitAdmin(FilteredMemberFieldMixin, nested_admin.NestedModelAdmin): super(FreizeitAdmin, self).__init__(*args, **kwargs) def save_model(self, request, obj, form, change): - print("saving model") if not change and hasattr(request.user, 'member') and hasattr(obj, 'statement'): - print("setting obj statement created") obj.statement.created_by = request.user.member obj.statement.save() super().save_model(request, obj, form, change) - def get_queryset(self, request): - queryset = super().get_queryset(request) - if request.user.has_perm('members.may_list_all_excursions'): - return queryset - - if not hasattr(request.user, 'member'): - return Member.objects.none() - - return Freizeit.filter_queryset_by_permissions(request.user.member, queryset) - def may_view_excursion(self, request, memberlist): return request.user.has_perm('members.may_view_everyone') or \ ( hasattr(request.user, 'member') and \ diff --git a/jdav_web/members/migrations/0007_alter_permission_options.py b/jdav_web/members/migrations/0007_alter_permission_options.py new file mode 100644 index 0000000..7bb1a6f --- /dev/null +++ b/jdav_web/members/migrations/0007_alter_permission_options.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.1 on 2023-04-03 20:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0006_rename_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='freizeit', + options={'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'}, + ), + migrations.AlterModelOptions( + name='member', + options={'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'}, + ), + migrations.AlterModelOptions( + name='membertraining', + options={'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'}, + ), + ] diff --git a/jdav_web/members/migrations/0008_change_default_permissions.py b/jdav_web/members/migrations/0008_change_default_permissions.py new file mode 100644 index 0000000..b192d57 --- /dev/null +++ b/jdav_web/members/migrations/0008_change_default_permissions.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.1 on 2023-04-03 21:20 + +from django.db import migrations +import django.db.migrations.operations.special + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0007_alter_permission_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='member', + options={'default_permissions': ('add_global', 'change_global', 'delete_global', 'view_global'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'}, + ), + migrations.AlterModelOptions( + name='freizeit', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'}, + ), + migrations.AlterModelOptions( + name='ljpproposal', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'LJP Proposal', 'verbose_name_plural': 'LJP Proposals'}, + ), + migrations.AlterModelOptions( + name='membertraining', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'}, + ), + migrations.AlterModelOptions( + name='newmemberonlist', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'verbose_name': 'Member', 'verbose_name_plural': 'Members'}, + ), + migrations.AlterModelOptions( + name='member', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'}, + ), + ] diff --git a/jdav_web/members/migrations/0009_alter_freizeit_options_alter_ljpproposal_options_and_more.py b/jdav_web/members/migrations/0009_alter_freizeit_options_alter_ljpproposal_options_and_more.py new file mode 100644 index 0000000..8f4a133 --- /dev/null +++ b/jdav_web/members/migrations/0009_alter_freizeit_options_alter_ljpproposal_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0.1 on 2023-04-04 12:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0008_change_default_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='freizeit', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Freizeit', 'verbose_name_plural': 'Freizeiten'}, + ), + migrations.AlterModelOptions( + name='ljpproposal', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'LJP Proposal', 'verbose_name_plural': 'LJP Proposals'}, + ), + migrations.AlterModelOptions( + name='member', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('may_see_qualities', 'Is allowed to see the quality overview'), ('may_set_auth_user', 'Is allowed to set auth user member connections.'), ('change_member_group', 'Can change the group field')), 'verbose_name': 'member', 'verbose_name_plural': 'members'}, + ), + migrations.AlterModelOptions( + name='membertraining', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Training', 'verbose_name_plural': 'Trainings'}, + ), + migrations.AlterModelOptions( + name='memberwaitinglist', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'permissions': (('may_manage_waiting_list', 'Can view and manage the waiting list.'),), 'verbose_name': 'Waiter', 'verbose_name_plural': 'Waiters'}, + ), + migrations.AlterModelOptions( + name='newmemberonlist', + options={'default_permissions': ('add_global', 'change_global', 'view_global', 'delete_global', 'list_global', 'view'), 'verbose_name': 'Member', 'verbose_name_plural': 'Members'}, + ), + ] diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index ff37517..50b6b55 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -18,8 +18,12 @@ from django.contrib.auth.models import User from django.conf import settings from django.core.validators import MinValueValidator -from dateutil.relativedelta import relativedelta +from .rules import may_view, may_change, may_delete, is_own_training, is_oneself, is_leader, is_leader_of_excursion +import rules +from contrib.models import CommonModel +from contrib.rules import memberize_user, has_global_perm +from dateutil.relativedelta import relativedelta def generate_random_key(): return uuid.uuid4().hex @@ -29,6 +33,7 @@ GEMEINSCHAFTS_TOUR = MUSKELKRAFT_ANREISE = 0 FUEHRUNGS_TOUR = OEFFENTLICHE_ANREISE = 1 AUSBILDUNGS_TOUR = FAHRGEMEINSCHAFT_ANREISE = 2 + class ActivityCategory(models.Model): """ Describes one kind of activity @@ -69,7 +74,7 @@ class MemberManager(models.Manager): return super().get_queryset().filter(confirmed=True) -class Person(models.Model): +class Person(CommonModel): """ Represents an abstract person. Not necessarily a member of any group. """ @@ -89,7 +94,7 @@ class Person(models.Model): confirm_mail_key = models.CharField(max_length=32, default="") confirm_mail_parents_key = models.CharField(max_length=32, default="") - class Meta: + class Meta(CommonModel.Meta): abstract = True def __str__(self): @@ -123,9 +128,9 @@ class Person(models.Model): self.confirmed_mail_parents = False self.confirm_mail_parents_key = uuid.uuid4().hex send_mail(_('Email confirmation needed'), - CONFIRM_MAIL_TEXT.format(name=self.prename, - link=get_mail_confirmation_link(self.confirm_mail_parents_key), - whattoconfirm='der Emailadresse deiner Eltern'), + settings.CONFIRM_MAIL_TEXT.format(name=self.prename, + link=get_mail_confirmation_link(self.confirm_mail_parents_key), + whattoconfirm='der Emailadresse deiner Eltern'), settings.DEFAULT_SENDING_MAIL, self.email_parents) else: @@ -293,11 +298,21 @@ class Member(Person): return groupstring get_group.short_description = _('Group') - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('member') verbose_name_plural = _('members') - permissions = (('may_see_qualities', 'Is allowed to see the quality overview'), - ('may_set_auth_user', 'Is allowed to set auth user member connections.')) + permissions = ( + ('may_see_qualities', 'Is allowed to see the quality overview'), + ('may_set_auth_user', 'Is allowed to set auth user member connections.'), + ('change_member_group', 'Can change the group field'), + ) + rules_permissions = { + 'members': rules.always_allow, + 'add_obj': has_global_perm('members.add_global_member'), + 'view_obj': may_view | has_global_perm('members.view_global_member'), + 'change_obj': may_change | has_global_perm('members.change_global_member'), + 'delete_obj': may_delete | has_global_perm('members.delete_global_member'), + } def get_skills(self): # get skills by summing up all the activities taken part in @@ -333,13 +348,51 @@ class Member(Person): settings.DEFAULT_SENDING_MAIL, jl.email) - def filter_queryset_by_permissions(self, queryset=None, annotate=False): + def filter_queryset_by_permissions(self, queryset=None, annotate=False, model=None): + name = model._meta.object_name if queryset is None: queryset = Member.objects.all() - # every member may list themself + if name == "Message": + return self.filter_messages_by_permissions(queryset, annotate) + elif name == "Member": + return self.filter_members_by_permissions(queryset, annotate) + elif name == "StatementUnSubmitted": + return self.filter_statements_by_permissions(queryset, annotate) + elif name == "Freizeit": + return self.filter_excursions_by_permissions(queryset, annotate) + elif name == "LJPProposal": + return queryset + elif name == "MemberTraining": + return queryset + elif name == "NewMemberOnList": + return queryset + elif name == "Statement": + return queryset + elif name == "BillOnExcursionProxy": + return queryset + elif name == "Intervention": + return queryset + elif name == "BillOnStatementProxy": + return queryset + elif name == "Attachment": + return queryset + elif name == "Group": + return queryset + else: + raise ValueError(name) + + def filter_members_by_permissions(self, queryset, annotate=False): + #mems = Member.objects.all().prefetch_related('group') + + #list_pks = [ m.pk for m in mems if self.may_list(m) ] + #view_pks = [ m.pk for m in mems if self.may_view(m) ] + + ## every member may list themself pks = [self.pk] view_pks = [self.pk] + + if hasattr(self, 'permissions'): pks += [ m.pk for m in self.permissions.list_members.all() ] view_pks += [ m.pk for m in self.permissions.view_members.all() ] @@ -367,6 +420,21 @@ class Member(Person): return filtered.annotate(_viewable=Case(When(pk__in=view_pks, then=Value(True)), default=Value(False), output_field=models.BooleanField())) + def filter_messages_by_permissions(self, queryset, annotate=False): + # ignores annotate + return queryset.filter(created_by=self) + + def filter_statements_by_permissions(self, queryset, annotate=False): + # ignores annotate + return queryset.filter(Q(created_by=self) | Q(excursion__jugendleiter=self)) + + def filter_excursions_by_permissions(self, queryset, annotate=False): + # ignores annotate + groups = self.leited_groups.all() + # one may view all excursions by leited groups and leited excursions + queryset = queryset.filter(Q(groups__in=groups) | Q(jugendleiter=self)).distinct() + return queryset + def may_list(self, other): if self.pk == other.pk: return True @@ -493,10 +561,16 @@ class MemberWaitingList(Person): default=None, verbose_name=_('Invited for group'), on_delete=models.SET_NULL) - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('Waiter') verbose_name_plural = _('Waiters') permissions = (('may_manage_waiting_list', 'Can view and manage the waiting list.'),) + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_memberwaitinglist'), + 'view_obj': has_global_perm('members.view_global_memberwaitinglist'), + 'change_obj': has_global_perm('members.change_global_memberwaitinglist'), + 'delete_obj': has_global_perm('members.delete_global_memberwaitinglist'), + } @property def waiting_confirmation_needed(self): @@ -565,7 +639,7 @@ class MemberWaitingList(Person): else self.email) -class NewMemberOnList(models.Model): +class NewMemberOnList(CommonModel): """ Connects members to a list of members. """ @@ -579,9 +653,15 @@ class NewMemberOnList(models.Model): def __str__(self): return str(self.member) - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('Member') verbose_name_plural = _('Members') + rules_permissions = { + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader, + 'delete_obj': is_leader, + } @property def comments_tex(self): @@ -604,7 +684,7 @@ class NewMemberOnList(models.Model): return ", ".join(qualities) -class Freizeit(models.Model): +class Freizeit(CommonModel): """Lets the user create a 'Freizeit' and generate a members overview in pdf format. """ name = models.CharField(verbose_name=_('Activity'), default='', @@ -641,9 +721,15 @@ class Freizeit(models.Model): """String represenation""" return self.name - class Meta: + class Meta(CommonModel.Meta): verbose_name = "Freizeit" verbose_name_plural = "Freizeiten" + rules_permissions = { + 'add_obj': has_global_perm('members.add_global_freizeit'), + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader | has_global_perm('members.change_global_freizeit'), + 'delete_obj': is_leader | has_global_perm('members.delete_global_freizeit'), + } def get_tour_type(self): if self.tour_type == FUEHRUNGS_TOUR: @@ -827,7 +913,7 @@ class RegistrationPassword(models.Model): verbose_name_plural = _('registration passwords') -class LJPProposal(models.Model): +class LJPProposal(CommonModel): """A proposal for LJP""" title = models.CharField(verbose_name=_('Title'), max_length=30) @@ -843,14 +929,20 @@ class LJPProposal(models.Model): null=True, on_delete=models.SET_NULL) - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('LJP Proposal') verbose_name_plural = _('LJP Proposals') + rules_permissions = { + 'add_obj': is_leader, + 'view_obj': is_leader | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader, + 'delete_obj': is_leader, + } def __str__(self): return self.title -class Intervention(models.Model): +class Intervention(CommonModel): """An intervention during a seminar as part of a LJP proposal""" date_start = models.DateTimeField(verbose_name=_('Starting time')) duration = models.DecimalField(verbose_name=_('Duration in hours'), @@ -866,6 +958,12 @@ class Intervention(models.Model): class Meta: verbose_name = _('Intervention') verbose_name_plural = _('Interventions') + rules_permissions = { + 'add_obj': is_leader_of_excursion, + 'view_obj': is_leader_of_excursion | has_global_perm('members.view_global_freizeit'), + 'change_obj': is_leader_of_excursion, + 'delete_obj': is_leader_of_excursion, + } def annotate_activity_score(queryset): @@ -1030,7 +1128,7 @@ class TrainingCategory(models.Model): return self.name -class MemberTraining(models.Model): +class MemberTraining(CommonModel): """Represents a training planned or attended by a member.""" member = models.ForeignKey(Member, on_delete=models.CASCADE, related_name='traininigs') title = models.CharField(verbose_name=_('Title'), max_length=30) @@ -1040,9 +1138,17 @@ class MemberTraining(models.Model): participated = models.BooleanField(verbose_name=_('Participated')) passed = models.BooleanField(verbose_name=_('Passed')) - class Meta: + class Meta(CommonModel.Meta): verbose_name = _('Training') verbose_name_plural = _('Trainings') + rules_permissions = { + # sine this is used in an inline, the member and not the training is passed + 'add_obj': is_oneself | has_global_perm('members.add_global_membertraining'), + 'view_obj': is_oneself | has_global_perm('members.view_global_membertraining'), + 'change_obj': is_oneself | has_global_perm('members.change_global_membertraining'), + 'delete_obj': is_oneself | has_global_perm('members.delete_global_membertraining'), + } + def import_from_csv(path): @@ -1179,3 +1285,4 @@ CLUBDESK_TO_KOMPASS = { 'Erziehungsberechtigte': 'legal_guardians', 'Mobil Eltern': 'phone_number_parents', } + diff --git a/jdav_web/members/rules.py b/jdav_web/members/rules.py new file mode 100644 index 0000000..c45a032 --- /dev/null +++ b/jdav_web/members/rules.py @@ -0,0 +1,75 @@ +from contrib.rules import memberize_user +from rules import predicate + + +@predicate +@memberize_user +def is_oneself(self, other): + assert other is not None + return self.pk == other.pk + + +@predicate +@memberize_user +def may_view(self, other): + assert other is not None + return self.may_view(other) + + +@predicate +@memberize_user +def may_change(self, other): + assert other is not None + return self.may_change(other) + + +@predicate +@memberize_user +def may_delete(self, other): + assert other is not None + return self.may_delete(other) + + +@predicate +@memberize_user +def is_own_training(self, training): + assert training is not None + return training.member == self + + +@predicate +@memberize_user +def is_leader_of_excursion(self, ljpproposal): + assert ljpproposal is not None + if not hasattr(ljpproposal, 'excursion'): + return _is_leader(self, ljpproposal) + return _is_leader(self, ljpproposal.excursion) + + +@predicate +@memberize_user +def is_leader(self, excursion): + assert excursion is not None + return _is_leader(self, excursion) + + +def _is_leader(member, excursion): + if not hasattr(member, 'pk'): + return False + if member.pk is None: + return False + if member in excursion.jugendleiter.all(): + return True + yl = [ yl for group in member.group.all() for yl in group.leiters.all() ] + return member in yl + + +@predicate +@memberize_user +def statement_not_submitted(self, excursion): + assert excursion is not None + if not hasattr(excursion, 'statement'): + return False + if excursion.statement is None: + return False + return not excursion.statement.submitted