696 lines
23 KiB
Python

from flask import Flask, request, jsonify, g, redirect, url_for, session as flask_session
from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from werkzeug.security import generate_password_hash, check_password_hash
from functools import wraps
import jwt
import datetime
import os
import json
import logging
import uuid
import aiohttp
import asyncio
import requests
from logging.handlers import RotatingFileHandler
from datetime import timedelta
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass
import sqlite3
from authlib.integrations.flask_client import OAuth
from tapo import ApiClient
from dotenv import load_dotenv
# Lade Umgebungsvariablen
load_dotenv()
# Initialisierung
app = Flask(__name__)
CORS(app, supports_credentials=True)
# Konfiguration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_secret_key')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///myp.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('FLASK_ENV') == 'production'
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
# GitHub OAuth Konfiguration
app.config['GITHUB_CLIENT_ID'] = os.environ.get('OAUTH_CLIENT_ID')
app.config['GITHUB_CLIENT_SECRET'] = os.environ.get('OAUTH_CLIENT_SECRET')
app.config['GITHUB_API_BASE_URL'] = os.environ.get('GITHUB_API_BASE_URL', 'https://api.github.com/')
app.config['GITHUB_AUTHORIZE_URL'] = os.environ.get('GITHUB_AUTHORIZE_URL', 'https://github.com/login/oauth/authorize')
app.config['GITHUB_TOKEN_URL'] = os.environ.get('GITHUB_TOKEN_URL', 'https://github.com/login/oauth/access_token')
# Tapo-Konfiguration
TAPO_USERNAME = os.environ.get('TAPO_USERNAME')
TAPO_PASSWORD = os.environ.get('TAPO_PASSWORD')
TAPO_DEVICES = json.loads(os.environ.get('TAPO_DEVICES', '{}'))
# Logging
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/myp.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('MYP Backend starting up')
# DB Setup
db = SQLAlchemy(app)
migrate = Migrate(app, db)
# OAuth Setup
oauth = OAuth(app)
github = oauth.register(
name='github',
client_id=app.config['GITHUB_CLIENT_ID'],
client_secret=app.config['GITHUB_CLIENT_SECRET'],
access_token_url=app.config['GITHUB_TOKEN_URL'],
authorize_url=app.config['GITHUB_AUTHORIZE_URL'],
api_base_url=app.config['GITHUB_API_BASE_URL'],
client_kwargs={'scope': 'user:email'},
)
# Models
class User(db.Model):
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
github_id = db.Column(db.Integer, unique=True)
username = db.Column(db.String(64), index=True, unique=True)
display_name = db.Column(db.String(100))
email = db.Column(db.String(120), index=True, unique=True)
role = db.Column(db.String(20), default='guest') # admin, user, guest
jobs = db.relationship('PrintJob', backref='user', lazy='dynamic')
def to_dict(self):
return {
'id': self.id,
'github_id': self.github_id,
'username': self.username,
'displayName': self.display_name,
'email': self.email,
'role': self.role
}
class Session(db.Model):
id = db.Column(db.String(36), primary_key=True)
user_id = db.Column(db.String(36), db.ForeignKey('user.id'), nullable=False)
expires_at = db.Column(db.DateTime, nullable=False)
user = db.relationship('User', backref=db.backref('sessions', lazy=True))
class Printer(db.Model):
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = db.Column(db.String(64), index=True, nullable=False)
description = db.Column(db.Text, nullable=False)
status = db.Column(db.Integer, default=0) # 0=available, 1=busy, 2=maintenance
ip_address = db.Column(db.String(15), nullable=True) # IP-Adresse der Tapo-Steckdose
jobs = db.relationship('PrintJob', backref='printer', lazy='dynamic')
def get_latest_job(self):
return PrintJob.query.filter_by(printer_id=self.id).order_by(PrintJob.start_at.desc()).first()
def to_dict(self):
latest_job = self.get_latest_job()
return {
'id': self.id,
'name': self.name,
'description': self.description,
'status': self.status,
'latestJob': latest_job.to_dict() if latest_job else None
}
class PrintJob(db.Model):
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
printer_id = db.Column(db.String(36), db.ForeignKey('printer.id'), nullable=False)
user_id = db.Column(db.String(36), db.ForeignKey('user.id'), nullable=False)
start_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
duration_in_minutes = db.Column(db.Integer, nullable=False)
comments = db.Column(db.Text, nullable=True)
aborted = db.Column(db.Boolean, default=False)
abort_reason = db.Column(db.Text, nullable=True)
@property
def end_at(self):
return self.start_at + timedelta(minutes=self.duration_in_minutes)
def remaining_time(self):
if self.aborted:
return 0
now = datetime.datetime.utcnow()
if now > self.end_at:
return 0
diff = self.end_at - now
return int(diff.total_seconds() / 60)
def to_dict(self):
return {
'id': self.id,
'printerId': self.printer_id,
'userId': self.user_id,
'startAt': self.start_at.isoformat(),
'durationInMinutes': self.duration_in_minutes,
'comments': self.comments,
'aborted': self.aborted,
'abortReason': self.abort_reason,
'remainingMinutes': self.remaining_time()
}
# Tapo Steckdosen-Steuerung
class TapoControl:
def __init__(self, username, password):
self.username = username
self.password = password
self.clients = {}
async def get_client(self, ip_address):
if ip_address not in self.clients:
try:
client = ApiClient(self.username, self.password)
await client.login()
self.clients[ip_address] = client
except Exception as e:
app.logger.error(f"Fehler bei der Anmeldung an Tapo-Gerät {ip_address}: {e}")
return None
return self.clients[ip_address]
async def turn_on(self, ip_address):
client = await self.get_client(ip_address)
if client:
try:
device = await client.p115(ip_address)
await device.on()
app.logger.info(f"Tapo-Steckdose {ip_address} eingeschaltet")
return True
except Exception as e:
app.logger.error(f"Fehler beim Einschalten der Tapo-Steckdose {ip_address}: {e}")
return False
async def turn_off(self, ip_address):
client = await self.get_client(ip_address)
if client:
try:
device = await client.p115(ip_address)
await device.off()
app.logger.info(f"Tapo-Steckdose {ip_address} ausgeschaltet")
return True
except Exception as e:
app.logger.error(f"Fehler beim Ausschalten der Tapo-Steckdose {ip_address}: {e}")
return False
tapo_control = TapoControl(TAPO_USERNAME, TAPO_PASSWORD)
# Hilfsfunktionen
def get_current_user():
session_id = flask_session.get('session_id')
if not session_id:
return None
session = Session.query.get(session_id)
if not session or session.expires_at < datetime.datetime.utcnow():
if session:
db.session.delete(session)
db.session.commit()
flask_session.pop('session_id', None)
return None
return session.user
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({'message': 'Authentifizierung erforderlich!'}), 401
g.current_user = user
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not g.current_user or g.current_user.role != 'admin':
return jsonify({'message': 'Admin-Rechte erforderlich!'}), 403
return f(*args, **kwargs)
return decorated
def create_session(user):
session_id = str(uuid.uuid4())
expires_at = datetime.datetime.utcnow() + timedelta(days=7)
session = Session(id=session_id, user_id=user.id, expires_at=expires_at)
db.session.add(session)
db.session.commit()
flask_session['session_id'] = session_id
flask_session.permanent = True
return session_id
# Authentifizierungs-Routen
@app.route('/auth/login', methods=['GET'])
def login():
redirect_uri = url_for('github_callback', _external=True)
return github.authorize_redirect(redirect_uri)
@app.route('/auth/login/callback', methods=['GET'])
def github_callback():
token = github.authorize_access_token()
resp = github.get('user', token=token)
github_user = resp.json()
# GitHub-User-Informationen
github_id = github_user['id']
username = github_user['login']
display_name = github_user.get('name', username)
# E-Mail abrufen
emails_resp = github.get('user/emails', token=token)
emails = emails_resp.json()
primary_email = next((email['email'] for email in emails if email.get('primary')), None)
if not primary_email and emails:
primary_email = emails[0]['email']
# Benutzer suchen oder erstellen
user = User.query.filter_by(github_id=github_id).first()
if not user:
user = User(
github_id=github_id,
username=username,
display_name=display_name,
email=primary_email,
role='guest' # Standardrolle für neue Benutzer
)
db.session.add(user)
db.session.commit()
app.logger.info(f'Neuer Benutzer über GitHub registriert: {username}')
else:
# Aktualisiere Benutzerdaten, falls sie sich geändert haben
user.username = username
user.display_name = display_name
if primary_email:
user.email = primary_email
db.session.commit()
# Session erstellen
create_session(user)
# Weiterleitung zur Frontend-App
return redirect('/')
@app.route('/auth/logout', methods=['POST'])
def logout():
session_id = flask_session.get('session_id')
if session_id:
session = Session.query.get(session_id)
if session:
db.session.delete(session)
db.session.commit()
flask_session.pop('session_id', None)
return jsonify({'message': 'Erfolgreich abgemeldet!'}), 200
# API-Routen
@app.route('/api/me', methods=['GET'])
def get_me():
user = get_current_user()
if not user:
return jsonify({'authenticated': False}), 401
return jsonify({
'authenticated': True,
'user': user.to_dict()
})
@app.route('/api/printers', methods=['GET'])
def get_printers():
printers = Printer.query.all()
return jsonify([printer.to_dict() for printer in printers])
@app.route('/api/printers', methods=['POST'])
@login_required
@admin_required
def create_printer():
data = request.get_json()
if not data or not data.get('name') or not data.get('description'):
return jsonify({'message': 'Name und Beschreibung sind erforderlich!'}), 400
printer = Printer(
name=data.get('name'),
description=data.get('description'),
status=data.get('status', 0),
ip_address=data.get('ipAddress')
)
db.session.add(printer)
db.session.commit()
return jsonify(printer.to_dict()), 201
@app.route('/api/printers/<printer_id>', methods=['GET'])
def get_printer(printer_id):
printer = Printer.query.get_or_404(printer_id)
return jsonify(printer.to_dict())
@app.route('/api/printers/<printer_id>', methods=['PUT'])
@login_required
@admin_required
def update_printer(printer_id):
printer = Printer.query.get_or_404(printer_id)
data = request.get_json()
if data.get('name'):
printer.name = data['name']
if data.get('description'):
printer.description = data['description']
if 'status' in data:
printer.status = data['status']
if data.get('ipAddress'):
printer.ip_address = data['ipAddress']
db.session.commit()
return jsonify(printer.to_dict())
@app.route('/api/printers/<printer_id>', methods=['DELETE'])
@login_required
@admin_required
def delete_printer(printer_id):
printer = Printer.query.get_or_404(printer_id)
db.session.delete(printer)
db.session.commit()
return jsonify({'message': 'Drucker gelöscht!'})
@app.route('/api/jobs', methods=['GET'])
@login_required
def get_jobs():
# Admins sehen alle Jobs, normale Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
jobs = PrintJob.query.all()
else:
jobs = PrintJob.query.filter_by(user_id=g.current_user.id).all()
return jsonify([job.to_dict() for job in jobs])
@app.route('/api/jobs', methods=['POST'])
@login_required
def create_job():
data = request.get_json()
if not data or not data.get('printerId') or not data.get('durationInMinutes'):
return jsonify({'message': 'Drucker-ID und Dauer sind erforderlich!'}), 400
printer = Printer.query.get_or_404(data['printerId'])
if printer.status != 0: # 0 = available
return jsonify({'message': 'Drucker ist nicht verfügbar!'}), 400
duration = int(data['durationInMinutes'])
job = PrintJob(
printer_id=printer.id,
user_id=g.current_user.id,
duration_in_minutes=duration,
comments=data.get('comments', '')
)
# Drucker als belegt markieren
printer.status = 1 # 1 = busy
db.session.add(job)
db.session.commit()
# Steckdose einschalten, falls IP-Adresse hinterlegt ist
if printer.ip_address:
try:
asyncio.run(tapo_control.turn_on(printer.ip_address))
except Exception as e:
app.logger.error(f"Fehler beim Einschalten der Steckdose: {e}")
return jsonify(job.to_dict()), 201
@app.route('/api/jobs/<job_id>', methods=['GET'])
@login_required
def get_job(job_id):
# Admins können alle Jobs sehen, Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
job = PrintJob.query.get_or_404(job_id)
else:
job = PrintJob.query.filter_by(id=job_id, user_id=g.current_user.id).first_or_404()
return jsonify(job.to_dict())
@app.route('/api/jobs/<job_id>/abort', methods=['POST'])
@login_required
def abort_job(job_id):
# Admins können alle Jobs abbrechen, Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
job = PrintJob.query.get_or_404(job_id)
else:
job = PrintJob.query.filter_by(id=job_id, user_id=g.current_user.id).first_or_404()
data = request.get_json()
job.aborted = True
job.abort_reason = data.get('reason', '')
# Drucker wieder verfügbar machen
printer = job.printer
printer.status = 0 # 0 = available
db.session.commit()
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
if printer.ip_address:
try:
asyncio.run(tapo_control.turn_off(printer.ip_address))
except Exception as e:
app.logger.error(f"Fehler beim Ausschalten der Steckdose: {e}")
return jsonify(job.to_dict())
@app.route('/api/jobs/<job_id>/finish', methods=['POST'])
@login_required
def finish_job(job_id):
# Admins können alle Jobs beenden, Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
job = PrintJob.query.get_or_404(job_id)
else:
job = PrintJob.query.filter_by(id=job_id, user_id=g.current_user.id).first_or_404()
# Aktuelle Zeit als Ende setzen
now = datetime.datetime.utcnow()
actual_duration = int((now - job.start_at).total_seconds() / 60)
job.duration_in_minutes = actual_duration
# Drucker wieder verfügbar machen
printer = job.printer
printer.status = 0 # 0 = available
db.session.commit()
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
if printer.ip_address:
try:
asyncio.run(tapo_control.turn_off(printer.ip_address))
except Exception as e:
app.logger.error(f"Fehler beim Ausschalten der Steckdose: {e}")
return jsonify(job.to_dict())
@app.route('/api/jobs/<job_id>/extend', methods=['POST'])
@login_required
def extend_job(job_id):
# Admins können alle Jobs verlängern, Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
job = PrintJob.query.get_or_404(job_id)
else:
job = PrintJob.query.filter_by(id=job_id, user_id=g.current_user.id).first_or_404()
data = request.get_json()
minutes = int(data.get('minutes', 0))
hours = int(data.get('hours', 0))
additional_minutes = minutes + (hours * 60)
if additional_minutes <= 0:
return jsonify({'message': 'Ungültige Verlängerungszeit!'}), 400
job.duration_in_minutes += additional_minutes
db.session.commit()
return jsonify(job.to_dict())
@app.route('/api/jobs/<job_id>/comments', methods=['PUT'])
@login_required
def update_job_comments(job_id):
# Admins können alle Jobs bearbeiten, Benutzer nur ihre eigenen
if g.current_user.role == 'admin':
job = PrintJob.query.get_or_404(job_id)
else:
job = PrintJob.query.filter_by(id=job_id, user_id=g.current_user.id).first_or_404()
data = request.get_json()
job.comments = data.get('comments', '')
db.session.commit()
return jsonify(job.to_dict())
@app.route('/api/job/<job_id>/remaining-time', methods=['GET'])
def job_remaining_time(job_id):
job = PrintJob.query.get_or_404(job_id)
remaining = job.remaining_time()
return jsonify({'remaining_minutes': remaining})
@app.route('/api/users', methods=['GET'])
@login_required
@admin_required
def get_users():
users = User.query.all()
return jsonify([user.to_dict() for user in users])
@app.route('/api/users/<user_id>', methods=['GET'])
@login_required
@admin_required
def get_user(user_id):
user = User.query.get_or_404(user_id)
return jsonify(user.to_dict())
@app.route('/api/users/<user_id>', methods=['PUT'])
@login_required
@admin_required
def update_user(user_id):
user = User.query.get_or_404(user_id)
data = request.get_json()
if data.get('username'):
user.username = data['username']
if data.get('displayName'):
user.display_name = data['displayName']
if data.get('email'):
user.email = data['email']
if data.get('role'):
user.role = data['role']
db.session.commit()
return jsonify(user.to_dict())
@app.route('/api/users/<user_id>', methods=['DELETE'])
@login_required
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
# Löschen aller Sessions des Benutzers
Session.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
return jsonify({'message': 'Benutzer gelöscht!'})
@app.route('/api/stats', methods=['GET'])
@login_required
@admin_required
def stats():
# Drucker-Nutzungsstatistiken
total_printers = Printer.query.count()
available_printers = Printer.query.filter_by(status=0).count()
# Job-Statistiken
total_jobs = PrintJob.query.count()
active_jobs = PrintJob.query.filter(
PrintJob.aborted == False,
PrintJob.start_at + timedelta(minutes=PrintJob.duration_in_minutes) > datetime.datetime.utcnow()
).count()
completed_jobs = PrintJob.query.filter(
PrintJob.aborted == False,
PrintJob.start_at + timedelta(minutes=PrintJob.duration_in_minutes) <= datetime.datetime.utcnow()
).count()
# Benutzerstatistiken
total_users = User.query.count()
# Durchschnittliche Druckdauer
avg_duration_result = db.session.query(db.func.avg(PrintJob.duration_in_minutes)).first()
avg_duration = int(avg_duration_result[0]) if avg_duration_result[0] else 0
return jsonify({
'printers': {
'total': total_printers,
'available': available_printers,
'utilization_rate': (total_printers - available_printers) / total_printers if total_printers > 0 else 0
},
'jobs': {
'total': total_jobs,
'active': active_jobs,
'completed': completed_jobs,
'avg_duration': avg_duration
},
'users': {
'total': total_users
}
})
# Tägliche Überprüfung der Jobs und automatische Abschaltung der Steckdosen
@app.cli.command("check-jobs")
def check_jobs():
"""Überprüft abgelaufene Jobs und schaltet Steckdosen aus."""
now = datetime.datetime.utcnow()
# Abgelaufene Jobs finden
expired_jobs = PrintJob.query.filter(
PrintJob.aborted == False,
PrintJob.start_at + timedelta(minutes=PrintJob.duration_in_minutes) <= now
).all()
for job in expired_jobs:
printer = job.printer
# Drucker-Status auf verfügbar setzen
if printer.status == 1: # busy
printer.status = 0 # available
app.logger.info(f"Job {job.id} abgelaufen. Drucker {printer.id} auf verfügbar gesetzt.")
# Steckdose ausschalten, falls IP-Adresse hinterlegt ist
if printer.ip_address:
try:
asyncio.run(tapo_control.turn_off(printer.ip_address))
app.logger.info(f"Steckdose {printer.ip_address} für abgelaufenen Job {job.id} ausgeschaltet.")
except Exception as e:
app.logger.error(f"Fehler beim Ausschalten der Steckdose {printer.ip_address}: {e}")
db.session.commit()
app.logger.info(f"{len(expired_jobs)} abgelaufene Jobs überprüft und Steckdosen aktualisiert.")
@app.route('/api/test', methods=['GET'])
def test():
return jsonify({'message': 'MYP Backend API funktioniert!'})
# Error Handler
@app.errorhandler(404)
def not_found(error):
return jsonify({'message': 'Nicht gefunden!'}), 404
@app.errorhandler(500)
def server_error(error):
app.logger.error(f'Serverfehler: {error}')
return jsonify({'message': 'Interner Serverfehler!'}), 500
# Server starten
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')