@@ -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
|
||||||
|
'
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Neue Fahrt – PBT{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Neue Fahrt erfassen</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label for="verkehrsmittel">Verkehrsmittel</label>
|
||||||
|
<select id="verkehrsmittel" name="verkehrsmittel" required>
|
||||||
|
{% for option in verkehrsmittel_optionen %}
|
||||||
|
<option value="{{ option }}">{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="linie">Linie (optional)</label>
|
||||||
|
<input type="text" id="linie" name="linie" placeholder="z.B. S3, Bus 10">
|
||||||
|
|
||||||
|
<label for="von">Von</label>
|
||||||
|
<input type="text" id="von" name="von" required>
|
||||||
|
|
||||||
|
<label for="nach">Nach</label>
|
||||||
|
<input type="text" id="nach" name="nach" required>
|
||||||
|
|
||||||
|
<label for="datum">Datum</label>
|
||||||
|
<input type="date" id="datum" name="datum" value="{{ heute }}" required>
|
||||||
|
|
||||||
|
<label for="bewertung">Bewertung (1–5)</label>
|
||||||
|
<select id="bewertung" name="bewertung" required>
|
||||||
|
<option value="5">★★★★★ Sehr gut</option>
|
||||||
|
<option value="4">★★★★☆ Gut</option>
|
||||||
|
<option value="3" selected>★★★☆☆ Okay</option>
|
||||||
|
<option value="2">★★☆☆☆ Schlecht</option>
|
||||||
|
<option value="1">★☆☆☆☆ Sehr schlecht</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="kommentar">Kommentar (optional)</label>
|
||||||
|
<textarea id="kommentar" name="kommentar" rows="3"></textarea>
|
||||||
|
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -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/<int:fahrt_id>/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)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}PBT – Öffi-Erfahrungen{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||||
|
header h1 { font-size: 1.3rem; margin: 0; }
|
||||||
|
nav a { margin-left: 1rem; }
|
||||||
|
.flash { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||||
|
.flash.error { background: #fbdada; color: #7a1c1c; }
|
||||||
|
.flash.success { background: #d8f5d8; color: #1c5e1c; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 0.8rem; max-width: 420px; }
|
||||||
|
label { font-weight: 600; font-size: 0.9rem; }
|
||||||
|
input, select, textarea {
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1a5fb4;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
button.secondary { background: #999; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }
|
||||||
|
.stars { color: #d4a017; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🚋 PBT – Öffi-Erfahrungen</h1>
|
||||||
|
<nav>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a href="{{ url_for('dashboard') }}">Meine Fahrten</a>
|
||||||
|
<a href="{{ url_for('neue_fahrt') }}">Neue Fahrt</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Logout ({{ current_user.username }})</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('login') }}">Login</a>
|
||||||
|
<a href="{{ url_for('register') }}">Registrieren</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Meine Fahrten – PBT{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Meine Fahrten</h2>
|
||||||
|
|
||||||
|
{% if not fahrten %}
|
||||||
|
<p>Noch keine Fahrten erfasst. <a href="{{ url_for('neue_fahrt') }}">Jetzt die erste hinzufügen</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Verkehrsmittel</th>
|
||||||
|
<th>Linie</th>
|
||||||
|
<th>Strecke</th>
|
||||||
|
<th>Bewertung</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in fahrten %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ f.datum.strftime("%d.%m.%Y") }}</td>
|
||||||
|
<td>{{ f.verkehrsmittel }}</td>
|
||||||
|
<td>{{ f.linie or "–" }}</td>
|
||||||
|
<td>{{ f.von }} → {{ f.nach }}</td>
|
||||||
|
<td class="stars">{{ "★" * f.bewertung }}{{ "☆" * (5 - f.bewertung) }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('fahrt_loeschen', fahrt_id=f.id) }}" onsubmit="return confirm('Fahrt wirklich löschen?');">
|
||||||
|
<button type="submit" class="secondary">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if f.kommentar %}
|
||||||
|
<tr><td></td><td colspan="5" style="color:#666;">{{ f.kommentar }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login – PBT{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
|
||||||
|
<button type="submit">Einloggen</button>
|
||||||
|
</form>
|
||||||
|
<p>Noch kein Konto? <a href="{{ url_for('register') }}">Jetzt registrieren</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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())
|
||||||
+14
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Registrieren – PBT{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Registrieren</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8">
|
||||||
|
|
||||||
|
<button type="submit">Konto erstellen</button>
|
||||||
|
</form>
|
||||||
|
<p>Schon registriert? <a href="{{ url_for('login') }}">Zum Login</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user