forked from beba/foerderbarometer
Compare commits
10 Commits
b3484965b3
...
ebd7ebd3fd
| Author | SHA1 | Date |
|---|---|---|
|
|
ebd7ebd3fd | |
|
|
a750f0d7d2 | |
|
|
864df9613a | |
|
|
1dbd38dc4a | |
|
|
2c79732200 | |
|
|
e8848b0c97 | |
|
|
7365218adb | |
|
|
c751a9fc37 | |
|
|
1c98092473 | |
|
|
7fcde34897 |
40
README.md
40
README.md
|
|
@ -25,7 +25,6 @@ run the development server with
|
||||||
access via
|
access via
|
||||||
|
|
||||||
http://localhost:8000/
|
http://localhost:8000/
|
||||||
http://localhost:8000/intern/ (login required)
|
|
||||||
http://localhost:8000/admin/ (login reqiured)
|
http://localhost:8000/admin/ (login reqiured)
|
||||||
|
|
||||||
## docker compose development setup
|
## docker compose development setup
|
||||||
|
|
@ -44,7 +43,6 @@ Create your superuser account with
|
||||||
You can access the application via
|
You can access the application via
|
||||||
|
|
||||||
http://localhost:8000/
|
http://localhost:8000/
|
||||||
http://localhost:8000/intern/ (login required)
|
|
||||||
http://localhost:8000/admin/ (login required)
|
http://localhost:8000/admin/ (login required)
|
||||||
|
|
||||||
## additional admin functionality
|
## additional admin functionality
|
||||||
|
|
@ -56,28 +54,24 @@ entries to a csv file
|
||||||
|
|
||||||
- There is a new button in the bottom of every Project to "save as new"
|
- There is a new button in the bottom of every Project to "save as new"
|
||||||
|
|
||||||
## versions used in development
|
## mail attachments
|
||||||
|
|
||||||
asgiref==3.2.10
|
For all mails, attachments can be defined as URLs. These URLs are fetched and chached when sending the mail and attached to the mail.
|
||||||
Django==3.1.2
|
|
||||||
gunicorn==20.0.4
|
Configuration is done via the `ATTACHMENT_URLS` setting. Attachments can be set for user (`RECIPIENT_APPLICANT`) and staff (`RECIPIENT_STAFF`) mails. The following mail types exist:
|
||||||
mysqlclient==2.1.1
|
|
||||||
sqlparse==0.4.3
|
* `TYPE_BIB` Bibliotheksstipendium
|
||||||
whitenoise==6.2.0
|
* `TYPE_ELIT` eLiteraturstipendium
|
||||||
asgiref==3.2.10
|
* `TYPE_SOFT` Softwarestipendium
|
||||||
Authlib==1.2.1
|
* `TYPE_MAIL` E-Mail-Adresse
|
||||||
certifi==2023.7.22
|
* `TYPE_IFG` Kostenübernahme IFG-Anfrage
|
||||||
cffi==1.16.0
|
* `TYPE_LIT` Literaturstipendium
|
||||||
chardet==5.2.0
|
* `TYPE_LIST` Mailingliste
|
||||||
charset-normalizer==3.3.0
|
* `TYPE_TRAV` Reisekosten
|
||||||
cryptography==41.0.4
|
* `TYPE_VIS` Visitenkarten
|
||||||
idna==3.4
|
* `TYPE_PROJ` Projektförderung
|
||||||
pycparser==2.21
|
|
||||||
pytz==2023.3.post1
|
For further details see `foerderbarometer/settings.py`
|
||||||
requests==2.31.0
|
|
||||||
six==1.16.0
|
|
||||||
typing_extensions==4.8.0
|
|
||||||
urllib3==2.0.6
|
|
||||||
|
|
||||||
## testing
|
## testing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
TYPE_ALL = 'ALL'
|
||||||
|
|
||||||
|
TYPE_BIB = 'BIB' # Bibliotheksstipendium
|
||||||
|
TYPE_ELIT = 'ELIT' # eLiteraturstipendium
|
||||||
|
TYPE_SOFT = 'SOFT' # Softwarestipendium
|
||||||
|
TYPE_MAIL = 'MAIL' # E-Mail-Adresse
|
||||||
|
TYPE_IFG = 'IFG' # Kostenübernahme IFG-Anfrage
|
||||||
|
TYPE_LIT = 'LIT' # Literaturstipendium
|
||||||
|
TYPE_LIST = 'LIST' # Mailingliste
|
||||||
|
TYPE_TRAV = 'TRAV' # Reisekosten
|
||||||
|
TYPE_VIS = 'VIS' # Visitenkarten
|
||||||
|
TYPE_PROJ = 'PROJ' # Projektförderung
|
||||||
|
|
||||||
|
TYPES = [
|
||||||
|
TYPE_BIB,
|
||||||
|
TYPE_ELIT,
|
||||||
|
TYPE_SOFT,
|
||||||
|
TYPE_MAIL,
|
||||||
|
TYPE_IFG,
|
||||||
|
TYPE_LIT,
|
||||||
|
TYPE_LIST,
|
||||||
|
TYPE_TRAV,
|
||||||
|
TYPE_VIS,
|
||||||
|
TYPE_PROJ,
|
||||||
|
]
|
||||||
|
|
||||||
|
RECIPIENT_APPLICANT = 'applicant'
|
||||||
|
RECIPIENT_STAFF = 'staff'
|
||||||
|
|
||||||
|
RECIPIENTS = [
|
||||||
|
RECIPIENT_APPLICANT,
|
||||||
|
RECIPIENT_STAFF,
|
||||||
|
]
|
||||||
|
|
@ -6,6 +6,8 @@ from dotenv import load_dotenv
|
||||||
|
|
||||||
from input.utils.settings import env, password_validators
|
from input.utils.settings import env, password_validators
|
||||||
|
|
||||||
|
from .constants import *
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parents[1]
|
BASE_DIR = Path(__file__).parents[1]
|
||||||
|
|
||||||
load_dotenv(BASE_DIR / '.env')
|
load_dotenv(BASE_DIR / '.env')
|
||||||
|
|
@ -166,32 +168,25 @@ NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM = 'static/input/nutzungsbedingungen-lite
|
||||||
NUTZUNGSBEDINGUNGEN_OTRS = 'static/input/2025_Nutzungsvereinbarung_OTRS.docx.pdf'
|
NUTZUNGSBEDINGUNGEN_OTRS = 'static/input/2025_Nutzungsvereinbarung_OTRS.docx.pdf'
|
||||||
NUTZUNGSBEDINGUNGEN_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf'
|
NUTZUNGSBEDINGUNGEN_VISITENKARTEN = 'static/input/nutzungsbedingungen-visitenkarten.pdf'
|
||||||
|
|
||||||
# Directory where downloaded attachments will be cached
|
MAIL_ATTACHMENT_CACHE_DIR = env('MAIL_ATTACHMENT_CACHE_DIR', BASE_DIR / 'var' / 'mail-attachments')
|
||||||
MAIL_ATTACHMENT_CACHE_DIR = BASE_DIR / 'var' / 'mail_attachments'
|
MAIL_ATTACHMENT_TTL_SECONDS = env('MAIL_ATTACHMENT_TTL_SECONDS', 24 * 60 * 60)
|
||||||
|
|
||||||
# Cache TTL (default: 1 day)
|
|
||||||
MAIL_ATTACHMENT_TTL_SECONDS = 24 * 60 * 60
|
|
||||||
|
|
||||||
# File attachments via URL:
|
|
||||||
# - "applicant": attachments for emails sent to applicants
|
|
||||||
# - "staff": attachments for emails sent to the team (community@wikimedia.de)
|
|
||||||
#
|
|
||||||
# Top-level keys: "applicant" / "staff"
|
|
||||||
# Second-level keys: service code ("choice") or "ALL" for global attachments
|
|
||||||
# that should be included in all emails of this type.
|
|
||||||
MAIL_ATTACHMENT_URLS = {
|
MAIL_ATTACHMENT_URLS = {
|
||||||
'applicant': {
|
RECIPIENT_APPLICANT: {
|
||||||
# Global attachments for all applicant emails
|
TYPE_ALL: [],
|
||||||
'ALL': [],
|
TYPE_VIS: [
|
||||||
# Special attachments for specific services:
|
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf',
|
||||||
'VIS': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-visitenkarten.pdf', 'Nutzungsbedingungen-Visitenkarten.pdf')], # Business cards
|
],
|
||||||
'MAIL': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf', 'Nutzungsbedingungen-Mail.pdf')], # Emails
|
TYPE_MAIL: [
|
||||||
'LIST': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mailinglisten.pdf', 'Nutzungsbedingungen-Mailinglisten.pdf')], # Mailing lists
|
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mail.pdf',
|
||||||
'LIT': [('https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-literaturstipendium.pdf', 'Nutzungsbedingungen-Literaturstipendium.pdf')], # Literature grants
|
],
|
||||||
|
TYPE_LIST: [
|
||||||
|
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-mailinglisten.pdf',
|
||||||
|
],
|
||||||
|
TYPE_LIT: [
|
||||||
|
'https://foerderung.wikimedia.de/static/input/nutzungsbedingungen-literaturstipendium.pdf',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'staff': {
|
RECIPIENT_STAFF: {
|
||||||
# Global attachments for all staff emails
|
TYPE_ALL: [],
|
||||||
'ALL': [],
|
|
||||||
# Example: 'IFG': ['https://example.com/internal-guideline.pdf']
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
249
input/forms.py
249
input/forms.py
|
|
@ -1,23 +1,19 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.admin.widgets import AdminDateWidget
|
from django.contrib.admin.widgets import AdminDateWidget
|
||||||
from django.forms import ModelForm, ChoiceField, RadioSelect, BooleanField, CharField, EmailField
|
from django.forms import ModelForm
|
||||||
from django.forms.renderers import DjangoTemplates
|
from django.forms.renderers import DjangoTemplates
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
TYPE_CHOICES,
|
|
||||||
Project,
|
Project,
|
||||||
ProjectCategory,
|
ProjectCategory,
|
||||||
WikimediaProject,
|
WikimediaProject,
|
||||||
ConcreteVolunteer,
|
|
||||||
ConcreteExtern,
|
|
||||||
IFG,
|
IFG,
|
||||||
Library,
|
Library,
|
||||||
ELiterature,
|
ELiterature,
|
||||||
Software,
|
Software,
|
||||||
HonoraryCertificate,
|
|
||||||
Travel,
|
Travel,
|
||||||
Email,
|
Email,
|
||||||
Literature,
|
Literature,
|
||||||
|
|
@ -27,108 +23,33 @@ from .models import (
|
||||||
|
|
||||||
|
|
||||||
class TableFormRenderer(DjangoTemplates):
|
class TableFormRenderer(DjangoTemplates):
|
||||||
|
"""
|
||||||
|
Set in settings as the default form renderer.
|
||||||
|
"""
|
||||||
|
|
||||||
form_template_name = 'django/forms/table.html'
|
form_template_name = 'django/forms/table.html'
|
||||||
|
|
||||||
|
|
||||||
class FdbForm(ModelForm):
|
class RadioField(forms.ChoiceField):
|
||||||
'''this base class provides the required css class for all forms'''
|
widget = forms.RadioSelect
|
||||||
required_css_class = 'required'
|
|
||||||
|
|
||||||
|
|
||||||
class CommonOrderMixin(forms.Form):
|
class BaseApplicationForm(ModelForm):
|
||||||
"""
|
|
||||||
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. Put the 'head' fields first.
|
|
||||||
2. Put all other fields in the middle.
|
|
||||||
3. Put the 'tail' fields last.
|
|
||||||
|
|
||||||
Non-existing fields are ignored by `order_fields`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
ordered = {*self.field_order_head, *self.field_order_tail}
|
|
||||||
|
|
||||||
self.order_fields([
|
|
||||||
*self.field_order_head,
|
|
||||||
*[field for field in self.fields if field not in ordered],
|
|
||||||
*self.field_order_tail,
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class ExternForm(FdbForm):
|
|
||||||
choice = ChoiceField(choices=TYPE_CHOICES.items(), widget=RadioSelect,
|
|
||||||
label='Was möchtest Du beantragen?')
|
|
||||||
|
|
||||||
check = BooleanField(required=True,
|
|
||||||
label=format_html("Ich stimme den <a href='{}' target='_blank' rel='noopener'>Datenschutzbestimmungen</a> und der<br> <a href='{}' target='_blank' rel='noopener'>Richtlinie zur Förderung der Communitys</a> zu",
|
|
||||||
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ConcreteExtern
|
|
||||||
exclude = ('username', 'granted', 'granted_date', 'survey_mail_send', 'service_id', 'survey_mail_date', 'mail_state')
|
|
||||||
|
|
||||||
|
|
||||||
INTERN_CHOICES = {
|
|
||||||
'PRO': 'Projektsteckbrief',
|
|
||||||
'HON': 'Ehrenamtsbescheinigung, Akkreditierung oder Redaktionsbestätigung',
|
|
||||||
'TRAV': 'Reisekostenerstattung',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class InternForm(FdbForm):
|
|
||||||
choice = ChoiceField(choices=INTERN_CHOICES.items(), widget=RadioSelect,
|
|
||||||
label='Was möchtest Du eingeben?')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ConcreteVolunteer
|
|
||||||
exclude = ('granted', 'granted_date', 'survey_mail_send', 'survey_mail_date', 'mail_state')
|
|
||||||
|
|
||||||
|
|
||||||
class BaseApplicationForm(FdbForm):
|
|
||||||
"""
|
"""
|
||||||
Base form for all external applications.
|
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_css_class = 'required'
|
||||||
|
|
||||||
|
check = forms.BooleanField(
|
||||||
required=True,
|
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 <br> Wikimedia Deutschland bei Rückfragen oder für <br> die Zusage kontaktieren kann.'
|
|
||||||
)
|
|
||||||
check = BooleanField(required=True,
|
|
||||||
label=format_html(
|
label=format_html(
|
||||||
"Ich stimme den <a href='{}' target='_blank' rel='noopener'>Datenschutzbestimmungen</a> und der<br> <a href='{}' target='_blank' rel='noopener'>Richtlinie zur Förderung der Communitys</a> zu",
|
"""Ich stimme den <a href="{}" target="_blank" rel="noopener">Datenschutzbestimmungen</a> und der<br>
|
||||||
settings.DATAPROTECTION, settings.FOERDERRICHTLINIEN))
|
<a href="{}" target="_blank" rel="noopener">Richtlinie zur Förderung der Communitys</a> zu.""",
|
||||||
|
settings.DATAPROTECTION,
|
||||||
|
settings.FOERDERRICHTLINIEN
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
PROJECT_COST_GT_1000_MESSAGE = format_html(
|
PROJECT_COST_GT_1000_MESSAGE = format_html(
|
||||||
|
|
@ -166,7 +87,7 @@ class BaseProjectForm(ModelForm):
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
|
class ProjectForm(BaseProjectForm, BaseApplicationForm):
|
||||||
OPTIONAL_FIELDS = {
|
OPTIONAL_FIELDS = {
|
||||||
'categories_other',
|
'categories_other',
|
||||||
'wikimedia_projects_other',
|
'wikimedia_projects_other',
|
||||||
|
|
@ -186,6 +107,8 @@ class ProjectForm(CommonOrderMixin, BaseProjectForm, BaseApplicationForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = [
|
fields = [
|
||||||
|
'realname',
|
||||||
|
'email',
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'categories',
|
'categories',
|
||||||
|
|
@ -227,10 +150,10 @@ HOTEL_CHOICES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TravelForm(BaseApplicationForm, CommonOrderMixin):
|
class TravelForm(BaseApplicationForm):
|
||||||
# TODO: add some javascript to show/hide other-field
|
# TODO: add some javascript to show/hide other-field
|
||||||
|
|
||||||
hotel = ChoiceField(label='Hotelzimmer benötigt:', choices=HOTEL_CHOICES.items(), widget=RadioSelect())
|
hotel = RadioField(label='Hotelzimmer benötigt', choices=HOTEL_CHOICES)
|
||||||
|
|
||||||
# this is the code, to change required to false if needed
|
# this is the code, to change required to false if needed
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -244,9 +167,17 @@ class TravelForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Travel
|
model = Travel
|
||||||
fields = ['project_name', 'transport', 'travelcost', 'checkin', 'checkout', 'hotel', 'notes']
|
fields = [
|
||||||
exclude = ('granted', 'granted_date', 'survey_mail_send', 'realname', 'email', 'survey_mail_date', 'project',
|
'realname',
|
||||||
'request_url', 'payed_for_hotel_by', 'payed_for_travel_by', 'intern_notes', 'mail_state')
|
'email',
|
||||||
|
'project_name',
|
||||||
|
'transport',
|
||||||
|
'travelcost',
|
||||||
|
'checkin',
|
||||||
|
'checkout',
|
||||||
|
'hotel',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'checkin': AdminDateWidget,
|
'checkin': AdminDateWidget,
|
||||||
'checkout': AdminDateWidget,
|
'checkout': AdminDateWidget,
|
||||||
|
|
@ -259,12 +190,18 @@ class TravelForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LibraryForm(BaseApplicationForm, CommonOrderMixin):
|
class LibraryForm(BaseApplicationForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Library
|
model = Library
|
||||||
fields = ['cost', 'library', 'duration', 'notes', 'survey_mail_send']
|
fields = [
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'cost',
|
||||||
|
'library',
|
||||||
|
'duration',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
@ -286,43 +223,32 @@ class SoftwareForm(LibraryForm):
|
||||||
model = Software
|
model = Software
|
||||||
|
|
||||||
|
|
||||||
class HonoraryCertificateForm(FdbForm):
|
class IFGForm(BaseApplicationForm):
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = HonoraryCertificate
|
|
||||||
fields = ['request_url', 'project']
|
|
||||||
exclude = ['intern_notes']
|
|
||||||
|
|
||||||
class Media:
|
|
||||||
js = ('dropdown/js/otrs_link.js',)
|
|
||||||
|
|
||||||
|
|
||||||
class IFGForm(BaseApplicationForm, CommonOrderMixin):
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IFG
|
model = IFG
|
||||||
fields = ['cost', 'url', 'notes']
|
fields = [
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'cost',
|
||||||
|
'url',
|
||||||
|
'notes',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CheckForm(FdbForm):
|
class TermsForm(BaseApplicationForm):
|
||||||
termstoaccept = settings.NUTZUNGSBEDINGUNGEN
|
terms_accepted_label = 'Ich stimme den <a href="{}">Nutzungsbedingungen</a> zu.'
|
||||||
|
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# 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
|
self.fields['terms_accepted'].required = True
|
||||||
# Set custom label with link to terms
|
self.fields['terms_accepted'].label = format_html(self.terms_accepted_label, self.terms_accepted_url)
|
||||||
self.fields['terms_accepted'].label = format_html(
|
|
||||||
"Ich stimme den <a href='{}'>Nutzungsbedingungen</a> zu",
|
|
||||||
self.termstoaccept
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
|
class LiteratureForm(TermsForm):
|
||||||
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
|
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_LITERATURSTIPENDIUM
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
@ -330,8 +256,18 @@ class LiteratureForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Literature
|
model = Literature
|
||||||
fields = ['cost', 'info', 'source', 'notes', 'selfbuy', 'selfbuy_data', 'selfbuy_give_data', 'terms_accepted']
|
fields = [
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'cost',
|
||||||
|
'info',
|
||||||
|
'source',
|
||||||
|
'notes',
|
||||||
|
'selfbuy',
|
||||||
|
'selfbuy_data',
|
||||||
|
'selfbuy_give_data',
|
||||||
|
'terms_accepted',
|
||||||
|
]
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('dropdown/js/literature.js',)
|
js = ('dropdown/js/literature.js',)
|
||||||
|
|
@ -343,8 +279,8 @@ ADULT_CHOICES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class EmailForm(BaseApplicationForm, CommonOrderMixin):
|
class EmailForm(TermsForm):
|
||||||
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
|
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_EMAIL_SERVICE
|
||||||
|
|
||||||
# this is the code, to change required to false if needed
|
# this is the code, to change required to false if needed
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -352,20 +288,27 @@ class EmailForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
self.fields['adult'].required = True
|
self.fields['adult'].required = True
|
||||||
self.fields['other'].required = True
|
self.fields['other'].required = True
|
||||||
|
|
||||||
adult = ChoiceField(label='Volljährigkeit', choices=ADULT_CHOICES.items(), widget=RadioSelect())
|
adult = RadioField(label='Volljährigkeit', choices=ADULT_CHOICES)
|
||||||
|
|
||||||
# TODO: add some javascript to show/hide other-field
|
# TODO: add some javascript to show/hide other-field
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Email
|
model = Email
|
||||||
fields = ['domain', 'address', 'other', 'adult', 'terms_accepted']
|
fields = [
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'domain',
|
||||||
|
'address',
|
||||||
|
'other',
|
||||||
|
'adult',
|
||||||
|
'terms_accepted',
|
||||||
|
]
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('dropdown/js/mail.js',)
|
js = ('dropdown/js/mail.js',)
|
||||||
|
|
||||||
|
|
||||||
class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
|
class BusinessCardForm(TermsForm):
|
||||||
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
|
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_VISITENKARTEN
|
||||||
|
|
||||||
# this is the code, to change required to false if needed
|
# this is the code, to change required to false if needed
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -375,20 +318,34 @@ class BusinessCardForm(BaseApplicationForm, CommonOrderMixin):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = BusinessCard
|
model = BusinessCard
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
fields = [
|
||||||
fields = ['project', 'data', 'variant', 'url_of_pic', 'send_data_to_print', 'sent_to', 'terms_accepted']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'project',
|
||||||
|
'data',
|
||||||
|
'variant',
|
||||||
|
'url_of_pic',
|
||||||
|
'send_data_to_print',
|
||||||
|
'sent_to',
|
||||||
|
'terms_accepted',
|
||||||
|
]
|
||||||
|
|
||||||
class Media:
|
class Media:
|
||||||
js = ('dropdown/js/businessCard.js',)
|
js = ('dropdown/js/businessCard.js',)
|
||||||
|
|
||||||
|
|
||||||
class ListForm(BaseApplicationForm, CommonOrderMixin):
|
class ListForm(TermsForm):
|
||||||
termstoaccept = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
|
terms_accepted_url = settings.NUTZUNGSBEDINGUNGEN_MAILINGLISTEN
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = List
|
model = List
|
||||||
fields = ['domain', 'address', 'terms_accepted']
|
fields = [
|
||||||
exclude = ['intern_notes', 'survey_mail_send', 'mail_state']
|
'realname',
|
||||||
|
'email',
|
||||||
|
'domain',
|
||||||
|
'address',
|
||||||
|
'terms_accepted',
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ from django.utils.functional import cached_property, classproperty
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from foerderbarometer.constants import *
|
||||||
|
|
||||||
|
|
||||||
EMAIL_STATES = {
|
EMAIL_STATES = {
|
||||||
'NONE': 'noch keine Mail versendet',
|
'NONE': 'noch keine Mail versendet',
|
||||||
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
|
'INF': 'die Benachrichtigung zur Projektabschlussmail wurde versendet',
|
||||||
|
|
@ -22,7 +25,7 @@ EMAIL_STATES = {
|
||||||
class TermsConsentMixin(models.Model):
|
class TermsConsentMixin(models.Model):
|
||||||
"""Abstract mixin to add a terms_accepted field for documenting user consent."""
|
"""Abstract mixin to add a terms_accepted field for documenting user consent."""
|
||||||
|
|
||||||
terms_accepted = models.BooleanField(default=False, verbose_name="Nutzungsbedingungen zugestimmt")
|
terms_accepted = models.BooleanField(default=False, verbose_name='Nutzungsbedingungen zugestimmt')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
@ -200,6 +203,14 @@ class ProjectCategoryField(models.ManyToManyField):
|
||||||
|
|
||||||
return super().formfield(**kwargs)
|
return super().formfield(**kwargs)
|
||||||
|
|
||||||
|
def save_form_data(self, instance, data):
|
||||||
|
data = list(data)
|
||||||
|
|
||||||
|
with suppress(ValueError):
|
||||||
|
data.remove(self.remote_field.model.other)
|
||||||
|
|
||||||
|
return super().save_form_data(instance, data)
|
||||||
|
|
||||||
|
|
||||||
class Project(Volunteer):
|
class Project(Volunteer):
|
||||||
end_mail_send = models.BooleanField(default=False, verbose_name='Keine Projektabschlussmail schicken')
|
end_mail_send = models.BooleanField(default=False, verbose_name='Keine Projektabschlussmail schicken')
|
||||||
|
|
@ -403,17 +414,6 @@ def type_link(path, label):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
TYPE_BIB = 'BIB'
|
|
||||||
TYPE_ELIT = 'ELIT'
|
|
||||||
TYPE_MAIL = 'MAIL'
|
|
||||||
TYPE_IFG = 'IFG'
|
|
||||||
TYPE_LIT = 'LIT'
|
|
||||||
TYPE_LIST = 'LIST'
|
|
||||||
TYPE_TRAV = 'TRAV'
|
|
||||||
TYPE_SOFT = 'SOFT'
|
|
||||||
TYPE_VIS = 'VIS'
|
|
||||||
TYPE_PROJ = 'PROJ'
|
|
||||||
|
|
||||||
TYPE_CHOICES = {
|
TYPE_CHOICES = {
|
||||||
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
|
TYPE_BIB: type_link('Zugang_zu_Fachliteratur#Bibliotheksstipendium', 'Bibliotheksstipendium'),
|
||||||
TYPE_ELIT: type_link('Zugang_zu_Fachliteratur#eLiteraturstipendium', 'eLiteraturstipendium'),
|
TYPE_ELIT: type_link('Zugang_zu_Fachliteratur#eLiteraturstipendium', 'eLiteraturstipendium'),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
<!doctype html>
|
{% extends 'input/base.html' %}
|
||||||
<html lang="de">
|
|
||||||
<head>
|
{% block head_extra %}
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Projektförderung ab 1.000,— EUR</title>
|
<title>Projektförderung ab 1.000,— EUR</title>
|
||||||
</head>
|
<style>
|
||||||
<body>
|
.wm-main {
|
||||||
|
max-width: 80vw;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<h1>Projektförderung mit einer Gesamtsumme ab 1.000,— EUR</h1>
|
<h1>Projektförderung mit einer Gesamtsumme ab 1.000,— EUR</h1>
|
||||||
<p>Vielen Dank für dein Interesse an einer Projektförderung!<br>
|
<p>Vielen Dank für dein Interesse an einer Projektförderung!<br>
|
||||||
Für Projektförderungen mit einer Gesamtsumme ab 1.000,— EUR ist ein öffentlicher Projektplan
|
Für Projektförderungen mit einer Gesamtsumme ab 1.000,— EUR ist ein öffentlicher Projektplan
|
||||||
|
|
@ -14,5 +21,4 @@
|
||||||
Für Fragen steht dir das Team Community-Konferenzen & Förderung gern unter
|
Für Fragen steht dir das Team Community-Konferenzen & Förderung gern unter
|
||||||
<a href="mailto:community@wikimedia.de">community@wikimedia.de</a> zur Verfügung.
|
<a href="mailto:community@wikimedia.de">community@wikimedia.de</a> zur Verfügung.
|
||||||
</p>
|
</p>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
|
from .admin import AdminTestCase
|
||||||
from .models import ModelTestCase
|
from .models import ModelTestCase
|
||||||
from .views import AuthenticatedViewTestCase, AnonymousViewTestCase
|
from .views import AuthenticatedViewTestCase, AnonymousViewTestCase
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.forms import model_to_dict
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from input.models import (
|
||||||
|
Project,
|
||||||
|
ProjectRequest,
|
||||||
|
ProjectDeclined,
|
||||||
|
Library,
|
||||||
|
ELiterature,
|
||||||
|
Email,
|
||||||
|
IFG,
|
||||||
|
Literature,
|
||||||
|
List,
|
||||||
|
Travel,
|
||||||
|
Software,
|
||||||
|
BusinessCard, ProjectCategory, WikimediaProject, Account,
|
||||||
|
)
|
||||||
|
from input.utils.admin import admin_url
|
||||||
|
from input.utils.testing import request, create_superuser, login
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTestCase(TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user = user = create_superuser('admin', first_name='Max', last_name='Mustermann')
|
||||||
|
|
||||||
|
cls.data = data = {
|
||||||
|
'realname': f'{user.first_name} {user.last_name}',
|
||||||
|
'email': user.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
cls.objs = [
|
||||||
|
ProjectCategory.objects.order_by('?').first(),
|
||||||
|
WikimediaProject.objects.order_by('?').first(),
|
||||||
|
Project.objects.create(**data, granted=True),
|
||||||
|
ProjectRequest.objects.create(**data, granted=None),
|
||||||
|
ProjectDeclined.objects.create(**data, granted=False),
|
||||||
|
Library.objects.create(**data, library='Test'),
|
||||||
|
ELiterature.objects.create(**data, library='Test'),
|
||||||
|
Software.objects.create(**data, library='Test'),
|
||||||
|
Email.objects.create(**data),
|
||||||
|
IFG.objects.create(**data),
|
||||||
|
Literature.objects.create(**data, selfbuy_give_data=False),
|
||||||
|
List.objects.create(**data),
|
||||||
|
Travel.objects.create(**data),
|
||||||
|
BusinessCard.objects.create(**data),
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
login(self)
|
||||||
|
|
||||||
|
def test_changelists(self):
|
||||||
|
for obj in self.objs:
|
||||||
|
model = type(obj)
|
||||||
|
url = admin_url(model, 'changelist')
|
||||||
|
|
||||||
|
with self.subTest(model=model):
|
||||||
|
request(self, url)
|
||||||
|
|
||||||
|
def test_change_views(self):
|
||||||
|
for obj in self.objs:
|
||||||
|
model = type(obj)
|
||||||
|
url = admin_url(model, 'change', obj.id)
|
||||||
|
|
||||||
|
with self.subTest(model=model):
|
||||||
|
request(self, url)
|
||||||
|
|
||||||
|
def test_display_values(self):
|
||||||
|
for obj in self.objs:
|
||||||
|
model = type(obj)
|
||||||
|
|
||||||
|
with self.subTest(model=model):
|
||||||
|
self.assertTrue(f'{obj}')
|
||||||
|
|
||||||
|
def test_grant_project_request(self):
|
||||||
|
account = Account.objects.create(code='test')
|
||||||
|
category = ProjectCategory.objects.first()
|
||||||
|
wikimedia = WikimediaProject.objects.first()
|
||||||
|
obj = ProjectRequest.objects.create(
|
||||||
|
**self.data,
|
||||||
|
name='Test',
|
||||||
|
description='Test',
|
||||||
|
otrs='https://example.com',
|
||||||
|
granted=None,
|
||||||
|
start=datetime.date(2025, 1, 1),
|
||||||
|
end=datetime.date(2026, 1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.categories.add(category)
|
||||||
|
obj.wikimedia_projects.add(wikimedia)
|
||||||
|
|
||||||
|
url = admin_url(ProjectRequest, 'change', obj.id)
|
||||||
|
expected_url = admin_url(ProjectRequest, 'changelist')
|
||||||
|
data = {
|
||||||
|
**model_to_dict(obj),
|
||||||
|
'granted': True,
|
||||||
|
'granted_date': obj.start,
|
||||||
|
'granted_from': self.user.username,
|
||||||
|
'account': account.code,
|
||||||
|
'categories': [category.id],
|
||||||
|
'wikimedia_projects': [wikimedia.id],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in list(data):
|
||||||
|
if data[key] is None:
|
||||||
|
data.pop(key)
|
||||||
|
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
request(self, url, expected_url=expected_url, data=data)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 2)
|
||||||
|
|
@ -90,6 +90,7 @@ class AnonymousViewTestCase(TestCase):
|
||||||
'selfbuy_data': 'NONE',
|
'selfbuy_data': 'NONE',
|
||||||
'selfbuy_give_data': True,
|
'selfbuy_give_data': True,
|
||||||
'check': True,
|
'check': True,
|
||||||
|
'terms_accepted': True,
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_extern_lit_without_consent_fails(self):
|
def test_extern_lit_without_consent_fails(self):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.db.models import Model
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
def admin_url(model: type[Model], view: str, *args, site=None, **kwargs) -> str:
|
||||||
|
return reverse(admin_url_name(model, view, site=site), args=args, kwargs=kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_url_name(model: type[Model], view: str, *, site=None) -> str:
|
||||||
|
namespace = (site or admin.site).name
|
||||||
|
view_name = admin_view_name(model, view)
|
||||||
|
|
||||||
|
return f'{namespace}:{view_name}'
|
||||||
|
|
||||||
|
|
||||||
|
def admin_view_name(model: type[Model], view: str) -> str:
|
||||||
|
return f'{model._meta.app_label}_{model._meta.model_name}_{view}'
|
||||||
|
|
@ -4,13 +4,12 @@ from django.template.loader import get_template
|
||||||
|
|
||||||
from input.models import Project
|
from input.models import Project
|
||||||
|
|
||||||
from .attachments import collect_attachment_paths, attach_files
|
from .attachments import collect_and_attach
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'build_email',
|
'build_email',
|
||||||
'send_email',
|
'send_email',
|
||||||
'collect_attachment_paths',
|
'collect_and_attach',
|
||||||
'attach_files',
|
|
||||||
'send_applicant_decision_mail',
|
'send_applicant_decision_mail',
|
||||||
'send_staff_decision_mail',
|
'send_staff_decision_mail',
|
||||||
'send_decision_mails',
|
'send_decision_mails',
|
||||||
|
|
@ -58,7 +57,7 @@ def send_base_decision_mail(obj: Project, scope: str, subject: str, recipient: s
|
||||||
decision_label = 'bewilligt' if obj.granted else 'abgelehnt'
|
decision_label = 'bewilligt' if obj.granted else 'abgelehnt'
|
||||||
subject = subject.format(name=obj.name, decision=decision_label)
|
subject = subject.format(name=obj.name, decision=decision_label)
|
||||||
|
|
||||||
return send_email(f'approval_{scope}_{decision}', context, subject, recipient)
|
return send_email(f'approval_{decision}_{scope}', context, subject, recipient)
|
||||||
|
|
||||||
|
|
||||||
def send_applicant_decision_mail(obj: Project):
|
def send_applicant_decision_mail(obj: Project):
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,98 @@
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List, Tuple
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import urlretrieve
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
|
||||||
|
from foerderbarometer.constants import *
|
||||||
|
|
||||||
def _ensure_cache_dir() -> Path:
|
PathList = list[Path]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(directory: PathLike) -> Path:
|
||||||
"""
|
"""
|
||||||
Ensure that the cache directory for attachments exists.
|
Ensure that the given directory exists.
|
||||||
Creates it recursively if it doesn't.
|
Creates it recursively if it doesn't.
|
||||||
"""
|
"""
|
||||||
cache_dir = Path(settings.MAIL_ATTACHMENT_CACHE_DIR)
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
directory = Path(directory)
|
||||||
return cache_dir
|
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
def _cached_filename_for(url: str) -> str:
|
def is_fresh(path: Path, ttl_seconds: int) -> bool:
|
||||||
"""
|
|
||||||
Generate a unique cache filename for the given URL (hash + original suffix if present).
|
|
||||||
"""
|
|
||||||
h = hashlib.sha1(url.encode('utf-8')).hexdigest()[:16]
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
# path part only (without query/fragment)
|
|
||||||
name = Path(parsed.path).name # e.g. 'foo.pdf'
|
|
||||||
suffix = Path(name).suffix # e.g. '.pdf'
|
|
||||||
return f'{h}{suffix}' if suffix else h
|
|
||||||
|
|
||||||
|
|
||||||
def _is_fresh(path: Path, ttl_seconds: int) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Check if the cached file exists and is still fresh within TTL.
|
Check if the cached file exists and is still fresh within TTL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
age = time.time() - path.stat().st_mtime
|
mtime = path.stat().st_mtime
|
||||||
return age < ttl_seconds
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def download_with_cache(url: str, *, timeout: float = 10.0, size_cap_bytes: int = 8 * 1024 * 1024) -> Path:
|
|
||||||
"""
|
|
||||||
Download the file from the given URL into the cache directory, or return the cached
|
|
||||||
file if it's still fresh. Uses a temporary '.part' file and atomic replace.
|
|
||||||
A simple size cap protects against unexpectedly large downloads.
|
|
||||||
"""
|
|
||||||
cache_dir = _ensure_cache_dir()
|
|
||||||
ttl = int(getattr(settings, 'MAIL_ATTACHMENT_TTL_SECONDS', 86400))
|
|
||||||
filename = _cached_filename_for(url)
|
|
||||||
path = cache_dir / filename
|
|
||||||
|
|
||||||
if _is_fresh(path, ttl):
|
|
||||||
return path
|
|
||||||
|
|
||||||
tmp_path = path.with_suffix(path.suffix + '.part')
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(url, timeout=timeout) as resp, open(tmp_path, 'wb') as f:
|
|
||||||
# Read in chunks up to size_cap_bytes
|
|
||||||
remaining = size_cap_bytes
|
|
||||||
chunk_size = 64 * 1024
|
|
||||||
while True:
|
|
||||||
chunk = resp.read(min(chunk_size, remaining))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
f.write(chunk)
|
|
||||||
remaining -= len(chunk)
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
os.replace(tmp_path, path)
|
|
||||||
return path
|
|
||||||
except Exception:
|
|
||||||
# Best-effort cleanup of partial file
|
|
||||||
try:
|
|
||||||
if tmp_path.exists():
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Re-raise to let caller decide
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _filename_from_url(url: str) -> str:
|
|
||||||
"""
|
|
||||||
Derive a display filename from URL path as a fallback when none provided in settings.
|
|
||||||
"""
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
name = Path(parsed.path).name or 'attachment'
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def collect_attachment_paths(kind: str, choice: str) -> List[Tuple[Path, str]]:
|
|
||||||
"""
|
|
||||||
Return a list of (path, filename) for attachments based on settings.MAIL_ATTACHMENT_URLS.
|
|
||||||
Supports both 'url' strings and (url, filename) tuples.
|
|
||||||
"""
|
|
||||||
cfg = getattr(settings, 'MAIL_ATTACHMENT_URLS', {})
|
|
||||||
channel = cfg.get(kind, {})
|
|
||||||
urls: list = []
|
|
||||||
urls.extend(channel.get('ALL', []))
|
|
||||||
urls.extend(channel.get(choice, []))
|
|
||||||
|
|
||||||
result: List[Tuple[Path, str]] = []
|
|
||||||
for item in urls:
|
|
||||||
if isinstance(item, tuple):
|
|
||||||
url, filename = item
|
|
||||||
else:
|
else:
|
||||||
url, filename = item, _filename_from_url(item)
|
return time.time() - mtime < ttl_seconds
|
||||||
|
|
||||||
path = download_with_cache(url)
|
|
||||||
# Only append if the file exists (download_with_cache raises on error by default)
|
|
||||||
result.append((path, filename))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def attach_files(message: EmailMultiAlternatives, files: Iterable[Tuple[Path, str]]) -> None:
|
def get_attachment(url: str) -> Path:
|
||||||
|
filepath = urlparse(url).path
|
||||||
|
filename = posixpath.basename(filepath)
|
||||||
|
destination = ensure_dir(settings.MAIL_ATTACHMENT_CACHE_DIR) / filename
|
||||||
|
|
||||||
|
if is_fresh(destination, settings.MAIL_ATTACHMENT_TTL_SECONDS):
|
||||||
|
return destination
|
||||||
|
|
||||||
|
return download_attachment(url, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def download_attachment(url: str, destination: Path) -> Path:
|
||||||
|
filepath = destination.with_suffix('.tmp')
|
||||||
|
|
||||||
|
try:
|
||||||
|
urlretrieve(url, filepath)
|
||||||
|
os.replace(filepath, destination)
|
||||||
|
finally:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def collect_attachment_paths(recipient: str, type_code: str) -> PathList:
|
||||||
|
assert recipient in RECIPIENTS
|
||||||
|
assert type_code in TYPES
|
||||||
|
|
||||||
|
config = settings.MAIL_ATTACHMENT_URLS[recipient]
|
||||||
|
urls = [*config[TYPE_ALL], *config.get(type_code, [])]
|
||||||
|
|
||||||
|
return [get_attachment(url) for url in urls]
|
||||||
|
|
||||||
|
|
||||||
|
def get_mime_type(path: Path) -> str:
|
||||||
|
mime_type, encoding = mimetypes.guess_type(path)
|
||||||
|
|
||||||
|
return mime_type or 'application/octet-stream'
|
||||||
|
|
||||||
|
|
||||||
|
def attach_files(message: EmailMultiAlternatives, files: list[Path]):
|
||||||
"""
|
"""
|
||||||
Attach files to the EmailMultiAlternatives message.
|
Attach files to the EmailMultiAlternatives message.
|
||||||
MIME type is guessed from filename; falls back to application/octet-stream.
|
MIME type is guessed from path; falls back to application/octet-stream.
|
||||||
"""
|
"""
|
||||||
for path, filename in files:
|
|
||||||
# Guess MIME type from final filename first; fallback to path suffix
|
|
||||||
ctype, _ = mimetypes.guess_type(filename)
|
|
||||||
if not ctype:
|
|
||||||
ctype, _ = mimetypes.guess_type(str(path))
|
|
||||||
ctype = ctype or 'application/octet-stream'
|
|
||||||
|
|
||||||
with open(path, 'rb') as f:
|
for path in files:
|
||||||
message.attach(filename, f.read(), ctype)
|
mime_type = get_mime_type(path)
|
||||||
|
|
||||||
|
with open(path, 'rb') as fp:
|
||||||
|
message.attach(path.name, fp.read(), mime_type)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_and_attach(email: EmailMultiAlternatives, recipient: str, type_code: str):
|
||||||
|
return attach_files(email, collect_attachment_paths(recipient, type_code))
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from django.views.generic import TemplateView
|
||||||
from django.views.generic.edit import FormView
|
from django.views.generic.edit import FormView
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
|
||||||
from input.utils.mail import collect_attachment_paths, attach_files, build_email
|
from input.utils.mail import build_email, collect_and_attach
|
||||||
|
|
||||||
from .forms import (
|
from .forms import (
|
||||||
BaseApplicationForm,
|
BaseApplicationForm,
|
||||||
|
|
@ -289,9 +289,8 @@ class ApplicationView(FormView):
|
||||||
|
|
||||||
def send_email(self, kind, template_name, subject, recipient, context, *, fail_silently=False):
|
def send_email(self, kind, template_name, subject, recipient, context, *, fail_silently=False):
|
||||||
email = build_email(template_name, context, subject, recipient)
|
email = build_email(template_name, context, subject, recipient)
|
||||||
applicant_files = collect_attachment_paths(kind=kind, choice=self.type_code)
|
|
||||||
|
|
||||||
attach_files(email, applicant_files)
|
collect_and_attach(email, kind, self.type_code)
|
||||||
|
|
||||||
return email.send(fail_silently)
|
return email.send(fail_silently)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue