diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..d48ec98
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,40 @@
+name: Deploy PBT
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: SSH-Key laden
+ uses: webfactory/ssh-agent@v0.9.0
+ with:
+ ssh-private-key: ${{ secrets.PBT_DEPLOY_KEY }}
+
+ - name: Host-Key vertrauen
+ run: ssh-keyscan -H pbt >> ~/.ssh/known_hosts
+
+ - name: Dateien auf die pbt-LXC syncen
+ run: |
+ rsync -avz --delete \
+ --exclude '.git' \
+ --exclude '.gitea' \
+ --exclude 'venv' \
+ --exclude '__pycache__' \
+ ./ deploy@pbt:/opt/pbt/
+
+ - name: Abhängigkeiten installieren & Service neu starten
+ run: |
+ ssh deploy@pbt '
+ set -e
+ cd /opt/pbt
+ python3 -m venv venv
+ source venv/bin/activate
+ pip install -q -r requirements.txt
+ sudo /usr/bin/systemctl restart pbt
+ '
diff --git a/add_entry.html b/add_entry.html
new file mode 100644
index 0000000..8066a0d
--- /dev/null
+++ b/add_entry.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% block title %}Neue Fahrt – PBT{% endblock %}
+{% block content %}
+
Neue Fahrt erfassen
+
+{% endblock %}
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..e5d155f
--- /dev/null
+++ b/app.py
@@ -0,0 +1,141 @@
+from datetime import date
+
+from flask import Flask, render_template, redirect, url_for, request, flash
+from flask_login import (
+ LoginManager,
+ login_user,
+ logout_user,
+ login_required,
+ current_user,
+)
+
+from config import Config
+from models import db, User, Fahrt, VERKEHRSMITTEL_OPTIONEN
+
+login_manager = LoginManager()
+login_manager.login_view = "login"
+
+
+def create_app():
+ app = Flask(__name__)
+ app.config.from_object(Config)
+
+ db.init_app(app)
+ login_manager.init_app(app)
+
+ with app.app_context():
+ db.create_all()
+
+ register_routes(app)
+ return app
+
+
+@login_manager.user_loader
+def load_user(user_id):
+ return User.query.get(int(user_id))
+
+
+def register_routes(app):
+ @app.route("/")
+ def index():
+ if current_user.is_authenticated:
+ return redirect(url_for("dashboard"))
+ return redirect(url_for("login"))
+
+ @app.route("/register", methods=["GET", "POST"])
+ def register():
+ if request.method == "POST":
+ username = request.form["username"].strip()
+ email = request.form["email"].strip().lower()
+ password = request.form["password"]
+
+ if User.query.filter(
+ (User.username == username) | (User.email == email)
+ ).first():
+ flash("Benutzername oder E-Mail bereits vergeben.", "error")
+ return redirect(url_for("register"))
+
+ user = User(username=username, email=email)
+ user.set_password(password)
+ db.session.add(user)
+ db.session.commit()
+
+ login_user(user)
+ return redirect(url_for("dashboard"))
+
+ return render_template("register.html")
+
+ @app.route("/login", methods=["GET", "POST"])
+ def login():
+ if request.method == "POST":
+ username = request.form["username"].strip()
+ password = request.form["password"]
+
+ user = User.query.filter_by(username=username).first()
+ if user is None or not user.check_password(password):
+ flash("Login fehlgeschlagen. Bitte prüfe deine Daten.", "error")
+ return redirect(url_for("login"))
+
+ login_user(user)
+ return redirect(url_for("dashboard"))
+
+ return render_template("login.html")
+
+ @app.route("/logout")
+ @login_required
+ def logout():
+ logout_user()
+ return redirect(url_for("login"))
+
+ @app.route("/dashboard")
+ @login_required
+ def dashboard():
+ fahrten = (
+ Fahrt.query.filter_by(user_id=current_user.id)
+ .order_by(Fahrt.datum.desc())
+ .all()
+ )
+ return render_template("dashboard.html", fahrten=fahrten)
+
+ @app.route("/fahrt/neu", methods=["GET", "POST"])
+ @login_required
+ def neue_fahrt():
+ if request.method == "POST":
+ fahrt = Fahrt(
+ user_id=current_user.id,
+ verkehrsmittel=request.form["verkehrsmittel"],
+ linie=request.form.get("linie", "").strip(),
+ von=request.form["von"].strip(),
+ nach=request.form["nach"].strip(),
+ datum=request.form.get("datum") or date.today(),
+ bewertung=int(request.form["bewertung"]),
+ kommentar=request.form.get("kommentar", "").strip(),
+ )
+ db.session.add(fahrt)
+ db.session.commit()
+ flash("Fahrt gespeichert.", "success")
+ return redirect(url_for("dashboard"))
+
+ return render_template(
+ "add_entry.html",
+ verkehrsmittel_optionen=VERKEHRSMITTEL_OPTIONEN,
+ heute=date.today().isoformat(),
+ )
+
+ @app.route("/fahrt//loeschen", methods=["POST"])
+ @login_required
+ def fahrt_loeschen(fahrt_id):
+ fahrt = Fahrt.query.get_or_404(fahrt_id)
+ if fahrt.user_id != current_user.id:
+ flash("Nicht erlaubt.", "error")
+ return redirect(url_for("dashboard"))
+ db.session.delete(fahrt)
+ db.session.commit()
+ flash("Fahrt gelöscht.", "success")
+ return redirect(url_for("dashboard"))
+
+
+app = create_app()
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=5000, debug=True)
diff --git a/base.html b/base.html
new file mode 100644
index 0000000..d14825b
--- /dev/null
+++ b/base.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ {% block title %}PBT – Öffi-Erfahrungen{% endblock %}
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% for category, message in messages %}
+ {{ message }}
+ {% endfor %}
+ {% endwith %}
+
+ {% block content %}{% endblock %}
+
+
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..98979a8
--- /dev/null
+++ b/config.py
@@ -0,0 +1,10 @@
+import os
+
+class Config:
+ # Beispiel: postgresql://pbt_app:PASSWORT@10.10.10.x/pbt
+ SQLALCHEMY_DATABASE_URI = os.environ.get(
+ "PBT_DATABASE_URL",
+ "postgresql://pbt_app:changeme@postgres/pbt",
+ )
+ SQLALCHEMY_TRACK_MODIFICATIONS = False
+ SECRET_KEY = os.environ.get("PBT_SECRET_KEY", "bitte-in-produktion-aendern")
diff --git a/dashboard.html b/dashboard.html
new file mode 100644
index 0000000..a8abf0b
--- /dev/null
+++ b/dashboard.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% block title %}Meine Fahrten – PBT{% endblock %}
+{% block content %}
+ Meine Fahrten
+
+ {% if not fahrten %}
+ Noch keine Fahrten erfasst. Jetzt die erste hinzufügen.
+ {% else %}
+
+
+
+ | Datum |
+ Verkehrsmittel |
+ Linie |
+ Strecke |
+ Bewertung |
+ |
+
+
+
+ {% for f in fahrten %}
+
+ | {{ f.datum.strftime("%d.%m.%Y") }} |
+ {{ f.verkehrsmittel }} |
+ {{ f.linie or "–" }} |
+ {{ f.von }} → {{ f.nach }} |
+ {{ "★" * f.bewertung }}{{ "☆" * (5 - f.bewertung) }} |
+
+
+ |
+
+ {% if f.kommentar %}
+ | {{ f.kommentar }} |
+ {% endif %}
+ {% endfor %}
+
+
+ {% endif %}
+{% endblock %}
diff --git a/gitignore b/gitignore
new file mode 100644
index 0000000..e813d97
--- /dev/null
+++ b/gitignore
@@ -0,0 +1,5 @@
+venv/
+__pycache__/
+*.pyc
+.env
+instance/
diff --git a/login.html b/login.html
new file mode 100644
index 0000000..57e1907
--- /dev/null
+++ b/login.html
@@ -0,0 +1,15 @@
+{% extends "base.html" %}
+{% block title %}Login – PBT{% endblock %}
+{% block content %}
+ Login
+
+ Noch kein Konto? Jetzt registrieren
+{% endblock %}
diff --git a/models.py b/models.py
new file mode 100644
index 0000000..4b72ae5
--- /dev/null
+++ b/models.py
@@ -0,0 +1,53 @@
+from datetime import date
+from flask_sqlalchemy import SQLAlchemy
+from flask_login import UserMixin
+from werkzeug.security import generate_password_hash, check_password_hash
+
+db = SQLAlchemy()
+
+
+class User(UserMixin, db.Model):
+ __tablename__ = "users"
+
+ id = db.Column(db.Integer, primary_key=True)
+ username = db.Column(db.String(64), unique=True, nullable=False, index=True)
+ email = db.Column(db.String(120), unique=True, nullable=False, index=True)
+ password_hash = db.Column(db.String(255), nullable=False)
+
+ fahrten = db.relationship(
+ "Fahrt", backref="user", lazy=True, cascade="all, delete-orphan"
+ )
+
+ def set_password(self, password: str) -> None:
+ self.password_hash = generate_password_hash(password)
+
+ def check_password(self, password: str) -> bool:
+ return check_password_hash(self.password_hash, password)
+
+
+VERKEHRSMITTEL_OPTIONEN = [
+ "Bus",
+ "Straßenbahn",
+ "U-Bahn",
+ "S-Bahn",
+ "Regionalzug",
+ "Fernzug (ÖBB/Railjet/WESTbahn)",
+ "Sonstiges",
+]
+
+
+class Fahrt(db.Model):
+ __tablename__ = "fahrten"
+
+ id = db.Column(db.Integer, primary_key=True)
+ user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
+
+ verkehrsmittel = db.Column(db.String(50), nullable=False)
+ linie = db.Column(db.String(50))
+ von = db.Column(db.String(120), nullable=False)
+ nach = db.Column(db.String(120), nullable=False)
+ datum = db.Column(db.Date, nullable=False, default=date.today)
+ bewertung = db.Column(db.Integer, nullable=False) # 1-5
+ kommentar = db.Column(db.Text)
+
+ erstellt_am = db.Column(db.DateTime, server_default=db.func.now())
diff --git a/pbt.service b/pbt.service
new file mode 100644
index 0000000..f27aeb0
--- /dev/null
+++ b/pbt.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=PBT Flask App (Gunicorn)
+After=network.target
+
+[Service]
+User=www-data
+WorkingDirectory=/opt/pbt
+Environment="PBT_DATABASE_URL=postgresql://pbt_app:changeme@postgres/pbt"
+Environment="PBT_SECRET_KEY=bitte-aendern"
+ExecStart=/opt/pbt/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/register.html b/register.html
new file mode 100644
index 0000000..e387195
--- /dev/null
+++ b/register.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block title %}Registrieren – PBT{% endblock %}
+{% block content %}
+ Registrieren
+
+ Schon registriert? Zum Login
+{% endblock %}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..19a5802
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+Flask==3.0.3
+Flask-SQLAlchemy==3.1.1
+Flask-Login==0.6.3
+psycopg2-binary==2.9.9
+python-dotenv==1.0.1
+gunicorn==22.0.0
+email-validator==2.2.0