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