Initial commit
This commit is contained in:
843
app/app.py
Normal file
843
app/app.py
Normal file
@@ -0,0 +1,843 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, session, flash
|
||||
from urllib.parse import urljoin
|
||||
import requests
|
||||
import urllib3
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image
|
||||
from functools import wraps
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import func
|
||||
|
||||
# Disable SSL warnings for local services
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
app = Flask(__name__)
|
||||
UPLOAD_FOLDER = '/app/static/uploads'
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////data/dashboard.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'a-secure-default-secret-key-for-development-only')
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# --- Caching for Status Checks ---
|
||||
URL_CACHE = {}
|
||||
CACHE_DURATION = 300 # 5 minutes
|
||||
|
||||
# --- Custom Filters ---
|
||||
@app.template_filter('json_loads')
|
||||
def json_loads_filter(s):
|
||||
if not s: return {}
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# --- Database Models ---
|
||||
|
||||
class Setting(db.Model):
|
||||
key = db.Column(db.String(50), primary_key=True)
|
||||
value = db.Column(db.String(255))
|
||||
|
||||
class Page(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
order = db.Column(db.Integer, default=0)
|
||||
groups = db.relationship('Group', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Group.order")
|
||||
widgets = db.relationship('Widget', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Widget.order")
|
||||
|
||||
class Group(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
order = db.Column(db.Integer, default=0)
|
||||
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False)
|
||||
links = db.relationship('Link', backref='group', lazy=True, cascade="all, delete-orphan", order_by="Link.order")
|
||||
|
||||
class Link(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(255), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
subtitle = db.Column(db.String(255))
|
||||
target = db.Column(db.String(20), default='_self')
|
||||
order = db.Column(db.Integer, default=0)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=False)
|
||||
|
||||
class Widget(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
type = db.Column(db.String(50), nullable=False) # e.g., 'pihole_status', 'openmeteo_weather', 'iframe_widget'
|
||||
config = db.Column(db.Text, nullable=True) # JSON-formatted string
|
||||
order = db.Column(db.Integer, default=0)
|
||||
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False) # Changed from group_id to page_id
|
||||
|
||||
def migrate_db():
|
||||
from sqlalchemy import text
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
# Check Page order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM page LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Page")
|
||||
conn.execute(text("ALTER TABLE page ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Group order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM \"group\" LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Group")
|
||||
conn.execute(text("ALTER TABLE \"group\" ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Link order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM link LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Link")
|
||||
conn.execute(text("ALTER TABLE link ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Widget page_id migration and Cleanup group_id
|
||||
try:
|
||||
# Check if group_id still exists by trying to select it
|
||||
conn.execute(text("SELECT group_id FROM widget LIMIT 1"))
|
||||
print("Migrating DB: Removing group_id from Widget (Recreating Table)")
|
||||
|
||||
# 1. Create new table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE widget_new (
|
||||
id INTEGER NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
config TEXT,
|
||||
"order" INTEGER,
|
||||
page_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY(page_id) REFERENCES page (id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 2. Copy data
|
||||
# If page_id was already populated by previous migration, use it.
|
||||
# If not (failed partial migration), try to derive from group.
|
||||
# We try to copy page_id first.
|
||||
try:
|
||||
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT id, type, config, \"order\", page_id FROM widget WHERE page_id IS NOT NULL"))
|
||||
except Exception:
|
||||
# Fallback: Calculate page_id from group_id link if page_id was missing/null
|
||||
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT w.id, w.type, w.config, w.\"order\", g.page_id FROM widget w JOIN \"group\" g ON w.group_id = g.id"))
|
||||
|
||||
# 3. Drop old table
|
||||
conn.execute(text("DROP TABLE widget"))
|
||||
|
||||
# 4. Rename new table
|
||||
conn.execute(text("ALTER TABLE widget_new RENAME TO widget"))
|
||||
|
||||
conn.commit()
|
||||
print("Widget table migrated and cleaned successfully.")
|
||||
|
||||
except Exception as e:
|
||||
# If group_id doesn't exist, we assume table is already clean or check logic is different
|
||||
# But we should ensure page_id exists
|
||||
try:
|
||||
conn.execute(text("SELECT page_id FROM widget LIMIT 1"))
|
||||
except:
|
||||
print(f"Deep migration error: {e}")
|
||||
|
||||
# Check Widget order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM widget LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Widget")
|
||||
conn.execute(text("ALTER TABLE widget ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration general warning: {e}")
|
||||
|
||||
migrate_db()
|
||||
|
||||
|
||||
# --- Authentication ---
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get('logged_in'):
|
||||
return redirect(url_for('login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
if username == os.environ.get('ADMIN_USERNAME') and password == os.environ.get('ADMIN_PASSWORD'):
|
||||
session['logged_in'] = True
|
||||
flash('Login erfolgreich!', 'success')
|
||||
next_url = request.args.get('next')
|
||||
return redirect(next_url or url_for('admin'))
|
||||
else:
|
||||
flash('Falscher Benutzername oder Passwort.', 'danger')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
flash('Du wurdest ausgeloggt.', 'info')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# --- Helper & Image Processing ---
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def save_and_scale_icon(request_file):
|
||||
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
|
||||
try:
|
||||
unique_filename = f"icon_{uuid.uuid4().hex}.png"
|
||||
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
|
||||
img = Image.open(request_file.stream)
|
||||
img = img.resize((48, 48), Image.Resampling.LANCZOS)
|
||||
if img.mode != 'RGBA': img = img.convert('RGBA')
|
||||
img.save(temp_path, 'PNG')
|
||||
return unique_filename
|
||||
except Exception as e:
|
||||
print(f"Fehler bei der Bildverarbeitung: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def save_background_or_logo(request_file, prefix):
|
||||
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
|
||||
try:
|
||||
filename = secure_filename(f"{prefix}_{request_file.filename}")
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
request_file.save(filepath)
|
||||
return filename
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Speichern: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# --- Database Helper ---
|
||||
|
||||
def get_all_data():
|
||||
settings_query = Setting.query.all()
|
||||
settings = {s.key: s.value for s in settings_query}
|
||||
|
||||
defaults = default_settings()
|
||||
for key, value in defaults.items():
|
||||
if key not in settings:
|
||||
settings[key] = value
|
||||
|
||||
pages = Page.query.order_by(Page.order).all()
|
||||
|
||||
return {
|
||||
"settings": settings,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
def default_settings():
|
||||
return {
|
||||
"title": "Mein Dashboard",
|
||||
"bg_image": "",
|
||||
"logo_image": "",
|
||||
"header_color": "#363636",
|
||||
"header_font_color": "#FFFFFF",
|
||||
"header_inactive_font_color": "#C0C0C0",
|
||||
"group_title_font_color": "#FFFFFF",
|
||||
"columns": "is-one-quarter-desktop",
|
||||
"theme_color": "#3273dc",
|
||||
"overlay_opacity": "0.85"
|
||||
}
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
return ",".join(str(int(hex_color[i:i+2], 16)) for i in (0, 2, 4))
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
data = get_all_data()
|
||||
if not data['pages']:
|
||||
return render_template('empty.html')
|
||||
|
||||
active_page_index = request.args.get('page', 0, type=int)
|
||||
if active_page_index >= len(data['pages']):
|
||||
active_page_index = 0
|
||||
|
||||
active_page = data['pages'][active_page_index]
|
||||
|
||||
if 'group_bg_color' in data['settings']:
|
||||
data['settings']['group_bg_color_rgb'] = hex_to_rgb(data['settings']['group_bg_color'])
|
||||
|
||||
return render_template('index.html', data=data, active_page=active_page, active_index=active_page_index)
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
def admin():
|
||||
data = get_all_data()
|
||||
return render_template('admin.html', data=data)
|
||||
|
||||
@app.route('/admin/local_icons')
|
||||
@login_required
|
||||
def local_icons():
|
||||
return jsonify([f for f in os.listdir(UPLOAD_FOLDER) if f.startswith('icon_') or f.startswith('upload_') or f.startswith('link_')])
|
||||
|
||||
@app.route('/admin/media_manager')
|
||||
@login_required
|
||||
def media_manager():
|
||||
files = []
|
||||
for f in os.listdir(UPLOAD_FOLDER):
|
||||
filepath = os.path.join(UPLOAD_FOLDER, f)
|
||||
try:
|
||||
if os.path.isfile(filepath):
|
||||
files.append({
|
||||
'name': f,
|
||||
'size': f"{os.path.getsize(filepath) / 1024:.2f} KB",
|
||||
'type': 'Icon' if f.startswith('icon_') else 'Hintergrund' if f.startswith('bg_') else 'Logo' if f.startswith('logo_') else 'Unbekannt'
|
||||
})
|
||||
except Exception as e:
|
||||
continue
|
||||
return render_template('media.html', files=files)
|
||||
|
||||
def _update_setting(key, value):
|
||||
setting = Setting.query.filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.session.add(Setting(key=key, value=value))
|
||||
|
||||
@app.route('/admin/update_settings', methods=['POST'])
|
||||
@login_required
|
||||
def update_settings():
|
||||
for key, value in request.form.items():
|
||||
_update_setting(key, value)
|
||||
|
||||
bg_filename = save_background_or_logo(request.files.get('bg_file'), 'bg')
|
||||
if bg_filename:
|
||||
_update_setting('bg_image', bg_filename)
|
||||
|
||||
logo_filename = save_background_or_logo(request.files.get('logo_file'), 'logo')
|
||||
if logo_filename:
|
||||
_update_setting('logo_image', logo_filename)
|
||||
|
||||
_update_setting('settings_configured', 'true')
|
||||
|
||||
db.session.commit()
|
||||
flash('Einstellungen gespeichert!', 'success')
|
||||
return redirect(url_for('admin', tab='settings'))
|
||||
|
||||
@app.route('/admin/delete_media/<string:filename>')
|
||||
@login_required
|
||||
def delete_media(filename):
|
||||
if '..' in filename or os.path.sep in filename:
|
||||
return "Ungültiger Dateiname", 400
|
||||
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Check if this file was used as the background or logo
|
||||
bg_setting = Setting.query.filter_by(key='bg_image', value=filename).first()
|
||||
if bg_setting:
|
||||
bg_setting.value = ""
|
||||
|
||||
logo_setting = Setting.query.filter_by(key='logo_image', value=filename).first()
|
||||
if logo_setting:
|
||||
logo_setting.value = ""
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Datei {filename} gelöscht.', 'info')
|
||||
return redirect(url_for('media_manager'))
|
||||
|
||||
# --- CRUD Routes (Pages, Groups, Links, Widgets) ---
|
||||
|
||||
@app.route('/admin/add_page', methods=['POST'])
|
||||
@login_required
|
||||
def add_page():
|
||||
name = request.form.get('name')
|
||||
if name:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_page = Page(name=name, icon=icon_val)
|
||||
db.session.add(new_page)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_page/<int:page_id>')
|
||||
@login_required
|
||||
def delete_page(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
db.session.delete(page)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_group/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_group(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
name = request.form.get('name')
|
||||
if name:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_group = Group(name=name, icon=icon_val, page_id=page.id)
|
||||
db.session.add(new_group)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_group/<int:group_id>')
|
||||
@login_required
|
||||
def delete_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
db.session.delete(group)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_link/<int:group_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_link(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
name = request.form.get('name')
|
||||
url = request.form.get('url')
|
||||
if name and url:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_link = Link(
|
||||
name=name,
|
||||
url=url,
|
||||
icon=icon_val,
|
||||
subtitle=request.form.get('subtitle'),
|
||||
target=request.form.get('target', '_blank'),
|
||||
group_id=group.id
|
||||
)
|
||||
db.session.add(new_link)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_link/<int:link_id>')
|
||||
@login_required
|
||||
def delete_link(link_id):
|
||||
link = Link.query.get_or_404(link_id)
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_widget/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_widget(page_id):
|
||||
widget_type = request.form.get('type')
|
||||
if not widget_type:
|
||||
flash('Widget-Typ nicht angegeben.', 'danger')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
config = {}
|
||||
if widget_type == 'pihole_status':
|
||||
config = {
|
||||
'api_url': request.form.get('pihole_api_url'),
|
||||
'api_key': request.form.get('pihole_api_key')
|
||||
}
|
||||
elif widget_type == 'openmeteo_weather':
|
||||
config = {
|
||||
'latitude': request.form.get('weather_lat'),
|
||||
'longitude': request.form.get('weather_lon')
|
||||
}
|
||||
elif widget_type == 'iframe_widget':
|
||||
config = {
|
||||
'url': request.form.get('iframe_url'),
|
||||
'height': request.form.get('iframe_height')
|
||||
}
|
||||
|
||||
new_widget = Widget(
|
||||
page_id=page_id,
|
||||
type=widget_type,
|
||||
config=json.dumps(config)
|
||||
)
|
||||
db.session.add(new_widget)
|
||||
db.session.commit()
|
||||
flash('Widget erfolgreich hinzugefügt!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_widget/<int:widget_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_widget(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
|
||||
config = {}
|
||||
if widget.type == 'pihole_status':
|
||||
config = {
|
||||
'api_url': request.form.get('pihole_api_url'),
|
||||
'api_key': request.form.get('pihole_api_key')
|
||||
}
|
||||
elif widget.type == 'openmeteo_weather':
|
||||
config = {
|
||||
'latitude': request.form.get('weather_lat'),
|
||||
'longitude': request.form.get('weather_lon')
|
||||
}
|
||||
elif widget.type == 'iframe_widget':
|
||||
config = {
|
||||
'url': request.form.get('iframe_url'),
|
||||
'height': request.form.get('iframe_height')
|
||||
}
|
||||
|
||||
widget.config = json.dumps(config)
|
||||
db.session.commit()
|
||||
flash('Widget erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_widget/<int:widget_id>')
|
||||
@login_required
|
||||
def delete_widget(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
db.session.delete(widget)
|
||||
db.session.commit()
|
||||
flash('Widget gelöscht.', 'info')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_link/<int:link_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_link(link_id):
|
||||
link = Link.query.get_or_404(link_id)
|
||||
link.name = request.form.get('name')
|
||||
link.url = request.form.get('url')
|
||||
link.subtitle = request.form.get('subtitle')
|
||||
link.target = request.form.get('target')
|
||||
|
||||
new_group_id = request.form.get('group_id')
|
||||
if new_group_id and int(new_group_id) != link.group_id:
|
||||
link.group_id = int(new_group_id)
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
link.icon = new_icon_file
|
||||
elif new_icon_text:
|
||||
link.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_page/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_page(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
page.name = request.form.get('name')
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
page.icon = new_icon_file
|
||||
elif new_icon_text or 'icon_text' in request.form:
|
||||
page.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
flash('Seite erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_group/<int:group_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
group.name = request.form.get('name')
|
||||
|
||||
new_page_id = request.form.get('page_id')
|
||||
if new_page_id and int(new_page_id) != group.page_id:
|
||||
group.page_id = int(new_page_id)
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
group.icon = new_icon_file
|
||||
elif new_icon_text or 'icon_text' in request.form:
|
||||
group.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
flash('Gruppe erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
# --- Reordering ---
|
||||
|
||||
@app.route('/admin/reorder_pages', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_pages():
|
||||
order_data = request.json.get('order')
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, page_id in enumerate(order_data):
|
||||
page = Page.query.get(page_id)
|
||||
if page: page.order = index
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
@app.route('/admin/reorder_groups', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_groups():
|
||||
data = request.json
|
||||
order_data = data.get('order')
|
||||
page_id = data.get('page_id')
|
||||
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, group_id in enumerate(order_data):
|
||||
group = Group.query.get(group_id)
|
||||
if group:
|
||||
group.order = index
|
||||
if page_id:
|
||||
group.page_id = int(page_id)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
@app.route('/admin/reorder_links', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_links():
|
||||
data = request.json
|
||||
order_data = data.get('order')
|
||||
group_id = data.get('group_id')
|
||||
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, link_id in enumerate(order_data):
|
||||
link = Link.query.get(link_id)
|
||||
if link:
|
||||
link.order = index
|
||||
if group_id:
|
||||
link.group_id = int(group_id)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
# --- Utility Routes ---
|
||||
|
||||
@app.route('/check_url')
|
||||
def check_url():
|
||||
url = request.args.get('url')
|
||||
if not url:
|
||||
return jsonify({'status': 'offline', 'reason': 'No URL provided'}), 400
|
||||
|
||||
# Check Cache
|
||||
current_time = time.time()
|
||||
if url in URL_CACHE:
|
||||
status, timestamp = URL_CACHE[url]
|
||||
if current_time - timestamp < CACHE_DURATION:
|
||||
return jsonify({'status': status, 'cached': True})
|
||||
|
||||
status = 'offline'
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
# Use a short timeout, and don't verify SSL certs for local services
|
||||
response = requests.head(url, headers=headers, timeout=5, verify=False, allow_redirects=True)
|
||||
|
||||
# If we get ANY response, the server is online/reachable.
|
||||
status = 'online'
|
||||
|
||||
except requests.exceptions.SSLError:
|
||||
# SSL Handshake failed, but server is there.
|
||||
status = 'online'
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
# Connection failed (Timeout, DNS, Refused)
|
||||
# Try one last time with GET, sometimes HEAD is blocked/buggy
|
||||
try:
|
||||
requests.get(url, headers=headers, timeout=5, verify=False, stream=True)
|
||||
status = 'online'
|
||||
except Exception as e2:
|
||||
# Log only actual failures
|
||||
print(f"Check URL failed for {url}: {e2}", file=sys.stdout, flush=True)
|
||||
status = 'offline'
|
||||
|
||||
# Update Cache
|
||||
URL_CACHE[url] = (status, current_time)
|
||||
return jsonify({'status': status})
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
query = request.args.get('q', '').strip().lower()
|
||||
if not query or len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
search_term = f"%{query}%"
|
||||
|
||||
results = db.session.query(Link, Group.name, Page.name)\
|
||||
.join(Group, Link.group_id == Group.id)\
|
||||
.join(Page, Group.page_id == Page.id)\
|
||||
.filter(
|
||||
(func.lower(Link.name).like(search_term)) |
|
||||
(func.lower(Link.subtitle).like(search_term))
|
||||
).all()
|
||||
|
||||
output = [{
|
||||
'name': link.name,
|
||||
'url': link.url,
|
||||
'subtitle': link.subtitle,
|
||||
'icon': link.icon,
|
||||
'group_name': group_name,
|
||||
'page_name': page_name
|
||||
} for link, group_name, page_name in results]
|
||||
|
||||
return jsonify(output)
|
||||
|
||||
|
||||
# --- Widget Data Handlers ---
|
||||
|
||||
def get_pihole_status(config):
|
||||
user_url = config.get('api_url', '').rstrip('/')
|
||||
password = config.get('api_key')
|
||||
|
||||
if not user_url:
|
||||
return {'error': "Pi-hole API URL not configured."}
|
||||
|
||||
try:
|
||||
if user_url.endswith('/api'):
|
||||
v6_api_base = user_url + '/'
|
||||
else:
|
||||
v6_api_base = urljoin(user_url, 'api/')
|
||||
|
||||
# Step 1: Authenticate
|
||||
auth_url = urljoin(v6_api_base, 'auth')
|
||||
auth_response = requests.post(auth_url, json={'password': password}, verify=False, timeout=5)
|
||||
|
||||
if auth_response.status_code == 404:
|
||||
raise requests.exceptions.RequestException("Auth endpoint not found (404), falling back to v5.")
|
||||
|
||||
auth_response.raise_for_status()
|
||||
|
||||
sid = auth_response.json().get('session', {}).get('sid')
|
||||
if not sid:
|
||||
return {'error': 'Pi-hole v6 auth successful, but no SID returned.'}
|
||||
|
||||
# Step 2a: Get blocking status
|
||||
blocking_url = urljoin(v6_api_base, 'dns/blocking')
|
||||
blocking_response = requests.get(blocking_url, params={'sid': sid}, verify=False, timeout=5)
|
||||
blocking_response.raise_for_status()
|
||||
blocking_data = blocking_response.json()
|
||||
|
||||
is_enabled = blocking_data.get('blocking') == 'enabled'
|
||||
status = 'enabled' if is_enabled else 'disabled'
|
||||
|
||||
# Step 2b: Get summary stats
|
||||
summary_url = urljoin(v6_api_base, 'stats/summary')
|
||||
summary_response = requests.get(summary_url, params={'sid': sid}, verify=False, timeout=5)
|
||||
summary_response.raise_for_status()
|
||||
summary_data = summary_response.json()
|
||||
|
||||
# Parse nested v6 structure
|
||||
queries_data = summary_data.get('queries', {})
|
||||
gravity_data = summary_data.get('gravity', {})
|
||||
|
||||
domains = gravity_data.get('domains_being_blocked', 0)
|
||||
queries = queries_data.get('total', 0)
|
||||
percent = queries_data.get('percent_blocked', 0.0)
|
||||
|
||||
return {
|
||||
'status': status,
|
||||
'domains_being_blocked': domains,
|
||||
'dns_queries_today': queries,
|
||||
'ads_percentage_today': percent
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Fallback for v5
|
||||
try:
|
||||
params = {'summary': ''}
|
||||
if password:
|
||||
params['auth'] = password
|
||||
|
||||
v5_base_url = user_url
|
||||
if v5_base_url.endswith('/api'):
|
||||
v5_base_url = v5_base_url[:-4]
|
||||
|
||||
v5_url = f"{v5_base_url.rstrip('/')}/admin/api.php"
|
||||
|
||||
response = requests.get(v5_url, params=params, timeout=5, verify=False)
|
||||
response.raise_for_status()
|
||||
v5_data = response.json()
|
||||
|
||||
if 'ads_percentage_today' in v5_data and isinstance(v5_data['ads_percentage_today'], str):
|
||||
v5_data['ads_percentage_today'] = float(v5_data['ads_percentage_today'].replace(',', '.'))
|
||||
return v5_data
|
||||
except Exception as e2:
|
||||
return {'error': f'Pi-hole connection failed: {e2}'}
|
||||
|
||||
def get_openmeteo_weather(config):
|
||||
lat = config.get('latitude')
|
||||
lon = config.get('longitude')
|
||||
|
||||
if not lat or not lon:
|
||||
return {'error': 'Latitude/Longitude not configured.'}
|
||||
|
||||
try:
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current": "temperature_2m,weather_code,wind_speed_10m",
|
||||
"daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset",
|
||||
"timezone": "auto"
|
||||
}
|
||||
response = requests.get(url, params=params, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return {
|
||||
'current': data.get('current', {}),
|
||||
'daily': data.get('daily', {}),
|
||||
'units': data.get('current_units', {})
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f"Weather API failed: {e}"}
|
||||
|
||||
def get_iframe_widget(config):
|
||||
return {
|
||||
'url': config.get('url'),
|
||||
'height': config.get('height', '300')
|
||||
}
|
||||
|
||||
WIDGET_HANDLERS = {
|
||||
'pihole_status': get_pihole_status,
|
||||
'openmeteo_weather': get_openmeteo_weather,
|
||||
'iframe_widget': get_iframe_widget
|
||||
}
|
||||
|
||||
@app.route('/api/widget_data/<int:widget_id>')
|
||||
def widget_data(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
|
||||
try:
|
||||
config = json.loads(widget.config) if widget.config else {}
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({'error': 'Invalid widget configuration format.'}), 500
|
||||
|
||||
handler = WIDGET_HANDLERS.get(widget.type)
|
||||
|
||||
if not handler:
|
||||
return jsonify({'error': f"No handler for widget type '{widget.type}'."}), 404
|
||||
|
||||
data = handler(config)
|
||||
|
||||
if 'error' in data:
|
||||
return jsonify(data), 500
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
app.run(host='0.0.0.0', port=8080)
|
||||
Reference in New Issue
Block a user