Initial commit: Notes Manager App (notes.braetter-int.de)

- Python/Flask Backend
- SQLAlchemy Models (notes, tasks, templates, users)
- Gunicorn + Nginx Deploy-Konfiguration
- Static Assets (CSS/JS)
- Jinja2 Templates
This commit is contained in:
2026-04-15 09:28:33 +02:00
commit 5c7ce5d0ca
24 changed files with 1666 additions and 0 deletions

2
.env.example Executable file
View File

@@ -0,0 +1,2 @@
SECRET_KEY=<your_value>
FLASK_ENV=<your_value>

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.venv/
__pycache__/
*.pyc
*.pyo
instance/
*.db
.env
*.bak

62
README.md Executable file
View File

@@ -0,0 +1,62 @@
# NotesManager
NotesManager ist ein schlankes webbasiertes System zur täglichen Dokumentation von Arbeiten, Änderungen, Störungen und Übergaben.
## Funktionen
- Login-System mit Rollen (Admin / User)
- Dashboard mit Tagesübersicht
- Dokumentationseinträge anlegen, bearbeiten, löschen, filtern
- Dokumentationsvorlagen verwalten
- Aufgabenverwaltung für tägliche Routinen
- Markdown-Export der Dokumentation
- SQLite als einfache Datenbank
- Bootstrap-Oberfläche für Desktop und Mobilgeräte
## Demo-Login
- Benutzer: `admin`
- Passwort: `admin1234`
## Lokaler Start
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python run.py
```
Danach erreichbar unter:
- `http://127.0.0.1:5000`
## Produktion
Empfohlen ist der Betrieb hinter Nginx mit Gunicorn und systemd.
## Standardfunktionen für die tägliche Dokumentation
Jeder Eintrag enthält:
- Datum
- Titel
- Kategorie
- System / Bereich
- Priorität
- Status
- Freitext-Dokumentation
- Tags
- Ersteller
## Geeignete Einsatzszenarien
- IT-Betriebsdokumentation
- Schichtübergaben
- Tagesberichte
- Änderungsprotokolle
- Kunden- oder Projektjournal
## Produktionsdateien
Im Ordner `deploy/` liegen eine Beispiel-`systemd`-Service-Datei und eine Beispiel-Nginx-Konfiguration.

77
app/__init__.py Executable file
View File

@@ -0,0 +1,77 @@
from flask import Flask
from .models import db, login_manager, User, DocumentationTemplate, DocumentationEntry, TaskItem
from .routes import main_bp
from pathlib import Path
from datetime import date
def create_app():
app = Flask(__name__, instance_relative_config=True)
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
app.config['SECRET_KEY'] = 'change-me-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{Path(app.instance_path) / 'notesmanager.db'}"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'main.login'
app.register_blueprint(main_bp)
with app.app_context():
db.create_all()
seed_data()
return app
def seed_data():
if DocumentationTemplate.query.count() == 0:
templates = [
DocumentationTemplate(
name='Tagesabschluss',
description='Standardvorlage für den täglichen Abschluss.',
content=(
'## Tagesziel\n- \n\n'
'## Erledigte Arbeiten\n- \n\n'
'## Offene Punkte\n- \n\n'
'## Besonderheiten / Störungen\n- \n\n'
'## Übergabe\n- '
)
),
DocumentationTemplate(
name='Wartung / Änderung',
description='Vorlage für technische Änderungen oder Wartungen.',
content=(
'## Betroffenes System\n- \n\n'
'## Anlass\n- \n\n'
'## Durchgeführte Schritte\n1. \n2. \n\n'
'## Ergebnis\n- \n\n'
'## Rollback / Risiko\n- '
)
)
]
db.session.add_all(templates)
if TaskItem.query.count() == 0:
demo_tasks = [
TaskItem(title='Backup-Prüfung dokumentieren', description='Kontrolle des letzten Backup-Laufs eintragen.', due_date=date.today(), status='offen', priority='hoch'),
TaskItem(title='Tagesbericht erstellen', description='Wichtige Arbeiten des Tages zusammenfassen.', due_date=date.today(), status='in_bearbeitung', priority='mittel')
]
db.session.add_all(demo_tasks)
if DocumentationEntry.query.count() == 0:
db.session.add(DocumentationEntry(
title='Beispiel: Morgenroutine',
work_date=date.today(),
category='Betrieb',
system_name='Allgemein',
priority='mittel',
status='erledigt',
content='Server geprüft, Tickets gesichtet und Tagesprioritäten festgelegt.',
tags='betrieb,morgenroutine',
created_by='Administrator'
))
db.session.commit()

54
app/models.py Executable file
View File

