Compare commits

..

No commits in common. "ecaf5ab897801d37e3e8831281e40e6c6c1cff0b" and "8ae984060115951343fdc9b8fbb172150fc80159" have entirely different histories.

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
View file

@ -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": {

View file

@ -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
];

View file

@ -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()

View file

@ -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;

View file

@ -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>&copy; 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 %}

View file

@ -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);