added debugging, switch to json

This commit is contained in:
Jonas Heinrich 2026-03-02 08:44:18 +01:00
parent fcffccb718
commit 56f1f4e8fb
3 changed files with 509 additions and 523 deletions

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766473571,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=",
"lastModified": 1772047000,
"narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3",
"rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e",
"type": "github"
},
"original": {

View file

@ -18,7 +18,7 @@
overlay = final: prev: {
meinantrag = with final; python3Packages.buildPythonApplication rec {
pname = "meinantrag";
version = "0.0.2";
version = "0.0.3";
format = "other";
src = self;

View file

@ -14,15 +14,27 @@ import google.generativeai as genai
import re
from io import BytesIO
from datetime import datetime
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')
# 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")
class BaseTemplateResource:
"""Base class for resources that need template rendering"""
@ -30,7 +42,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
@ -38,36 +50,43 @@ 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
@ -77,14 +96,15 @@ 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 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}/",
)
class ImpressumResource(BaseTemplateResource):
def __init__(self):
template_dir = self._get_template_dir()
@ -92,15 +112,16 @@ 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()
@ -108,22 +129,23 @@ 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
api_key = os.environ.get('GOOGLE_GEMINI_API_KEY')
api_key = os.environ.get("GOOGLE_GEMINI_API_KEY")
if api_key:
genai.configure(api_key=api_key)
self.model = genai.GenerativeModel('gemini-3-pro-preview')
self.model = genai.GenerativeModel("gemini-2.5-pro")
else:
self.model = None
@ -133,122 +155,65 @@ 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
@ -256,74 +221,91 @@ 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.
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. Je nachdem: Entweder Sätze und oder Liste von Forderungen.
- Der letzte Teil ist Begründung/Sachverhalt (ohne diesen Titel im Text)
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')"
}
WICHTIG:
- Reinen Text, verwende KEINE Markdown-Formatierung oder sonstige Formatierungen, ausgenommen Listen und Aufzählungen.
- Sachlicher, offizieller Ton einer Fraktion, keine persönlichen Formulierungen.
- 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).
"""
Anliegen: """
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)
# Log the raw response for debugging
logger.debug(f"Gemini raw response: {generated_text}")
# Generate email text
email_prompt = f"""Erstelle einen kurzen, höflichen E-Mail-Text in der ERSTEN PERSON (persönlich, ich-rede) an eine Fraktion.
Die E-Mail soll:
- Mit "Guten Tag," beginnen
- Das Anliegen kurz erklären und prägnant
- Erwähnen, dass eine Antragsvorlage im Anhang beigefügt ist
- Mit "Mit freundlichen Grüßen," enden
- Verwende KEINE Markdown-Formatierung
- Schreibe keinen Betreff-Entwurf dazu
# 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 ```
Anliegen: {anliegen}
"""
email_response = self.model.generate_content(email_prompt)
email_text = self._remove_markdown(email_response.text)
# 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()
# 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"],
"email_body": email_text,
"party_name": party_id if party_id else "",
}
)
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"Error generating antrag: {str(e)}", exc_info=True)
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 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')
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')
assets_dir = os.path.join(script_dir, "..", "assets")
self.template_path = os.path.join(assets_dir, "antrag_vorlage.docx")
def _generate_word(self, title, demand, justification, party_name=""):
"""Generate a Word document using the template"""
@ -347,42 +329,42 @@ class GenerateWordResource:
continue
# Replace FRAKTION
if 'FRAKTION' in full_text:
if "FRAKTION" in full_text:
for run in paragraph.runs:
if 'FRAKTION' in run.text:
run.text = run.text.replace('FRAKTION', party_name)
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:
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)
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:
if "ANTRAGSTITEL" in full_text:
paragraph.clear()
run = paragraph.add_run(title)
run.bold = True
# Replace ANTRAGSTEXT
if 'ANTRAGSTEXT' in full_text:
if "ANTRAGSTEXT" in full_text:
paragraph.clear()
lines = antragtext.split('\n')
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')
paragraph.add_run("\n")
# Replace BEGRÜNDUNGSTEXT
if 'BEGRÜNDUNGSTEXT' in full_text:
if "BEGRÜNDUNGSTEXT" in full_text:
paragraph.clear()
lines = justification.split('\n')
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')
paragraph.add_run("\n")
# Check text boxes (shapes) for placeholders
# Text boxes are stored in the document's part relationships
@ -405,15 +387,17 @@ class GenerateWordResource:
# Search in main document body
if party_name:
replace_in_element(document_part.element, 'FRAKTION', 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:
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)
replace_in_element(
header_footer_part.element, "FRAKTION", party_name
)
except Exception:
pass
except Exception as e:
@ -430,38 +414,40 @@ class GenerateWordResource:
if not full_text:
continue
if party_name and 'FRAKTION' in full_text:
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)
if "FRAKTION" in run.text:
run.text = run.text.replace("FRAKTION", party_name)
if 'XX.XX.XXXX' in full_text:
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)
if "XX.XX.XXXX" in run.text:
run.text = run.text.replace(
"XX.XX.XXXX", current_date
)
if 'ANTRAGSTITEL' in full_text:
if "ANTRAGSTITEL" in full_text:
paragraph.clear()
run = paragraph.add_run(title)
run.bold = True
if 'ANTRAGSTEXT' in full_text:
if "ANTRAGSTEXT" in full_text:
paragraph.clear()
lines = antragtext.split('\n')
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')
paragraph.add_run("\n")
if 'BEGRÜNDUNGSTEXT' in full_text:
if "BEGRÜNDUNGSTEXT" in full_text:
paragraph.clear()
lines = justification.split('\n')
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')
paragraph.add_run("\n")
# Save to buffer
buffer = BytesIO()
@ -474,29 +460,28 @@ 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
@ -504,31 +489,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>
@ -537,25 +522,26 @@ 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()
@ -566,24 +552,24 @@ 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-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()