@@ -0,0 +1,54 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin
from datetime import datetime
db = SQLAlchemy()
login_manager = LoginManager()
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
display_name = db.Column(db.String(120), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(30), nullable=False, default='user')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class DocumentationEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
work_date = db.Column(db.Date, nullable=False)
category = db.Column(db.String(100), nullable=False)
system_name = db.Column(db.String(120), nullable=True)
priority = db.Column(db.String(20), nullable=False, default='mittel')
status = db.Column(db.String(30), nullable=False, default='offen')
content = db.Column(db.Text, nullable=False)
tags = db.Column(db.String(255), nullable=True)
created_by = db.Column(db.String(120), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class DocumentationTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
description = db.Column(db.String(255), nullable=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class TaskItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
due_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(30), nullable=False, default='offen')
priority = db.Column(db.String(20), nullable=False, default='mittel')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

285
app/routes.py Executable file
View File

@@ -0,0 +1,285 @@
from datetime import datetime, date
from flask import Blueprint, render_template, request, redirect, url_for, flash, Response
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash, generate_password_hash
from .models import db, User, DocumentationEntry, DocumentationTemplate, TaskItem
main_bp = Blueprint('main', __name__)
def parse_date(value):
if not value:
return date.today()
return datetime.strptime(value, '%Y-%m-%d').date()
@main_bp.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return redirect(url_for('main.login'))
@main_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
flash('Anmeldung erfolgreich.', 'success')
return redirect(url_for('main.dashboard'))
flash('Ungültige Zugangsdaten.', 'danger')
return render_template('login.html')
@main_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('Du wurdest abgemeldet.', 'info')
return redirect(url_for('main.login'))
@main_bp.route('/dashboard')
@login_required
def dashboard():
today = date.today()
entries_today = DocumentationEntry.query.filter_by(work_date=today).order_by(DocumentationEntry.created_at.desc()).all()
open_tasks = TaskItem.query.filter(TaskItem.status != 'erledigt').order_by(TaskItem.priority.desc(), TaskItem.due_date.asc()).all()
latest_entries = DocumentationEntry.query.order_by(DocumentationEntry.updated_at.desc()).limit(8).all()
stats = {
'entries_total': DocumentationEntry.query.count(),
'entries_today': len(entries_today),
'templates_total': DocumentationTemplate.query.count(),
'open_tasks': len(open_tasks),
}
return render_template('dashboard.html', today=today, entries_today=entries_today, open_tasks=open_tasks, latest_entries=latest_entries, stats=stats)
@main_bp.route('/entries')
@login_required
def entries():
q = request.args.get('q', '').strip()
status = request.args.get('status', '').strip()
category = request.args.get('category', '').strip()
query = DocumentationEntry.query
if q:
like = f'%{q}%'
query = query.filter(
db.or_(
DocumentationEntry.title.ilike(like),
DocumentationEntry.system_name.ilike(like),
DocumentationEntry.content.ilike(like),
DocumentationEntry.tags.ilike(like),
)
)
if status:
query = query.filter_by(status=status)
if category:
query = query.filter_by(category=category)
items = query.order_by(DocumentationEntry.work_date.desc(), DocumentationEntry.updated_at.desc()).all()
categories = [c[0] for c in db.session.query(DocumentationEntry.category).distinct().order_by(DocumentationEntry.category).all()]
return render_template('entries.html', items=items, q=q, status=status, category=category, categories=categories)
@main_bp.route('/entries/new', methods=['GET', 'POST'])
@login_required
def entry_new():
templates = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
if request.method == 'POST':
entry = DocumentationEntry(
title=request.form.get('title', '').strip(),
work_date=parse_date(request.form.get('work_date')),
category=request.form.get('category', '').strip() or 'Allgemein',
system_name=request.form.get('system_name', '').strip(),
priority=request.form.get('priority', 'mittel').strip(),
status=request.form.get('status', 'offen').strip(),
content=request.form.get('content', '').strip(),
tags=request.form.get('tags', '').strip(),
created_by=current_user.display_name,
)
if not entry.title or not entry.content:
flash('Titel und Inhalt sind Pflichtfelder.', 'danger')
return render_template('entry_form.html', templates=templates, entry=None)
db.session.add(entry)
db.session.commit()
flash('Dokumentationseintrag wurde erstellt.', 'success')
return redirect(url_for('main.entries'))
return render_template('entry_form.html', templates=templates, entry=None)
@main_bp.route('/entries/<int:entry_id>/edit', methods=['GET', 'POST'])
@login_required
def entry_edit(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
templates = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
if request.method == 'POST':
entry.title = request.form.get('title', '').strip()
entry.work_date = parse_date(request.form.get('work_date'))
entry.category = request.form.get('category', '').strip() or 'Allgemein'
entry.system_name = request.form.get('system_name', '').strip()
entry.priority = request.form.get('priority', 'mittel').strip()
entry.status = request.form.get('status', 'offen').strip()
entry.content = request.form.get('content', '').strip()
entry.tags = request.form.get('tags', '').strip()
if not entry.title or not entry.content:
flash('Titel und Inhalt sind Pflichtfelder.', 'danger')
return render_template('entry_form.html', templates=templates, entry=entry)
db.session.commit()
flash('Eintrag aktualisiert.', 'success')
return redirect(url_for('main.entries'))
return render_template('entry_form.html', templates=templates, entry=entry)
@main_bp.route('/entries/<int:entry_id>/delete', methods=['POST'])
@login_required
def entry_delete(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
db.session.delete(entry)
db.session.commit()
flash('Eintrag gelöscht.', 'info')
return redirect(url_for('main.entries'))
@main_bp.route('/entries/<int:entry_id>')
@login_required
def entry_view(entry_id):
entry = DocumentationEntry.query.get_or_404(entry_id)
return render_template('entry_view.html', entry=entry)
@main_bp.route('/templates', methods=['GET', 'POST'])
@login_required
def templates():
if request.method == 'POST':
name = request.form.get('name', '').strip()
content = request.form.get('content', '').strip()
description = request.form.get('description', '').strip()
if name and content:
db.session.add(DocumentationTemplate(name=name, description=description, content=content))
db.session.commit()
flash('Vorlage gespeichert.', 'success')
return redirect(url_for('main.templates'))
flash('Name und Inhalt sind Pflichtfelder.', 'danger')
items = DocumentationTemplate.query.order_by(DocumentationTemplate.name).all()
return render_template('templates.html', items=items)
@main_bp.route('/templates/<int:template_id>/delete', methods=['POST'])
@login_required
def template_delete(template_id):
item = DocumentationTemplate.query.get_or_404(template_id)
db.session.delete(item)
db.session.commit()
flash('Vorlage gelöscht.', 'info')
return redirect(url_for('main.templates'))
@main_bp.route('/tasks', methods=['GET', 'POST'])
@login_required
def tasks():
if request.method == 'POST':
task = TaskItem(
title=request.form.get('title', '').strip(),
description=request.form.get('description', '').strip(),
due_date=parse_date(request.form.get('due_date')) if request.form.get('due_date') else None,
status=request.form.get('status', 'offen').strip(),
priority=request.form.get('priority', 'mittel').strip(),
)
if not task.title:
flash('Titel ist ein Pflichtfeld.', 'danger')
else:
db.session.add(task)
db.session.commit()
flash('Aufgabe gespeichert.', 'success')
return redirect(url_for('main.tasks'))
items = TaskItem.query.order_by(TaskItem.status.asc(), TaskItem.priority.desc(), TaskItem.due_date.asc()).all()
return render_template('tasks.html', items=items)
@main_bp.route('/tasks/<int:task_id>/toggle', methods=['POST'])
@login_required
def task_toggle(task_id):
task = TaskItem.query.get_or_404(task_id)
task.status = 'erledigt' if task.status != 'erledigt' else 'offen'
db.session.commit()
flash('Aufgabenstatus geändert.', 'success')
return redirect(url_for('main.tasks'))
@main_bp.route('/tasks/<int:task_id>/delete', methods=['POST'])
@login_required
def task_delete(task_id):
task = TaskItem.query.get_or_404(task_id)
db.session.delete(task)
db.session.commit()
flash('Aufgabe gelöscht.', 'info')
return redirect(url_for('main.tasks'))
@main_bp.route('/users', methods=['GET', 'POST'])
@login_required
def users():
if current_user.role != 'admin':
flash('Nur Administratoren dürfen Benutzer verwalten.', 'danger')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
username = request.form.get('username', '').strip()
display_name = request.form.get('display_name', '').strip()
password = request.form.get('password', '').strip()
role = request.form.get('role', 'user').strip()
if not username or not display_name or not password:
flash('Bitte alle Pflichtfelder füllen.', 'danger')
elif User.query.filter_by(username=username).first():
flash('Benutzername existiert bereits.', 'warning')
else:
db.session.add(User(
username=username,
display_name=display_name,
role=role,
password_hash=generate_password_hash(password)
))
db.session.commit()
flash('Benutzer angelegt.', 'success')
return redirect(url_for('main.users'))
items = User.query.order_by(User.username).all()
return render_template('users.html', items=items)
@main_bp.route('/export/markdown')
@login_required
def export_markdown():
items = DocumentationEntry.query.order_by(DocumentationEntry.work_date.desc(), DocumentationEntry.id.desc()).all()
lines = ['# Export Dokumentation', '']
for item in items:
lines.extend([
f'## {item.title}',
f'- Datum: {item.work_date.isoformat()}',
f'- Kategorie: {item.category}',
f'- System: {item.system_name or "-"}',
f'- Priorität: {item.priority}',
f'- Status: {item.status}',
f'- Tags: {item.tags or "-"}',
f'- Erstellt von: {item.created_by}',
'',
item.content,
'',
'---',
''
])
content = '\n'.join(lines)
return Response(
content,
mimetype='text/markdown',
headers={'Content-Disposition': 'attachment; filename=dokumentation_export.md'}
)

370
app/static/css/style.css Executable file
View File

@@ -0,0 +1,370 @@
/* ============================================================
NotesManager Modernes Grau-Design, barrierefrei (WCAG 2.1 AA)
============================================================ */
/* --- Farb-Variablen --- */
:root {
--nm-bg: #f0f2f5;
--nm-surface: #ffffff;
--nm-nav-bg: #1c1f26;
--nm-nav-hover: #2a2e38;
--nm-nav-active: #353a47;
--nm-nav-text: #c8cdd8;
--nm-nav-text-act: #ffffff;
--nm-accent: #4a6fa5;
--nm-accent-hover: #3a5a8f;
--nm-accent-light: #eaf0fa;
--nm-text: #1a1d23;
--nm-text-muted: #5a6070;
--nm-border: #d4d8e1;
--nm-shadow-sm: 0 1px 4px rgba(0,0,0,0.07), 0 2px 8px rgba(0,0,0,0.05);
--nm-shadow-md: 0 2px 8px rgba(0,0,0,0.09), 0 4px 16px rgba(0,0,0,0.07);
--nm-radius: 0.5rem;
--nm-radius-sm: 0.35rem;
--nm-transition: 0.18s ease;
}
/* --- Basis --- */
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--nm-bg);
color: var(--nm-text);
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 0.9375rem;
line-height: 1.6;
min-height: 100vh;
}
/* --- Barrierefreiheit: Fokus-Indikator --- */
:focus-visible {
outline: 3px solid var(--nm-accent);
outline-offset: 2px;
border-radius: var(--nm-radius-sm);
}
a:focus-visible, button:focus-visible, input:focus-visible,
select:focus-visible, textarea:focus-visible {
outline: 3px solid var(--nm-accent);
outline-offset: 2px;
}
/* --- Navigation --- */
.navbar {
background: var(--nm-nav-bg) !important;
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 0.65rem 1.25rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.navbar-brand {
color: var(--nm-nav-text-act) !important;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-brand::before {
content: "▪";
color: var(--nm-accent);
font-size: 1.3rem;
}
.nav-link {
color: var(--nm-nav-text) !important;
font-size: 0.875rem;
font-weight: 500;
padding: 0.45rem 0.8rem !important;
border-radius: var(--nm-radius-sm);
transition: background var(--nm-transition), color var(--nm-transition);
}
.nav-link:hover {
background: var(--nm-nav-hover);
color: var(--nm-nav-text-act) !important;
}
.nav-link.active {
background: var(--nm-nav-active);
color: var(--nm-nav-text-act) !important;
}
/* Navbar-User-Bereich */
.navbar .nm-user-info {
font-size: 0.8rem;
color: var(--nm-nav-text);
padding: 0.3rem 0.7rem;
background: rgba(255,255,255,0.06);
border-radius: var(--nm-radius-sm);
border: 1px solid rgba(255,255,255,0.1);
}
.btn-outline-light.btn-sm {
font-size: 0.8rem;
padding: 0.3rem 0.85rem;
border-color: rgba(255,255,255,0.3);
color: #fff;
transition: background var(--nm-transition), border-color var(--nm-transition);
}
.btn-outline-light.btn-sm:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.5);
color: #fff;
}
/* --- Main-Content --- */
main.container {
max-width: 1200px;
padding-top: 1.75rem;
padding-bottom: 4rem;
}
/* --- Seitenüberschriften --- */
h1, h2, h3, h4, h5, h6 {
color: var(--nm-text);
font-weight: 600;
line-height: 1.3;
}
/* --- Cards --- */
.card {
background: var(--nm-surface);
border: 1px solid var(--nm-border) !important;
border-radius: var(--nm-radius) !important;
box-shadow: var(--nm-shadow-sm);
transition: box-shadow var(--nm-transition);
}
.card:hover {
box-shadow: var(--nm-shadow-md);
}
.card-body {
padding: 1.4rem 1.5rem;
}
/* --- Stat-Cards (Dashboard) --- */
.stat-card {
border-left: 3px solid var(--nm-accent) !important;
transition: transform var(--nm-transition), box-shadow var(--nm-transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--nm-shadow-md);
}
.stat-number {
font-size: 2.1rem;
font-weight: 700;
color: var(--nm-accent);
line-height: 1.1;
}
.stat-label {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--nm-text-muted);
margin-top: 0.2rem;
}
/* --- Buttons --- */
.btn {
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--nm-radius-sm);
transition: background var(--nm-transition), border-color var(--nm-transition),
transform var(--nm-transition), box-shadow var(--nm-transition);
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--nm-accent);
border-color: var(--nm-accent);
color: #fff;
}
.btn-primary:hover, .btn-primary:focus {
background: var(--nm-accent-hover);
border-color: var(--nm-accent-hover);
color: #fff;
box-shadow: 0 2px 8px rgba(74,111,165,0.35);
}
.btn-outline-secondary {
color: var(--nm-text-muted);
border-color: var(--nm-border);
}
.btn-outline-secondary:hover {
background: #eef0f4;
color: var(--nm-text);
border-color: #c0c5d0;
}
.btn-outline-primary {
color: var(--nm-accent);
border-color: var(--nm-accent);
}
.btn-outline-primary:hover {
background: var(--nm-accent);
color: #fff;
}
.btn-danger {
background: #c0392b;
border-color: #a93226;
}
.btn-danger:hover { background: #a93226; border-color: #922b21; }
.btn-sm {
font-size: 0.8rem;
padding: 0.28rem 0.7rem;
}
/* --- Formularelemente --- */
.form-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--nm-text);
margin-bottom: 0.35rem;
}
.form-control, .form-select {
border: 1px solid var(--nm-border);
border-radius: var(--nm-radius-sm);
color: var(--nm-text);
background: #fff;
font-size: 0.9rem;
transition: border-color var(--nm-transition), box-shadow var(--nm-transition);
padding: 0.5rem 0.75rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--nm-accent);
box-shadow: 0 0 0 3px rgba(74,111,165,0.18);
outline: none;
}
textarea.form-control { min-height: 9rem; resize: vertical; }
/* --- Tabellen --- */
.table {
color: var(--nm-text);
border-color: var(--nm-border);
font-size: 0.875rem;
}
.table > thead > tr > th {
background: #f4f6f9;
color: var(--nm-text-muted);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 2px solid var(--nm-border);
padding: 0.75rem 1rem;
}
.table > tbody > tr > td {
padding: 0.75rem 1rem;
vertical-align: middle;
border-bottom: 1px solid #eef0f4;
}
.table-hover > tbody > tr:hover > td {
background: #f7f9fc;
}
/* --- Badges --- */
.badge {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.28em 0.65em;
border-radius: 0.25rem;
}
.text-bg-secondary {
background: #e4e7ed !important;
color: var(--nm-text) !important;
}
.text-bg-success { background: #d4edda !important; color: #1a5c35 !important; }
.text-bg-danger { background: #f8d7da !important; color: #7a1c1c !important; }
.text-bg-warning { background: #fff3cd !important; color: #664d03 !important; }
.text-bg-info { background: #d1ecf1 !important; color: #0c4a55 !important; }
.text-bg-primary { background: var(--nm-accent-light) !important; color: var(--nm-accent) !important; }
/* --- List-Group --- */
.list-group-item {
border-color: var(--nm-border);
color: var(--nm-text);
font-size: 0.875rem;
padding: 0.8rem 1rem;
transition: background var(--nm-transition);
}
.list-group-item-action:hover,
.list-group-item-action:focus {
background: #f4f6fa;
color: var(--nm-text);
}
.list-group-flush .list-group-item { border-left: 0; border-right: 0; }
/* --- Alerts --- */
.alert {
border-radius: var(--nm-radius-sm);
font-size: 0.875rem;
border-width: 1px;
}
.alert-success { background:#d4edda; color:#1a5c35; border-color:#b8dfc7; }
.alert-danger { background:#f8d7da; color:#7a1c1c; border-color:#f1b0b5; }
.alert-warning { background:#fff3cd; color:#664d03; border-color:#ffe69c; }
.alert-info { background:#d1ecf1; color:#0c4a55; border-color:#a8d8e1; }
/* --- Login-Seite --- */
.login-wrapper {
min-height: calc(100vh - 5rem);
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-logo {
width: 2.5rem;
height: 2.5rem;
background: var(--nm-accent);
border-radius: var(--nm-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
color: #fff;
font-weight: 800;
margin: 0 auto 1rem;
}
/* --- Text-Hilfklassen --- */
.text-muted { color: var(--nm-text-muted) !important; }
/* --- Seitenüberschrift-Bereich --- */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin-bottom: 0.15rem;
font-size: 1.5rem;
}
/* --- Content pre (Dokumentation) --- */
.content-pre {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
margin: 0;
color: var(--nm-text);
line-height: 1.7;
}
/* --- Trennlinie --- */
hr { border-color: var(--nm-border); opacity: 1; }
/* --- Scrollbar (Webkit) --- */
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #c5c9d4; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a8adb9; }

14
app/static/js/app.js Executable file
View File

@@ -0,0 +1,14 @@
document.addEventListener('DOMContentLoaded', () => {
const content = document.getElementById('content');
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!content) return;
const tpl = btn.getAttribute('data-template');
if (content.value.trim() && !confirm('Vorhandenen Inhalt durch Vorlage ersetzen?')) {
return;
}
content.value = tpl;
content.focus();
});
});
});

54
app/templates/base.html Executable file
View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NotesManager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4" aria-label="Hauptnavigation">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">NotesManager</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Navigation umschalten">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
{% if current_user.is_authenticated %}
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.entries') }}">Dokumentation</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.templates') }}">Vorlagen</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.tasks') }}">Aufgaben</a></li>
{% if current_user.role == 'admin' %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.users') }}">Benutzer</a></li>
{% endif %}
</ul>
<div class="d-flex align-items-center gap-2">
<span class="nm-user-info">{{ current_user.display_name }}</span>
<a class="btn btn-outline-light btn-sm" href="{{ url_for('main.logout') }}">Abmelden</a>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="container pb-5">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Schließen"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>

82
app/templates/dashboard.html Executable file
View File

@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block title %}Dashboard - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h2 mb-1">Dashboard</h1>
<div class="text-muted">Stand: {{ today.strftime('%d.%m.%Y') }}</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.entry_new') }}" class="btn btn-primary">Neuer Eintrag</a>
<a href="{{ url_for('main.export_markdown') }}" class="btn btn-outline-secondary">Markdown-Export</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.entries_total }}</div><div class="text-muted">Einträge gesamt</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.entries_today }}</div><div class="text-muted">Einträge heute</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.templates_total }}</div><div class="text-muted">Vorlagen</div></div></div></div>
<div class="col-md-3"><div class="card stat-card"><div class="card-body"><div class="stat-number">{{ stats.open_tasks }}</div><div class="text-muted">Offene Aufgaben</div></div></div></div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3">Neueste Dokumentation</h2>
{% if latest_entries %}
<div class="list-group list-group-flush">
{% for item in latest_entries %}
<a href="{{ url_for('main.entry_view', entry_id=item.id) }}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between flex-wrap gap-2">
<strong>{{ item.title }}</strong>
<span class="badge text-bg-secondary">{{ item.work_date.strftime('%d.%m.%Y') }}</span>
</div>
<div class="small text-muted">{{ item.category }} · {{ item.system_name or 'kein System' }} · {{ item.status }}</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted">Noch keine Einträge vorhanden.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card shadow-sm mb-4">
<div class="card-body">
<h2 class="h5 mb-3">Heutige Einträge</h2>
{% if entries_today %}
<ul class="list-group list-group-flush">
{% for item in entries_today %}
<li class="list-group-item px-0">
<strong>{{ item.title }}</strong><br>
<span class="small text-muted">{{ item.category }} · {{ item.priority }} · {{ item.created_by }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Heute gibt es noch keine Einträge.</p>
{% endif %}
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3">Offene Aufgaben</h2>
{% if open_tasks %}
<ul class="list-group list-group-flush">
{% for task in open_tasks[:6] %}
<li class="list-group-item px-0">
<strong>{{ task.title }}</strong><br>
<span class="small text-muted">{{ task.priority }}{% if task.due_date %} · Fällig {{ task.due_date.strftime('%d.%m.%Y') }}{% endif %}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Keine offenen Aufgaben.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

75
app/templates/entries.html Executable file
View File

@@ -0,0 +1,75 @@
{% extends 'base.html' %}
{% block title %}Dokumentation - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<h1 class="h2 mb-0">Dokumentation</h1>
<a href="{{ url_for('main.entry_new') }}" class="btn btn-primary">Neuer Eintrag</a>
</div>
<form class="card card-body shadow-sm mb-4" method="get">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Suche</label>
<input name="q" value="{{ q }}" class="form-control" placeholder="Titel, Inhalt, Tags, System ...">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="">Alle</option>
{% for s in ['offen', 'in_bearbeitung', 'erledigt'] %}
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select name="category" class="form-select">
<option value="">Alle</option>
{% for c in categories %}
<option value="{{ c }}" {% if category == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-1 d-flex align-items-end">
<button class="btn btn-outline-secondary w-100">Filter</button>
</div>
</div>
</form>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Kategorie</th>
<th>System</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.work_date.strftime('%d.%m.%Y') }}</td>
<td><strong>{{ item.title }}</strong><div class="small text-muted">{{ item.tags or '' }}</div></td>
<td>{{ item.category }}</td>
<td>{{ item.system_name or '-' }}</td>
<td><span class="badge text-bg-light border">{{ item.status }}</span></td>
<td class="text-nowrap">
<a href="{{ url_for('main.entry_view', entry_id=item.id) }}" class="btn btn-sm btn-outline-primary">Ansehen</a>
<a href="{{ url_for('main.entry_edit', entry_id=item.id) }}" class="btn btn-sm btn-outline-secondary">Bearbeiten</a>
<form method="post" action="{{ url_for('main.entry_delete', entry_id=item.id) }}" class="d-inline" onsubmit="return confirm('Eintrag wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">Keine Einträge gefunden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

