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:
2
.env.example
Executable file
2
.env.example
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
SECRET_KEY=<your_value>
|
||||||
|
FLASK_ENV=<your_value>
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
instance/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
*.bak
|
||||||
62
README.md
Executable file
62
README.md
Executable 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
77
app/__init__.py
Executable 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
54
app/models.py
Executable 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
285
app/routes.py
Executable 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
370
app/static/css/style.css
Executable 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
14
app/static/js/app.js
Executable 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
54
app/templates/base.html
Executable 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
82
app/templates/dashboard.html
Executable 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
75
app/templates/entries.html
Executable 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
77
app/templates/entry_form.html
Executable 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
25
app/templates/entry_view.html
Executable 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
26
app/templates/login.html
Executable 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
76
app/templates/tasks.html
Executable 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
57
app/templates/templates.html
Executable 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
59
app/templates/users.html
Executable 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
199
claude.md
Executable 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
12
deploy/notesmanager.nginx
Executable 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
15
deploy/notesmanager.service
Executable 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
4
gunicorn.conf.py
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
bind = "127.0.0.1:5000"
|
||||||
|
workers = 2
|
||||||
|
threads = 4
|
||||||
|
timeout = 120
|
||||||
22
install_local.sh
Executable file
22
install_local.sh
Executable 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
5
requirements.txt
Executable 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
|
||||||
Reference in New Issue
Block a user