Initialer Import: Raceplaner mit Flask & Nginx Proxy

This commit is contained in:
root
2026-01-23 21:42:48 +01:00
commit 634d3d865a
23 changed files with 993 additions and 0 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
# Python & OS
__pycache__/
*.py[cod]
.env
.DS_Store
# Datenbanken (WICHTIG!)
data/*.db
data/*.sqlite
# Zertifikate (WICHTIG!)
# Wir laden die Ordnerstruktur hoch, aber nicht die Schlüssel selbst
certs/*.pem
certs/*.key
certs/*.crt
# IDE & Venv
.vscode/
venv/
pyenv/
.python-version

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Python & OS
__pycache__/
*.py[cod]
.env
.DS_Store
# Datenbanken (WICHTIG!)
data/*.db
data/*.sqlite
# Zertifikate (WICHTIG!)
# Wir laden die Ordnerstruktur hoch, aber nicht die Schlüssel selbst
certs/*.pem
certs/*.key
certs/*.crt
# IDE & Venv
.vscode/
venv/
pyenv/
.python-version

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
build-essential \
libcairo2-dev \
pkg-config \
python3-dev \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "main.py"]

301
app/main.py Normal file
View File

@@ -0,0 +1,301 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, make_response, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
import os, json
from datetime import datetime, timedelta
from io import BytesIO
from xhtml2pdf import pisa
app = Flask(__name__)
# --- Konfiguration ---
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:////app/data/raceplanner.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'renn-strategie-2026-final-v3'
app.config['REMEMBER_COOKIE_DURATION'] = timedelta(days=31)
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=31)
SCENARIO_DIR = '/app/data/scenarios/'
db = SQLAlchemy(app)
migrate = Migrate(app, db)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
if not os.path.exists(SCENARIO_DIR):
os.makedirs(SCENARIO_DIR)
# --- Models ---
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False)
password = db.Column(db.String(200), nullable=False)
class Driver(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
car_number = db.Column(db.Integer, nullable=False)
cons_per_lap = db.Column(db.Float, default=16.0)
avg_lap_time = db.Column(db.Float, default=540.0)
tire_life_laps = db.Column(db.Integer, default=50)
order_index = db.Column(db.Integer, default=0)
class Stint(db.Model):
id = db.Column(db.Integer, primary_key=True)
car_number = db.Column(db.Integer, nullable=False)
driver_id = db.Column(db.Integer, db.ForeignKey('driver.id'))
order_index = db.Column(db.Integer, default=0)
driver = db.relationship('Driver', backref='stints')
class RaceConfig(db.Model):
id = db.Column(db.Integer, primary_key=True)
tank_capacity = db.Column(db.Float, default=100.0)
race_duration_hours = db.Column(db.Integer, default=24)
start_time = db.Column(db.DateTime, default=datetime(2026, 1, 31, 12, 0))
interruption_mins = db.Column(db.Integer, default=0)
always_change_tires = db.Column(db.Boolean, default=False)
min_pit_stop_sec = db.Column(db.Integer, default=180)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
def save_undo_state(car_num):
stints = Stint.query.filter_by(car_number=car_num).order_by(Stint.order_index).all()
session[f'undo_{car_num}'] = [{'driver_id': s.driver_id, 'order_index': s.order_index} for s in stints]
def get_calculated_schedule(car_num):
config = RaceConfig.query.first()
stints = Stint.query.filter_by(car_number=car_num).order_by(Stint.order_index).all()
schedule = []
current_time = (config.start_time or datetime(2026, 1, 31, 12, 0)) + timedelta(minutes=config.interruption_mins)
race_end = config.start_time + timedelta(hours=config.race_duration_hours)
current_tire_laps = 0
for i, s in enumerate(stints):
if current_time >= race_end: break
driver = s.driver
if not driver: continue
laps_possible = int(config.tank_capacity / driver.cons_per_lap)
stint_dur = laps_possible * driver.avg_lap_time
end_time = current_time + timedelta(seconds=stint_dur)
is_finish = False
laps_to_do = laps_possible
if end_time >= race_end:
is_finish = True
rem_sec = (race_end - current_time).total_seconds()
laps_to_do = int(rem_sec / driver.avg_lap_time)
end_time = race_end
change_tires = config.always_change_tires or (current_tire_laps + laps_to_do) > driver.tire_life_laps
fuel = round(laps_to_do * driver.cons_per_lap, 1)
schedule.append({
'number': i + 1, 'stint_id': s.id, 'driver_id': driver.id, 'driver_name': driver.name,
'start': current_time.strftime('%H:%M'), 'end': end_time.strftime('%H:%M'),
'date': current_time.strftime('%d.%m.'), 'laps': laps_to_do, 'fuel': fuel,
'change_tires': change_tires, 'is_finish': is_finish
})
if is_finish: break
current_tire_laps = laps_to_do if change_tires else current_tire_laps + laps_to_do
current_time = end_time + timedelta(seconds=config.min_pit_stop_sec)
return schedule
@app.route('/')
@login_required
def index():
config = RaceConfig.query.first()
sch1, sch2 = get_calculated_schedule(1), get_calculated_schedule(2)
stint_counts = {d.id: 0 for d in Driver.query.all()}
for s in sch1 + sch2:
stint_counts[s['driver_id']] = stint_counts.get(s['driver_id'], 0) + 1
c1_drivers = Driver.query.filter_by(car_number=1).order_by(Driver.order_index).all()
c2_drivers = Driver.query.filter_by(car_number=2).order_by(Driver.order_index).all()
scenarios = [f for f in os.listdir(SCENARIO_DIR) if f.endswith('.json')]
return render_template('index.html', config=config, schedule1=sch1, schedule2=sch2,
car1_drivers=c1_drivers, car2_drivers=c2_drivers,
stint_counts=stint_counts, scenarios=scenarios)
@app.route('/update_config', methods=['POST'])
@login_required
def update_config():
config = RaceConfig.query.first()
config.tank_capacity = float(request.form.get('tank_capacity'))
config.race_duration_hours = int(request.form.get('race_duration_hours'))
config.min_pit_stop_sec = int(request.form.get('min_pit_stop_sec'))
config.always_change_tires = 'always_change_tires' in request.form
dt_str = request.form.get('start_datetime')
if dt_str: config.start_time = datetime.strptime(dt_str, '%Y-%m-%dT%H:%M')
db.session.commit()
return redirect(url_for('index'))
@app.route('/add_driver', methods=['POST'])
@login_required
def add_driver():
car_num = int(request.form.get('car_number'))
new_d = Driver(name=request.form.get('name'), car_number=car_num,
cons_per_lap=float(request.form.get('cons') or 16.0),
avg_lap_time=float(request.form.get('lap_time') or 540.0))
db.session.add(new_d); db.session.commit()
return redirect(url_for('index'))
@app.route('/update_driver/<int:id>', methods=['POST'])
@login_required
def update_driver(id):
d = db.session.get(Driver, id)
if d:
d.name = request.form.get('name')
d.cons_per_lap = float(request.form.get('cons'))
d.avg_lap_time = float(request.form.get('lap_time'))
db.session.commit()
return redirect(url_for('index'))
@app.route('/delete_driver/<int:id>')
@login_required
def delete_driver(id):
d = db.session.get(Driver, id)
if d:
Stint.query.filter_by(driver_id=id).delete()
db.session.delete(d); db.session.commit()
return redirect(url_for('index'))
@app.route('/update_stint_driver', methods=['POST'])
@login_required
def update_stint_driver():
data = request.json
stint = db.session.get(Stint, data.get('stint_id'))
if stint:
save_undo_state(stint.car_number)
stint.driver_id = data.get('driver_id')
db.session.commit()
return jsonify({'status': 'ok'})
@app.route('/reorder_stints', methods=['POST'])
@login_required
def reorder_stints():
order = request.json.get('order', [])
if order:
first = db.session.get(Stint, order[0])
if first: save_undo_state(first.car_number)
for idx, sid in enumerate(order):
s = db.session.get(Stint, sid)
if s: s.order_index = idx
db.session.commit()
return jsonify({'status': 'ok'})
@app.route('/undo/<int:car_num>')
@login_required
def undo(car_num):
state = session.get(f'undo_{car_num}')
if state:
Stint.query.filter_by(car_number=car_num).delete()
for s_data in state:
db.session.add(Stint(car_number=car_num, driver_id=s_data['driver_id'], order_index=s_data['order_index']))
db.session.commit()
session.pop(f'undo_{car_num}')
return redirect(url_for('index'))
@app.route('/generate_schedule/<int:car_num>')
@login_required
def generate_schedule(car_num):
drivers = Driver.query.filter_by(car_number=car_num).order_by(Driver.order_index).all()
if not drivers: return redirect(url_for('index'))
save_undo_state(car_num)
Stint.query.filter_by(car_number=car_num).delete()
for i in range(40):
db.session.add(Stint(car_number=car_num, driver_id=drivers[i % len(drivers)].id, order_index=i))
db.session.commit()
return redirect(url_for('index'))
@app.route('/save_scenario', methods=['POST'])
@login_required
def save_scenario():
name = request.form.get('scenario_name')
config = RaceConfig.query.first()
data = {
'config': {'tank_capacity': config.tank_capacity, 'race_duration_hours': config.race_duration_hours, 'start_time': config.start_time.isoformat(), 'min_pit_stop_sec': config.min_pit_stop_sec, 'always_change_tires': config.always_change_tires},
'drivers': [{'id': d.id, 'name': d.name, 'car_number': d.car_number, 'cons_per_lap': d.cons_per_lap, 'avg_lap_time': d.avg_lap_time} for d in Driver.query.all()],
'stints': [{'car_number': s.car_number, 'driver_id': s.driver_id, 'order_index': s.order_index} for s in Stint.query.all()]
}
with open(os.path.join(SCENARIO_DIR, f"{name}.json"), 'w') as f: json.dump(data, f)
return redirect(url_for('index'))
@app.route('/load_scenario', methods=['POST'])
@login_required
def load_scenario():
path = os.path.join(SCENARIO_DIR, request.form.get('scenario_file'))
with open(path, 'r') as f: data = json.load(f)
Stint.query.delete(); Driver.query.delete(); RaceConfig.query.delete()
c = data['config']
db.session.add(RaceConfig(tank_capacity=c['tank_capacity'], race_duration_hours=c['race_duration_hours'], start_time=datetime.fromisoformat(c['start_time']), min_pit_stop_sec=c.get('min_pit_stop_sec', 180), always_change_tires=c['always_change_tires']))
id_map = {}
for d in data['drivers']:
new_d = Driver(name=d['name'], car_number=d['car_number'], cons_per_lap=d['cons_per_lap'], avg_lap_time=d['avg_lap_time'])
db.session.add(new_d); db.session.flush(); id_map[d['id']] = new_d.id
for s in data['stints']:
db.session.add(Stint(car_number=s['car_number'], driver_id=id_map.get(s['driver_id']), order_index=s['order_index']))
db.session.commit()
return redirect(url_for('index'))
@app.route('/export/car/<int:car_num>')
@login_required
def export_car_pdf(car_num):
config = RaceConfig.query.first()
html = render_template('pdf_template.html', schedule=get_calculated_schedule(car_num), config=config, title=f"Auto #{car_num}")
res = BytesIO()
pisa.CreatePDF(BytesIO(html.encode("utf-8")), dest=res)
response = make_response(res.getvalue())
response.headers['Content-Type'] = 'application/pdf'
return response
@app.route('/export/driver/<int:driver_id>')
@login_required
def export_driver_pdf(driver_id):
driver = db.session.get(Driver, driver_id)
full_sch = get_calculated_schedule(driver.car_number)
stints = [s for s in full_sch if s['driver_id'] == driver_id]
config = RaceConfig.query.first()
html = render_template('pdf_template.html', schedule=stints, config=config, title=f"Fahrer-Plan: {driver.name}")
res = BytesIO()
pisa.CreatePDF(BytesIO(html.encode("utf-8")), dest=res)
response = make_response(res.getvalue())
response.headers['Content-Type'] = 'application/pdf'
return response
# HIER IST DIE FEHLENDE ROUTE
@app.route('/driver/<int:id>')
@login_required
def driver_detail(id):
driver = db.session.get(Driver, id)
full_sch = get_calculated_schedule(driver.car_number)
stints = [s for s in full_sch if s['driver_id'] == id]
return render_template('driver_detail.html', driver=driver, stints=stints)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
u = User.query.filter_by(username='mscaltenbach').first()
if u and check_password_hash(u.password, request.form.get('password')):
session.permanent = True
login_user(u, remember=True)
return redirect(url_for('index'))
return render_template('login.html')
@app.route('/logout')
def logout():
logout_user()
return redirect(url_for('login'))
if __name__ == '__main__':
with app.app_context():
db.create_all()
if not RaceConfig.query.first(): db.session.add(RaceConfig()); db.session.commit()
if not User.query.filter_by(username='mscaltenbach').first():
db.session.add(User(username='mscaltenbach', password=generate_password_hash('SendIt123!', method='pbkdf2:sha256')))
db.session.commit()
app.run(host='0.0.0.0', port=5000)

1
app/migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
app/migrations/env.py Normal file
View File

@@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,32 @@
"""Auto-Migration
Revision ID: 0489144eea7b
Revises:
Create Date: 2026-01-21 19:09:43.187967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0489144eea7b'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('driver', schema=None) as batch_op:
batch_op.add_column(sa.Column('order_index', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('driver', schema=None) as batch_op:
batch_op.drop_column('order_index')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""Auto-Migration
Revision ID: a2d7fac86703
Revises: 0489144eea7b
Create Date: 2026-01-21 19:12:54.952493
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a2d7fac86703'
down_revision = '0489144eea7b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('stint',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('car_number', sa.Integer(), nullable=False),
sa.Column('driver_id', sa.Integer(), nullable=True),
sa.Column('order_index', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['driver_id'], ['driver.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('stint')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Auto-Migration
Revision ID: e8ff60abd420
Revises: a2d7fac86703
Create Date: 2026-01-21 19:24:58.766275
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e8ff60abd420'
down_revision = 'a2d7fac86703'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('race_config', schema=None) as batch_op:
batch_op.add_column(sa.Column('always_change_tires', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('min_pit_stop_sec', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('race_config', schema=None) as batch_op:
batch_op.drop_column('min_pit_stop_sec')
batch_op.drop_column('always_change_tires')
# ### end Alembic commands ###

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{{ driver.name }} - Details</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-[#0a0e17] text-white p-8">
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8">
<h1 class="text-4xl font-black uppercase italic">{{ driver.name }} <span class="text-blue-500">#{{ driver.car_number }}</span></h1>
<a href="/" class="bg-slate-800 px-4 py-2 rounded text-xs uppercase font-bold">Zurück</a>
</div>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Verbrauch/Runde</div>
<div class="text-2xl font-black text-green-500">{{ driver.cons_per_lap }}L</div>
</div>
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Ø Rundenzeit</div>
<div class="text-2xl font-black text-blue-500">{{ driver.avg_lap_time }}s</div>
</div>
<div class="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div class="text-[10px] text-slate-500 uppercase font-bold">Stints Gesamt</div>
<div class="text-2xl font-black text-orange-500">{{ stints|length }}</div>
</div>
</div>
<h2 class="text-xl font-bold mb-4 uppercase text-slate-400">Geplante Einsätze</h2>
<div class="space-y-2">
{% for s in stints %}
<div class="bg-slate-900/80 p-4 rounded-lg flex justify-between items-center border-l-4 border-blue-600">
<div>
<div class="text-[10px] text-slate-500 font-mono">{{ s.date }}</div>
<div class="text-lg font-black">{{ s.start }} - {{ s.end }}</div>
</div>
<div class="text-right">
<div class="text-sm font-bold">{{ s.laps }} Runden</div>
<div class="text-xs text-green-500">{{ s.fuel }}L Benzin</div>
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>

0
app/templates/index.h Normal file
View File

0
app/templates/index.hmtl Normal file
View File

157
app/templates/index.html Normal file
View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RACEPLANNER 2026</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #0a0e17; color: #e2e8f0; }
.glass { background: rgba(15, 23, 42, 0.8); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); }
.stint-card { transition: all 0.2s ease; border-left: 4px solid transparent; }
.stint-card:hover { transform: translateX(4px); background: rgba(30, 41, 59, 0.7); }
.handle { cursor: grab; }
input, select { background: #0f172a; border: 1px solid #1e293b; color: white; padding: 0.5rem; border-radius: 0.375rem; }
</style>
</head>
<body class="p-4 md:p-8">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col md:flex-row justify-between items-center mb-8 gap-4">
<h1 class="text-3xl font-black italic tracking-tighter text-blue-500 uppercase">Raceplanner <span class="text-white">2026</span></h1>
<div class="flex gap-4 items-center glass p-4 rounded-xl">
<form action="/save_scenario" method="POST" class="flex gap-2">
<input type="text" name="scenario_name" placeholder="Scenario Name" class="text-xs w-32" required>
<button class="bg-green-600 hover:bg-green-500 text-[10px] font-bold px-3 py-2 rounded uppercase">Save</button>
</form>
<form action="/load_scenario" method="POST" class="flex gap-2 border-l border-slate-700 pl-4">
<select name="scenario_file" class="text-xs w-32">
{% for s in scenarios %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
<button class="bg-blue-600 hover:bg-blue-500 text-[10px] font-bold px-3 py-2 rounded uppercase">Load</button>
</form>
<a href="/logout" class="text-xs text-slate-500 hover:text-white ml-2">Logout</a>
</div>
</div>
<form action="/update_config" method="POST" class="glass p-6 rounded-2xl mb-12 grid grid-cols-2 md:grid-cols-6 gap-6 items-end">
<div class="col-span-2">
<label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Race Start</label>
<input type="datetime-local" name="start_datetime" value="{{ config.start_time.strftime('%Y-%m-%dT%H:%M') }}" class="w-full font-mono text-sm">
</div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Dur(h)</label><input type="number" name="race_duration_hours" value="{{ config.race_duration_hours }}" class="w-full text-center"></div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Tank</label><input type="number" step="0.1" name="tank_capacity" value="{{ config.tank_capacity }}" class="w-full text-center"></div>
<div><label class="block text-[10px] uppercase font-bold text-slate-500 mb-2">Box(s)</label><input type="number" name="min_pit_stop_sec" value="{{ config.min_pit_stop_sec }}" class="w-full text-center"></div>
<div class="flex items-center gap-2 pb-3">
<input type="checkbox" name="always_change_tires" id="tires" {% if config.always_change_tires %}checked{% endif %}>
<label for="tires" class="text-[10px] uppercase font-bold text-slate-400">Force Tires</label>
<button type="submit" class="ml-4 bg-blue-600 hover:bg-blue-500 text-white font-black text-xs px-6 py-2 rounded uppercase shadow-lg shadow-blue-900/20">Apply</button>
</div>
</form>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12">
{% for car_num, drivers, schedule in [(1, car1_drivers, schedule1), (2, car2_drivers, schedule2)] %}
<div class="space-y-6">
<div class="flex items-center justify-between border-b border-slate-800 pb-4">
<h2 class="text-2xl font-black italic uppercase"><span class="text-blue-500">#{{ car_num }}</span> Vehicle Dashboard</h2>
<div class="flex gap-2">
<a href="/export/car/{{ car_num }}" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-[10px] font-bold px-3 py-1.5 rounded uppercase">PDF Plan</a>
<a href="/generate_schedule/{{ car_num }}" class="bg-red-950 text-red-400 border border-red-900/50 text-[10px] font-bold px-3 py-1.5 rounded uppercase">Auto-Generate</a>
</div>
</div>
<div class="space-y-3">
{% for d in drivers %}
<form action="/update_driver/{{ d.id }}" method="POST" class="glass p-4 rounded-xl flex items-center gap-4 stint-card">
<div class="w-8 h-8 rounded-full bg-orange-500/20 text-orange-500 flex items-center justify-center font-black text-xs">{{ stint_counts.get(d.id, 0) }}</div>
<div class="flex-1"><input type="text" name="name" value="{{ d.name }}" class="bg-transparent border-none p-0 font-bold text-blue-400 w-full"></div>
<div class="text-right"><label class="block text-[8px] uppercase text-slate-500">L/R</label><input type="number" step="0.1" name="cons" value="{{ d.cons_per_lap }}" class="w-16 bg-slate-900/50 text-[10px] text-center"></div>
<div class="text-right"><label class="block text-[8px] uppercase text-slate-500">Sec</label><input type="number" step="0.1" name="lap_time" value="{{ d.avg_lap_time }}" class="w-16 bg-slate-900/50 text-[10px] text-center"></div>
<div class="flex gap-2 ml-auto">
<button type="submit" class="bg-blue-600/20 text-blue-400 text-[10px] px-2 py-1 rounded font-black border border-blue-600/30">OK</button>
<a href="/export/driver/{{ d.id }}" target="_blank" class="bg-gray-800 px-2 py-1 rounded text-xs hover:bg-green-600">📄</a>
<a href="/driver/{{ d.id }}" class="bg-gray-800 px-2 py-1 rounded text-xs hover:bg-blue-600">👁️</a>
<a href="/delete_driver/{{ d.id }}" class="text-slate-600 hover:text-red-500"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M6 18L18 6M6 6l12 12"/></svg></a>
</div>
</form>
{% endfor %}
<form action="/add_driver" method="POST" class="flex gap-2 mt-4">
<input type="hidden" name="car_number" value="{{ car_num }}">
<input type="text" name="name" placeholder="Driver Name" class="flex-1 text-sm h-10" required>
<input type="number" step="0.1" name="cons" placeholder="L/R" class="w-20 text-sm h-10">
<input type="number" step="0.1" name="lap_time" placeholder="Sec" class="w-20 text-sm h-10">
<button class="bg-blue-600 text-white font-black text-[10px] px-6 py-2 rounded uppercase">Add</button>
</form>
</div>
<div class="relative py-8">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-slate-800"></div>
</div>
<div class="relative flex justify-center">
<span class="bg-[#0a0e17] px-4 text-xs font-black text-slate-500 uppercase tracking-widest">Race Schedule #{{ car_num }}</span>
</div>
</div>
<div id="stints-car-{{ car_num }}" class="space-y-2">
{% for s in schedule %}
<div class="stint-item glass rounded-xl p-4 stint-card grid grid-cols-12 gap-2 items-center" data-id="{{ s.stint_id }}">
<div class="col-span-1 text-sm font-black text-blue-400">#{{ s.number }}</div>
<div class="col-span-1 handle text-slate-700 hover:text-slate-400">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 7h2v2H7V7zm0 4h2v2H7v-2zm4-4h2v2h-2V7zm0 4h2v2h-2v-2zM7 15h2v2H7v-2zm4 0h2v2h-2v-2z"/></svg>
</div>
<div class="col-span-2 font-mono">
<div class="text-[11px] font-bold text-slate-300 mb-0.5">{{ s.date }}</div>
<div class="text-sm font-black text-blue-500">{{ s.start }}</div>
<div class="text-[10px] text-slate-500">{{ s.end }}</div>
</div>
<div class="col-span-4">
<select onchange="updateStintDriver('{{ s.stint_id }}', this.value)" class="w-full bg-slate-900/80 border-slate-800 text-xs font-bold py-1">
{% for d in drivers %}<option value="{{ d.id }}" {% if d.id == s.driver_id %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
</div>
<div class="col-span-2 text-center">
<div class="font-black">{{ s.laps }} R</div>
<div class="text-[10px] text-green-500">{{ s.fuel }}L</div>
</div>
<div class="col-span-2 text-right">
{% if s.is_finish %}<span class="bg-blue-600 text-[8px] font-black px-2 py-1 rounded text-white uppercase">Finish</span>
{% elif s.change_tires %}<span class="bg-green-600/20 text-green-500 text-[8px] font-black px-2 py-1 rounded border border-green-500/30 uppercase">Tires</span>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<script>
function updateStintDriver(stintId, driverId) {
fetch('/update_stint_driver', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({stint_id: stintId, driver_id: driverId})
}).then(() => window.location.reload());
}
[1, 2].forEach(num => {
const el = document.getElementById('stints-car-' + num);
if(el) {
Sortable.create(el, {
handle: '.handle', animation: 150,
onEnd: function() {
const order = Array.from(el.children).map(item => item.dataset.id);
fetch('/reorder_stints', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({order: order})
}).then(() => window.location.reload());
}
});
}
});
</script>
</body>
</html>

25
app/templates/login.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RacePlanner Login</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-white flex items-center justify-center h-screen">
<div class="bg-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-sm border border-gray-700">
<h1 class="text-3xl font-black text-blue-500 mb-6 text-center italic">RACEPLANNER</h1>
<form action="/login" method="POST" class="space-y-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Benutzer</label>
<input type="text" name="username" class="w-full bg-gray-900 border-none rounded p-3" required>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Passwort</label>
<input type="password" name="password" class="w-full bg-gray-900 border-none rounded p-3" required>
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 font-bold py-3 rounded uppercase transition">Anmelden</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
@page { size: A4; margin: 1cm; }
body { font-family: sans-serif; font-size: 9pt; color: #222; }
.header { text-align: center; border-bottom: 2px solid #3498db; padding-bottom: 10px; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
th { background-color: #2c3e50; color: white; border: 0.5pt solid #1a252f; padding: 8px; text-align: left; }
td { border: 0.5pt solid #ccc; padding: 8px; vertical-align: middle; }
.col-num { width: 30pt; text-align: center; }
.col-time { width: 100pt; font-weight: bold; }
.date { font-size: 7pt; color: #666; display: block; }
.action { font-weight: bold; font-size: 8pt; }
</style>
</head>
<body>
<div class="header">
<h1>{{ title }}</h1>
<div style="font-size: 8pt;">Rennstart: {{ config.start_time.strftime('%d.%m.%Y %H:%M') }}</div>
</div>
<table>
<thead>
<tr>
<th class="col-num">#</th>
<th class="col-time">Zeitraum</th>
<th>Fahrer</th>
<th style="width: 30pt; text-align: center;">R</th>
<th style="width: 50pt;">Sprit</th>
<th style="width: 100pt;">Aktion</th>
</tr>
</thead>
<tbody>
{% for s in schedule %}
<tr>
<td class="col-num">{{ s.number }}</td>
<td class="col-time">
<span class="date">{{ s.date }}2026</span>
{{ s.start }} - {{ s.end }}
</td>
<td style="font-weight: bold;">{{ s.driver_name }}</td>
<td style="text-align: center;">{{ s.laps }}</td>
<td style="font-weight: bold; color: #27ae60;">{{ s.fuel }}L</td>
<td class="action">
{% if s.is_finish %}<span style="color: #2980b9;">ZIELANKUNFT</span>
{% elif s.change_tires %}<span style="color: #27ae60;">REIFEN &amp; SPRIT</span>
{% else %}<span style="color: #e67e22;">NUR SPRIT</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
version: '3.3'
services:
flask_app:
build: .
container_name: raceplanner_backend
restart: always
volumes:
- ./app:/app
- ./data:/app/data
environment:
- SECRET_KEY=renn-strategie-2026-sicher
- DATABASE_URL=sqlite:////app/data/raceplanner.db
- PYTHONUNBUFFERED=1
nginx:
image: nginx:latest
container_name: raceplanner_proxy
restart: always
ports:
- "6060:443" # <--- Externer Zugriff über Port 6060
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs
command: >
/bin/bash -c "
mkdir -p /etc/nginx/certs &&
if [ ! -f /etc/nginx/certs/fullchain.pem ]; then
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/certs/privkey.pem \
-out /etc/nginx/certs/fullchain.pem \
-subj '/C=DE/ST=Berlin/L=Berlin/O=RaceTeam/CN=localhost';
fi &&
nginx -g 'daemon off;'"
depends_on:
- flask_app

16
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://flask_app: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;
}
}

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
flask
flask-sqlalchemy
flask-migrate
flask-login
gunicorn
xhtml2pdf