77
app/templates/entry_form.html Executable file
View File

@@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% block title %}Eintrag {% if entry %}bearbeiten{% else %}anlegen{% endif %} - NotesManager{% endblock %}
{% block content %}
<h1 class="h2 mb-4">{% if entry %}Eintrag bearbeiten{% else %}Neuen Eintrag anlegen{% endif %}</h1>
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card shadow-sm">
<div class="card-body">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Titel</label>
<input name="title" class="form-control" required value="{{ entry.title if entry else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Datum</label>
<input type="date" name="work_date" class="form-control" value="{{ entry.work_date.isoformat() if entry else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Kategorie</label>
<input name="category" class="form-control" value="{{ entry.category if entry else '' }}" placeholder="z. B. Betrieb">
</div>
<div class="col-md-4">
<label class="form-label">System / Bereich</label>
<input name="system_name" class="form-control" value="{{ entry.system_name if entry else '' }}" placeholder="Server, Kunde, Projekt ...">
</div>
<div class="col-md-2">
<label class="form-label">Priorität</label>
<select name="priority" class="form-select">
{% for p in ['niedrig', 'mittel', 'hoch'] %}
<option value="{{ p }}" {% if (entry and entry.priority == p) or (not entry and p == 'mittel') %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Status</label>
<select name="status" class="form-select">
{% for s in ['offen', 'in_bearbeitung', 'erledigt'] %}
<option value="{{ s }}" {% if (entry and entry.status == s) or (not entry and s == 'offen') %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<label class="form-label">Tags</label>
<input name="tags" class="form-control" value="{{ entry.tags if entry else '' }}" placeholder="z. B. backup, kunde-a, patchday">
</div>
<div class="col-12">
<label class="form-label">Inhalt</label>
<textarea id="content" name="content" rows="16" class="form-control" required>{{ entry.content if entry else '' }}</textarea>
</div>
</div>
</div>
<div class="card-footer d-flex gap-2 justify-content-end">
<a href="{{ url_for('main.entries') }}" class="btn btn-outline-secondary">Abbrechen</a>
<button class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5">Vorlagen</h2>
<p class="text-muted small">Klicke auf eine Vorlage, um den Inhalt in das Formular zu übernehmen.</p>
<div class="list-group">
{% for t in templates %}
<button type="button" class="list-group-item list-group-item-action template-btn" data-template="{{ t.content | e }}">
<strong>{{ t.name }}</strong>
<div class="small text-muted">{{ t.description }}</div>
</button>
{% else %}
<div class="text-muted">Keine Vorlagen vorhanden.</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

25
app/templates/entry_view.html Executable file
View File

@@ -0,0 +1,25 @@
{% extends 'base.html' %}
{% block title %}{{ entry.title }} - NotesManager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h1 class="h2 mb-1">{{ entry.title }}</h1>
<div class="text-muted">{{ entry.work_date.strftime('%d.%m.%Y') }} · {{ entry.category }} · {{ entry.system_name or 'kein System' }}</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('main.entry_edit', entry_id=entry.id) }}" class="btn btn-outline-secondary">Bearbeiten</a>
<a href="{{ url_for('main.entries') }}" class="btn btn-primary">Zur Übersicht</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="mb-3 small text-muted">
<strong>Status:</strong> {{ entry.status }} ·
<strong>Priorität:</strong> {{ entry.priority }} ·
<strong>Tags:</strong> {{ entry.tags or '-' }} ·
<strong>Von:</strong> {{ entry.created_by }}
</div>
<pre class="content-pre">{{ entry.content }}</pre>
</div>
</div>
{% endblock %}

