commit 5c7ce5d0caad163402cd17a0902611684fbf6bbf Author: Nicolay Braetter Date: Wed Apr 15 09:28:33 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..f2fc14a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SECRET_KEY= +FLASK_ENV= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4df651a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +instance/ +*.db +.env +*.bak diff --git a/README.md b/README.md new file mode 100755 index 0000000..4621ba8 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100755 index 0000000..d30852d --- /dev/null +++ b/app/__init__.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100755 index 0000000..1756bf1 --- /dev/null +++ b/app/models.py @@ -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)) diff --git a/app/routes.py b/app/routes.py new file mode 100755 index 0000000..39e6b41 --- /dev/null +++ b/app/routes.py @@ -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//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//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/') +@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//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//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//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'} + ) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100755 index 0000000..0cbe806 --- /dev/null +++ b/app/static/css/style.css @@ -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; } diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100755 index 0000000..c7debff --- /dev/null +++ b/app/static/js/app.js @@ -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(); + }); + }); +}); diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100755 index 0000000..8d2ee2b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,54 @@ + + + + + + {% block title %}NotesManager{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100755 index 0000000..1f69a66 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,82 @@ +{% extends 'base.html' %} +{% block title %}Dashboard - NotesManager{% endblock %} +{% block content %} +
+
+

Dashboard

+
Stand: {{ today.strftime('%d.%m.%Y') }}
+
+ +
+ +
+
{{ stats.entries_total }}
Einträge gesamt
+
{{ stats.entries_today }}
Einträge heute
+
{{ stats.templates_total }}
Vorlagen
+
{{ stats.open_tasks }}
Offene Aufgaben
+
+ +
+
+
+
+

Neueste Dokumentation

+ {% if latest_entries %} + + {% else %} +

Noch keine Einträge vorhanden.

+ {% endif %} +
+
+
+
+
+
+

Heutige Einträge

+ {% if entries_today %} +
    + {% for item in entries_today %} +
  • + {{ item.title }}
    + {{ item.category }} · {{ item.priority }} · {{ item.created_by }} +
  • + {% endfor %} +
+ {% else %} +

Heute gibt es noch keine Einträge.

+ {% endif %} +
+
+
+
+

Offene Aufgaben

+ {% if open_tasks %} +
    + {% for task in open_tasks[:6] %} +
  • + {{ task.title }}
    + {{ task.priority }}{% if task.due_date %} · Fällig {{ task.due_date.strftime('%d.%m.%Y') }}{% endif %} +
  • + {% endfor %} +
+ {% else %} +

Keine offenen Aufgaben.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/entries.html b/app/templates/entries.html new file mode 100755 index 0000000..31a63b5 --- /dev/null +++ b/app/templates/entries.html @@ -0,0 +1,75 @@ +{% extends 'base.html' %} +{% block title %}Dokumentation - NotesManager{% endblock %} +{% block content %} +
+

Dokumentation

+ Neuer Eintrag +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
DatumTitelKategorieSystemStatusAktionen
{{ item.work_date.strftime('%d.%m.%Y') }}{{ item.title }}
{{ item.tags or '' }}
{{ item.category }}{{ item.system_name or '-' }}{{ item.status }} + Ansehen + Bearbeiten +
+ +
+
Keine Einträge gefunden.
+
+
+{% endblock %} diff --git a/app/templates/entry_form.html b/app/templates/entry_form.html new file mode 100755 index 0000000..ebf18e2 --- /dev/null +++ b/app/templates/entry_form.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} +{% block title %}Eintrag {% if entry %}bearbeiten{% else %}anlegen{% endif %} - NotesManager{% endblock %} +{% block content %} +

{% if entry %}Eintrag bearbeiten{% else %}Neuen Eintrag anlegen{% endif %}

+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+

Vorlagen

+

Klicke auf eine Vorlage, um den Inhalt in das Formular zu übernehmen.

+
+ {% for t in templates %} + + {% else %} +
Keine Vorlagen vorhanden.
+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/entry_view.html b/app/templates/entry_view.html new file mode 100755 index 0000000..b6a2f3b --- /dev/null +++ b/app/templates/entry_view.html @@ -0,0 +1,25 @@ +{% extends 'base.html' %} +{% block title %}{{ entry.title }} - NotesManager{% endblock %} +{% block content %} +
+
+

{{ entry.title }}

+
{{ entry.work_date.strftime('%d.%m.%Y') }} · {{ entry.category }} · {{ entry.system_name or 'kein System' }}
+
+ +
+
+
+
+ Status: {{ entry.status }} · + Priorität: {{ entry.priority }} · + Tags: {{ entry.tags or '-' }} · + Von: {{ entry.created_by }} +
+
{{ entry.content }}
+
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100755 index 0000000..09a860a --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block title %}Anmelden – NotesManager{% endblock %} +{% block content %} + +{% endblock %} diff --git a/app/templates/tasks.html b/app/templates/tasks.html new file mode 100755 index 0000000..b4f3678 --- /dev/null +++ b/app/templates/tasks.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} +{% block title %}Aufgaben - NotesManager{% endblock %} +{% block content %} +
+
+
+
+

Neue Aufgabe

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+

Aufgabenliste

+
+ {% for item in items %} +
+
+
+ {{ item.title }} +
{{ item.description or '' }}
+
{{ item.status }} · {{ item.priority }}{% if item.due_date %} · {{ item.due_date.strftime('%d.%m.%Y') }}{% endif %}
+
+
+
+ +
+
+ +
+
+
+
+ {% else %} +

Noch keine Aufgaben vorhanden.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/templates.html b/app/templates/templates.html new file mode 100755 index 0000000..02737e6 --- /dev/null +++ b/app/templates/templates.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} +{% block title %}Vorlagen - NotesManager{% endblock %} +{% block content %} +
+
+
+
+

Neue Vorlage

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+

Vorhandene Vorlagen

+
+ {% for item in items %} +
+

+ +

+
+
+
{{ item.description }}
+
{{ item.content }}
+
+ +
+
+
+
+ {% else %} +

Noch keine Vorlagen vorhanden.

+ {% endfor %} +
+
+
+
+
+{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100755 index 0000000..137e900 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} +{% block title %}Benutzer - NotesManager{% endblock %} +{% block content %} +
+
+
+
+

Benutzer anlegen

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+

Vorhandene Benutzer

+
+ + + + {% for item in items %} + + + + + + + {% else %} + + {% endfor %} + +
BenutzernameAnzeigenameRolleErstellt
{{ item.username }}{{ item.display_name }}{{ item.role }}{{ item.created_at.strftime('%d.%m.%Y %H:%M') }}
Keine Benutzer vorhanden.
+
+
+
+
+
+{% endblock %} diff --git a/claude.md b/claude.md new file mode 100755 index 0000000..6923cb1 --- /dev/null +++ b/claude.md @@ -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**. diff --git a/deploy/notesmanager.nginx b/deploy/notesmanager.nginx new file mode 100755 index 0000000..264af77 --- /dev/null +++ b/deploy/notesmanager.nginx @@ -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; + } +} diff --git a/deploy/notesmanager.service b/deploy/notesmanager.service new file mode 100755 index 0000000..ce1dfd2 --- /dev/null +++ b/deploy/notesmanager.service @@ -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 diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100755 index 0000000..207d750 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,4 @@ +bind = "127.0.0.1:5000" +workers = 2 +threads = 4 +timeout = 120 diff --git a/install_local.sh b/install_local.sh new file mode 100755 index 0000000..ae1cd8a --- /dev/null +++ b/install_local.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..383c505 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100755 index 0000000..560d58b --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True)