generate antrag

This commit is contained in:
Jonas Heinrich 2025-12-25 22:27:34 +01:00
parent 6c46660d0c
commit 8ae9840601
5 changed files with 719 additions and 149 deletions

8
flake.lock generated
View file

@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763622513,
"narHash": "sha256-1jQnuyu82FpiSxowrF/iFK6Toh9BYprfDqfs4BB+19M=",
"lastModified": 1766473571,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c58bc7f5459328e4afac201c5c4feb7c818d604b",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-25.05",
"ref": "nixos-25.11",
"type": "indirect"
}
},

View file

@ -1,7 +1,7 @@
{
description = "meinantrag package and service";
inputs.nixpkgs.url = "nixpkgs/nixos-25.05";
inputs.nixpkgs.url = "nixpkgs/nixos-25.11";
outputs = { self, nixpkgs }:
let
@ -18,14 +18,21 @@
overlay = final: prev: {
meinantrag = with final; python3Packages.buildPythonApplication rec {
pname = "meinantrag";
version = "0.0.1";
version = "0.0.2";
format = "other";
src = self;
dontBuild = true;
dependencies = with python3Packages; [ falcon requests jinja2 ];
dependencies = with python3Packages; [
falcon
requests
jinja2
google-generativeai # Dependency for Gemini API
reportlab # Dependency for PDF generation
python-docx # Dependency for Word document generation
];
installPhase = ''
install -Dm755 ${./meinantrag.py} $out/bin/meinantrag

View file

@ -6,10 +6,25 @@ MeinAntrag - A web application to generate prefilled government requests
import falcon
import json
import requests
from urllib.parse import urlencode
from urllib.parse import urlencode, parse_qs
import os
import sys
from jinja2 import Environment, FileSystemLoader
import google.generativeai as genai
import re
from io import BytesIO
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
SITE_BASE_URL = os.environ.get('MEINANTRAG_BASE_URL', 'http://localhost:8000')
@ -59,7 +74,6 @@ class BaseTemplateResource:
class MeinAntragApp(BaseTemplateResource):
def __init__(self):
self.fragdenstaat_api = "https://fragdenstaat.de/api/v1"
# Setup Jinja2 template environment
template_dir = self._get_template_dir()
print(f"Using template directory: {template_dir}")
@ -74,42 +88,6 @@ class MeinAntragApp(BaseTemplateResource):
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}/"
)
def on_post(self, req, resp):
"""Handle form submission and generate link"""
try:
# Parse form data - use get_param for form fields
publicbody_id = req.get_param('publicbody_id', default='')
subject = req.get_param('subject', default='')
body = req.get_param('body', default='')
# Generate FragDenStaat.de link
base_url = "https://fragdenstaat.de/anfrage-stellen/"
if publicbody_id:
base_url += f"an/{publicbody_id}/"
params = {}
if subject:
params['subject'] = subject
if body:
params['body'] = body
if params:
base_url += "?" + urlencode(params)
resp.content_type = 'application/json'
resp.text = json.dumps({
'success': True,
'link': base_url
})
except Exception as e:
resp.status = falcon.HTTP_500
resp.content_type = 'application/json'
resp.text = json.dumps({
'success': False,
'error': str(e)
})
class ImpressumResource(BaseTemplateResource):
def __init__(self):
@ -143,42 +121,422 @@ class DatenschutzResource(BaseTemplateResource):
noindex=True
)
class PublicBodiesResource:
class GenerateAntragResource:
def __init__(self):
self.fragdenstaat_api = "https://fragdenstaat.de/api/v1"
# 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-flash-latest')
else:
self.model = None
def on_get(self, req, resp):
"""API endpoint to search public bodies"""
def _remove_markdown(self, text):
"""Remove markdown formatting from text"""
if not text:
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)
# Remove heading markdown: /Heading or # Heading
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
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:
search = req.get_param('search', default='')
page = req.get_param('page', default=1)
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'
})
return
# Build API URL
url = f"{self.fragdenstaat_api}/publicbody/"
params = {
'limit': 20,
'offset': (int(page) - 1) * 20
}
# Get form data - try multiple methods for Falcon compatibility
anliegen = ''
party_id = ''
if search:
params['q'] = search
# 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 ''
# Make request to FragDenStaat API
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
# 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')
# Parse URL-encoded data manually
parsed = parse_qs(raw_body)
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
data = response.json()
# Remove any whitespace and check if actually empty
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'
})
return
# Create prompt for Gemini
prompt = """Erzeuge aus dem folgenden Anliegen-Text je nach Anliegen eine Anfrage oder einen Antrag an die Karlsruher Stadtverwaltung im Namen einer Stadtratsfraktion.
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.
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:
- 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!
"""
prompt += anliegen
# Call Gemini API
response = self.model.generate_content(prompt)
generated_text = response.text
# 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(data)
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:
import traceback
traceback.print_exc()
resp.status = falcon.HTTP_500
resp.content_type = 'application/json'
resp.text = json.dumps({
'error': str(e),
'results': [],
'next': None
'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)
# Container for the 'Flowable' objects
story = []
# Define styles
styles = getSampleStyleSheet()
# 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:
import traceback
traceback.print_exc()
resp.status = falcon.HTTP_500
resp.content_type = 'application/json'
resp.text = json.dumps({
'success': False,
'error': str(e)
})
class GenerateWordResource:
def _generate_word(self, title, demand, justification, party_name=""):
"""Generate a Word document that looks like a city council proposal"""
doc = Document()
# Set default font
style = doc.styles['Normal']
font = style.font
font.name = 'Arial'
font.size = Pt(11)
# 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()
# 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)
# 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():
doc.add_paragraph(line.strip())
# 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():
doc.add_paragraph(line.strip())
# Save to buffer
buffer = BytesIO()
doc.save(buffer)
buffer.seek(0)
return buffer
def on_post(self, req, resp):
"""Generate Word document from form data"""
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'
})
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 ''
# 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 Word document
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.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)
})
class RobotsResource:
@ -224,14 +582,18 @@ if not STATIC_DIR:
meinantrag = MeinAntragApp()
impressum = ImpressumResource()
datenschutz = DatenschutzResource()
publicbodies = PublicBodiesResource()
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/publicbodies', publicbodies)
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)

View file

@ -16,6 +16,19 @@ in
enable = lib.mkEnableOption "MeinAntrag web app";
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";
};
description = ''
Additional environment variables to pass to the MeinAntrag service.
For example, set GOOGLE_GEMINI_API_KEY for Gemini API integration.
'';
};
};
};
@ -51,7 +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.environment);
settings = {
"static-map" = "/static=${pkgs.meinantrag}/share/meinantrag/assets";

View file

@ -11,107 +11,295 @@
</p>
<form id="meinantragForm" class="text-start">
<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>
</select>
<div id="inputFields">
<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>
<option value="GRÜNEN">GRÜNEN</option>
<option value="CDU">CDU</option>
<option value="FDP/FW">FDP/FW</option>
<option value="Volt">Volt</option>
<option value="DIE LINKE">DIE LINKE</option>
<option value="KAL">KAL</option>
</select>
</div>
<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>
<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 class="mb-4">
<label for="body" class="form-label fw-bold">Mein Anliegen:</label>
<textarea class="form-control" id="body" name="body" rows="5"
placeholder="Beschreibe hier, welche Anfrage oder Antrag du an die Stadtverwaltung stellen möchtest ..." required></textarea>
</div>
<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 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">
</div>
<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>
</div>
<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>
</div>
<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>
</div>
</div>
</form>
<div id="result" class="mt-4" style="display: none;">
<h5 class="text-success mb-3">Link erfolgreich generiert!</h5>
<div class="input-group">
<input type="text" class="form-control" id="generatedLinkInput" readonly>
<button class="btn btn-outline-primary" type="button" id="copyBtn">In Zwischenablage</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
// Initialize Select2 for public bodies
$('#publicbody').select2({
theme: 'bootstrap-5',
placeholder: 'Behörde auswählen...',
allowClear: true,
width: '100%',
ajax: {
url: '/api/publicbodies',
dataType: 'json',
delay: 250,
data: function(params) {
return {
search: params.term,
page: params.page || 1
};
},
processResults: function(data, params) {
params.page = params.page || 1;
return {
results: data.objects.map(function(item) {
return {
id: item.id,
text: item.name + ' (' + item.jurisdiction.name + ')'
};
}),
pagination: {
more: data.meta.next !== null
}
};
},
cache: true
}
});
// Handle form submission (client-side)
$('#meinantragForm').on('submit', function(e) {
e.preventDefault();
const publicbodyId = $('#publicbody').val() || '';
const subject = $('#subject').val() || '';
const body = $('#body').val() || '';
const partyId = $('#party').val() || '';
const anliegen = $('#anliegen').val() || '';
let link = 'https://fragdenstaat.de/anfrage-stellen/';
if (publicbodyId) {
link += `an/${publicbodyId}/`;
if (!anliegen.trim()) {
alert('Bitte geben Sie ein Anliegen ein.');
return;
}
const params = new URLSearchParams();
if (subject) params.set('subject', subject);
if (body) params.set('body', body);
// Show loading state
const $button = $(this).find('button[type="submit"]');
const $btnText = $button.find('.btn-text');
const $loading = $button.find('.loading');
$button.prop('disabled', true);
$btnText.text('Generiere Antrag...');
$loading.css('display', 'inline-block');
if ([...params].length > 0) {
link += `?${params.toString()}`;
// Prepare form data as URL-encoded
const formData = new URLSearchParams();
formData.append('anliegen', anliegen);
if (partyId) {
formData.append('party_id', partyId);
}
$('#generatedLinkInput').val(link);
$('#result').show();
// Send request to generate text
fetch('/api/generate-antrag', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
})
.then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error(err.error || 'Fehler beim Generieren des Antrags');
});
}
return response.json();
})
.then(data => {
if (data.success) {
// Fill in the result fields
$('#antragstitel').val(data.title || '');
$('#forderung').val(data.demand || '');
$('#begruendung').val(data.justification || '');
// Store party name for mail button
$('#resultFields').data('party-name', data.party_name || '');
// Update mail button text
if (data.party_name) {
$('#mailBtnText').text('Mail an ' + data.party_name + ' senden');
}
// Hide input fields and show result fields
$('#inputFields').hide();
$('#resultFields').show();
} else {
throw new Error(data.error || 'Fehler beim Generieren des Antrags');
}
// Reset button state
$button.prop('disabled', false);
$btnText.text('Antrag generieren');
$loading.hide();
})
.catch(error => {
console.error('Error:', error);
alert('Fehler beim Generieren des Antrags: ' + error.message);
// Reset button state
$button.prop('disabled', false);
$btnText.text('Antrag generieren');
$loading.hide();
});
});
// Copy to clipboard
$(document).on('click', '#copyBtn', function() {
const link = $('#generatedLinkInput').val();
navigator.clipboard.writeText(link).then(function() {
const btn = $('#copyBtn');
const originalText = btn.text();
btn.text('Kopiert!');
setTimeout(() => btn.text(originalText), 1500);
// Handle back link click
$('#backLink').on('click', function(e) {
e.preventDefault();
$('#resultFields').hide();
$('#inputFields').show();
});
// Mail-Kontakte für Fraktionen
const partyContacts = {
'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
$('#mailBtn').on('click', function() {
const partyName = $('#resultFields').data('party-name') || '';
const email = partyContacts[partyName] || '';
const subject = encodeURIComponent($('#antragstitel').val() || '');
const title = $('#antragstitel').val() || '';
const demand = $('#forderung').val() || '';
const justification = $('#begruendung').val() || '';
// 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) {
window.location.href = `mailto:${email}?subject=${subject}&body=${bodyEncoded}`;
} else {
alert('Keine E-Mail-Adresse für diese Fraktion hinterlegt.');
}
});
// 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);
}
// 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() {
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);
}
// Download Word file
fetch('/api/generate-word', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
})
.then(response => {
if (!response.ok) {
throw new Error('Fehler beim Generieren der Word-Datei');
}
return response.blob();
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'antrag.docx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
})
.catch(error => {
console.error('Error:', error);
alert('Fehler beim Generieren der Word-Datei: ' + error.message);
});
});
});