26
app/templates/login.html Executable file
View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Anmelden NotesManager{% endblock %}
{% block content %}
<div class="login-wrapper">
<div class="login-card">
<div class="card">
<div class="card-body p-4 p-sm-5">
<div class="login-logo" aria-hidden="true">N</div>
<h1 class="h4 text-center mb-1">NotesManager</h1>
<p class="text-muted text-center small mb-4">Bitte melde dich an, um fortzufahren.</p>
<form method="post" novalidate>
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input id="username" name="username" class="form-control" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label for="password" class="form-label">Passwort</label>
<input id="password" name="password" type="password" class="form-control" autocomplete="current-password" required>
</div>
<button class="btn btn-primary w-100" type="submit">Anmelden</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

76
app/templates/tasks.html Executable file
View File

@@ -0,0 +1,76 @@
{% extends 'base.html' %}
{% block title %}Aufgaben - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Neue Aufgabe</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Titel</label>
<input name="title" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Fällig am</label>
<input type="date" name="due_date" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="offen">offen</option>
<option value="in_bearbeitung">in_bearbeitung</option>
<option value="erledigt">erledigt</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Priorität</label>
<select name="priority" class="form-select">
<option value="niedrig">niedrig</option>
<option value="mittel" selected>mittel</option>
<option value="hoch">hoch</option>
</select>
</div>
</div>
<button class="btn btn-primary">Speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Aufgabenliste</h1>
<div class="list-group">
{% for item in items %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<strong>{{ item.title }}</strong>
<div class="small text-muted">{{ item.description or '' }}</div>
<div class="small text-muted mt-1">{{ item.status }} · {{ item.priority }}{% if item.due_date %} · {{ item.due_date.strftime('%d.%m.%Y') }}{% endif %}</div>
</div>
<div class="d-flex gap-2">
<form method="post" action="{{ url_for('main.task_toggle', task_id=item.id) }}">
<button class="btn btn-sm btn-outline-success">Umschalten</button>
</form>
<form method="post" action="{{ url_for('main.task_delete', task_id=item.id) }}" onsubmit="return confirm('Aufgabe wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</div>
</div>
</div>
{% else %}
<p class="text-muted">Noch keine Aufgaben vorhanden.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

57
app/templates/templates.html Executable file
View File

@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block title %}Vorlagen - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Neue Vorlage</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Name</label>
<input name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<input name="description" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">Inhalt</label>
<textarea name="content" rows="12" class="form-control" required></textarea>
</div>
<button class="btn btn-primary">Speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Vorhandene Vorlagen</h1>
<div class="accordion" id="templateList">
{% for item in items %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#tpl{{ item.id }}">
{{ item.name }}
</button>
</h2>
<div id="tpl{{ item.id }}" class="accordion-collapse collapse" data-bs-parent="#templateList">
<div class="accordion-body">
<div class="small text-muted mb-2">{{ item.description }}</div>
<pre class="content-pre">{{ item.content }}</pre>
<form method="post" action="{{ url_for('main.template_delete', template_id=item.id) }}" onsubmit="return confirm('Vorlage wirklich löschen?');">
<button class="btn btn-sm btn-outline-danger">Löschen</button>
</form>
</div>
</div>
</div>
{% else %}
<p class="text-muted">Noch keine Vorlagen vorhanden.</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

59
app/templates/users.html Executable file
View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}Benutzer - NotesManager{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Benutzer anlegen</h1>
<form method="post">
<div class="mb-3">
<label class="form-label">Benutzername</label>
<input name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Anzeigename</label>
<input name="display_name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Rolle</label>
<select name="role" class="form-select">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<button class="btn btn-primary">Benutzer speichern</button>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="h4 mb-3">Vorhandene Benutzer</h1>
<div class="table-responsive">
<table class="table align-middle">
<thead><tr><th>Benutzername</th><th>Anzeigename</th><th>Rolle</th><th>Erstellt</th></tr></thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.username }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.role }}</td>
<td>{{ item.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-muted">Keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

199
claude.md Executable file
View File

@@ -0,0 +1,199 @@
# Claude-Code Prompt für Deployment von NotesManager auf Proxmox LXC
Nutze den folgenden Prompt direkt in Claude Code.
---
Du arbeitest als selbstständig handelnder Linux-, Proxmox-, Netzwerk-, DNS-, Reverse-Proxy- und Deployment-Administrator.
## Ziel
Deploye das beigefügte Webprojekt **NotesManager** als produktives System in einem **neuen LXC-Container** auf dem Proxmox-Host **pve-braetter.braetter.local**. Das Ergebnis muss vollständig lauffähig und sauber dokumentiert sein.
## Harte Vorgaben
- Proxmox-Host: `pve-braetter.braetter.local`
- Container-Typ: **LXC**
- Container-OS: **Ubuntu 24.04**
- LXC Template: pve-braetter.braetter.local/Isos/CT Templates
- LXC Laufwerk: osdisk -> Größe 150GB
- LXC-ID: **nicht fest vorgegeben** → ermittle selbstständig eine **freie LXC-ID** auf `pve-braetter.braetter.local`
- IP-Adresse: **nicht fest vorgegeben** → ermittle per **nmap** eine **freie statische IP** im Netz `192.168.0.0/24`
- Gateway: `192.168.0.1`
- DNS-Server: `192.168.0.202`
- Hostname/FQDN lokal: `notes.braetter.local`
- Externe Domain hinter Reverse Proxy: `notes.braetter-int.de`
- Hauptbenutzer im Container: `nicolay`
- Passwort Hauptbenutzer: `N17b011975`
- Remoteinstallationsbenutzer: `claude`
- Passwort Remoteinstallationsbenutzer: `Agent`
- Der Benutzer `claude` soll volle `sudo`-Rechte **ohne Passwortabfrage** erhalten
- Der Benutzer `claude` besitzt eine vollständige Public-Key-Authentifizierung; nutze die vorhandene Konfiguration bzw. Schlüssel aus `/home/claude/.ssh/config` zur Verteilung an Zielsysteme, falls benötigt
- Reverse Proxy / SSL läuft über **Nginx Proxy Manager**
- Das SSL-Zertifikat für `*.braetter-int.de` ist **bereits im Proxy vorhanden**
- DNS-Zusatzaufgabe: Auf dem DNS-Server muss für die Zone `braetter.local` ein Hosteintrag `notes` auf die ermittelte statische Container-IP angelegt werden
- Zentrales Logging: `logserver.braetter.local`
- Das System soll nach Abschluss **vollständig erreichbar und getestet** sein
- Arbeite weitgehend autonom und beantworte notwendige Standardfragen selbst
## Wichtige Arbeitsweise
1. Triff sinnvolle Entscheidungen selbst.
2. Frage nur dann nach, wenn es absolut technisch unmöglich ist, ohne Rückfrage weiterzumachen.
3. Prüfe jede Annahme aktiv.
4. Validiere jeden großen Schritt direkt nach der Umsetzung.
5. Gib am Ende eine klare Zusammenfassung mit:
- gewählter LXC-ID
- gewählter IP-Adresse
- Hostname
- Pfaden
- Diensten
- Testergebnissen
- Zugangsdaten
- offenen Punkten, falls etwas extern blockiert ist
## Technische Zielarchitektur
Das bereitgestellte Projekt **NotesManager** soll im Container produktiv betrieben werden mit:
- Python 3
- virtuelle Umgebung (`venv`)
- Flask-Anwendung
- Gunicorn als App-Server
- Nginx lokal im Container als Reverse Proxy zu Gunicorn
- systemd-Service für Gunicorn
- SQLite-Datenbank lokal im Applikationsverzeichnis bzw. Instance-Verzeichnis
- UFW so restriktiv wie sinnvoll, aber funktional für Webbetrieb
- Logging an `logserver.braetter.local`
## Deployment-Aufgaben im Detail
### 1. Proxmox vorbereiten
- Verbinde dich mit `pve-braetter.braetter.local`
- Ermittle eine freie LXC-ID automatisch
- Benutze den vorgabe Storage selbstständig
- Verwende ein Ubuntu-24.04-kompatibles LXC-Template, sofern vorhanden; falls mehrere vorhanden sind, nimm das sinnvollste aktuelle Template
- Prüfe RAM-, CPU-Ressourcen des Hosts und wähle eine sinnvolle Containergröße für eine kleine bis mittlere interne Webanwendung
- Dokumentiere die gewählten Ressourcen
### 2. Freie statische IP ermitteln
- Nutze **nmap**, um im Netz `192.168.0.0/24` eine freie IP zu ermitteln
- Verifiziere zusätzlich, dass die gewählte IP nicht bereits in ARP-/Lease-/DNS-Kontexten auffällig belegt ist
- Konfiguriere diese IP statisch im Container mit:
- IP: automatisch ermittelt
- Gateway: `192.168.0.1`
- DNS: `192.168.0.202`
### 3. Container anlegen
- Erstelle den LXC-Container
- Hostname: `notes.braetter.local`
- Lege die Benutzer `nicolay` und `claude` an
- Setze die vorgegebenen Passwörter
- Hinterlege für `claude` funktionierende SSH-Authentifizierung
- Setze `claude` als Passwortlos-Sudoer
- Führe Systemupdates durch
### 4. NotesManager deployen
- Übertrage das mitgelieferte Projekt in den Container, vorzugsweise nach `/opt/notesmanager`
- Prüfe die Projektstruktur
- Erstelle eine Python-`venv`
- Installiere alle Abhängigkeiten aus `requirements.txt`
- Stelle sicher, dass die Anwendung im Produktionsmodus mit Gunicorn läuft
- Nutze die vorhandenen Deployment-Dateien im Projekt, wenn sinnvoll (`deploy/notesmanager.service`, `gunicorn.conf.py`, Beispiel-Nginx-Datei)
- Passe Konfigurationen auf die echte Zielumgebung an
- Setze Dateirechte sinnvoll
- Sorge dafür, dass die App nach Reboot automatisch startet
### 5. Nginx im Container konfigurieren
- Installiere und konfiguriere Nginx im Container
- Lokales Nginx soll Requests an Gunicorn auf `127.0.0.1:5000` weiterreichen oder auf den final gewählten lokalen Gunicorn-Socket/-Port
- Prüfe die Konfiguration mit `nginx -t`
- Aktiviere und starte Nginx sauber per systemd
### 6. Reverse Proxy in Nginx Proxy Manager
- Lege in **Nginx Proxy Manager** einen neuen Proxy Host für `notes.braetter-int.de` an
- Ziel ist der neue Container mit seiner statischen IP und dem internen HTTP-Port der Anwendung
- Weise das bereits vorhandene Zertifikat für `*.braetter-int.de` zu
- Aktiviere sinnvolle SSL-/Proxy-Optionen
- Teste den externen Zugriff über `https://notes.braetter-int.de`
### 7. DNS-Eintrag anlegen
- Lege auf dem DNS-Server einen A-Record für `notes` in der Zone `braetter.local` an
- Ziel ist die ermittelte statische IP des Containers
- Prüfe die Namensauflösung mit `dig`, `host` oder `nslookup`
### 8. Logging anbinden
- Richte System- und Web-Logging so ein, dass Logs an `logserver.braetter.local` weitergeleitet werden
- Nutze dafür eine saubere rsyslog- oder journald-kompatible Lösung
- Prüfe die Übertragung mit einem Testlogeintrag
### 9. Sicherheit und Betriebsfähigkeit
- Deaktiviere unnötige Dienste
- Härte die Standardkonfiguration sinnvoll ab, ohne die Funktion zu beeinträchtigen
- Stelle sicher, dass nur notwendige Ports erreichbar sind
- Prüfe Boot-Verhalten, Service-Status und Web-Erreichbarkeit
- Führe Smoke-Tests durch:
- Container pingbar
- DNS-Auflösung intern funktioniert
- Nginx lokal antwortet
- Gunicorn-Service aktiv
- NotesManager Login-Seite erreichbar
- Reverse-Proxy-Zugriff extern erreichbar
### 10. Funktionstest der Anwendung
- Öffne die Weboberfläche
- Prüfe, ob Login funktioniert
- Prüfe, ob die Flask-App korrekt antwortet
- Prüfe, ob statische Inhalte geladen werden
- Falls nötig, führe einen minimalen App-Test durch, z. B. Startseite/Login-Seite und HTTP-Statuscodes
## Erwartete Ergebnisdokumentation
Gib am Ende eine strukturierte Abschlussdokumentation aus mit:
1. Gewählter LXC-ID
2. Gewählter statischer IP
3. Container-Hostname
4. Installierter Software
5. Verwendetem Storage
6. CPU/RAM/Disk-Zuweisung
7. Pfad der Anwendung
8. Aktivierten Diensten
9. Reverse-Proxy-Konfiguration
10. DNS-Eintrag
11. Logging-Anbindung
12. Testresultaten
13. Zugangsdaten:
- `nicolay / N17b011975`
- `claude / Agent`
14. Hinweisen, welche Standard-Anwendungsanmeldung initial gesetzt wurde
15. Ggf. Restarbeiten oder manuelle Nacharbeiten
## Zusatzanforderung für die Anwendung
Falls im Projekt noch Standardwerte enthalten sind, passe sie produktiv an:
- Setze einen sicheren Flask-Secret-Key
- Falls die App einen Demo-Admin enthält, dokumentiere diesen Zustand sauber oder ändere ihn auf einen produktiven Erstlogin-Mechanismus
- Stelle sicher, dass keine Entwicklungsoptionen wie `debug=True` produktiv aktiv bleiben
## Wichtig
- Arbeite sauber, nachvollziehbar und mit sichtbaren Prüfungen.
- Bevorzuge robuste, wartbare Standardlösungen.
- Kein unfertiges Ergebnis abliefern.
- Wenn ein externer Zugriff auf Nginx Proxy Manager oder DNS technisch nicht möglich ist, dokumentiere exakt, **welcher konkrete Schritt** blockiert war und liefere die **genauen finalen Befehle oder Klickschritte**, die noch auszuführen sind.
---
Falls die Projektdaten lokal vorliegen, nutze sie direkt als Quelle für das Deployment. Projektname: **NotesManager**.

12
deploy/notesmanager.nginx Executable file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name notes.braetter.local;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

15
deploy/notesmanager.service Executable file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=NotesManager Gunicorn Service
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/opt/notesmanager
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/notesmanager/.venv/bin/gunicorn -c /opt/notesmanager/gunicorn.conf.py run:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

4
gunicorn.conf.py Executable file
View File

@@ -0,0 +1,4 @@
bind = "127.0.0.1:5000"
workers = 2
threads = 4
timeout = 120

22
install_local.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/notesmanager"
PYTHON_BIN="python3"
sudo mkdir -p "$APP_DIR"
sudo cp -r ./* "$APP_DIR/"
cd "$APP_DIR"
$PYTHON_BIN -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
cat <<'MSG'
Installation abgeschlossen.
Starten mit:
cd /opt/notesmanager
source .venv/bin/activate
python run.py
MSG

5
requirements.txt Executable file
View File

@@ -0,0 +1,5 @@
Flask==3.1.0
Flask-Login==0.6.3
Flask-SQLAlchemy==3.1.1
Werkzeug==3.1.3
gunicorn==23.0.0

6
run.py Executable file
View File

@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)