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 %} + + + +
+

🚋 PBT – Öffi-Erfahrungen

+ +
+ + {% 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 %} + + + + + + + + + + + + + {% for f in fahrten %} + + + + + + + + + {% if f.kommentar %} + + {% endif %} + {% endfor %} + +
DatumVerkehrsmittelLinieStreckeBewertung
{{ f.datum.strftime("%d.%m.%Y") }}{{ f.verkehrsmittel }}{{ f.linie or "–" }}{{ f.von }} → {{ f.nach }}{{ "★" * f.bewertung }}{{ "☆" * (5 - f.bewertung) }} +
+ +
+
{{ f.kommentar }}
+ {% 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