Compare commits
No commits in common. "ecaf5ab897801d37e3e8831281e40e6c6c1cff0b" and "8ae984060115951343fdc9b8fbb172150fc80159" have entirely different histories.
ecaf5ab897
...
8ae9840601
9 changed files with 821 additions and 954 deletions
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
flake.lock
generated
6
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772047000,
|
||||
"narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=",
|
||||
"lastModified": 1766473571,
|
||||
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e",
|
||||
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
overlay = final: prev: {
|
||||
meinantrag = with final; python3Packages.buildPythonApplication rec {
|
||||
pname = "meinantrag";
|
||||
version = "0.0.3";
|
||||
version = "0.0.2";
|
||||
format = "other";
|
||||
|
||||
src = self;
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
requests
|
||||
jinja2
|
||||
google-generativeai # Dependency for Gemini API
|
||||
grpcio # Required by google-generativeai
|
||||
reportlab # Dependency for PDF generation
|
||||
python-docx # Dependency for Word document generation
|
||||
];
|
||||
|
||||
|
|
|
|||
678
meinantrag.py
678
meinantrag.py
|
|
@ -13,44 +13,20 @@ from jinja2 import Environment, FileSystemLoader
|
|||
import google.generativeai as genai
|
||||
import re
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
DOCX_AVAILABLE = True
|
||||
except ImportError:
|
||||
DOCX_AVAILABLE = False
|
||||
|
||||
# Setup logging
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=os.environ.get('LOG_LEVEL', 'INFO').upper(),
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SITE_BASE_URL = os.environ.get("MEINANTRAG_BASE_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
def resolve_env_value(value):
|
||||
"""
|
||||
Resolve environment variable values that may use file: prefix.
|
||||
If the value starts with 'file:', read the content from the specified path.
|
||||
"""
|
||||
if value and value.startswith("file:"):
|
||||
file_path = value[5:]
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read().strip()
|
||||
except (IOError, OSError) as e:
|
||||
logger.warning(f"Failed to read credential file {file_path}: {e}")
|
||||
return None
|
||||
return value
|
||||
|
||||
SITE_BASE_URL = os.environ.get('MEINANTRAG_BASE_URL', 'http://localhost:8000')
|
||||
|
||||
class BaseTemplateResource:
|
||||
"""Base class for resources that need template rendering"""
|
||||
|
|
@ -58,7 +34,7 @@ class BaseTemplateResource:
|
|||
def _get_template_dir(self):
|
||||
"""Get the template directory path, handling both development and installed environments"""
|
||||
# Allow overriding via environment variable (for packaged deployments)
|
||||
env_dir = os.environ.get("MEINANTRAG_TEMPLATES_DIR")
|
||||
env_dir = os.environ.get('MEINANTRAG_TEMPLATES_DIR')
|
||||
if env_dir and os.path.exists(env_dir):
|
||||
return env_dir
|
||||
|
||||
|
|
@ -66,43 +42,36 @@ class BaseTemplateResource:
|
|||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Try development templates first
|
||||
dev_template_dir = os.path.join(script_dir, "templates")
|
||||
dev_template_dir = os.path.join(script_dir, 'templates')
|
||||
if os.path.exists(dev_template_dir):
|
||||
return dev_template_dir
|
||||
|
||||
# Try to find templates relative to the executable
|
||||
try:
|
||||
# If we're running from a Nix store, look for templates in share/meinantrag
|
||||
if "/nix/store/" in script_dir:
|
||||
if '/nix/store/' in script_dir:
|
||||
# Go up from bin to share/meinantrag/templates
|
||||
share_dir = os.path.join(
|
||||
script_dir, "..", "share", "meinantrag", "templates"
|
||||
)
|
||||
share_dir = os.path.join(script_dir, '..', 'share', 'meinantrag', 'templates')
|
||||
if os.path.exists(share_dir):
|
||||
return share_dir
|
||||
|
||||
# Alternative: look for templates in the same store path
|
||||
store_root = script_dir.split("/nix/store/")[1].split("/")[0]
|
||||
store_root = script_dir.split('/nix/store/')[1].split('/')[0]
|
||||
store_path = f"/nix/store/{store_root}"
|
||||
alt_share_dir = os.path.join(
|
||||
store_path, "share", "meinantrag", "templates"
|
||||
)
|
||||
alt_share_dir = os.path.join(store_path, 'share', 'meinantrag', 'templates')
|
||||
if os.path.exists(alt_share_dir):
|
||||
return alt_share_dir
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: try to find any templates directory
|
||||
for root, dirs, files in os.walk("/nix/store"):
|
||||
if "templates" in dirs and "index.html" in os.listdir(
|
||||
os.path.join(root, "templates")
|
||||
):
|
||||
return os.path.join(root, "templates")
|
||||
for root, dirs, files in os.walk('/nix/store'):
|
||||
if 'templates' in dirs and 'index.html' in os.listdir(os.path.join(root, 'templates')):
|
||||
return os.path.join(root, 'templates')
|
||||
|
||||
# Fallback to current directory
|
||||
return dev_template_dir
|
||||
|
||||
|
||||
class MeinAntragApp(BaseTemplateResource):
|
||||
def __init__(self):
|
||||
# Setup Jinja2 template environment
|
||||
|
|
@ -112,15 +81,14 @@ class MeinAntragApp(BaseTemplateResource):
|
|||
|
||||
def on_get(self, req, resp):
|
||||
"""Serve the main page"""
|
||||
template = self.jinja_env.get_template("index.html")
|
||||
resp.content_type = "text/html; charset=utf-8"
|
||||
template = self.jinja_env.get_template('index.html')
|
||||
resp.content_type = 'text/html; charset=utf-8'
|
||||
resp.text = template.render(
|
||||
meta_title="MeinAntrag – Anträge an die Karlsruher Stadtverwaltung",
|
||||
meta_description="Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!",
|
||||
canonical_url=f"{SITE_BASE_URL}/",
|
||||
meta_title='MeinAntrag – Anfragelinks für FragDenStaat',
|
||||
meta_description='Erstelle vorausgefüllte Anfragelinks für FragDenStaat.de, suche Behörden, füge Betreff und Text hinzu und teile den Link.',
|
||||
canonical_url=f"{SITE_BASE_URL}/"
|
||||
)
|
||||
|
||||
|
||||
class ImpressumResource(BaseTemplateResource):
|
||||
def __init__(self):
|
||||
template_dir = self._get_template_dir()
|
||||
|
|
@ -128,16 +96,15 @@ class ImpressumResource(BaseTemplateResource):
|
|||
|
||||
def on_get(self, req, resp):
|
||||
"""Serve the Impressum page"""
|
||||
template = self.jinja_env.get_template("impressum.html")
|
||||
resp.content_type = "text/html; charset=utf-8"
|
||||
template = self.jinja_env.get_template('impressum.html')
|
||||
resp.content_type = 'text/html; charset=utf-8'
|
||||
resp.text = template.render(
|
||||
meta_title="Impressum – MeinAntrag",
|
||||
meta_description="Impressum für MeinAntrag.",
|
||||
meta_title='Impressum – MeinAntrag',
|
||||
meta_description='Impressum für MeinAntrag.',
|
||||
canonical_url=f"{SITE_BASE_URL}/impressum",
|
||||
noindex=True,
|
||||
noindex=True
|
||||
)
|
||||
|
||||
|
||||
class DatenschutzResource(BaseTemplateResource):
|
||||
def __init__(self):
|
||||
template_dir = self._get_template_dir()
|
||||
|
|
@ -145,24 +112,22 @@ class DatenschutzResource(BaseTemplateResource):
|
|||
|
||||
def on_get(self, req, resp):
|
||||
"""Serve the Datenschutz page"""
|
||||
template = self.jinja_env.get_template("datenschutz.html")
|
||||
resp.content_type = "text/html; charset=utf-8"
|
||||
template = self.jinja_env.get_template('datenschutz.html')
|
||||
resp.content_type = 'text/html; charset=utf-8'
|
||||
resp.text = template.render(
|
||||
meta_title="Datenschutz – MeinAntrag",
|
||||
meta_description="Datenschutzerklärung für MeinAntrag. Keine Cookies, es werden nur Anfragen an die FragDenStaat-API gestellt.",
|
||||
meta_title='Datenschutz – MeinAntrag',
|
||||
meta_description='Datenschutzerklärung für MeinAntrag. Keine Cookies, es werden nur Anfragen an die FragDenStaat-API gestellt.',
|
||||
canonical_url=f"{SITE_BASE_URL}/datenschutz",
|
||||
noindex=True,
|
||||
noindex=True
|
||||
)
|
||||
|
||||
|
||||
class GenerateAntragResource:
|
||||
def __init__(self):
|
||||
# Initialize Gemini API with file-macro support
|
||||
api_key_raw = os.environ.get("GOOGLE_GEMINI_API_KEY")
|
||||
api_key = resolve_env_value(api_key_raw)
|
||||
# Initialize Gemini API
|
||||
api_key = os.environ.get('GOOGLE_GEMINI_API_KEY')
|
||||
if api_key:
|
||||
genai.configure(api_key=api_key)
|
||||
self.model = genai.GenerativeModel("gemini-2.5-pro")
|
||||
self.model = genai.GenerativeModel('gemini-flash-latest')
|
||||
else:
|
||||
self.model = None
|
||||
|
||||
|
|
@ -172,65 +137,122 @@ class GenerateAntragResource:
|
|||
return text
|
||||
|
||||
# Remove bold/italic markdown: **text** or *text* or __text__ or _text_
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
text = re.sub(r'_(.+?)_', r'\1', text)
|
||||
|
||||
# Remove heading markdown: /Heading or # Heading
|
||||
text = re.sub(r"^/\s*", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r'^/\s*', '', text, flags=re.MULTILINE)
|
||||
text = re.sub(r'^#+\s*', '', text, flags=re.MULTILINE)
|
||||
|
||||
# Remove other markdown elements
|
||||
text = re.sub(r"`(.+?)`", r"\1", text) # Code
|
||||
text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) # Links
|
||||
text = re.sub(r'`(.+?)`', r'\1', text) # Code
|
||||
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) # Links
|
||||
|
||||
return text.strip()
|
||||
|
||||
def _parse_gemini_response(self, text):
|
||||
"""Parse the response from Gemini into title, demand, and justification"""
|
||||
# Remove markdown formatting first
|
||||
text = self._remove_markdown(text)
|
||||
|
||||
# Split by "Begründung/Sachverhalt" or similar patterns
|
||||
parts = re.split(r'(begründung|sachverhalt|begründung/sachverhalt)', text, maxsplit=1, flags=re.IGNORECASE)
|
||||
|
||||
if len(parts) >= 3:
|
||||
# We have a split at "Begründung/Sachverhalt"
|
||||
before_justification = parts[0].strip()
|
||||
justification = parts[2].strip() if len(parts) > 2 else ""
|
||||
|
||||
# Remove "Begründung/Sachverhalt" or "Sachverhalt" from the beginning of justification
|
||||
justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?', '', justification, flags=re.IGNORECASE).strip()
|
||||
else:
|
||||
# Try to split by paragraphs
|
||||
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
|
||||
if len(paragraphs) >= 3:
|
||||
before_justification = '\n\n'.join(paragraphs[:-1])
|
||||
justification = paragraphs[-1]
|
||||
# Remove "Begründung/Sachverhalt" or "Sachverhalt" from the beginning
|
||||
justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?', '', justification, flags=re.IGNORECASE).strip()
|
||||
else:
|
||||
before_justification = text
|
||||
justification = ""
|
||||
|
||||
# Extract title (first line or first paragraph)
|
||||
title_match = re.match(r'^(.+?)(?:\n\n|\n|$)', before_justification)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
demand = before_justification[len(title):].strip()
|
||||
else:
|
||||
lines = before_justification.split('\n', 1)
|
||||
title = lines[0].strip()
|
||||
demand = lines[1].strip() if len(lines) > 1 else ""
|
||||
|
||||
# Remove title from demand if it's duplicated
|
||||
if demand.startswith(title):
|
||||
demand = demand[len(title):].strip()
|
||||
|
||||
# Remove markdown from each part
|
||||
title = self._remove_markdown(title)
|
||||
demand = self._remove_markdown(demand)
|
||||
justification = self._remove_markdown(justification)
|
||||
|
||||
# Final cleanup: remove any remaining "Sachverhalt" or "Begründung/Sachverhalt" at the start
|
||||
justification = re.sub(r'^(begründung\s*/?\s*sachverhalt|sachverhalt|begründung)\s*:?\s*\n?\s*', '', justification, flags=re.IGNORECASE).strip()
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'demand': demand,
|
||||
'justification': justification
|
||||
}
|
||||
|
||||
def on_post(self, req, resp):
|
||||
"""Generate text from user input using Gemini API"""
|
||||
try:
|
||||
if not self.model:
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps(
|
||||
{"success": False, "error": "Gemini API key not configured"}
|
||||
)
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': 'Gemini API key not configured'
|
||||
})
|
||||
return
|
||||
|
||||
# Get form data - try multiple methods for Falcon compatibility
|
||||
anliegen = ""
|
||||
party_id = ""
|
||||
anliegen = ''
|
||||
party_id = ''
|
||||
|
||||
# Method 1: Try get_param (works for URL-encoded form data)
|
||||
anliegen = req.get_param("anliegen", default="") or ""
|
||||
party_id = req.get_param("party_id", default="") or ""
|
||||
anliegen = req.get_param('anliegen', default='') or ''
|
||||
party_id = req.get_param('party_id', default='') or ''
|
||||
|
||||
# Method 2: If empty, try to read from stream and parse manually
|
||||
if not anliegen:
|
||||
try:
|
||||
# Read the raw body - use bounded_stream if available, otherwise stream
|
||||
stream = getattr(req, "bounded_stream", req.stream)
|
||||
raw_body = stream.read().decode("utf-8")
|
||||
stream = getattr(req, 'bounded_stream', req.stream)
|
||||
raw_body = stream.read().decode('utf-8')
|
||||
# Parse URL-encoded data manually
|
||||
parsed = parse_qs(raw_body)
|
||||
anliegen = parsed.get("anliegen", [""])[0]
|
||||
party_id = parsed.get("party_id", [""])[0]
|
||||
anliegen = parsed.get('anliegen', [''])[0]
|
||||
party_id = parsed.get('party_id', [''])[0]
|
||||
except Exception as e:
|
||||
# Log the exception for debugging
|
||||
print(f"Error parsing form data: {e}")
|
||||
pass
|
||||
|
||||
# Remove any whitespace and check if actually empty
|
||||
anliegen = anliegen.strip() if anliegen else ""
|
||||
party_id = party_id.strip() if party_id else ""
|
||||
anliegen = anliegen.strip() if anliegen else ''
|
||||
party_id = party_id.strip() if party_id else ''
|
||||
|
||||
if not anliegen:
|
||||
resp.status = falcon.HTTP_400
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps(
|
||||
{"success": False, "error": "Anliegen-Feld ist erforderlich"}
|
||||
)
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': 'Anliegen-Feld ist erforderlich'
|
||||
})
|
||||
return
|
||||
|
||||
# Create prompt for Gemini
|
||||
|
|
@ -238,233 +260,229 @@ class GenerateAntragResource:
|
|||
|
||||
Der Antrag soll im sachlichen, offiziellen Ton einer Fraktion verfasst sein - KEINE persönliche Anrede, KEINE "ich" oder "wir" Formulierungen. Verwende die dritte Person oder Passiv-Formulierungen.
|
||||
|
||||
Gib das Ergebnis ALS GÜLTIGES JSON mit den folgenden Keys zurück:
|
||||
{
|
||||
"antragstitel": "PRÄGNANTER Titel (max. 8-10 Wörter)",
|
||||
"forderung": "Forderungstext oder Liste von Forderungen",
|
||||
"begruendung": "Begründung/Sachverhalt",
|
||||
"mail_recipient": "Empfänger-E-Mail (z.B. 'fraktion@example.com')",
|
||||
"mail_subject": "Betreff für die E-Mail",
|
||||
"mail_body": "Höflicher E-Mail-Text in der ersten Person",
|
||||
"filename": "Dateiname (z.B. 'antragsentwurf_...docx')"
|
||||
}
|
||||
Struktur:
|
||||
- Die erste Zeile ist der Antragstitel. Der Titel soll PRÄGNANT, EINFACH und EINPRÄGSAM sein - maximal 8-10 Wörter. Vermeide komplizierte Formulierungen, technische Fachbegriffe oder zu lange Titel. Der Titel soll eine gute Außenwirkung haben und das Anliegen klar und verständlich kommunizieren. Beispiele für gute Titel: "Nachtabsenkung der öffentlichen Straßenbeleuchtung", "Vielfalt in Bewegung – Kulturelle Begleitmaßnahmen World Games 2029", "Prüfung digitaler Zahlungsdienstleister und WERO-Alternative"
|
||||
- Der zweite Absatz ist der Forderungsteil ("Der Gemeinderat möge beschließen:"). Hier können nach einem kurzen Satz auch Stichpunkte verwendet werden, wenn dies sinnvoll ist.
|
||||
- Der letzte Teil ist Begründung/Sachverhalt (ohne diesen Titel im Text)
|
||||
|
||||
WICHTIG:
|
||||
- ANTWORTE NUR MIT GÜLTIGEM JSON, KEINERLEI ERKLÄRUNGEN ODER ZUSÄTZLICHEN TEXT!
|
||||
- Der Titel soll PRÄGNANT, EINFACH und EINPRÄGSAM sein - maximal 8-10 Wörter.
|
||||
- Der Dateiname soll knapp sein, z.B. 'antragsentwurf_...docx'.
|
||||
- Keine Markdown-Formatierung im Text.
|
||||
- Sachlicher, offizieller Ton einer Fraktion, keine persönlichen Formulierungen im Antrag.
|
||||
- Der E-Mail-Text soll persönlich sein (ich-Form).
|
||||
- Verwende KEINE Markdown-Formatierung. Keine **fett**, keine *kursiv*, keine /Überschriften, keine # Hashtags, keine Links oder andere Formatierung.
|
||||
- Schreibe nur reinen Text ohne jegliche Markdown-Syntax.
|
||||
- Sachlicher, offizieller Ton einer Fraktion, keine persönlichen Formulierungen.
|
||||
- Der Antragstitel muss prägnant, einfach verständlich und einprägsam sein - keine komplizierten Formulierungen!
|
||||
|
||||
Anliegen: """
|
||||
"""
|
||||
prompt += anliegen
|
||||
|
||||
# Call Gemini API
|
||||
response = self.model.generate_content(prompt)
|
||||
generated_text = response.text
|
||||
|
||||
# Log the raw response for debugging
|
||||
logger.debug(f"Gemini raw response: {generated_text}")
|
||||
|
||||
# Extract JSON from markdown code block if present
|
||||
if generated_text.startswith('```json'):
|
||||
generated_text = generated_text[7:].strip() # Remove ```json
|
||||
if generated_text.endswith('```'):
|
||||
generated_text = generated_text[:-3].strip() # Remove trailing ```
|
||||
elif generated_text.startswith('```'):
|
||||
generated_text = generated_text[3:].strip() # Remove leading ```
|
||||
if generated_text.endswith('```'):
|
||||
generated_text = generated_text[:-3].strip() # Remove trailing ```
|
||||
|
||||
# Parse the JSON response
|
||||
gemini_data = json.loads(generated_text)
|
||||
logger.debug(f"Parsed Gemini data: {gemini_data}")
|
||||
parsed = {
|
||||
"title": gemini_data.get("antragstitel", ""),
|
||||
"demand": gemini_data.get("forderung", ""),
|
||||
"justification": gemini_data.get("begruendung", ""),
|
||||
"mail_recipient": gemini_data.get("mail_recipient", ""),
|
||||
"mail_subject": gemini_data.get("mail_subject", ""),
|
||||
"mail_body": gemini_data.get("mail_body", ""),
|
||||
"filename": gemini_data.get("filename", ""),
|
||||
}
|
||||
email_text = parsed["mail_body"]
|
||||
|
||||
# Ensure proper format - clean up and ensure structure
|
||||
email_text = email_text.strip()
|
||||
# Parse the response
|
||||
parsed = self._parse_gemini_response(generated_text)
|
||||
|
||||
# Return JSON with the generated text parts
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"title": parsed["title"],
|
||||
"demand": parsed["demand"],
|
||||
"justification": parsed["justification"],
|
||||
"email_body": email_text,
|
||||
"party_name": party_id if party_id else "",
|
||||
}
|
||||
)
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': True,
|
||||
'title': parsed['title'],
|
||||
'demand': parsed['demand'],
|
||||
'justification': parsed['justification'],
|
||||
'party_name': party_id if party_id else ""
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating antrag: {str(e)}", exc_info=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps({"success": False, "error": str(e)})
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
class GeneratePDFResource:
|
||||
def _generate_pdf(self, title, demand, justification, party_name=""):
|
||||
"""Generate a PDF that looks like a city council proposal"""
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4,
|
||||
rightMargin=2.5*cm, leftMargin=2.5*cm,
|
||||
topMargin=2.5*cm, bottomMargin=2.5*cm)
|
||||
|
||||
class GenerateWordResource:
|
||||
def __init__(self):
|
||||
# Get template path
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.template_path = os.path.join(script_dir, "assets", "antrag_vorlage.docx")
|
||||
# Fallback if not in assets
|
||||
if not os.path.exists(self.template_path):
|
||||
assets_dir = os.path.join(script_dir, "..", "assets")
|
||||
self.template_path = os.path.join(assets_dir, "antrag_vorlage.docx")
|
||||
# Container for the 'Flowable' objects
|
||||
story = []
|
||||
|
||||
def _generate_word(self, title, demand, justification, party_name=""):
|
||||
"""Generate a Word document using the template"""
|
||||
# Load template
|
||||
if os.path.exists(self.template_path):
|
||||
doc = Document(self.template_path)
|
||||
else:
|
||||
# Fallback: create new document if template not found
|
||||
doc = Document()
|
||||
# Define styles
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Get current date in DD.MM.YYYY format
|
||||
current_date = datetime.now().strftime("%d.%m.%Y")
|
||||
|
||||
# Use demand directly without heading
|
||||
antragtext = demand
|
||||
|
||||
# Replace placeholders in all paragraphs
|
||||
for paragraph in doc.paragraphs:
|
||||
full_text = paragraph.text
|
||||
if not full_text:
|
||||
continue
|
||||
|
||||
# Replace FRAKTION
|
||||
if "FRAKTION" in full_text:
|
||||
for run in paragraph.runs:
|
||||
if "FRAKTION" in run.text:
|
||||
run.text = run.text.replace("FRAKTION", party_name)
|
||||
|
||||
# Replace XX.XX.XXXX with current date
|
||||
if "XX.XX.XXXX" in full_text:
|
||||
for run in paragraph.runs:
|
||||
if "XX.XX.XXXX" in run.text:
|
||||
run.text = run.text.replace("XX.XX.XXXX", current_date)
|
||||
|
||||
# Replace ANTRAGSTITEL (bold)
|
||||
if "ANTRAGSTITEL" in full_text:
|
||||
paragraph.clear()
|
||||
run = paragraph.add_run(title)
|
||||
run.bold = True
|
||||
|
||||
# Replace ANTRAGSTEXT
|
||||
if "ANTRAGSTEXT" in full_text:
|
||||
paragraph.clear()
|
||||
lines = antragtext.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip():
|
||||
paragraph.add_run(line.strip())
|
||||
if i < len(lines) - 1:
|
||||
paragraph.add_run("\n")
|
||||
|
||||
# Replace BEGRÜNDUNGSTEXT
|
||||
if "BEGRÜNDUNGSTEXT" in full_text:
|
||||
paragraph.clear()
|
||||
lines = justification.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip():
|
||||
paragraph.add_run(line.strip())
|
||||
if i < len(lines) - 1:
|
||||
paragraph.add_run("\n")
|
||||
|
||||
# Check text boxes (shapes) for placeholders
|
||||
# Text boxes are stored in the document's part relationships
|
||||
try:
|
||||
# Access document part to search for text boxes
|
||||
document_part = doc.part
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
# Search for FRAKTION in text boxes
|
||||
# Text boxes are in w:txbxContent elements within w:p (paragraphs)
|
||||
# We need to search the entire XML tree
|
||||
def replace_in_element(element, search_text, replace_text):
|
||||
"""Recursively replace text in XML elements"""
|
||||
if element.text and search_text in element.text:
|
||||
element.text = element.text.replace(search_text, replace_text)
|
||||
if element.tail and search_text in element.tail:
|
||||
element.tail = element.tail.replace(search_text, replace_text)
|
||||
for child in element:
|
||||
replace_in_element(child, search_text, replace_text)
|
||||
|
||||
# Search in main document body
|
||||
if party_name:
|
||||
replace_in_element(document_part.element, "FRAKTION", party_name)
|
||||
|
||||
# Also search in header and footer parts
|
||||
for rel in document_part.rels.values():
|
||||
if "header" in rel.target_ref or "footer" in rel.target_ref:
|
||||
try:
|
||||
header_footer_part = rel.target_part
|
||||
if party_name:
|
||||
replace_in_element(
|
||||
header_footer_part.element, "FRAKTION", party_name
|
||||
# Custom styles for the document
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=16,
|
||||
textColor='black',
|
||||
spaceAfter=30,
|
||||
alignment=TA_LEFT,
|
||||
fontName='Helvetica-Bold'
|
||||
)
|
||||
|
||||
heading_style = ParagraphStyle(
|
||||
'CustomHeading',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
textColor='black',
|
||||
spaceAfter=12,
|
||||
spaceBefore=20,
|
||||
alignment=TA_LEFT,
|
||||
fontName='Helvetica-Bold'
|
||||
)
|
||||
|
||||
body_style = ParagraphStyle(
|
||||
'CustomBody',
|
||||
parent=styles['Normal'],
|
||||
fontSize=11,
|
||||
textColor='black',
|
||||
spaceAfter=12,
|
||||
alignment=TA_JUSTIFY,
|
||||
fontName='Helvetica'
|
||||
)
|
||||
|
||||
# Header with party name if provided
|
||||
if party_name:
|
||||
party_para = Paragraph(f"<b>Antrag der {party_name}</b>", body_style)
|
||||
story.append(party_para)
|
||||
story.append(Spacer(1, 0.5*cm))
|
||||
|
||||
# Title
|
||||
if title:
|
||||
title_para = Paragraph(f"<b>{title}</b>", title_style)
|
||||
story.append(title_para)
|
||||
|
||||
# Demand section
|
||||
if demand:
|
||||
story.append(Spacer(1, 0.3*cm))
|
||||
demand_heading = Paragraph("<b>Der Gemeinderat möge beschließen:</b>", heading_style)
|
||||
story.append(demand_heading)
|
||||
|
||||
# Process demand text - replace newlines with proper breaks
|
||||
demand_lines = demand.split('\n')
|
||||
for line in demand_lines:
|
||||
if line.strip():
|
||||
demand_para = Paragraph(line.strip(), body_style)
|
||||
story.append(demand_para)
|
||||
|
||||
# Justification section
|
||||
if justification:
|
||||
story.append(Spacer(1, 0.5*cm))
|
||||
justification_heading = Paragraph("<b>Begründung/Sachverhalt</b>", heading_style)
|
||||
story.append(justification_heading)
|
||||
|
||||
# Process justification text
|
||||
justification_lines = justification.split('\n')
|
||||
for line in justification_lines:
|
||||
if line.strip():
|
||||
justification_para = Paragraph(line.strip(), body_style)
|
||||
story.append(justification_para)
|
||||
|
||||
# Build PDF
|
||||
doc.build(story)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
def on_post(self, req, resp):
|
||||
"""Generate PDF from form data"""
|
||||
try:
|
||||
# Get form data
|
||||
title = req.get_param('title', default='') or ''
|
||||
demand = req.get_param('demand', default='') or ''
|
||||
justification = req.get_param('justification', default='') or ''
|
||||
party_name = req.get_param('party_name', default='') or ''
|
||||
|
||||
# If empty, try to read from stream
|
||||
if not title:
|
||||
try:
|
||||
stream = getattr(req, 'bounded_stream', req.stream)
|
||||
raw_body = stream.read().decode('utf-8')
|
||||
parsed = parse_qs(raw_body)
|
||||
title = parsed.get('title', [''])[0]
|
||||
demand = parsed.get('demand', [''])[0]
|
||||
justification = parsed.get('justification', [''])[0]
|
||||
party_name = parsed.get('party_name', [''])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = self._generate_pdf(title, demand, justification, party_name)
|
||||
|
||||
# Return PDF
|
||||
resp.content_type = 'application/pdf'
|
||||
resp.set_header('Content-Disposition', 'inline; filename="antrag.pdf"')
|
||||
resp.data = pdf_buffer.read()
|
||||
|
||||
except Exception as e:
|
||||
# If text box access fails, continue with other replacements
|
||||
print(f"Warning: Could not replace in text boxes: {e}")
|
||||
pass
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# Also check tables for placeholders
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
full_text = paragraph.text
|
||||
if not full_text:
|
||||
continue
|
||||
class GenerateWordResource:
|
||||
def _generate_word(self, title, demand, justification, party_name=""):
|
||||
"""Generate a Word document that looks like a city council proposal"""
|
||||
doc = Document()
|
||||
|
||||
if party_name and "FRAKTION" in full_text:
|
||||
for run in paragraph.runs:
|
||||
if "FRAKTION" in run.text:
|
||||
run.text = run.text.replace("FRAKTION", party_name)
|
||||
# Set default font
|
||||
style = doc.styles['Normal']
|
||||
font = style.font
|
||||
font.name = 'Arial'
|
||||
font.size = Pt(11)
|
||||
|
||||
if "XX.XX.XXXX" in full_text:
|
||||
for run in paragraph.runs:
|
||||
if "XX.XX.XXXX" in run.text:
|
||||
run.text = run.text.replace(
|
||||
"XX.XX.XXXX", current_date
|
||||
)
|
||||
# Header with party name if provided
|
||||
if party_name:
|
||||
party_para = doc.add_paragraph(f"Antrag der {party_name}")
|
||||
party_para.runs[0].bold = True
|
||||
party_para.runs[0].font.size = Pt(11)
|
||||
doc.add_paragraph()
|
||||
|
||||
if "ANTRAGSTITEL" in full_text:
|
||||
paragraph.clear()
|
||||
run = paragraph.add_run(title)
|
||||
run.bold = True
|
||||
# Title
|
||||
if title:
|
||||
title_para = doc.add_paragraph(title)
|
||||
title_para.runs[0].bold = True
|
||||
title_para.runs[0].font.size = Pt(16)
|
||||
title_para.paragraph_format.space_after = Pt(30)
|
||||
|
||||
if "ANTRAGSTEXT" in full_text:
|
||||
paragraph.clear()
|
||||
lines = antragtext.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# Demand section
|
||||
if demand:
|
||||
doc.add_paragraph()
|
||||
demand_heading = doc.add_paragraph("Der Gemeinderat möge beschließen:")
|
||||
demand_heading.runs[0].bold = True
|
||||
demand_heading.runs[0].font.size = Pt(12)
|
||||
demand_heading.paragraph_format.space_before = Pt(20)
|
||||
demand_heading.paragraph_format.space_after = Pt(12)
|
||||
|
||||
# Process demand text
|
||||
demand_lines = demand.split('\n')
|
||||
for line in demand_lines:
|
||||
if line.strip():
|
||||
paragraph.add_run(line.strip())
|
||||
if i < len(lines) - 1:
|
||||
paragraph.add_run("\n")
|
||||
doc.add_paragraph(line.strip())
|
||||
|
||||
if "BEGRÜNDUNGSTEXT" in full_text:
|
||||
paragraph.clear()
|
||||
lines = justification.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# Justification section
|
||||
if justification:
|
||||
doc.add_paragraph()
|
||||
justification_heading = doc.add_paragraph("Begründung/Sachverhalt")
|
||||
justification_heading.runs[0].bold = True
|
||||
justification_heading.runs[0].font.size = Pt(12)
|
||||
justification_heading.paragraph_format.space_before = Pt(20)
|
||||
justification_heading.paragraph_format.space_after = Pt(12)
|
||||
|
||||
# Process justification text
|
||||
justification_lines = justification.split('\n')
|
||||
for line in justification_lines:
|
||||
if line.strip():
|
||||
paragraph.add_run(line.strip())
|
||||
if i < len(lines) - 1:
|
||||
paragraph.add_run("\n")
|
||||
doc.add_paragraph(line.strip())
|
||||
|
||||
# Save to buffer
|
||||
buffer = BytesIO()
|
||||
|
|
@ -477,28 +495,29 @@ class GenerateWordResource:
|
|||
try:
|
||||
if not DOCX_AVAILABLE:
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps(
|
||||
{"success": False, "error": "python-docx not installed"}
|
||||
)
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': 'python-docx not installed'
|
||||
})
|
||||
return
|
||||
|
||||
# Get form data
|
||||
title = req.get_param("title", default="") or ""
|
||||
demand = req.get_param("demand", default="") or ""
|
||||
justification = req.get_param("justification", default="") or ""
|
||||
party_name = req.get_param("party_name", default="") or ""
|
||||
title = req.get_param('title', default='') or ''
|
||||
demand = req.get_param('demand', default='') or ''
|
||||
justification = req.get_param('justification', default='') or ''
|
||||
party_name = req.get_param('party_name', default='') or ''
|
||||
|
||||
# If empty, try to read from stream
|
||||
if not title:
|
||||
try:
|
||||
stream = getattr(req, "bounded_stream", req.stream)
|
||||
raw_body = stream.read().decode("utf-8")
|
||||
stream = getattr(req, 'bounded_stream', req.stream)
|
||||
raw_body = stream.read().decode('utf-8')
|
||||
parsed = parse_qs(raw_body)
|
||||
title = parsed.get("title", [""])[0]
|
||||
demand = parsed.get("demand", [""])[0]
|
||||
justification = parsed.get("justification", [""])[0]
|
||||
party_name = parsed.get("party_name", [""])[0]
|
||||
title = parsed.get('title', [''])[0]
|
||||
demand = parsed.get('demand', [''])[0]
|
||||
justification = parsed.get('justification', [''])[0]
|
||||
party_name = parsed.get('party_name', [''])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -506,31 +525,31 @@ class GenerateWordResource:
|
|||
word_buffer = self._generate_word(title, demand, justification, party_name)
|
||||
|
||||
# Return Word document
|
||||
resp.content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
resp.set_header("Content-Disposition", 'attachment; filename="antrag.docx"')
|
||||
resp.content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
resp.set_header('Content-Disposition', 'attachment; filename="antrag.docx"')
|
||||
resp.data = word_buffer.read()
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
resp.status = falcon.HTTP_500
|
||||
resp.content_type = "application/json"
|
||||
resp.text = json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
resp.content_type = 'application/json'
|
||||
resp.text = json.dumps({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
class RobotsResource:
|
||||
def on_get(self, req, resp):
|
||||
resp.content_type = "text/plain; charset=utf-8"
|
||||
resp.content_type = 'text/plain; charset=utf-8'
|
||||
resp.text = f"""User-agent: *
|
||||
Allow: /
|
||||
Sitemap: {SITE_BASE_URL}/sitemap.xml
|
||||
"""
|
||||
|
||||
|
||||
class SitemapResource:
|
||||
def on_get(self, req, resp):
|
||||
resp.content_type = "application/xml; charset=utf-8"
|
||||
resp.content_type = 'application/xml; charset=utf-8'
|
||||
resp.text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>{SITE_BASE_URL}/</loc></url>
|
||||
|
|
@ -539,54 +558,55 @@ class SitemapResource:
|
|||
</urlset>
|
||||
"""
|
||||
|
||||
|
||||
# Create Falcon application
|
||||
app = falcon.App()
|
||||
|
||||
# Discover static assets directory
|
||||
STATIC_DIR = os.environ.get("MEINANTRAG_STATIC_DIR")
|
||||
STATIC_DIR = os.environ.get('MEINANTRAG_STATIC_DIR')
|
||||
if not STATIC_DIR:
|
||||
# Prefer local assets folder in development (relative to this file)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
candidate = os.path.join(script_dir, "assets")
|
||||
candidate = os.path.join(script_dir, 'assets')
|
||||
if os.path.isdir(candidate):
|
||||
STATIC_DIR = candidate
|
||||
else:
|
||||
# Try current working directory (useful when running packaged binary from project root)
|
||||
cwd_candidate = os.path.join(os.getcwd(), "assets")
|
||||
cwd_candidate = os.path.join(os.getcwd(), 'assets')
|
||||
if os.path.isdir(cwd_candidate):
|
||||
STATIC_DIR = cwd_candidate
|
||||
else:
|
||||
# Fallback to packaged location under share
|
||||
STATIC_DIR = os.path.join(script_dir, "..", "share", "meinantrag", "assets")
|
||||
STATIC_DIR = os.path.join(script_dir, '..', 'share', 'meinantrag', 'assets')
|
||||
|
||||
# Add routes
|
||||
meinantrag = MeinAntragApp()
|
||||
impressum = ImpressumResource()
|
||||
datenschutz = DatenschutzResource()
|
||||
generate_antrag = GenerateAntragResource()
|
||||
generate_pdf = GeneratePDFResource()
|
||||
generate_word = GenerateWordResource()
|
||||
robots = RobotsResource()
|
||||
sitemap = SitemapResource()
|
||||
|
||||
app.add_route("/", meinantrag)
|
||||
app.add_route("/impressum", impressum)
|
||||
app.add_route("/datenschutz", datenschutz)
|
||||
app.add_route("/api/generate-antrag", generate_antrag)
|
||||
app.add_route("/api/generate-word", generate_word)
|
||||
app.add_route("/robots.txt", robots)
|
||||
app.add_route("/sitemap.xml", sitemap)
|
||||
app.add_route('/', meinantrag)
|
||||
app.add_route('/impressum', impressum)
|
||||
app.add_route('/datenschutz', datenschutz)
|
||||
app.add_route('/api/generate-antrag', generate_antrag)
|
||||
app.add_route('/api/generate-pdf', generate_pdf)
|
||||
app.add_route('/api/generate-word', generate_word)
|
||||
app.add_route('/robots.txt', robots)
|
||||
app.add_route('/sitemap.xml', sitemap)
|
||||
|
||||
# Static file route
|
||||
if STATIC_DIR and os.path.isdir(STATIC_DIR):
|
||||
app.add_static_route("/static", STATIC_DIR)
|
||||
app.add_static_route('/static', STATIC_DIR)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
import wsgiref.simple_server
|
||||
|
||||
print("Starting MeinAntrag web application...")
|
||||
print("Open your browser and navigate to: http://localhost:8000")
|
||||
print(f"Serving static assets from: {STATIC_DIR}")
|
||||
|
||||
httpd = wsgiref.simple_server.make_server("localhost", 8000, app)
|
||||
httpd = wsgiref.simple_server.make_server('localhost', 8000, app)
|
||||
httpd.serve_forever()
|
||||
|
|
|
|||
28
module.nix
28
module.nix
|
|
@ -16,31 +16,16 @@ in
|
|||
|
||||
enable = lib.mkEnableOption "MeinAntrag web app";
|
||||
|
||||
settings = lib.mkOption {
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = {
|
||||
GOOGLE_GEMINI_API_KEY = "your-api-key-here";
|
||||
MEINANTRAG_BASE_URL = "https://example.com";
|
||||
GOOGLE_GEMINI_API_KEY = "file:/run/secrets/gemini_api_key";
|
||||
};
|
||||
description = ''
|
||||
Additional environment variables to pass to the MeinAntrag service.
|
||||
Values starting with "file:" will be read from the specified path.
|
||||
For secrets with systemd LoadCredential, use the credentials option instead.
|
||||
'';
|
||||
};
|
||||
|
||||
credentials = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = {
|
||||
GOOGLE_GEMINI_API_KEY = "/run/secrets/gemini_api_key";
|
||||
};
|
||||
description = ''
|
||||
Credentials to pass to the MeinAntrag service.
|
||||
Maps environment variable names to file paths containing the secret values.
|
||||
These are loaded via systemd's LoadCredential mechanism.
|
||||
The Python app will automatically read the value from the file.
|
||||
For example, set GOOGLE_GEMINI_API_KEY for Gemini API integration.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -79,8 +64,7 @@ in
|
|||
"PYTHONPATH=${pkgs.meinantrag}/share/meinantrag:${pkgs.meinantrag.pythonPath}"
|
||||
"MEINANTRAG_TEMPLATES_DIR=${pkgs.meinantrag}/share/meinantrag/templates"
|
||||
"MEINANTRAG_STATIC_DIR=${pkgs.meinantrag}/share/meinantrag/assets"
|
||||
] ++ (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.settings)
|
||||
++ (lib.mapAttrsToList (name: _: "${name}=file:/run/credentials/uwsgi.service/${name}") cfg.credentials);
|
||||
] ++ (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.environment);
|
||||
|
||||
settings = {
|
||||
"static-map" = "/static=${pkgs.meinantrag}/share/meinantrag/assets";
|
||||
|
|
@ -90,10 +74,6 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Load credentials via systemd's LoadCredential mechanism
|
||||
systemd.services.uwsgi.serviceConfig.LoadCredential =
|
||||
lib.mapAttrsToList (key: value: "${key}:${value}") cfg.credentials;
|
||||
|
||||
# Ensure meinantrag user and group exist
|
||||
users.users.meinantrag = {
|
||||
isSystemUser = true;
|
||||
|
|
|
|||
|
|
@ -4,24 +4,24 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MeinAntrag{% endblock %}</title>
|
||||
<link type="text/css" href="/static/css/pages.css" media="all" rel="stylesheet">
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/css/select2.min.css" rel="stylesheet">
|
||||
<link href="/static/css/select2-bootstrap-5-theme.min.css" rel="stylesheet">
|
||||
|
||||
<meta name="description" content="{{ meta_description | default('Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!') }}">
|
||||
<meta name="description" content="{{ meta_description | default('Erstelle vorausgefüllte FragDenStaat.de-Anfragelinks und teile sie mit Freund:innen. Suche Behörden, füge Betreff und Text hinzu und generiere einen teilbaren Link.') }}">
|
||||
<link rel="canonical" href="{{ canonical_url | default('/') }}">
|
||||
<link rel="alternate" hreflang="de" href="{{ canonical_url | default('/') }}">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="MeinAntrag">
|
||||
<meta property="og:title" content="{{ meta_title | default('MeinAntrag') }}">
|
||||
<meta property="og:description" content="{{ meta_description | default('Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!') }}">
|
||||
<meta property="og:description" content="{{ meta_description | default('Erstelle vorausgefüllte FragDenStaat.de-Anfragelinks und teile sie mit Freund:innen.') }}">
|
||||
<meta property="og:locale" content="de_DE">
|
||||
<meta property="og:url" content="{{ canonical_url | default('/') }}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ meta_title | default('MeinAntrag') }}">
|
||||
<meta name="twitter:description" content="{{ meta_description | default('Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!') }}">
|
||||
<meta name="twitter:description" content="{{ meta_description | default('Erstelle vorausgefüllte FragDenStaat.de-Anfragelinks und teile sie mit Freund:innen.') }}">
|
||||
|
||||
{% if noindex %}<meta name="robots" content="noindex,follow">{% endif %}
|
||||
<meta name="theme-color" content="#667eea">
|
||||
|
|
@ -30,210 +30,60 @@
|
|||
|
||||
{% block meta_extra %}{% endblock %}
|
||||
<style>
|
||||
h2 {
|
||||
max-width: 700px !important;
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
select, textarea, input {
|
||||
max-width: 700px;
|
||||
.main-container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
padding: 3rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 800px;
|
||||
}
|
||||
.title {
|
||||
color: #2c3e50;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tutorial-list li {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tutorial-list li h4 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tutorial-list li p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Responsive design for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
h2 {
|
||||
max-width: 100% !important;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
select, textarea, input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tutorial-list li {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tutorial-list li h4 {
|
||||
.description {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tutorial-list li p {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #e9ecef;
|
||||
padding: 0.75rem 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 150px;
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#resultFields .btn-primary {
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
#resultFields .btn-primary:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tutorial-list li form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tutorial-list li form > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Fix wrapper width on mobile */
|
||||
.wrapper {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
width: 100% !important;
|
||||
padding: 20px 1rem;
|
||||
margin: 40px auto 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links.right {
|
||||
float: none;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links li {
|
||||
display: inline-block;
|
||||
margin: 0 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tutorial-list {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.tutorial-list p {
|
||||
margin: 15px 20px !important;
|
||||
}
|
||||
|
||||
.hero-spot {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.hero-spot h1 {
|
||||
font-size: 2rem;
|
||||
.result-link {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hero-spot h2 {
|
||||
font-size: 1rem;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tutorial {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 0 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tutorial-list li {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.tutorial-list li:after {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -29px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-spot h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-spot h2 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tutorial-list li h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links.right {
|
||||
float: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-footer .site-footer-links li {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tutorial-list li {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.tutorial-list li h4 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tutorial-list li p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Ensure Select2 scales properly */
|
||||
.select2-container {
|
||||
|
|
@ -250,58 +100,52 @@
|
|||
.select2-container .select2-selection__arrow {
|
||||
height: 100% !important;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.main-container {
|
||||
padding: 1.25rem;
|
||||
margin: 1rem auto 4rem auto;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: #4078c0;
|
||||
box-shadow: 0 0 0 2px rgba(64, 120, 192, 0.2);
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
textarea.form-control {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #4078c0;
|
||||
border-color: #4078c0;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #3669a3;
|
||||
border-color: #3669a3;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.loading {
|
||||
display: none;
|
||||
}
|
||||
.loading.active {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
.footer {
|
||||
background: transparent;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
.footer-links a {
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.footer-links a:hover {
|
||||
color: #667eea !important;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.footer-text a:hover {
|
||||
color: #667eea !important;
|
||||
}
|
||||
.footer-text a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer-text {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.legal-content {
|
||||
text-align: left;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.legal-content h2 {
|
||||
color: #2c3e50;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
|
@ -311,25 +155,42 @@
|
|||
.legal-content ul {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.main-container {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body class="home">
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="main-container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="page-footer">
|
||||
<ul class="site-footer-links right">
|
||||
<li><a href="/impressum">Impressum</a></li>
|
||||
<li><a href="/datenschutz">Datenschutz</a></li>
|
||||
<li><a href="https://git.project-insanity.org/onny/MeinAntrag" target="_blank">Source</a></li>
|
||||
</ul>
|
||||
<ul class="site-footer-links">
|
||||
<li>© 2025 <span>Project-Insanity.org</span></li>
|
||||
<li><a href="https://social.project-insanity.org/@pi_crew" target="_blank">Mastodon</a></li>
|
||||
</ul>
|
||||
<!-- Footer -->
|
||||
<footer class="footer mt-5 pt-4">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="footer-links mb-2">
|
||||
<a href="/impressum" class="text-muted text-decoration-none me-3">Impressum</a>
|
||||
<a href="/datenschutz" class="text-muted text-decoration-none me-3">Datenschutz</a>
|
||||
<a href="https://git.project-insanity.org/onny/MeinAntrag" class="text-muted text-decoration-none" target="_blank">Source</a>
|
||||
</div>
|
||||
<div class="footer-text">
|
||||
<small class="text-muted">
|
||||
Projekt von <a href="https://project-insanity.org" class="text-muted text-decoration-none" target="_blank">Project-Insanity.org</a>,
|
||||
follow us on <a href="https://social.project-insanity.org/@pi_crew" class="text-muted text-decoration-none" target="_blank">Mastodon</a> :)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/pages-jquery.js"></script>
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/jquery.min.js"></script>
|
||||
<script src="/static/js/select2.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,17 @@
|
|||
{% block title %}MeinAntrag{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section id="hero-spot" class="hero-spot">
|
||||
<h1>MeinAntrag</h1>
|
||||
<h2>Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!</h2>
|
||||
</section>
|
||||
<div class="text-center">
|
||||
<h1 class="title display-4">MeinAntrag</h1>
|
||||
<p class="description">
|
||||
Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung
|
||||
zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!
|
||||
</p>
|
||||
|
||||
<section id="tutorial" class="tutorial">
|
||||
|
||||
<ul id="project-site" class="tutorial-list wrapper active">
|
||||
<li class="question">
|
||||
|
||||
<form id="meinantragForm">
|
||||
<form id="meinantragForm" class="text-start">
|
||||
<div id="inputFields">
|
||||
<li>
|
||||
<h4>Fraktion auswählen</h4>
|
||||
<p>Wähle die Fraktion, an die der Antrag gerichtet ist:</p>
|
||||
<div class="mb-4">
|
||||
<label for="party" class="form-label fw-bold">Fraktion</label>
|
||||
<select class="form-select" id="party" name="party_id" required>
|
||||
<option value="">Fraktion auswählen...</option>
|
||||
<option value="SPD">SPD</option>
|
||||
|
|
@ -28,65 +24,69 @@
|
|||
<option value="DIE LINKE">DIE LINKE</option>
|
||||
<option value="KAL">KAL</option>
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h4>Dein Anliegen beschreiben</h4>
|
||||
<p>Beschreibe hier, welche Anfrage oder Antrag du an die Stadtverwaltung stellen möchtest:</p>
|
||||
<textarea class="form-control" id="anliegen" name="anliegen" rows="5" required></textarea>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="btn-text">Antrag generieren</span>
|
||||
<span class="loading" role="status" style="display: none;">...</span>
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div id="resultFields" style="display: none;">
|
||||
<li>
|
||||
<p><a href="#" id="backLink">← Zurück zum Formular</a></p>
|
||||
<br><br>
|
||||
</li>
|
||||
<div class="mb-4">
|
||||
<label for="anliegen" class="form-label fw-bold">Mein Anliegen:</label>
|
||||
<textarea class="form-control" id="anliegen" name="anliegen" rows="5"
|
||||
placeholder="Beschreibe hier, welche Anfrage oder Antrag du an die Stadtverwaltung stellen möchtest ..." required></textarea>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<h4>Antragstitel</h4>
|
||||
<p></p>
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<span class="btn-text">Antrag generieren</span>
|
||||
<span class="loading spinner-border spinner-border-sm ms-2" role="status"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultFields" class="text-start" style="display: none;">
|
||||
<div class="mb-3">
|
||||
<a href="#" id="backLink" class="text-decoration-none">
|
||||
<span>← Zurück</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="antragstitel" class="form-label fw-bold">Antragstitel</label>
|
||||
<input type="text" class="form-control" id="antragstitel" name="antragstitel">
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<h4>Forderung</h4>
|
||||
<p></p>
|
||||
<div class="mb-4">
|
||||
<label for="forderung" class="form-label fw-bold">Forderung</label>
|
||||
<textarea class="form-control" id="forderung" name="forderung" rows="5"></textarea>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<h4>Begründung/Sachverhalt</h4>
|
||||
<p></p>
|
||||
<div class="mb-4">
|
||||
<label for="begruendung" class="form-label fw-bold">Begründung/Sachverhalt</label>
|
||||
<textarea class="form-control" id="begruendung" name="begruendung" rows="8"></textarea>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<h4>Aktionen</h4>
|
||||
<p>Du kannst den Antrag jetzt per E-Mail senden oder als Word-Datei herunterladen:</p>
|
||||
<div class="mb-4 d-flex gap-2 flex-wrap justify-content-center">
|
||||
<button type="button" class="btn btn-primary" id="mailBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-envelope me-2" viewBox="0 0 16 16">
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1zm13 2.383-4.708 2.825L15 11.105zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741M1 11.105l4.708-2.897L1 5.383z"/>
|
||||
</svg>
|
||||
<span id="mailBtnText">Mail an Fraktion senden</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="pdfBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-pdf me-2" viewBox="0 0 16 16">
|
||||
<path d="M8.5 6a.5.5 0 0 0-1 0v1.5H6a.5.5 0 0 0 0 1h1.5V10a.5.5 0 0 0 1 0V8.5H10a.5.5 0 0 0 0-1H8.5z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
PDF anzeigen
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="wordBtn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark-word me-2" viewBox="0 0 16 16">
|
||||
<path d="M5.485 6.879a.5.5 0 1 0-.97.242l1.5 6a.5.5 0 0 0 .539.314l1.5-.5a.5.5 0 0 0 .186-.596l-.737-2.945 2.679-3.42a.5.5 0 1 0-.758-.652L6.978 8.616l-1.493-.5z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
Word-Datei herunterladen
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
|
@ -109,8 +109,8 @@
|
|||
const $btnText = $button.find('.btn-text');
|
||||
const $loading = $button.find('.loading');
|
||||
$button.prop('disabled', true);
|
||||
$btnText.text('Generiere Antrag');
|
||||
$loading.css('display', 'inline');
|
||||
$btnText.text('Generiere Antrag...');
|
||||
$loading.css('display', 'inline-block');
|
||||
|
||||
// Prepare form data as URL-encoded
|
||||
const formData = new URLSearchParams();
|
||||
|
|
@ -142,9 +142,8 @@
|
|||
$('#forderung').val(data.demand || '');
|
||||
$('#begruendung').val(data.justification || '');
|
||||
|
||||
// Store party name and email body for mail button
|
||||
// Store party name for mail button
|
||||
$('#resultFields').data('party-name', data.party_name || '');
|
||||
$('#resultFields').data('email-body', data.email_body || '');
|
||||
|
||||
// Update mail button text
|
||||
if (data.party_name) {
|
||||
|
|
@ -161,7 +160,7 @@
|
|||
// Reset button state
|
||||
$button.prop('disabled', false);
|
||||
$btnText.text('Antrag generieren');
|
||||
$loading.css('display', 'none');
|
||||
$loading.hide();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
|
|
@ -170,7 +169,7 @@
|
|||
// Reset button state
|
||||
$button.prop('disabled', false);
|
||||
$btnText.text('Antrag generieren');
|
||||
$loading.css('display', 'none');
|
||||
$loading.hide();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -183,13 +182,13 @@
|
|||
|
||||
// Mail-Kontakte für Fraktionen
|
||||
const partyContacts = {
|
||||
'SPD': 'spd@fraktion.karlsruhe.de',
|
||||
'GRÜNEN': 'gruene@fraktion.karlsruhe.de',
|
||||
'CDU': 'cdu@fraktion.karlsruhe.de',
|
||||
'FDP/FW': 'fdp-fw@fraktion.karlsruhe.de',
|
||||
'Volt': 'volt@fraktion.karlsruhe.de',
|
||||
'DIE LINKE': 'dielinke@gr.karlsruhe.de',
|
||||
'KAL': 'kal@fraktion.karlsruhe.de'
|
||||
'SPD': 'spd@karlsruhe.de',
|
||||
'GRÜNEN': 'gruene@karlsruhe.de',
|
||||
'CDU': 'cdu@karlsruhe.de',
|
||||
'FDP/FW': 'fdp@karlsruhe.de',
|
||||
'Volt': 'volt@karlsruhe.de',
|
||||
'DIE LINKE': 'dielinke@karlsruhe.de',
|
||||
'KAL': 'kal@karlsruhe.de'
|
||||
};
|
||||
|
||||
// Handle mail button click
|
||||
|
|
@ -197,9 +196,18 @@
|
|||
const partyName = $('#resultFields').data('party-name') || '';
|
||||
const email = partyContacts[partyName] || '';
|
||||
const subject = encodeURIComponent($('#antragstitel').val() || '');
|
||||
const emailBody = $('#resultFields').data('email-body') || '';
|
||||
const title = $('#antragstitel').val() || '';
|
||||
const demand = $('#forderung').val() || '';
|
||||
const justification = $('#begruendung').val() || '';
|
||||
|
||||
const bodyEncoded = encodeURIComponent(emailBody);
|
||||
// Build email body
|
||||
let body = title + '\n\n';
|
||||
body += 'Der Gemeinderat möge beschließen:\n';
|
||||
body += demand + '\n\n';
|
||||
body += 'Begründung/Sachverhalt\n';
|
||||
body += justification;
|
||||
|
||||
const bodyEncoded = encodeURIComponent(body);
|
||||
|
||||
// Open mail client
|
||||
if (email) {
|
||||
|
|
@ -209,37 +217,45 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Function to create a valid filename from title
|
||||
function createFilename(title) {
|
||||
if (!title || !title.trim()) {
|
||||
return 'antrag.docx';
|
||||
// Handle PDF button click
|
||||
$('#pdfBtn').on('click', function() {
|
||||
const title = $('#antragstitel').val() || '';
|
||||
const demand = $('#forderung').val() || '';
|
||||
const justification = $('#begruendung').val() || '';
|
||||
const partyName = $('#resultFields').data('party-name') || '';
|
||||
|
||||
// Prepare form data
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('title', title);
|
||||
formData.append('demand', demand);
|
||||
formData.append('justification', justification);
|
||||
if (partyName) {
|
||||
formData.append('party_name', partyName);
|
||||
}
|
||||
|
||||
// Remove or replace special characters
|
||||
let filename = title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[ä]/g, 'ae')
|
||||
.replace(/[ö]/g, 'oe')
|
||||
.replace(/[ü]/g, 'ue')
|
||||
.replace(/[ß]/g, 'ss')
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars except spaces and hyphens
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/_+/g, '_') // Replace multiple underscores with single
|
||||
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
|
||||
|
||||
// Limit length to 100 characters
|
||||
if (filename.length > 100) {
|
||||
filename = filename.substring(0, 100);
|
||||
}
|
||||
|
||||
// If empty after cleaning, use default
|
||||
if (!filename) {
|
||||
return 'antrag.docx';
|
||||
}
|
||||
|
||||
return filename + '.docx';
|
||||
// Open PDF in new window
|
||||
fetch('/api/generate-pdf', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Generieren des PDFs');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Fehler beim Generieren des PDFs: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle Word button click
|
||||
$('#wordBtn').on('click', function() {
|
||||
|
|
@ -257,9 +273,6 @@
|
|||
formData.append('party_name', partyName);
|
||||
}
|
||||
|
||||
// Generate filename from title
|
||||
const filename = createFilename(title);
|
||||
|
||||
// Download Word file
|
||||
fetch('/api/generate-word', {
|
||||
method: 'POST',
|
||||
|
|
@ -278,7 +291,7 @@
|
|||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.download = 'antrag.docx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue