diff --git a/input/forms.py b/input/forms.py index bdc71ee..8c2b6a5 100755 --- a/input/forms.py +++ b/input/forms.py @@ -1,9 +1,10 @@ from django.conf import settings -from django.forms import ModelForm, ChoiceField, RadioSelect, BooleanField +from django.forms import ModelForm, ChoiceField, RadioSelect, BooleanField, CharField, EmailField from django.contrib.admin.widgets import AdminDateWidget from django.forms.renderers import DjangoTemplates from django.utils.html import format_html from django.utils.safestring import mark_safe +from django import forms from .models import ( TYPE_CHOICES, @@ -33,20 +34,61 @@ class FdbForm(ModelForm): class ProjectForm(FdbForm): - # start = DateField(widget=AdminDateWidget()) class Meta: model = Project - exclude = ('pid', 'project_of_year', 'finance_id','granted', 'granted_date', 'realname', 'email',\ + exclude = ('pid', 'project_of_year', 'finance_id', 'granted', 'granted_date', 'realname', 'email', \ 'end_mail_send', 'status', 'persons', 'survey_mail_date', 'mail_state') widgets = {'start': AdminDateWidget(), - 'end': AdminDateWidget(),} + 'end': AdminDateWidget(), } class Media: js = ('dropdown/js/otrs_link.js',) +class CommonOrderMixin(forms.Form): + """ + Ensures a consistent field order for all forms that inherit from this mixin. + + The goal is to always render: + - "realname" and "email" fields at the top, + - all other fields in the middle (in the order Django provides), + - "check" (terms/privacy checkbox) at the bottom. + + This keeps the UX consistent across all forms without repeating field_order logic. + """ + + # Fields that should always appear first in the form + field_order_head = ('realname', 'email') + + # Fields that should always appear last in the form + field_order_tail = ('check',) # rename if your checkbox field is called differently + + def __init__(self, *args, **kwargs): + """ + Override the default form initialization to reorder fields. + The method: + 1. Collects all existing field names. + 2. Puts the 'head' fields first (if they exist in this form). + 3. Keeps all other fields in the middle. + 4. Puts the 'tail' fields last (if they exist in this form). + """ + super().__init__(*args, **kwargs) + + existing = list(self.fields.keys()) + + # Select only fields that actually exist in this form + head = [f for f in self.field_order_head if f in self.fields] + tail = [f for f in self.field_order_tail if f in self.fields] + + # All other fields that are not explicitly in head or tail + middle = [f for f in existing if f not in (*head, *tail)] + + # Apply the new order to the form fields + self.order_fields(head + middle + tail) + + class ExternForm(FdbForm): choice = ChoiceField(choices=TYPE_CHOICES.items(), widget=RadioSelect, @@ -75,11 +117,37 @@ class InternForm(FdbForm): HOTEL_CHOICES = {'TRUE': mark_safe('Hotelzimmer benötigt'), - 'FALSE': mark_safe('Kein Hotelzimmer benötigt') - } + 'FALSE': mark_safe('Kein Hotelzimmer benötigt') + } -class TravelForm(FdbForm): +class BaseApplicationForm(FdbForm): + """ + Base form for all external applications. + + - Adds standard fields that must appear in every form: + * realname (applicant's full name) + * email (contact address for notifications) + * check (confirmation of data protection and funding policy) + - Ensures consistency across all application types. + """ + realname = CharField( + label='Realname', + required=True, + help_text='Bitte gib deinen Vor- und Nachnamen ein.' + ) + email = EmailField( + label='E-Mail-Adresse', + required=True, + help_text='Bitte gib deine E-Mail-Adresse ein, damit dich
Wikimedia Deutschland bei Rückfragen oder für
die Zusage kontaktieren kann.' + ) + check = BooleanField(required=True, + label=format_html( + "Ich stimme den Datenschutzbestimmungen und der
Richtlinie zur Förderung der Communitys zu", + settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN)) + + +class TravelForm(BaseApplicationForm, CommonOrderMixin): # TODO: add some javascript to show/hide other-field # this is the code, to change required to false if needed @@ -107,7 +175,7 @@ class TravelForm(FdbForm): } -class LibraryForm(FdbForm): +class LibraryForm(BaseApplicationForm, CommonOrderMixin): class Meta: model = Library @@ -144,7 +212,7 @@ class HonoraryCertificateForm(FdbForm): js = ('dropdown/js/otrs_link.js',) -class IFGForm(FdbForm): +class IFGForm(BaseApplicationForm, CommonOrderMixin): class Meta: model = IFG fields = ['cost', 'url', 'notes'] @@ -168,7 +236,7 @@ class CheckForm(FdbForm): ) -class LiteratureForm(CheckForm): +class LiteratureForm(BaseApplicationForm, CommonOrderMixin): termstoaccept = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM def __init__(self, *args, **kwargs): @@ -186,7 +254,7 @@ ADULT_CHOICES = {'TRUE': mark_safe('Ich bin volljährig.'), } -class EmailForm(CheckForm): +class EmailForm(BaseApplicationForm, CommonOrderMixin): termstoaccept = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE @@ -210,7 +278,7 @@ class EmailForm(CheckForm): -class BusinessCardForm(CheckForm): +class BusinessCardForm(BaseApplicationForm, CommonOrderMixin): termstoaccept = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN # this is the code, to change required to false if needed def __init__(self, *args, **kwargs): @@ -226,7 +294,7 @@ class BusinessCardForm(CheckForm): js = ('dropdown/js/businessCard.js',) -class ListForm(CheckForm): +class ListForm(BaseApplicationForm, CommonOrderMixin): termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN class Meta: model = List diff --git a/input/static/css/forms.css b/input/static/css/forms.css new file mode 100644 index 0000000..548ddc7 --- /dev/null +++ b/input/static/css/forms.css @@ -0,0 +1,11 @@ +.star { + color: red; +} + +.wm-table { + margin: 0 auto; +} + +.col-request { + width: 40%; +} \ No newline at end of file diff --git a/input/templates/input/base.html b/input/templates/input/base.html new file mode 100644 index 0000000..7a08389 --- /dev/null +++ b/input/templates/input/base.html @@ -0,0 +1,37 @@ +{% load static %} + + + + + + + + + + + + + + + + + + + + + + + {% block head_extra %}{% endblock %} + + +{% include "input/partials/_header.html" %} + +
+ {% block pre_content %}{% endblock %} + {% block content %}{% endblock %} + {% block post_content %}{% endblock %} +
+ +{% include "input/partials/_footer.html" %} + + diff --git a/input/templates/input/extern.html b/input/templates/input/extern.html deleted file mode 100755 index d0721bf..0000000 --- a/input/templates/input/extern.html +++ /dev/null @@ -1,71 +0,0 @@ -{% load static %} - - - - - - -{{ form.media }} - - - - - -{% load i18n %} -{% block content %} - -
- - - - -

Schritt {{ wizard.steps.step1 }} von {{ wizard.steps.count }}

-
- {% csrf_token %} - -{% if choice %} -Du hast {{choice}} ausgewählt. -{% endif %} - {{ wizard.management_form }} - {% if wizard.form.forms %} - {{ wizard.form.management_form }} - {% for form in wizard.form.forms %} - {{ form }} - {% endfor %} - {% else %} - {{ wizard.form }} - {% endif %} -
-

- * Pflichtfeld -

- {% if wizard.steps.prev %} - - {% endif %} - {% if wizard.steps.current == wizard.steps.last %} - - {% else %} - - {% endif %} -

-

-

-Eine Übersicht aller Förderangebote von Wikimedia Deutschland findest du im - Förderportal in der deutschsprachigen Wikipedia. -
Für alle Fragen wende dich gern an das Team Communitys und Engagement. -

-Für interessierte Hacker gibts auch den Sourcecode zum Formular und was damit passiert. -

- Impressum -

{% endblock %} diff --git a/input/templates/input/forms/extern.html b/input/templates/input/forms/extern.html new file mode 100755 index 0000000..0566f2b --- /dev/null +++ b/input/templates/input/forms/extern.html @@ -0,0 +1,38 @@ +{% extends "input/base.html" %} + +{% load i18n %} +{% block content %} +
+ + + + + +
Was möchtest du beantragen? + Projektförderung + + + Serviceleistungen + +
+
+{% endblock %} diff --git a/input/templates/input/forms/form_generic.html b/input/templates/input/forms/form_generic.html new file mode 100644 index 0000000..b7b1f92 --- /dev/null +++ b/input/templates/input/forms/form_generic.html @@ -0,0 +1,30 @@ +{% extends "input/base.html" %} +{% load static %} + +{% block content %} + {{ form.media }} + +
+

Du hast {{ typestring }} ausgewählt.

+
+ +
+ {% csrf_token %} + + {% block pre_table %}{% endblock %} + + + {{ form.non_field_errors }} + {{ form.as_table }} +
+ + {% block post_table %}{% endblock %} + +

* Pflichtfeld

+ +
+ + +
+
+{% endblock %} diff --git a/input/templates/input/info_project_funding_gt_1000.html b/input/templates/input/info_project_funding_gt_1000.html new file mode 100644 index 0000000..d3e9856 --- /dev/null +++ b/input/templates/input/info_project_funding_gt_1000.html @@ -0,0 +1,18 @@ + + + + + Projektförderung ab 1.000,— EUR + + +

Projektförderung mit einer Gesamtsumme ab 1.000,— EUR

+

Vielen Dank für dein Interesse an einer Projektförderung!
+ Für Projektförderungen mit einer Gesamtsumme ab 1.000,— EUR ist ein öffentlicher Projektplan + erforderlich. Weitere Informationen zu diesem Prozess findest du unter + + Wikipedia:Förderung/Projektplanung.
+ Für Fragen steht dir das Team Community-Konferenzen & Förderung gern unter + community@wikimedia.de zur Verfügung. +

+ + diff --git a/input/templates/input/partials/_footer.html b/input/templates/input/partials/_footer.html new file mode 100644 index 0000000..af0f7e0 --- /dev/null +++ b/input/templates/input/partials/_footer.html @@ -0,0 +1,21 @@ + diff --git a/input/templates/input/partials/_header.html b/input/templates/input/partials/_header.html new file mode 100644 index 0000000..d2a45f4 --- /dev/null +++ b/input/templates/input/partials/_header.html @@ -0,0 +1,4 @@ +{% load static %} +
+ Wikimedia Deutschland +
diff --git a/input/urls.py b/input/urls.py index 807e9bf..3d89297 100755 --- a/input/urls.py +++ b/input/urls.py @@ -1,12 +1,41 @@ from django.urls import path - -from .views import ExternView, index, done, authorize, deny, export +from django.views.generic import TemplateView +from django.views.i18n import JavaScriptCatalog +from .views import ( + index, done, export, authorize, deny, + TravelApplicationView, IFGApplicationView, EmailApplicationView, + LiteratureApplicationView, ListApplicationView, BusinessCardApplicationView, + LibraryApplicationView, ELiteratureApplicationView, SoftwareApplicationView, +) urlpatterns = [ path('', index, name='index'), - path('extern', ExternView.as_view(), name='extern'), + path( + 'extern/', + TemplateView.as_view(template_name='input/forms/extern.html'), + name='extern', + ), path('saved', done, name='done'), path('export', export, name='export'), path('authorize//', authorize, name='authorize'), path('deny//', deny, name='deny'), + + # Static info page for project funding above 1000 EUR + path('extern/info/projektfoerderung-ab-1000/', + TemplateView.as_view(template_name='input/info_project_funding_gt_1000.html'), + name='info-foerderprojekt-ab-1000'), + + # New single-page application views + path('extern/reisekosten/', TravelApplicationView.as_view(), name='reisekosten'), + path('extern/ifg/', IFGApplicationView.as_view(), name='ifg'), + path('extern/email/', EmailApplicationView.as_view(), name='email'), + path('extern/literaturstipendium/', LiteratureApplicationView.as_view(), name='literatur'), + path('extern/mailingliste/', ListApplicationView.as_view(), name='mailingliste'), + path('extern/visitenkarten/', BusinessCardApplicationView.as_view(), name='visitenkarten'), + path('extern/bibliotheksstipendium/', LibraryApplicationView.as_view(), name='bibliotheksstipendium'), + path('extern/eliteraturstipendium/', ELiteratureApplicationView.as_view(), name='eliteraturstipendium'), + path('extern/softwarestipendium/', SoftwareApplicationView.as_view(), name='softwarestipendium'), + + # JavaScript translations for date widgets, etc. + path('jsi18n/', JavaScriptCatalog.as_view(), name='jsi18n'), ] diff --git a/input/views.py b/input/views.py index 46c92c7..04b5dc8 100755 --- a/input/views.py +++ b/input/views.py @@ -8,6 +8,7 @@ from django.core.mail import BadHeaderError, EmailMultiAlternatives from django.template.loader import get_template from django.conf import settings from django.contrib.auth.decorators import login_required +from django.views.generic.edit import FormView from .forms import ( ExternForm, @@ -29,6 +30,30 @@ LIBRARY_FORMS = { TYPE_SOFT: SoftwareForm, } +HELP_TEXTS = { + 'IFG': { + 'notes': ( + 'Bitte gib an, wie die gewonnenen Informationen den
' + 'Wikimedia-Projekten zugute kommen sollen.' + ) + }, + 'MAIL': { + 'domain': ( + 'Mit welcher Domain, bzw. für welches Wikimedia-Projekt,
' + 'möchtest du eine Mailadresse beantragen?' + ) + }, + 'LIT': { + 'notes': 'Bitte gib an, wofür du die Literatur verwenden möchtest.' + }, + 'LIST': { + 'domain': ( + 'Mit welcher Domain, bzw. für welches Wikimedia-Projekt,
' + 'möchtest du eine Mailingliste beantragen?' + ) + }, +} + def auth_deny(choice, pk, auth): if choice not in MODELS: @@ -51,7 +76,7 @@ def authorize(request, choice, pk): if ret := auth_deny(choice, pk, True): return ret else: - return HttpResponse(f"AUTHORIZED! choice: {choice}, pk: {pk}") + return HttpResponse(f'AUTHORIZED! choice: {choice}, pk: {pk}') @login_required @@ -62,143 +87,118 @@ def deny(request, choice, pk): if ret := auth_deny(choice, pk, False): return ret else: - return HttpResponse(f"DENIED! choice: {choice}, pk: {pk}") + return HttpResponse(f'DENIED! choice: {choice}, pk: {pk}') def done(request): - return HttpResponse("Deine Anfrage wurde gesendet. Du erhältst in Kürze eine E-Mail-Benachrichtigung mit deinen Angaben. Für alle Fragen kontaktiere bitte das Team Communitys und Engagement unter community@wikimedia.de.") + return HttpResponse( + 'Deine Anfrage wurde gesendet. Du erhältst in Kürze eine E-Mail-Benachrichtigung mit deinen Angaben. Für alle Fragen kontaktiere bitte das Team Communitys und Engagement unter community@wikimedia.de.') def index(request): return render(request, 'input/index.html') -class ExternView(CookieWizardView): - '''This View is for Volunteers''' +class BaseApplicationView(FormView): + """ + Base view for all application types. - template_name = "input/extern.html" - form_list = [ExternForm, LibraryForm] - - def get_form(self, step=None, data=None, files=None): - '''this function determines which part of the multipart form is - displayed next''' - - if step is None: - step = self.steps.current - print ("get_form() step " + step) - - if step == '1': - prev_data = self.get_cleaned_data_for_step('0') - choice = prev_data.get('choice') - print(f'choice detection in ExternView: {TYPE_CHOICES[choice]}') - if choice == 'IFG': - form = IFGForm(data) - form.fields['notes'].help_text = mark_safe("Bitte gib an, wie die gewonnenen Informationen den
Wikimedia-Projekten zugute kommen sollen.") - elif choice in LIBRARY_FORMS: - form = LIBRARY_FORMS[choice](data) - elif choice == 'MAIL': - form = EmailForm(data) - form.fields['domain'].help_text = mark_safe("Mit welcher Domain, bzw. für welches Wikimedia-Projekt,
möchtest du eine Mailadresse beantragen?") - elif choice == 'LIT': - form = LiteratureForm(data) - form.fields['notes'].help_text = "Bitte gib an, wofür du die Literatur verwenden möchtest." - elif choice == 'VIS': - form = BusinessCardForm(data) - elif choice == 'LIST': - form = ListForm(data) - form.fields['domain'].help_text = mark_safe("Mit welcher Domain, bzw. für welches Wikimedia-Projekt,
möchtest du eine Mailingliste beantragen?") - elif choice == 'TRAV': - form = TravelForm(data) - else: # pragma: no cover - raise RuntimeError(f'ERROR! UNKNOWN FORMTYPE {choice} in ExternView') - self.choice = choice - else: - form = super().get_form(step, data, files) - return form + - Each application type (travel, literature, email, etc.) gets its own subclass. + - Renders the generic form template. + - Handles saving the submitted form to the database. + - Adds extra fields from the session or request type if needed. + - Applies optional help_text overrides for certain fields. + - Sends confirmation mail to the applicant. + - Sends notification mail to the internal IF address. + - Returns the "done" response after successful processing. + """ + template_name = 'input/forms/form_generic.html' + type_code: str = '' def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if hasattr(self, 'choice'): - context["choice"] = TYPE_CHOICES[self.choice] - return context + """Add the human-readable type string (from TYPE_CHOICES) to the template context.""" + ctx = super().get_context_data(**kwargs) + ctx['typestring'] = TYPE_CHOICES.get(self.type_code, self.type_code) + return ctx - def done(self, form_list, **kwargs): - print('ExternView.done() reached') - # gather data from all forms - data = {} - for form in form_list: - data = {**data, **form.cleaned_data} + def get_form(self, form_class=None): + """Return the form instance and inject custom help_texts if defined for this type.""" + form = super().get_form(form_class) - if data['choice'] == 'LIT': - if data['selfbuy'] == 'TRUE': - data['selfbuy_give_data'] = 'False' + # Apply help_text overrides if defined for this type_code + if self.type_code in HELP_TEXTS: + for field, text in HELP_TEXTS[self.type_code].items(): + if field in form.fields: + form.fields[field].help_text = mark_safe(text) - # write data to database + return form + + def form_valid(self, form): + """ + Process a valid form submission: + - Enrich form data (e.g., set type_code, handle special rules). + - Save the model instance and related data. + - Send confirmation and notification mails. + - Return the "done" response. + """ + + # Collect cleaned data and mark the current type + data = form.cleaned_data.copy() + data['choice'] = self.type_code + + # Special rule for literature applications + if self.type_code == 'LIT' and data.get('selfbuy') == 'TRUE': + data['selfbuy_give_data'] = 'False' + + # Save model instance modell = form.save(commit=False) - # we have to copy the data from the first form here - # this is a bit ugly code. can we copy this without explicit writing? - if data['choice'] == 'LIT': + # Username from session if present + if user := self.request.session.get('user'): + modell.username = user.get('username') + + # Copy common fields if provided by the form + if 'realname' in data: + modell.realname = data['realname'] + if 'email' in data: + modell.email = data['email'] + + # Set model.type for specific request types + if self.type_code in ('BIB', 'ELIT', 'SOFT'): + modell.type = self.type_code + + # Literature-specific extra field + if self.type_code == 'LIT' and 'selfbuy_give_data' in data: modell.selfbuy_give_data = data['selfbuy_give_data'] - if user := self.request.session.get('user'): - modell.username = user['username'] + modell.save() + if hasattr(form, 'save_m2m'): + form.save_m2m() - modell.realname = data['realname'] - modell.email = data['email'] - # write type of form in some cases - if data['choice'] in ('BIB', 'ELIT', 'SOFT'): - modell.type = data['choice'] - - form.save() - - # add some data to context for mail templates + # Prepare minimal mail context and send mails data['pk'] = modell.pk data['url_prefix'] = settings.EMAIL_URL_PREFIX - data['grant'] = ('LIT', 'SOFT', 'ELIT', 'BIB', 'IFG') - data['DOMAIN'] = ('MAIL', 'LIST') - data['typestring'] = TYPE_CHOICES[data['choice']] + data['typestring'] = TYPE_CHOICES.get(self.type_code, self.type_code) + context = {'data': data} - # we need to send the following mails here: - context = { 'data': data } try: - # - mail with entered data to the Volunteer - - txt_mail_template1 = get_template('input/ifg_volunteer_mail.txt') - html_mail_template1 = get_template('input/ifg_volunteer_mail.html') - - subject1, from_email1, to1 = 'Formular ausgefüllt', settings.IF_EMAIL, data['email'] - text_content1 = txt_mail_template1.render(context) - html_content1 = html_mail_template1.render(context) - msg1 = EmailMultiAlternatives(subject1, text_content1, from_email1, [to1]) - msg1.attach_alternative(html_content1, "text/html") + # Mail to applicant + txt1 = get_template('input/ifg_volunteer_mail.txt').render(context) + html1 = get_template('input/ifg_volunteer_mail.html').render(context) + msg1 = EmailMultiAlternatives( + 'Formular ausgefüllt', txt1, settings.IF_EMAIL, [data['email']] + ) + msg1.attach_alternative(html1, 'text/html') msg1.send() - #print('ifg volunteer mail would have been sent') - #send_mail( - # 'Formular ausgefüllt', - # txt_mail_template1.render(context), - # IF_EMAIL, - # [data['email']], - # fail_silently=False) - ## - mail to IF with link to accept/decline - txt_mail_template = get_template('input/if_mail.txt') - html_mail_template = get_template('input/if_mail.html') - - subject, from_email, to = 'Formular ausgefüllt', settings.IF_EMAIL, settings.IF_EMAIL - text_content = txt_mail_template.render(context) - html_content = html_mail_template.render(context) - msg2 = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg2.attach_alternative(html_content, "text/html") + # Mail to IF + txt2 = get_template('input/if_mail.txt').render(context) + html2 = get_template('input/if_mail.html').render(context) + msg2 = EmailMultiAlternatives( + 'Formular ausgefüllt', txt2, settings.IF_EMAIL, [settings.IF_EMAIL] + ) + msg2.attach_alternative(html2, 'text/html') msg2.send() - #print('if mail would have been sent') - #send_mail( - # 'Formular ausgefüllt', - # txt_mail_template.render(context), - # IF_EMAIL, - # [IF_EMAIL], - # fail_silently=False) - ## raise SMTPException("testing pupose only") except BadHeaderError: modell.delete() @@ -207,5 +207,49 @@ class ExternView(CookieWizardView): modell.delete() return HttpResponse('Error in sending mails (probably wrong adress?). Data not saved!') - return done(self.request) + + +class TravelApplicationView(BaseApplicationView): + form_class = TravelForm + type_code = 'TRAV' + + +class LibraryApplicationView(BaseApplicationView): + form_class = LibraryForm + type_code = 'BIB' + + +class ELiteratureApplicationView(BaseApplicationView): + form_class = ELiteratureForm + type_code = 'ELIT' + + +class SoftwareApplicationView(BaseApplicationView): + form_class = SoftwareForm + type_code = 'SOFT' + + +class IFGApplicationView(BaseApplicationView): + form_class = IFGForm + type_code = 'IFG' + + +class EmailApplicationView(BaseApplicationView): + form_class = EmailForm + type_code = 'MAIL' + + +class LiteratureApplicationView(BaseApplicationView): + form_class = LiteratureForm + type_code = 'LIT' + + +class ListApplicationView(BaseApplicationView): + form_class = ListForm + type_code = 'LIST' + + +class BusinessCardApplicationView(BaseApplicationView): + form_class = BusinessCardForm + type_code = 'VIS'