From 7816ac4237b3ea7e0da4f4c4e442e8211a4639e4 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 26 Aug 2025 12:54:29 +0200 Subject: [PATCH 1/8] add terms_accepted field to Email, List, Literature and BusinessCard via mixin --- input/models.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/input/models.py b/input/models.py index c0a8949..3c8b91c 100755 --- a/input/models.py +++ b/input/models.py @@ -5,9 +5,19 @@ from django.utils.html import format_html from django.utils.safestring import mark_safe EMAIL_STATES = {'NONE': 'noch keine Mail versendet', - 'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet', - 'CLOSE': 'die Projektabschlussmail wurde versendet', - 'END': 'alle automatischen Mails, auch surveyMail, wurden versendet'} + 'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet', + 'CLOSE': 'die Projektabschlussmail wurde versendet', + 'END': 'alle automatischen Mails, auch surveyMail, wurden versendet'} + + +class TermsConsentMixin(models.Model): + """Abstract mixin to add a terms_accepted field for documenting user consent.""" + + terms_accepted = models.BooleanField(default=False, verbose_name="Nutzungsbedingungen zugestimmt") + + class Meta: + abstract = True + class Volunteer(models.Model): realname = models.CharField(max_length=200, null=True, verbose_name="Realname", @@ -323,7 +333,7 @@ SELFBUY_CHOICES = {'TRUE': mark_safe('Ich möchte das Werk selbst kaufen und per } -class Literature(Grant): +class Literature(TermsConsentMixin, Grant): info = models.CharField(max_length=500, verbose_name='Informationen zum Werk', help_text=mark_safe("Bitte gib alle Informationen zum benötigten Werk an,
\ die eine eindeutige Identifizierung ermöglichen (Autor, Titel, Verlag, ISBN, ...)")) @@ -367,7 +377,7 @@ ADULT_CHOICES = {'TRUE': mark_safe('Ich bin volljährig.'), 'FALSE': mark_safe('Ich bin noch nicht volljährig.') } -class Email(Domain): +class Email(TermsConsentMixin, Domain): address = models.CharField(max_length=50, choices=MAIL_CHOICES.items(), default='USERNAME', verbose_name='Adressbestandteil', @@ -377,7 +387,7 @@ class Email(Domain): adult = models.CharField( max_length=10, verbose_name='Volljährigkeit', choices=ADULT_CHOICES.items(), default='FALSE') intern_notes = models.TextField(max_length=1000, blank=True, verbose_name="interne Anmerkungen") -class List(Domain): +class List(TermsConsentMixin, Domain): address = models.CharField(max_length=50, default='NO_ADDRESS', verbose_name="Adressbestandteil für Projektmailingliste", help_text=mark_safe("Bitte gib hier den gewünschten Adressbestandteil an,
der sich vor der Domain befinden soll.")) @@ -396,7 +406,7 @@ PROJECT_CHOICE = {'PEDIA': 'Wikipedia', BC_VARIANT = {'PIC': 'mit Bild', 'NOPIC': 'ohne Bild'} -class BusinessCard(Extern): +class BusinessCard(TermsConsentMixin, Extern): project = models.CharField(max_length=20, choices=PROJECT_CHOICE.items(), default='PEDIA', verbose_name='Wikimedia-Projekt', help_text='Für welches Wikimedia-Projekt möchtest Du Visitenkarten?') From 9ef2af0a2fdb2fee6919940ef16e3173ba705368 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 26 Aug 2025 13:53:39 +0200 Subject: [PATCH 2/8] add terms_accepted to admin list display for BusinessCard, Literature, Email and List --- input/admin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/input/admin.py b/input/admin.py index 7da1e6f..6c8b0a8 100755 --- a/input/admin.py +++ b/input/admin.py @@ -57,7 +57,7 @@ class ProjectAdmin(admin.ModelAdmin): class BusinessCardAdmin(admin.ModelAdmin): save_as = True search_fields = ('realname', 'service_id', 'granted', 'granted_date', 'project') - list_display = ('realname', 'service_id', 'granted', 'granted_date', 'project') + list_display = ('realname', 'service_id', 'granted', 'granted_date', 'project', 'terms_accepted') list_display_links = ('realname', 'service_id') # action = ['export_as_csv'] date_hierarchy = 'granted_date' @@ -69,7 +69,7 @@ class BusinessCardAdmin(admin.ModelAdmin): class LiteratureAdmin(admin.ModelAdmin): save_as = True search_fields = ('realname', 'service_id', 'granted', 'granted_date') - list_display = ('realname', 'service_id', 'granted', 'granted_date') + list_display = ('realname', 'service_id', 'granted', 'granted_date', 'terms_accepted') list_display_links = ('realname', 'service_id') date_hierarchy = 'granted_date' readonly_fields = ['service_id'] @@ -142,7 +142,7 @@ class TravelAdmin(admin.ModelAdmin): class EmailAdmin(admin.ModelAdmin): save_as = True search_fields = ('realname', 'service_id', 'granted', 'granted_date') - list_display = ('realname', 'service_id', 'granted', 'granted_date') + list_display = ('realname', 'service_id', 'granted', 'granted_date', 'terms_accepted') list_display_links = ('realname', 'service_id') date_hierarchy = 'granted_date' radio_fields = {'adult': admin.VERTICAL} @@ -155,7 +155,7 @@ class EmailAdmin(admin.ModelAdmin): class ListAdmin(admin.ModelAdmin): save_as = True search_fields = ('realname', 'service_id', 'granted', 'granted_date') - list_display = ('realname', 'service_id', 'granted', 'granted_date') + list_display = ('realname', 'service_id', 'granted', 'granted_date', 'terms_accepted') list_display_links = ('realname', 'service_id') date_hierarchy = 'granted_date' readonly_fields = ['service_id'] From c187bf2e9e177e8c4b8d43a5457804b9484f2a5e Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 26 Aug 2025 15:37:33 +0200 Subject: [PATCH 3/8] Add custom save() in CheckForm to map 'check' field to model's 'terms_accepted' --- input/forms.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/input/forms.py b/input/forms.py index 32d40d6..0ee0237 100755 --- a/input/forms.py +++ b/input/forms.py @@ -164,7 +164,23 @@ class CheckForm(FdbForm): ) ) + def save(self, commit=True): + """Save the form instance and map the 'check' field to the model's 'terms_accepted' field, saving to the database if commit=True.""" + # Call the parent form's save() method with commit=False + # to get a model instance without saving it to the database yet. + instance = super().save(commit=False) + # If the model has a "terms_accepted" field, + # copy the value from the form field "check" into it. + if hasattr(instance, "terms_accepted"): + instance.terms_accepted = bool(self.cleaned_data.get('check')) + + # If commit=True (default), save the instance to the database now. + if commit: + instance.save() + + # Return the model instance (saved if commit=True, otherwise unsaved). + return instance """Baseclass for all classes which need a check for Nutzungsbedingungen""" # def __init__(self, *args, **kwargs): From e10ec7e691dae0690371f052d32658a9335eac9f Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 26 Aug 2025 15:39:43 +0200 Subject: [PATCH 4/8] Add migration 0099: add terms_accepted field to BusinessCard, Email, List, and Literature --- ..._accepted_email_terms_accepted_and_more.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 input/migrations/0099_businesscard_terms_accepted_email_terms_accepted_and_more.py diff --git a/input/migrations/0099_businesscard_terms_accepted_email_terms_accepted_and_more.py b/input/migrations/0099_businesscard_terms_accepted_email_terms_accepted_and_more.py new file mode 100644 index 0000000..180ada9 --- /dev/null +++ b/input/migrations/0099_businesscard_terms_accepted_email_terms_accepted_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.5 on 2025-08-26 11:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('input', '0098_add_eliterature_and_software_proxies'), + ] + + operations = [ + migrations.AddField( + model_name='businesscard', + name='terms_accepted', + field=models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt'), + ), + migrations.AddField( + model_name='email', + name='terms_accepted', + field=models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt'), + ), + migrations.AddField( + model_name='list', + name='terms_accepted', + field=models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt'), + ), + migrations.AddField( + model_name='literature', + name='terms_accepted', + field=models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt'), + ), + ] From 0d033e7e25442ffcbe4edfcff834ca6907b5c9c4 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 26 Aug 2025 22:48:13 +0200 Subject: [PATCH 5/8] Refactor CheckForm to use model field 'terms_accepted' instead of separate 'check' field --- input/forms.py | 44 +++++++++++--------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/input/forms.py b/input/forms.py index 0ee0237..bdc71ee 100755 --- a/input/forms.py +++ b/input/forms.py @@ -156,38 +156,16 @@ class CheckForm(FdbForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['check'] = BooleanField( - required=True, - label=format_html( + + # Check if the model field 'terms_accepted' is present + if 'terms_accepted' in self.fields: + # Make the field required (HTML5 validation) + self.fields['terms_accepted'].required = True + # Set custom label with link to terms + self.fields['terms_accepted'].label = format_html( "Ich stimme den Nutzungsbedingungen zu", self.termstoaccept ) - ) - - def save(self, commit=True): - """Save the form instance and map the 'check' field to the model's 'terms_accepted' field, saving to the database if commit=True.""" - # Call the parent form's save() method with commit=False - # to get a model instance without saving it to the database yet. - instance = super().save(commit=False) - - # If the model has a "terms_accepted" field, - # copy the value from the form field "check" into it. - if hasattr(instance, "terms_accepted"): - instance.terms_accepted = bool(self.cleaned_data.get('check')) - - # If commit=True (default), save the instance to the database now. - if commit: - instance.save() - - # Return the model instance (saved if commit=True, otherwise unsaved). - return instance - - """Baseclass for all classes which need a check for Nutzungsbedingungen""" -# def __init__(self, *args, **kwargs): -# check = BooleanField(required=True, -# label=format_html("Ich stimme den Nutzungsbedingungen zu", -# termstoaccept)) -# NUTZUNGSBEDINGUNGEN)) class LiteratureForm(CheckForm): @@ -198,7 +176,7 @@ class LiteratureForm(CheckForm): self.fields['selfbuy_give_data'].required = True class Meta: model = Literature - fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data'] + fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data', 'terms_accepted'] exclude = ['intern_notes', 'survey_mail_send', 'mail_state'] class Media: js = ('dropdown/js/literature.js',) @@ -225,7 +203,7 @@ class EmailForm(CheckForm): # TODO: add some javascript to show/hide other-field class Meta: model = Email - fields = ['domain', 'address', 'other', 'adult'] + fields = ['domain', 'address', 'other', 'adult', 'terms_accepted'] exclude = ['intern_notes', 'survey_mail_send', 'mail_state'] class Media: js = ('dropdown/js/mail.js',) @@ -243,7 +221,7 @@ class BusinessCardForm(CheckForm): class Meta: model = BusinessCard exclude = ['intern_notes', 'survey_mail_send', 'mail_state'] - fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to'] + fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to', 'terms_accepted'] class Media: js = ('dropdown/js/businessCard.js',) @@ -252,5 +230,5 @@ class ListForm(CheckForm): termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN class Meta: model = List - fields = ['domain', 'address'] + fields = ['domain', 'address', 'terms_accepted'] exclude = ['intern_notes', 'survey_mail_send','mail_state'] From ed2db043090c1f955c659049052f9d4ad0fcf105 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 1 Sep 2025 01:45:50 +0200 Subject: [PATCH 6/8] add negative test for Literature form submission without terms_accepted --- input/tests/views.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/input/tests/views.py b/input/tests/views.py index 53101d5..9bd961d 100644 --- a/input/tests/views.py +++ b/input/tests/views.py @@ -9,7 +9,7 @@ class AnonymousViewTestCase(TestCase): def test_index(self): response = request(self, 'index') - self.assertContains(response,'Sourcecode') + self.assertContains(response, 'Sourcecode') def test_extern(self): request(self, 'extern') @@ -131,3 +131,36 @@ class AuthenticatedViewTestCase(TestCase): def test_deny_error(self): self.helper_auth_deny_error('deny') + + +class TermsConsentViewTests(AnonymousViewTestCase): + + def test_extern_lit(self): + """ + Negative case: Literature form without terms_accepted must not succeed + and should display a required field error. + """ + # Step 1 + resp = request(self, 'extern', data=self.get_first_step_data('LIT')) + self.assertContains(resp, 'Literatur verwenden') + + # Step 2: submit without terms_accepted + payload = self.get_step_data(1, { + 'cost': 20, + 'info': 'Test', + 'source': 'Test', + 'notes': '', + 'selfbuy': 'TRUE', + 'selfbuy_data': 'NONE', + 'selfbuy_give_data': 'on', # checkbox checked + # terms_accepted intentionally omitted + }) + resp = request(self, 'extern', data=payload) + + # Expect to remain on step 2 with required field error + self.assertEqual(resp.status_code, 200) + self.assertNotContains(resp, 'Deine Anfrage wurde gesendet.') + self.assertRegex( + resp.content.decode(), + r'(Dieses Feld ist erforderlich|This field is required\.)' + ) From 6585eeaf78bf115050df0eeb3891cf37ad12b0ea Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 1 Sep 2025 11:42:20 +0200 Subject: [PATCH 7/8] replace regex assertion and remove status code check in test_extern_lit --- input/tests/views.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/input/tests/views.py b/input/tests/views.py index 9bd961d..4cbd3a5 100644 --- a/input/tests/views.py +++ b/input/tests/views.py @@ -158,9 +158,5 @@ class TermsConsentViewTests(AnonymousViewTestCase): resp = request(self, 'extern', data=payload) # Expect to remain on step 2 with required field error - self.assertEqual(resp.status_code, 200) self.assertNotContains(resp, 'Deine Anfrage wurde gesendet.') - self.assertRegex( - resp.content.decode(), - r'(Dieses Feld ist erforderlich|This field is required\.)' - ) + self.assertContains(resp, 'Dieses Feld ist zwingend erforderlich.') \ No newline at end of file From 63b07bdf222e2102b7c7038d3bce6d01333a0944 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 1 Sep 2025 12:47:24 +0200 Subject: [PATCH 8/8] set default LANGUAGE_CODE to 'de' instead of 'en-us' --- foerderbarometer/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foerderbarometer/settings.py b/foerderbarometer/settings.py index 7261d32..5fe2c46 100644 --- a/foerderbarometer/settings.py +++ b/foerderbarometer/settings.py @@ -119,7 +119,7 @@ AUTH_PASSWORD_VALIDATORS = password_validators( USE_I18N = True USE_L10N = True -LANGUAGE_CODE = env('LANGUAGE_CODE', 'en-us') +LANGUAGE_CODE = env('LANGUAGE_CODE', 'de') USE_TZ = True TIME_ZONE = env('TIME_ZONE', 'UTC')