Compare commits

...

10 commits

9 changed files with 956 additions and 823 deletions

BIN
assets/antrag_vorlage.docx Normal file

Binary file not shown.

1
assets/css/pages.css Normal file

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": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1766473571, "lastModified": 1772047000,
"narHash": "sha256-5G1NDO2PulBx1RoaA6U1YoUDX0qZslpPxv+n5GX6Qto=", "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "76701a179d3a98b07653e2b0409847499b2a07d3", "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -18,7 +18,7 @@
overlay = final: prev: { overlay = final: prev: {
meinantrag = with final; python3Packages.buildPythonApplication rec { meinantrag = with final; python3Packages.buildPythonApplication rec {
pname = "meinantrag"; pname = "meinantrag";
version = "0.0.2"; version = "0.0.3";
format = "other"; format = "other";
src = self; src = self;
@ -30,7 +30,7 @@
requests requests
jinja2 jinja2
google-generativeai # Dependency for Gemini API google-generativeai # Dependency for Gemini API
reportlab # Dependency for PDF generation grpcio # Required by google-generativeai
python-docx # Dependency for Word document generation python-docx # Dependency for Word document generation
]; ];

View file

@ -13,20 +13,44 @@ from jinja2 import Environment, FileSystemLoader
import google.generativeai as genai import google.generativeai as genai
import re import re
from io import BytesIO from io import BytesIO
from reportlab.lib.pagesizes import A4 from datetime import datetime
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: try:
from docx import Document from docx import Document
from docx.shared import Pt, Inches from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.text import WD_ALIGN_PARAGRAPH
DOCX_AVAILABLE = True DOCX_AVAILABLE = True
except ImportError: except ImportError:
DOCX_AVAILABLE = False 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")
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
class BaseTemplateResource: class BaseTemplateResource:
"""Base class for resources that need template rendering""" """Base class for resources that need template rendering"""
@ -34,7 +58,7 @@ class BaseTemplateResource:
def _get_template_dir(self): def _get_template_dir(self):
"""Get the template directory path, handling both development and installed environments""" """Get the template directory path, handling both development and installed environments"""
# Allow overriding via environment variable (for packaged deployments) # 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): if env_dir and os.path.exists(env_dir):
return env_dir return env_dir
@ -42,36 +66,43 @@ class BaseTemplateResource:
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
# Try development templates first # 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): if os.path.exists(dev_template_dir):
return dev_template_dir return dev_template_dir
# Try to find templates relative to the executable # Try to find templates relative to the executable
try: try:
# If we're running from a Nix store, look for templates in share/meinantrag # 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 # 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): if os.path.exists(share_dir):
return share_dir return share_dir
# Alternative: look for templates in the same store path # 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}" 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): if os.path.exists(alt_share_dir):
return alt_share_dir return alt_share_dir
except Exception: except Exception:
pass pass
# Last resort: try to find any templates directory # Last resort: try to find any templates directory
for root, dirs, files in os.walk('/nix/store'): for root, dirs, files in os.walk("/nix/store"):
if 'templates' in dirs and 'index.html' in os.listdir(os.path.join(root, 'templates')): if "templates" in dirs and "index.html" in os.listdir(
return os.path.join(root, 'templates') os.path.join(root, "templates")
):
return os.path.join(root, "templates")
# Fallback to current directory # Fallback to current directory
return dev_template_dir return dev_template_dir
class MeinAntragApp(BaseTemplateResource): class MeinAntragApp(BaseTemplateResource):
def __init__(self): def __init__(self):
# Setup Jinja2 template environment # Setup Jinja2 template environment
@ -81,14 +112,15 @@ class MeinAntragApp(BaseTemplateResource):
def on_get(self, req, resp): def on_get(self, req, resp):
"""Serve the main page""" """Serve the main page"""
template = self.jinja_env.get_template('index.html') template = self.jinja_env.get_template("index.html")
resp.content_type = 'text/html; charset=utf-8' resp.content_type = "text/html; charset=utf-8"
resp.text = template.render( resp.text = template.render(
meta_title='MeinAntrag Anfragelinks für FragDenStaat', meta_title="MeinAntrag Anträge an die Karlsruher Stadtverwaltung",
meta_description='Erstelle vorausgefüllte Anfragelinks für FragDenStaat.de, suche Behörden, füge Betreff und Text hinzu und teile den Link.', 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}/" canonical_url=f"{SITE_BASE_URL}/",
) )
class ImpressumResource(BaseTemplateResource): class ImpressumResource(BaseTemplateResource):
def __init__(self): def __init__(self):
template_dir = self._get_template_dir() template_dir = self._get_template_dir()
@ -96,15 +128,16 @@ class ImpressumResource(BaseTemplateResource):
def on_get(self, req, resp): def on_get(self, req, resp):
"""Serve the Impressum page""" """Serve the Impressum page"""
template = self.jinja_env.get_template('impressum.html') template = self.jinja_env.get_template("impressum.html")
resp.content_type = 'text/html; charset=utf-8' resp.content_type = "text/html; charset=utf-8"
resp.text = template.render( resp.text = template.render(
meta_title='Impressum MeinAntrag', meta_title="Impressum MeinAntrag",
meta_description='Impressum für MeinAntrag.', meta_description="Impressum für MeinAntrag.",
canonical_url=f"{SITE_BASE_URL}/impressum", canonical_url=f"{SITE_BASE_URL}/impressum",
noindex=True noindex=True,
) )
class DatenschutzResource(BaseTemplateResource): class DatenschutzResource(BaseTemplateResource):
def __init__(self): def __init__(self):
template_dir = self._get_template_dir() template_dir = self._get_template_dir()
@ -112,22 +145,24 @@ class DatenschutzResource(BaseTemplateResource):
def on_get(self, req, resp): def on_get(self, req, resp):
"""Serve the Datenschutz page""" """Serve the Datenschutz page"""
template = self.jinja_env.get_template('datenschutz.html') template = self.jinja_env.get_template("datenschutz.html")
resp.content_type = 'text/html; charset=utf-8' resp.content_type = "text/html; charset=utf-8"
resp.text = template.render( resp.text = template.render(
meta_title='Datenschutz MeinAntrag', meta_title="Datenschutz MeinAntrag",
meta_description='Datenschutzerklärung für MeinAntrag. Keine Cookies, es werden nur Anfragen an die FragDenStaat-API gestellt.', 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", canonical_url=f"{SITE_BASE_URL}/datenschutz",
noindex=True noindex=True,
) )
class GenerateAntragResource: class GenerateAntragResource:
def __init__(self): def __init__(self):
# Initialize Gemini API # Initialize Gemini API with file-macro support
api_key = os.environ.get('GOOGLE_GEMINI_API_KEY') api_key_raw = os.environ.get("GOOGLE_GEMINI_API_KEY")
api_key = resolve_env_value(api_key_raw)
if api_key: if api_key:
genai.configure(api_key=api_key) genai.configure(api_key=api_key)
self.model = genai.GenerativeModel('gemini-flash-latest') self.model = genai.GenerativeModel("gemini-2.5-pro")
else: else:
self.model = None self.model = None
@ -137,122 +172,65 @@ class GenerateAntragResource:
return text return text
# Remove bold/italic markdown: **text** or *text* or __text__ or _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 # 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 # Remove other markdown elements
text = re.sub(r'`(.+?)`', r'\1', text) # Code text = re.sub(r"`(.+?)`", r"\1", text) # Code
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text) # Links text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) # Links
return text.strip() 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): def on_post(self, req, resp):
"""Generate text from user input using Gemini API""" """Generate text from user input using Gemini API"""
try: try:
if not self.model: if not self.model:
resp.status = falcon.HTTP_500 resp.status = falcon.HTTP_500
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps(
'success': False, {"success": False, "error": "Gemini API key not configured"}
'error': 'Gemini API key not configured' )
})
return return
# Get form data - try multiple methods for Falcon compatibility # Get form data - try multiple methods for Falcon compatibility
anliegen = '' anliegen = ""
party_id = '' party_id = ""
# Method 1: Try get_param (works for URL-encoded form data) # Method 1: Try get_param (works for URL-encoded form data)
anliegen = req.get_param('anliegen', default='') or '' anliegen = req.get_param("anliegen", default="") or ""
party_id = req.get_param('party_id', default='') or '' party_id = req.get_param("party_id", default="") or ""
# Method 2: If empty, try to read from stream and parse manually # Method 2: If empty, try to read from stream and parse manually
if not anliegen: if not anliegen:
try: try:
# Read the raw body - use bounded_stream if available, otherwise stream # Read the raw body - use bounded_stream if available, otherwise stream
stream = getattr(req, 'bounded_stream', req.stream) stream = getattr(req, "bounded_stream", req.stream)
raw_body = stream.read().decode('utf-8') raw_body = stream.read().decode("utf-8")
# Parse URL-encoded data manually # Parse URL-encoded data manually
parsed = parse_qs(raw_body) parsed = parse_qs(raw_body)
anliegen = parsed.get('anliegen', [''])[0] anliegen = parsed.get("anliegen", [""])[0]
party_id = parsed.get('party_id', [''])[0] party_id = parsed.get("party_id", [""])[0]
except Exception as e: except Exception as e:
# Log the exception for debugging # Log the exception for debugging
print(f"Error parsing form data: {e}") print(f"Error parsing form data: {e}")
pass pass
# Remove any whitespace and check if actually empty # Remove any whitespace and check if actually empty
anliegen = anliegen.strip() if anliegen else '' anliegen = anliegen.strip() if anliegen else ""
party_id = party_id.strip() if party_id else '' party_id = party_id.strip() if party_id else ""
if not anliegen: if not anliegen:
resp.status = falcon.HTTP_400 resp.status = falcon.HTTP_400
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps(
'success': False, {"success": False, "error": "Anliegen-Feld ist erforderlich"}
'error': 'Anliegen-Feld ist erforderlich' )
})
return return
# Create prompt for Gemini # Create prompt for Gemini
@ -260,229 +238,233 @@ 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. 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: Gib das Ergebnis ALS GÜLTIGES JSON mit den folgenden Keys zurück:
- 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. "antragstitel": "PRÄGNANTER Titel (max. 8-10 Wörter)",
- Der letzte Teil ist Begründung/Sachverhalt (ohne diesen Titel im Text) "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: WICHTIG:
- Verwende KEINE Markdown-Formatierung. Keine **fett**, keine *kursiv*, keine /Überschriften, keine # Hashtags, keine Links oder andere Formatierung. - ANTWORTE NUR MIT GÜLTIGEM JSON, KEINERLEI ERKLÄRUNGEN ODER ZUSÄTZLICHEN TEXT!
- Schreibe nur reinen Text ohne jegliche Markdown-Syntax. - Der Titel soll PRÄGNANT, EINFACH und EINPRÄGSAM sein - maximal 8-10 Wörter.
- Sachlicher, offizieller Ton einer Fraktion, keine persönlichen Formulierungen. - Der Dateiname soll knapp sein, z.B. 'antragsentwurf_...docx'.
- Der Antragstitel muss prägnant, einfach verständlich und einprägsam sein - keine komplizierten Formulierungen! - 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 prompt += anliegen
# Call Gemini API # Call Gemini API
response = self.model.generate_content(prompt) response = self.model.generate_content(prompt)
generated_text = response.text generated_text = response.text
# Parse the response # Log the raw response for debugging
parsed = self._parse_gemini_response(generated_text) 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()
# Return JSON with the generated text parts # Return JSON with the generated text parts
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps(
'success': True, {
'title': parsed['title'], "success": True,
'demand': parsed['demand'], "title": parsed["title"],
'justification': parsed['justification'], "demand": parsed["demand"],
'party_name': party_id if party_id else "" "justification": parsed["justification"],
}) "email_body": email_text,
"party_name": party_id if party_id else "",
}
)
except Exception as e: except Exception as e:
import traceback logger.error(f"Error generating antrag: {str(e)}", exc_info=True)
traceback.print_exc()
resp.status = falcon.HTTP_500 resp.status = falcon.HTTP_500
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps({"success": False, "error": str(e)})
'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: 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")
def _generate_word(self, title, demand, justification, party_name=""): def _generate_word(self, title, demand, justification, party_name=""):
"""Generate a Word document that looks like a city council proposal""" """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() doc = Document()
# Set default font # Get current date in DD.MM.YYYY format
style = doc.styles['Normal'] current_date = datetime.now().strftime("%d.%m.%Y")
font = style.font
font.name = 'Arial'
font.size = Pt(11)
# Header with party name if provided # 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: if party_name:
party_para = doc.add_paragraph(f"Antrag der {party_name}") replace_in_element(document_part.element, "FRAKTION", party_name)
party_para.runs[0].bold = True
party_para.runs[0].font.size = Pt(11)
doc.add_paragraph()
# Title # Also search in header and footer parts
if title: for rel in document_part.rels.values():
title_para = doc.add_paragraph(title) if "header" in rel.target_ref or "footer" in rel.target_ref:
title_para.runs[0].bold = True try:
title_para.runs[0].font.size = Pt(16) header_footer_part = rel.target_part
title_para.paragraph_format.space_after = Pt(30) if party_name:
replace_in_element(
header_footer_part.element, "FRAKTION", party_name
)
except Exception:
pass
except Exception as e:
# If text box access fails, continue with other replacements
print(f"Warning: Could not replace in text boxes: {e}")
pass
# Demand section # Also check tables for placeholders
if demand: for table in doc.tables:
doc.add_paragraph() for row in table.rows:
demand_heading = doc.add_paragraph("Der Gemeinderat möge beschließen:") for cell in row.cells:
demand_heading.runs[0].bold = True for paragraph in cell.paragraphs:
demand_heading.runs[0].font.size = Pt(12) full_text = paragraph.text
demand_heading.paragraph_format.space_before = Pt(20) if not full_text:
demand_heading.paragraph_format.space_after = Pt(12) continue
# Process demand text if party_name and "FRAKTION" in full_text:
demand_lines = demand.split('\n') for run in paragraph.runs:
for line in demand_lines: if "FRAKTION" in run.text:
run.text = run.text.replace("FRAKTION", party_name)
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 "ANTRAGSTITEL" in full_text:
paragraph.clear()
run = paragraph.add_run(title)
run.bold = True
if "ANTRAGSTEXT" in full_text:
paragraph.clear()
lines = antragtext.split("\n")
for i, line in enumerate(lines):
if line.strip(): if line.strip():
doc.add_paragraph(line.strip()) paragraph.add_run(line.strip())
if i < len(lines) - 1:
paragraph.add_run("\n")
# Justification section if "BEGRÜNDUNGSTEXT" in full_text:
if justification: paragraph.clear()
doc.add_paragraph() lines = justification.split("\n")
justification_heading = doc.add_paragraph("Begründung/Sachverhalt") for i, line in enumerate(lines):
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(): if line.strip():
doc.add_paragraph(line.strip()) paragraph.add_run(line.strip())
if i < len(lines) - 1:
paragraph.add_run("\n")
# Save to buffer # Save to buffer
buffer = BytesIO() buffer = BytesIO()
@ -495,29 +477,28 @@ class GenerateWordResource:
try: try:
if not DOCX_AVAILABLE: if not DOCX_AVAILABLE:
resp.status = falcon.HTTP_500 resp.status = falcon.HTTP_500
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps(
'success': False, {"success": False, "error": "python-docx not installed"}
'error': 'python-docx not installed' )
})
return return
# Get form data # Get form data
title = req.get_param('title', default='') or '' title = req.get_param("title", default="") or ""
demand = req.get_param('demand', default='') or '' demand = req.get_param("demand", default="") or ""
justification = req.get_param('justification', default='') or '' justification = req.get_param("justification", default="") or ""
party_name = req.get_param('party_name', default='') or '' party_name = req.get_param("party_name", default="") or ""
# If empty, try to read from stream # If empty, try to read from stream
if not title: if not title:
try: try:
stream = getattr(req, 'bounded_stream', req.stream) stream = getattr(req, "bounded_stream", req.stream)
raw_body = stream.read().decode('utf-8') raw_body = stream.read().decode("utf-8")
parsed = parse_qs(raw_body) parsed = parse_qs(raw_body)
title = parsed.get('title', [''])[0] title = parsed.get("title", [""])[0]
demand = parsed.get('demand', [''])[0] demand = parsed.get("demand", [""])[0]
justification = parsed.get('justification', [''])[0] justification = parsed.get("justification", [""])[0]
party_name = parsed.get('party_name', [''])[0] party_name = parsed.get("party_name", [""])[0]
except Exception: except Exception:
pass pass
@ -525,31 +506,31 @@ class GenerateWordResource:
word_buffer = self._generate_word(title, demand, justification, party_name) word_buffer = self._generate_word(title, demand, justification, party_name)
# Return Word document # Return Word document
resp.content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' resp.content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
resp.set_header('Content-Disposition', 'attachment; filename="antrag.docx"') resp.set_header("Content-Disposition", 'attachment; filename="antrag.docx"')
resp.data = word_buffer.read() resp.data = word_buffer.read()
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
resp.status = falcon.HTTP_500 resp.status = falcon.HTTP_500
resp.content_type = 'application/json' resp.content_type = "application/json"
resp.text = json.dumps({ resp.text = json.dumps({"success": False, "error": str(e)})
'success': False,
'error': str(e)
})
class RobotsResource: class RobotsResource:
def on_get(self, req, resp): 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: * resp.text = f"""User-agent: *
Allow: / Allow: /
Sitemap: {SITE_BASE_URL}/sitemap.xml Sitemap: {SITE_BASE_URL}/sitemap.xml
""" """
class SitemapResource: class SitemapResource:
def on_get(self, req, resp): 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"?> resp.text = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>{SITE_BASE_URL}/</loc></url> <url><loc>{SITE_BASE_URL}/</loc></url>
@ -558,55 +539,54 @@ class SitemapResource:
</urlset> </urlset>
""" """
# Create Falcon application # Create Falcon application
app = falcon.App() app = falcon.App()
# Discover static assets directory # Discover static assets directory
STATIC_DIR = os.environ.get('MEINANTRAG_STATIC_DIR') STATIC_DIR = os.environ.get("MEINANTRAG_STATIC_DIR")
if not STATIC_DIR: if not STATIC_DIR:
# Prefer local assets folder in development (relative to this file) # Prefer local assets folder in development (relative to this file)
script_dir = os.path.dirname(os.path.abspath(__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): if os.path.isdir(candidate):
STATIC_DIR = candidate STATIC_DIR = candidate
else: else:
# Try current working directory (useful when running packaged binary from project root) # 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): if os.path.isdir(cwd_candidate):
STATIC_DIR = cwd_candidate STATIC_DIR = cwd_candidate
else: else:
# Fallback to packaged location under share # 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 # Add routes
meinantrag = MeinAntragApp() meinantrag = MeinAntragApp()
impressum = ImpressumResource() impressum = ImpressumResource()
datenschutz = DatenschutzResource() datenschutz = DatenschutzResource()
generate_antrag = GenerateAntragResource() generate_antrag = GenerateAntragResource()
generate_pdf = GeneratePDFResource()
generate_word = GenerateWordResource() generate_word = GenerateWordResource()
robots = RobotsResource() robots = RobotsResource()
sitemap = SitemapResource() sitemap = SitemapResource()
app.add_route('/', meinantrag) app.add_route("/", meinantrag)
app.add_route('/impressum', impressum) app.add_route("/impressum", impressum)
app.add_route('/datenschutz', datenschutz) app.add_route("/datenschutz", datenschutz)
app.add_route('/api/generate-antrag', generate_antrag) 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('/api/generate-word', generate_word) app.add_route("/robots.txt", robots)
app.add_route('/robots.txt', robots) app.add_route("/sitemap.xml", sitemap)
app.add_route('/sitemap.xml', sitemap)
# Static file route # Static file route
if STATIC_DIR and os.path.isdir(STATIC_DIR): 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 import wsgiref.simple_server
print("Starting MeinAntrag web application...") print("Starting MeinAntrag web application...")
print("Open your browser and navigate to: http://localhost:8000") print("Open your browser and navigate to: http://localhost:8000")
print(f"Serving static assets from: {STATIC_DIR}") 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() httpd.serve_forever()

View file

@ -16,16 +16,31 @@ in
enable = lib.mkEnableOption "MeinAntrag web app"; enable = lib.mkEnableOption "MeinAntrag web app";
environment = lib.mkOption { settings = lib.mkOption {
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
default = { }; default = { };
example = { example = {
GOOGLE_GEMINI_API_KEY = "your-api-key-here";
MEINANTRAG_BASE_URL = "https://example.com"; MEINANTRAG_BASE_URL = "https://example.com";
GOOGLE_GEMINI_API_KEY = "file:/run/secrets/gemini_api_key";
}; };
description = '' description = ''
Additional environment variables to pass to the MeinAntrag service. Additional environment variables to pass to the MeinAntrag service.
For example, set GOOGLE_GEMINI_API_KEY for Gemini API integration. 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.
''; '';
}; };
@ -64,7 +79,8 @@ in
"PYTHONPATH=${pkgs.meinantrag}/share/meinantrag:${pkgs.meinantrag.pythonPath}" "PYTHONPATH=${pkgs.meinantrag}/share/meinantrag:${pkgs.meinantrag.pythonPath}"
"MEINANTRAG_TEMPLATES_DIR=${pkgs.meinantrag}/share/meinantrag/templates" "MEINANTRAG_TEMPLATES_DIR=${pkgs.meinantrag}/share/meinantrag/templates"
"MEINANTRAG_STATIC_DIR=${pkgs.meinantrag}/share/meinantrag/assets" "MEINANTRAG_STATIC_DIR=${pkgs.meinantrag}/share/meinantrag/assets"
] ++ (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.environment); ] ++ (lib.mapAttrsToList (name: value: "${name}=${value}") cfg.settings)
++ (lib.mapAttrsToList (name: _: "${name}=file:/run/credentials/uwsgi.service/${name}") cfg.credentials);
settings = { settings = {
"static-map" = "/static=${pkgs.meinantrag}/share/meinantrag/assets"; "static-map" = "/static=${pkgs.meinantrag}/share/meinantrag/assets";
@ -74,6 +90,10 @@ 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 # Ensure meinantrag user and group exist
users.users.meinantrag = { users.users.meinantrag = {
isSystemUser = true; isSystemUser = true;

View file

@ -4,24 +4,24 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MeinAntrag{% endblock %}</title> <title>{% block title %}MeinAntrag{% endblock %}</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet"> <link type="text/css" href="/static/css/pages.css" media="all" rel="stylesheet">
<link href="/static/css/select2.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"> <link href="/static/css/select2-bootstrap-5-theme.min.css" rel="stylesheet">
<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.') }}"> <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!') }}">
<link rel="canonical" href="{{ canonical_url | default('/') }}"> <link rel="canonical" href="{{ canonical_url | default('/') }}">
<link rel="alternate" hreflang="de" href="{{ canonical_url | default('/') }}"> <link rel="alternate" hreflang="de" href="{{ canonical_url | default('/') }}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="MeinAntrag"> <meta property="og:site_name" content="MeinAntrag">
<meta property="og:title" content="{{ meta_title | default('MeinAntrag') }}"> <meta property="og:title" content="{{ meta_title | default('MeinAntrag') }}">
<meta property="og:description" content="{{ meta_description | default('Erstelle vorausgefüllte FragDenStaat.de-Anfragelinks und teile sie mit Freund:innen.') }}"> <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:locale" content="de_DE"> <meta property="og:locale" content="de_DE">
<meta property="og:url" content="{{ canonical_url | default('/') }}"> <meta property="og:url" content="{{ canonical_url | default('/') }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ meta_title | default('MeinAntrag') }}"> <meta name="twitter:title" content="{{ meta_title | default('MeinAntrag') }}">
<meta name="twitter:description" content="{{ meta_description | default('Erstelle vorausgefüllte FragDenStaat.de-Anfragelinks und teile sie mit Freund:innen.') }}"> <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!') }}">
{% if noindex %}<meta name="robots" content="noindex,follow">{% endif %} {% if noindex %}<meta name="robots" content="noindex,follow">{% endif %}
<meta name="theme-color" content="#667eea"> <meta name="theme-color" content="#667eea">
@ -30,60 +30,210 @@
{% block meta_extra %}{% endblock %} {% block meta_extra %}{% endblock %}
<style> <style>
body { h2 {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); max-width: 700px !important;
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
} }
.main-container { select, textarea, input {
background: white; max-width: 700px;
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; margin-bottom: 1rem;
} }
.description {
color: #7f8c8d; .tutorial-list li {
font-size: 1.1rem; margin-bottom: 30px;
margin-bottom: 2.5rem;
line-height: 1.6;
} }
.form-control, .form-select {
border-radius: 10px; .tutorial-list li h4 {
border: 2px solid #e9ecef; margin-bottom: 10px;
padding: 0.75rem 1rem;
transition: all 0.3s ease;
} }
.form-control:focus, .form-select:focus {
border-color: #667eea; .tutorial-list li p {
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); margin: 10px 0;
} }
.btn-primary {
background: linear-gradient(45deg, #667eea, #764ba2); /* Responsive design for mobile devices */
border: none; @media (max-width: 768px) {
border-radius: 15px; h2 {
padding: 1rem 2rem; max-width: 100% !important;
font-size: 1.1rem; font-size: 1.2rem;
font-weight: 600; padding: 0 1rem;
transition: all 0.3s ease;
} }
.btn-primary:hover {
transform: translateY(-2px); select, textarea, input {
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); max-width: 100%;
width: 100%;
margin-bottom: 1rem;
} }
.result-link {
background: #f8f9fa; .tutorial-list li {
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 1rem; padding: 1rem;
}
.tutorial-list li h4 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.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;
}
textarea.form-control {
min-height: 150px;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
font-size: 16px;
margin-bottom: 0.5rem;
box-sizing: border-box;
}
#resultFields .btn-primary {
margin-right: 0;
margin-left: 0;
display: block;
width: 100%;
margin-bottom: 0.5rem;
}
#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;
margin-top: 1rem; margin-top: 1rem;
word-break: break-all; }
.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;
}
} }
/* Ensure Select2 scales properly */ /* Ensure Select2 scales properly */
.select2-container { .select2-container {
@ -100,52 +250,58 @@
.select2-container .select2-selection__arrow { .select2-container .select2-selection__arrow {
height: 100% !important; height: 100% !important;
} }
/* Mobile adjustments */ .loading {
@media (max-width: 576px) { display: none;
.main-container {
padding: 1.25rem;
margin: 1rem auto 4rem auto;
} }
.title { .form-control, .form-select {
font-size: 2rem; width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 14px;
} }
.description { .form-control:focus, .form-select:focus {
font-size: 1rem; outline: none;
border-color: #4078c0;
box-shadow: 0 0 0 2px rgba(64, 120, 192, 0.2);
} }
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 { .loading {
display: none; display: none;
} }
.footer { .loading.active {
background: transparent; display: inline-block;
margin-top: 3rem; margin-left: 0.5rem;
}
.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 { .legal-content {
text-align: left; text-align: left;
line-height: 1.6; line-height: 1.6;
} }
.legal-content h2 { .legal-content h2 {
color: #2c3e50;
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -155,42 +311,25 @@
.legal-content ul { .legal-content ul {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.main-container {
margin-bottom: 4rem;
}
</style> </style>
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
</head> </head>
<body> <body class="home">
<div class="container">
<div class="main-container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
<!-- Footer --> <footer class="page-footer">
<footer class="footer mt-5 pt-4"> <ul class="site-footer-links right">
<div class="container"> <li><a href="/impressum">Impressum</a></li>
<div class="row"> <li><a href="/datenschutz">Datenschutz</a></li>
<div class="col-12 text-center"> <li><a href="https://git.project-insanity.org/onny/MeinAntrag" target="_blank">Source</a></li>
<div class="footer-links mb-2"> </ul>
<a href="/impressum" class="text-muted text-decoration-none me-3">Impressum</a> <ul class="site-footer-links">
<a href="/datenschutz" class="text-muted text-decoration-none me-3">Datenschutz</a> <li>&copy; 2025 <span>Project-Insanity.org</span></li>
<a href="https://git.project-insanity.org/onny/MeinAntrag" class="text-muted text-decoration-none" target="_blank">Source</a> <li><a href="https://social.project-insanity.org/@pi_crew" target="_blank">Mastodon</a></li>
</div> </ul>
<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> </footer>
</div>
<script src="/static/js/bootstrap.bundle.min.js"></script> <script src="/static/js/pages-jquery.js"></script>
<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/select2.min.js"></script> <script src="/static/js/select2.min.js"></script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}

View file

@ -3,17 +3,21 @@
{% block title %}MeinAntrag{% endblock %} {% block title %}MeinAntrag{% endblock %}
{% block content %} {% block content %}
<div class="text-center"> <section id="hero-spot" class="hero-spot">
<h1 class="title display-4">MeinAntrag</h1> <h1>MeinAntrag</h1>
<p class="description"> <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>
Erstelle einfach Vorlagen für Anfragen oder Anträge an die Karlsruher Stadtverwaltung </section>
zu deinem persönlichen Thema und schicke diese direkt an eine Stadtratsfraktion!
</p>
<form id="meinantragForm" class="text-start"> <section id="tutorial" class="tutorial">
<ul id="project-site" class="tutorial-list wrapper active">
<li class="question">
<form id="meinantragForm">
<div id="inputFields"> <div id="inputFields">
<div class="mb-4"> <li>
<label for="party" class="form-label fw-bold">Fraktion</label> <h4>Fraktion auswählen</h4>
<p>Wähle die Fraktion, an die der Antrag gerichtet ist:</p>
<select class="form-select" id="party" name="party_id" required> <select class="form-select" id="party" name="party_id" required>
<option value="">Fraktion auswählen...</option> <option value="">Fraktion auswählen...</option>
<option value="SPD">SPD</option> <option value="SPD">SPD</option>
@ -24,69 +28,65 @@
<option value="DIE LINKE">DIE LINKE</option> <option value="DIE LINKE">DIE LINKE</option>
<option value="KAL">KAL</option> <option value="KAL">KAL</option>
</select> </select>
</div> </li>
<div class="mb-4"> <li>
<label for="anliegen" class="form-label fw-bold">Mein Anliegen:</label> <h4>Dein Anliegen beschreiben</h4>
<textarea class="form-control" id="anliegen" name="anliegen" rows="5" <p>Beschreibe hier, welche Anfrage oder Antrag du an die Stadtverwaltung stellen möchtest:</p>
placeholder="Beschreibe hier, welche Anfrage oder Antrag du an die Stadtverwaltung stellen möchtest ..." required></textarea> <textarea class="form-control" id="anliegen" name="anliegen" rows="5" required></textarea>
</div> </li>
<div class="text-center"> <li>
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary">
<span class="btn-text">Antrag generieren</span> <span class="btn-text">Antrag generieren</span>
<span class="loading spinner-border spinner-border-sm ms-2" role="status"></span> <span class="loading" role="status" style="display: none;">...</span>
</button> </button>
</div> </li>
</div> </div>
<div id="resultFields" class="text-start" style="display: none;"> <div id="resultFields" style="display: none;">
<div class="mb-3"> <li>
<a href="#" id="backLink" class="text-decoration-none"> <p><a href="#" id="backLink">← Zurück zum Formular</a></p>
<span>← Zurück</span> <br><br>
</a> </li>
</div>
<div class="mb-4"> <li>
<label for="antragstitel" class="form-label fw-bold">Antragstitel</label> <h4>Antragstitel</h4>
<p></p>
<input type="text" class="form-control" id="antragstitel" name="antragstitel"> <input type="text" class="form-control" id="antragstitel" name="antragstitel">
</div> </li>
<div class="mb-4"> <li>
<label for="forderung" class="form-label fw-bold">Forderung</label> <h4>Forderung</h4>
<p></p>
<textarea class="form-control" id="forderung" name="forderung" rows="5"></textarea> <textarea class="form-control" id="forderung" name="forderung" rows="5"></textarea>
</div> </li>
<div class="mb-4"> <li>
<label for="begruendung" class="form-label fw-bold">Begründung/Sachverhalt</label> <h4>Begründung/Sachverhalt</h4>
<p></p>
<textarea class="form-control" id="begruendung" name="begruendung" rows="8"></textarea> <textarea class="form-control" id="begruendung" name="begruendung" rows="8"></textarea>
</div> </li>
<div class="mb-4 d-flex gap-2 flex-wrap justify-content-center"> <li>
<h4>Aktionen</h4>
<p>Du kannst den Antrag jetzt per E-Mail senden oder als Word-Datei herunterladen:</p>
<button type="button" class="btn btn-primary" id="mailBtn"> <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> <span id="mailBtnText">Mail an Fraktion senden</span>
</button> </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"> <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 Word-Datei herunterladen
</button> </button>
</div> </li>
</div> </div>
</form> </form>
</div>
</p>
</li>
</ul>
</section>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -109,8 +109,8 @@
const $btnText = $button.find('.btn-text'); const $btnText = $button.find('.btn-text');
const $loading = $button.find('.loading'); const $loading = $button.find('.loading');
$button.prop('disabled', true); $button.prop('disabled', true);
$btnText.text('Generiere Antrag...'); $btnText.text('Generiere Antrag');
$loading.css('display', 'inline-block'); $loading.css('display', 'inline');
// Prepare form data as URL-encoded // Prepare form data as URL-encoded
const formData = new URLSearchParams(); const formData = new URLSearchParams();
@ -142,8 +142,9 @@
$('#forderung').val(data.demand || ''); $('#forderung').val(data.demand || '');
$('#begruendung').val(data.justification || ''); $('#begruendung').val(data.justification || '');
// Store party name for mail button // Store party name and email body for mail button
$('#resultFields').data('party-name', data.party_name || ''); $('#resultFields').data('party-name', data.party_name || '');
$('#resultFields').data('email-body', data.email_body || '');
// Update mail button text // Update mail button text
if (data.party_name) { if (data.party_name) {
@ -160,7 +161,7 @@
// Reset button state // Reset button state
$button.prop('disabled', false); $button.prop('disabled', false);
$btnText.text('Antrag generieren'); $btnText.text('Antrag generieren');
$loading.hide(); $loading.css('display', 'none');
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
@ -169,7 +170,7 @@
// Reset button state // Reset button state
$button.prop('disabled', false); $button.prop('disabled', false);
$btnText.text('Antrag generieren'); $btnText.text('Antrag generieren');
$loading.hide(); $loading.css('display', 'none');
}); });
}); });
@ -182,13 +183,13 @@
// Mail-Kontakte für Fraktionen // Mail-Kontakte für Fraktionen
const partyContacts = { const partyContacts = {
'SPD': 'spd@karlsruhe.de', 'SPD': 'spd@fraktion.karlsruhe.de',
'GRÜNEN': 'gruene@karlsruhe.de', 'GRÜNEN': 'gruene@fraktion.karlsruhe.de',
'CDU': 'cdu@karlsruhe.de', 'CDU': 'cdu@fraktion.karlsruhe.de',
'FDP/FW': 'fdp@karlsruhe.de', 'FDP/FW': 'fdp-fw@fraktion.karlsruhe.de',
'Volt': 'volt@karlsruhe.de', 'Volt': 'volt@fraktion.karlsruhe.de',
'DIE LINKE': 'dielinke@karlsruhe.de', 'DIE LINKE': 'dielinke@gr.karlsruhe.de',
'KAL': 'kal@karlsruhe.de' 'KAL': 'kal@fraktion.karlsruhe.de'
}; };
// Handle mail button click // Handle mail button click
@ -196,18 +197,9 @@
const partyName = $('#resultFields').data('party-name') || ''; const partyName = $('#resultFields').data('party-name') || '';
const email = partyContacts[partyName] || ''; const email = partyContacts[partyName] || '';
const subject = encodeURIComponent($('#antragstitel').val() || ''); const subject = encodeURIComponent($('#antragstitel').val() || '');
const title = $('#antragstitel').val() || ''; const emailBody = $('#resultFields').data('email-body') || '';
const demand = $('#forderung').val() || '';
const justification = $('#begruendung').val() || '';
// Build email body const bodyEncoded = encodeURIComponent(emailBody);
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 // Open mail client
if (email) { if (email) {
@ -217,45 +209,37 @@
} }
}); });
// Handle PDF button click // Function to create a valid filename from title
$('#pdfBtn').on('click', function() { function createFilename(title) {
const title = $('#antragstitel').val() || ''; if (!title || !title.trim()) {
const demand = $('#forderung').val() || ''; return 'antrag.docx';
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 // Remove or replace special characters
fetch('/api/generate-pdf', { let filename = title
method: 'POST', .trim()
headers: { .toLowerCase()
'Content-Type': 'application/x-www-form-urlencoded' .replace(/[ä]/g, 'ae')
}, .replace(/[ö]/g, 'oe')
body: formData.toString() .replace(/[ü]/g, 'ue')
}) .replace(/[ß]/g, 'ss')
.then(response => { .replace(/[^a-z0-9\s-]/g, '') // Remove special chars except spaces and hyphens
if (!response.ok) { .replace(/\s+/g, '_') // Replace spaces with underscores
throw new Error('Fehler beim Generieren des PDFs'); .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';
} }
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 // Handle Word button click
$('#wordBtn').on('click', function() { $('#wordBtn').on('click', function() {
@ -273,6 +257,9 @@
formData.append('party_name', partyName); formData.append('party_name', partyName);
} }
// Generate filename from title
const filename = createFilename(title);
// Download Word file // Download Word file
fetch('/api/generate-word', { fetch('/api/generate-word', {
method: 'POST', method: 'POST',
@ -291,7 +278,7 @@
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'antrag.docx'; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);