Backend aufgeräumt und Steckdosen-Einschaltfunktion behoben

- TAPO_PASSWORD in .env korrigiert (Agent045)
- Unnötige Verzeichnisse entfernt (node_modules, archiv in backend/, etc.)
- .gitignore erstellt um .env-Dateien zu schützen
- Projektstruktur bereinigt (von 1.5MB auf 186KB reduziert)
- Flask Web UI vollständig funktionsfähig

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-09 00:42:48 +02:00
commit 5f2b3df924
218 changed files with 32909 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
packages/reservation-platform/docker/images/*.tar.xz filter=lfs diff=lfs merge=lfs -text

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Environment
.env
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
# Flask
instance/
.webassets-cache
# Database
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Node
node_modules/

53
CLAUDE.md Normal file
View File

@@ -0,0 +1,53 @@
# MYP Project Development Guidelines
## System Architecture
- **Frontend**:
- Located in `packages/reservation-platform`
- Runs on a Raspberry Pi connected to company network
- Has internet access on one interface
- Connected via LAN to an offline network
- Serves as the user interface
- Developed by another apprentice as part of IHK project work
- **Backend**:
- Located in `backend` directory
- Flask application running on a separate Raspberry Pi
- Connected only to the offline network
- Communicates with WiFi smart plugs
- Part of my IHK project work for digital networking qualification
- **Printers/Smart Plugs**:
- Printers can only be controlled (on/off) via WiFi smart plugs
- No other control mechanisms available
- Smart plugs and printers are equivalent in the system context
## Build/Run Commands
- Backend: `cd backend && source venv/bin/activate && python app.py`
- Frontend: `cd packages/reservation-platform && pnpm dev`
- Run tests: `cd backend && python -m unittest development/tests/tests.py`
- Run single test: `cd backend && python -m unittest development.tests.tests.MYPBackendTestCase.test_name`
- Check jobs manually: `cd backend && source venv/bin/activate && flask check-jobs`
- Lint frontend: `cd packages/reservation-platform && pnpm lint`
- Format frontend: `cd packages/reservation-platform && npx @biomejs/biome format --write ./src`
## Code Style
- **Python Backend**:
- Use PEP 8 conventions, 4-space indentation
- Line width: 100 characters max
- Add docstrings to functions and classes
- Error handling: Use try/except with specific exceptions
- Naming: snake_case for functions/variables, PascalCase for classes
- **Frontend (Next.js/TypeScript)**:
- Use Biome for formatting and linting (line width: 120 chars)
- Organize imports automatically with Biome
- Use TypeScript types for all code
- Use React hooks for state management
- Naming: camelCase for functions/variables, PascalCase for components
## Work Guidelines
- All changes must be committed to git
- Work efficiently and cost-effectively
- Don't repeatedly try the same solution if it doesn't work
- Create and check notes when encountering issues
- Clearly communicate if something is not possible so I can handle it manually

3
CREDENTIALS Normal file
View File

@@ -0,0 +1,3 @@
TAPO ADMIN: vT6Vsd^p
Admin-PW: 744563017196
Tapo: 744563017196A

11
Dokumentation.md Normal file
View File

@@ -0,0 +1,11 @@
# Dokumentation
Komplikationen:
- Netzwerkanbindung
- Ermitteln der Schnittstellen der Drucker
- Auswahl der Anbindung, Entwickeln eines Netzwerkkonzeptes
- Beschaffung der Hardware (beschränkte Auswahlmöglichkeiten)
- Welches Betriebssystem? OpenSuse, NixOS, Debian
- Frontend verstehen lernen
- Netzwerk einrichten, Frontend anbinden

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
<<<<<<< HEAD
# 📦 MYP
> Frontend: https://git.i.mercedes-benz.com/TBA-Berlin-FI/MYP/tree/main/packages/reservation-platform
> :warning: MYP ist zzt. in Entwicklung
MYP *(Manage your Printer)* ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde.
> ‼ Datenbank aus Blueprint steht im Konflikt zu MYP.sql - Integration muss besprochen werden
> 💡 Vorschlag wenn machbar @Torben: Aufteilung der Datenbanken? API ist ja nur für Drucker zuständig; WebInterface für Authentifizierung, Sessions etc.
> UltiMaker HTTP API: https://support.makerbot.com/s/article/1667412427787
---
# Datenbankstruktur
(MYP.sql - Gerät: Reservation Pi)
### Printer
- Speichert Informationen zu Druckern.
- Beinhaltet Details wie Namen, Beschreibung und Betriebsstatus.
- Verknüpft mit Druckaufträgen.
### PrintJob
- Enthält alle Druckaufträge.
- Jeder Auftrag ist einem Drucker und einem Benutzer zugeordnet.
- Speichert Startzeit, Dauer und Kommentare zu den Aufträgen.
- Erfassung, ob ein Auftrag abgebrochen wurde und die dazugehörige Begründung.
### Account
- Verwaltet Benutzerkonten.
- Speichert Authentifizierungsdetails wie Tokens und deren Ablaufzeiten.
### Session
- Erfasst Session-Daten.
- Beinhaltet eindeutige Session-Tokens und Ablaufdaten.
### User
- Speichert Benutzerinformationen.
- Verknüpft mit Druckaufträgen, Accounts und Sessions.
## Fremdschlüsselbeziehungen
- `User` ist verknüpft mit `PrintJob`, `Account` und `Session` über Benutzer-ID.
- `Printer` ist verknüpft mit `PrintJob` über die Drucker-ID.
=======
# Projektarbeit-MYP
>>>>>>> dfd63d7c9ddf4b3a654f06dff38bebdbec7395d7

View File

@@ -0,0 +1,106 @@
# 🖨️ 3D-Drucker Status API 📊
Willkommen beim Blueprint der 3D-Drucker Status API! Diese API ermöglicht es Ihnen, den Status mehrerer über LAN verbundener 3D-Drucker zu überwachen und Druckaufträge an sie zu senden.
## 🌟 Funktionen
- 🔍 Abrufen des Status von 3D-Druckern, einschließlich ihres aktuellen Status, Fortschrittes und Temperatur.
- 📥 Senden von Druckaufträgen an verfügbare 3D-Drucker.
- 💾 Speichern und Aktualisieren des Status jedes Druckers in einer SQLite-Datenbank.
## 🛠Verwendete Technologien
- 🐍 Python
- 🌶️ Flask
- 🗄️ SQLite
- 🌐 HTTP-Anfragen
## 📋 Verordnungen
Bevor Sie die API starten, stellen Sie sicher, dass Sie folgendes haben:
- Python 3.x installiert
- Flask und python-dotenv-Bibliotheken installiert (`pip install flask python-dotenv`)
- Eine Liste von IP-Adressen der 3D-Drucker, die Sie überwachen möchten
## 🚀 Erste Schritte
1. Klonen Sie das Repository:
```
git clone https://git.i.mercedes-benz.com/TBA-Berlin-FI/MYP
```
2. Installieren Sie die erforderlichen Abhängigkeiten:
```
pip install -r requirements.txt
```
3. Erstellen Sie eine `.env`-Datei im Projektverzeichnis und geben Sie die IP-Adressen Ihrer 3D-Drucker an:
```
PRINTER_IPS=192.168.0.10,192.168.0.11,192.168.0.12
```
4. Starten Sie das Skript, um die SQLite-Datenbank zu erstellen:
```
python create_db.py
```
5. Starten Sie den API-Server:
```
python app.py
```
6. Die API ist unter `http://localhost:5000` erreichbar.
## 📡 API-Endpunkte
- `GET /printer_status`: Rufen Sie den Status aller 3D-Drucker ab.
- `POST /print_job`: Senden Sie einen Druckauftrag an einen bestimmten 3D-Drucker.
## 📝 API-Nutzung
### Druckerstatus abrufen
Senden Sie eine `GET`-Anfrage an `/printer_status`, um den Status aller 3D-Drucker abzurufen.
Antwort:
```json
[
{
"ip": "192.168.0.10",
"status": "frei",
"progress": 0,
"temperature": 25
},
{
"ip": "192.168.0.11",
"status": "besetzt",
"progress": 50,
"temperature": 180
},
...
]
```
### Druckauftrag senden
Senden Sie eine `POST`-Anfrage an `/print_job` mit der folgenden JSON-Last, um einen Druckauftrag an einen bestimmten 3D-Drucker zu senden:
```json
{
"printer_ip": "192.168.0.10",
"file_url": "http://example.com/print_file.gcode"
}
```
Antwort:
```json
{
"message": "Druckauftrag gestartet"
}
```
## 📄 Lizenz
- --> Noch nicht verfügbar

View File

@@ -0,0 +1,25 @@
import sqlite3
from dotenv import load_dotenv
import os
load_dotenv()
printers = os.getenv('PRINTER_IPS').split(',')
def create_db():
conn = sqlite3.connect('printers.db')
c = conn.cursor()
# Tabelle 'printers' erstellen, falls sie nicht existiert
c.execute('''CREATE TABLE IF NOT EXISTS printers
(ip TEXT PRIMARY KEY, status TEXT)''')
# Drucker-IPs in die Tabelle einfügen, falls sie noch nicht vorhanden sind
for printer_ip in printers:
c.execute("INSERT OR IGNORE INTO printers (ip, status) VALUES (?, ?)", (printer_ip, "frei"))
conn.commit()
conn.close()
print("Datenbank 'printers.db' erfolgreich erstellt.")
if __name__ == '__main__':
create_db()

View File

@@ -0,0 +1,3 @@
flask==2.1.0
requests==2.25.1
python-dotenv==0.20.0

View File

@@ -0,0 +1,94 @@
from flask import Flask, jsonify, request
import requests
import sqlite3
from dotenv import load_dotenv
import os
load_dotenv()
printers = os.getenv('PRINTER_IPS').split(',')
app = Flask(__name__)
# SQLite-Datenbank initialisieren
def init_db():
conn = sqlite3.connect('printers.db')
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS printers
(ip TEXT PRIMARY KEY, status TEXT)''')
for printer_ip in printers:
c.execute("INSERT OR IGNORE INTO printers (ip, status) VALUES (?, ?)", (printer_ip, "frei"))
conn.commit()
conn.close()
@app.route('/printer_status', methods=['GET'])
def get_printer_status():
printer_status = []
conn = sqlite3.connect('printers.db')
c = conn.cursor()
for printer_ip in printers:
c.execute("SELECT status FROM printers WHERE ip = ?", (printer_ip,))
status = c.fetchone()[0]
try:
response = requests.get(f"http://{printer_ip}/api/printer/status")
if response.status_code == 200:
status_data = response.json()
printer_status.append({
"ip": printer_ip,
"status": status,
"progress": status_data["progress"],
"temperature": status_data["temperature"]
})
else:
printer_status.append({
"ip": printer_ip,
"status": "Fehler bei der Abfrage",
"progress": None,
"temperature": None
})
except:
printer_status.append({
"ip": printer_ip,
"status": "Drucker nicht erreichbar",
"progress": None,
"temperature": None
})
conn.close()
return jsonify(printer_status)
@app.route('/print_job', methods=['POST'])
def submit_print_job():
print_job = request.json
printer_ip = print_job["printer_ip"]
file_url = print_job["file_url"]
conn = sqlite3.connect('printers.db')
c = conn.cursor()
c.execute("SELECT status FROM printers WHERE ip = ?", (printer_ip,))
status = c.fetchone()[0]
if status == "frei":
try:
response = requests.post(f"http://{printer_ip}/api/print_job", json={"file_url": file_url})
if response.status_code == 200:
c.execute("UPDATE printers SET status = 'besetzt' WHERE ip = ?", (printer_ip,))
conn.commit()
conn.close()
return jsonify({"message": "Druckauftrag gestartet"}), 200
else:
conn.close()
return jsonify({"message": "Fehler beim Starten des Druckauftrags"}), 500
except:
conn.close()
return jsonify({"message": "Drucker nicht erreichbar"}), 500
else:
conn.close()
return jsonify({"message": "Drucker ist nicht frei"}), 400
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=5000)

View File

@@ -0,0 +1,38 @@
# entwendet aus:
# https://github.com/ut-hnl-lab/ultimakerpy
# auch zum lesen:
# https://github.com/MartinBienz/SDPremote?tab=readme-ov-file
import time
from ultimakerpy import UMS3, JobState
def print_started(state):
if state == JobState.PRINTING:
time.sleep(6.0)
return True
return False
def layer_reached(pos, n):
if round(pos / 0.2) >= n: # set layer pitch: 0.2 mm
return True
return False
printer = UMS3(name='MyPrinterName')
targets = {
'job_state': printer.job_state,
'bed_pos': printer.bed.position,
}
printer.print_from_dialog() # select file to print
printer.peripherals.camera_streaming()
with printer.data_logger('output2.csv', targets) as dl:
timer = dl.get_timer()
# sleep until active leveling finishes
timer.wait_for_datalog('job_state', print_started)
for n in range(1, 101):
# sleep until the printing of specified layer to start
timer.wait_for_datalog('bed_pos', lambda v: layer_reached(v, n))
print('printing layer:', n)

View File

View File

@@ -0,0 +1,148 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, session
import sqlite3
import bcrypt
app = Flask(__name__)
app.secret_key = 'supersecretkey'
# Database setup
def init_db():
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)''')
c.execute('''CREATE TABLE IF NOT EXISTS printers (id INTEGER PRIMARY KEY, name TEXT, status TEXT)''')
c.execute('''CREATE TABLE IF NOT EXISTS jobs (id INTEGER PRIMARY KEY, printer_id INTEGER, user TEXT, date TEXT, status TEXT)''')
conn.commit()
conn.close()
init_db()
# User registration (Admin setup)
def add_admin():
conn = sqlite3.connect('database.db')
c = conn.cursor()
hashed_pw = bcrypt.hashpw('adminpassword'.encode('utf-8'), bcrypt.gensalt())
c.execute("INSERT INTO users (username, password) VALUES (?, ?)", ('admin', hashed_pw))
conn.commit()
conn.close()
# Comment the next line after the first run
# add_admin()
# API Endpoints
@app.route('/api/printers/status', methods=['GET'])
def get_printer_status():
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("SELECT * FROM printers")
printers = c.fetchall()
conn.close()
return jsonify(printers)
@app.route('/api/printers/job', methods=['POST'])
def create_job():
if not session.get('logged_in'):
return jsonify({'error': 'Unauthorized'}), 403
data = request.json
user = session['username']
printer_id = data['printer_id']
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("SELECT status FROM printers WHERE id=?", (printer_id,))
status = c.fetchone()[0]
if status == 'frei':
c.execute("INSERT INTO jobs (printer_id, user, date, status) VALUES (?, ?, datetime('now'), 'in progress')",
(printer_id, user))
c.execute("UPDATE printers SET status='belegt' WHERE id=?", (printer_id,))
conn.commit()
elif status == 'belegt':
return jsonify({'error': 'Printer already in use'}), 409
else:
return jsonify({'error': 'Invalid printer status'}), 400
conn.close()
return jsonify({'message': 'Job created and printer turned on'}), 200
@app.route('/api/printers/reserve', methods=['POST'])
def reserve_printer():
if not session.get('logged_in'):
return jsonify({'error': 'Unauthorized'}), 403
data = request.json
printer_id = data['printer_id']
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("SELECT status FROM printers WHERE id=?", (printer_id,))
status = c.fetchone()[0]
if status == 'frei':
c.execute("UPDATE printers SET status='reserviert' WHERE id=?", (printer_id,))
conn.commit()
message = 'Printer reserved'
else:
message = 'Printer cannot be reserved'
conn.close()
return jsonify({'message': message}), 200
@app.route('/api/printers/release', methods=['POST'])
def release_printer():
if not session.get('logged_in'):
return jsonify({'error': 'Unauthorized'}), 403
data = request.json
printer_id = data['printer_id']
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("UPDATE printers SET status='frei' WHERE id=?", (printer_id,))
conn.commit()
conn.close()
return jsonify({'message': 'Printer released'}), 200
# Authentication routes
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password'].encode('utf-8')
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("SELECT * FROM users WHERE username=?", (username,))
user = c.fetchone()
conn.close()
if user and bcrypt.checkpw(password, user[2].encode('utf-8')):
session['logged_in'] = True
session['username'] = username
return redirect(url_for('dashboard'))
else:
return render_template('login.html', error='Invalid Credentials')
return render_template('login.html')
@app.route('/dashboard')
def dashboard():
if not session.get('logged_in'):
return redirect(url_for('login'))
conn = sqlite3.connect('database.db')
c = conn.cursor()
c.execute("SELECT * FROM printers")
printers = c.fetchall()
conn.close()
return render_template('dashboard.html', printers=printers)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Printer Management</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/daisyui@1.14.0/dist/full.css" rel="stylesheet">
</head>
<body class="bg-black text-white">
<nav class="bg-gray-800 p-4">
<div class="container mx-auto">
<h1 class="text-xl">3D Printer Management Dashboard</h1>
</div>
</nav>
<div class="container mx-auto mt-5">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<h2 class="text-2xl mb-4">Printer Status</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for printer in printers %}
<div class="card bg-gray-900 shadow-xl">
<div class="card-body">
<h2 class="card-title">{{ printer[1] }}</h2>
<p>Status: {{ printer[2] }}</p>
{% if printer[2] == 'frei' %}
<form method="POST" action="/api/printers/job">
<input type="hidden" name="printer_id" value="{{ printer[0] }}">
<button class="btn btn-success mt-4 w-full">Start Job</button>
</form>
{% elif printer[2] == 'belegt' %}
<button class="btn btn-warning mt-4 w-full" disabled>In Use</button>
{% elif printer[2] == 'reserviert' %}
<form method="POST" action="/api/printers/release">
<input type="hidden" name="printer_id" value="{{ printer[0] }}">
<button class="btn btn-info mt-4 w-full">Release</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<a href="/logout" class="btn btn-secondary mt-4">Logout</a>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-center items-center h-screen">
<div class="card w-96 bg-gray-900 shadow-xl">
<div class="card-body">
<h2 class="card-title">Login</h2>
<form method="POST">
<div class="form-control">
<label class="label">
<span class="label-text">Username</span>
</label>
<input type="text" name="username" class="input input-bordered w-full" required>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input type="password" name="password" class="input input-bordered w-full" required>
</div>
<div class="form-control mt-6">
<button class="btn btn-primary w-full">Login</button>
</div>
</form>
{% if error %}
<div class="mt-4 text-red-500">
{{ error }}
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
SECRET_KEY=change-me-to-a-real-secret-key
DATABASE_URL=sqlite:///app.db
JWT_SECRET=change-me-to-a-real-jwt-secret

View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Run database migrations
RUN mkdir -p /app/instance
ENV FLASK_APP=wsgi.py
# Expose port
EXPOSE 5000
# Run the application
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "wsgi:app"]

View File

@@ -0,0 +1,96 @@
# Reservation Platform Backend
This is the Flask backend for the 3D Printer Reservation Platform, providing a RESTful API for managing printers, reservations, and users.
## Features
- User authentication with email/password
- Role-based permission system (admin, user)
- Printer management
- Reservation system
- User management
## API Endpoints
### Authentication
- `POST /auth/register` - Register a new user
- `POST /auth/login` - Login with username/email and password
- `POST /auth/logout` - Log out a user by invalidating their session
### Printers
- `GET /api/printers` - Get all printers
- `GET /api/printers/<printer_id>` - Get a specific printer
- `POST /api/printers` - Create a new printer (admin only)
- `PUT /api/printers/<printer_id>` - Update a printer (admin only)
- `DELETE /api/printers/<printer_id>` - Delete a printer (admin only)
- `GET /api/printers/availability` - Get availability information for all printers
### Print Jobs
- `GET /api/jobs` - Get jobs for the current user or all jobs for admin
- `GET /api/jobs/<job_id>` - Get a specific job
- `POST /api/jobs` - Create a new print job (reserve a printer)
- `PUT /api/jobs/<job_id>` - Update a job
- `DELETE /api/jobs/<job_id>` - Delete a job (cancel reservation)
- `GET /api/jobs/<job_id>/remaining-time` - Get remaining time for a job (public endpoint)
### Users
- `GET /api/users` - Get all users (admin only)
- `GET /api/users/<user_id>` - Get a specific user (admin only)
- `PUT /api/users/<user_id>` - Update a user (admin only)
- `DELETE /api/users/<user_id>` - Delete a user (admin only)
- `GET /api/me` - Get the current user's profile
- `PUT /api/me` - Update the current user's profile
## Installation
### Prerequisites
- Python 3.11 or higher
- pip
### Setup
1. Clone the repository
```bash
git clone https://github.com/your-repo/reservation-platform.git
cd reservation-platform/packages/flask-backend
```
2. Install dependencies
```bash
pip install -r requirements.txt
```
3. Create a `.env` file with the following variables:
```
SECRET_KEY=your-secret-key
DATABASE_URL=sqlite:///app.db
JWT_SECRET=your-jwt-secret
```
4. Initialize the database
```bash
flask db upgrade
python scripts/init_db.py
```
5. Run the development server
```bash
python wsgi.py
```
## Docker Deployment
1. Build and run with Docker Compose
```bash
docker-compose up -d
```
## Development
### Running Migrations
To create a new migration after updating models:
```bash
flask db migrate -m "Description of changes"
flask db upgrade
```

View File

@@ -0,0 +1,32 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
from config import Config
db = SQLAlchemy()
migrate = Migrate()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
CORS(app)
# Register blueprints
from app.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix='/api')
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
@app.route('/health')
def health_check():
return {'status': 'ok'}
return app
from app import models

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('api', __name__)
from app.api import printers, jobs, users

View File

@@ -0,0 +1,219 @@
from flask import request, jsonify
from app import db
from app.api import bp
from app.models import PrintJob, Printer, User
from app.auth.routes import token_required, admin_required
from datetime import datetime, timedelta
@bp.route('/jobs', methods=['GET'])
@token_required
def get_jobs():
"""Get jobs for the current user or all jobs for admin"""
is_admin = request.user_role == 'admin'
user_id = request.user_id
# Parse query parameters
status = request.args.get('status') # active, upcoming, completed, aborted, all
printer_id = request.args.get('printer_id')
# Base query
query = PrintJob.query
# Filter by user unless admin
if not is_admin:
query = query.filter_by(user_id=user_id)
# Filter by printer if provided
if printer_id:
query = query.filter_by(printer_id=printer_id)
# Apply status filter
now = datetime.utcnow()
if status == 'active':
query = query.filter_by(aborted=False) \
.filter(PrintJob.start_at <= now) \
.filter(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) > now)
elif status == 'upcoming':
query = query.filter_by(aborted=False) \
.filter(PrintJob.start_at > now)
elif status == 'completed':
query = query.filter_by(aborted=False) \
.filter(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) <= now)
elif status == 'aborted':
query = query.filter_by(aborted=True)
# Order by start time, most recent first
query = query.order_by(PrintJob.start_at.desc())
# Execute query
jobs = query.all()
result = [job.to_dict() for job in jobs]
return jsonify(result)
@bp.route('/jobs/<job_id>', methods=['GET'])
@token_required
def get_job(job_id):
"""Get a specific job"""
job = PrintJob.query.get_or_404(job_id)
# Check permissions
is_admin = request.user_role == 'admin'
user_id = request.user_id
if not is_admin and job.user_id != user_id:
return jsonify({'error': 'Not authorized to view this job'}), 403
return jsonify(job.to_dict())
@bp.route('/jobs', methods=['POST'])
@token_required
def create_job():
"""Create a new print job (reserve a printer)"""
data = request.get_json() or {}
required_fields = ['printer_id', 'start_at', 'duration_in_minutes']
for field in required_fields:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Validate printer
printer = Printer.query.get(data['printer_id'])
if not printer:
return jsonify({'error': 'Printer not found'}), 404
if printer.status != 0: # Not operational
return jsonify({'error': 'Printer is not operational'}), 400
# Parse start time
try:
start_at = datetime.fromisoformat(data['start_at'].replace('Z', '+00:00'))
except ValueError:
return jsonify({'error': 'Invalid start_at format'}), 400
# Validate duration
try:
duration = int(data['duration_in_minutes'])
if duration <= 0 or duration > 480: # Max 8 hours
return jsonify({'error': 'Invalid duration (must be between 1 and 480 minutes)'}), 400
except ValueError:
return jsonify({'error': 'Duration must be a number'}), 400
end_at = start_at + timedelta(minutes=duration)
# Check if the printer is available during the requested time
conflicting_jobs = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(
(PrintJob.start_at < end_at) &
(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) > start_at)
) \
.all()
if conflicting_jobs:
return jsonify({'error': 'Printer is not available during the requested time'}), 409
# Create job
job = PrintJob(
printer_id=data['printer_id'],
user_id=request.user_id,
start_at=start_at,
duration_in_minutes=duration,
comments=data.get('comments', '')
)
db.session.add(job)
db.session.commit()
return jsonify(job.to_dict()), 201
@bp.route('/jobs/<job_id>', methods=['PUT'])
@token_required
def update_job(job_id):
"""Update a job"""
job = PrintJob.query.get_or_404(job_id)
# Check permissions
is_admin = request.user_role == 'admin'
user_id = request.user_id
if not is_admin and job.user_id != user_id:
return jsonify({'error': 'Not authorized to update this job'}), 403
data = request.get_json() or {}
# Only allow certain fields to be updated
if 'comments' in data:
job.comments = data['comments']
# Admin or owner can abort a job
if 'aborted' in data and data['aborted'] and not job.aborted:
job.aborted = True
job.abort_reason = data.get('abort_reason', '')
# Admin or owner can extend a job if it's active
now = datetime.utcnow()
is_active = (not job.aborted and
job.start_at <= now and
job.get_end_time() > now)
if 'extend_minutes' in data and is_active:
try:
extend_minutes = int(data['extend_minutes'])
if extend_minutes <= 0 or extend_minutes > 120: # Max extend 2 hours
return jsonify({'error': 'Invalid extension (must be between 1 and 120 minutes)'}), 400
new_end_time = job.get_end_time() + timedelta(minutes=extend_minutes)
# Check for conflicts with the extension
conflicting_jobs = PrintJob.query.filter_by(printer_id=job.printer_id, aborted=False) \
.filter(PrintJob.id != job.id) \
.filter(PrintJob.start_at < new_end_time) \
.filter(PrintJob.start_at > job.get_end_time()) \
.all()
if conflicting_jobs:
return jsonify({'error': 'Cannot extend job due to conflicts with other reservations'}), 409
job.duration_in_minutes += extend_minutes
except ValueError:
return jsonify({'error': 'Extend minutes must be a number'}), 400
db.session.commit()
return jsonify(job.to_dict())
@bp.route('/jobs/<job_id>', methods=['DELETE'])
@token_required
def delete_job(job_id):
"""Delete a job (cancel reservation)"""
job = PrintJob.query.get_or_404(job_id)
# Check permissions
is_admin = request.user_role == 'admin'
user_id = request.user_id
if not is_admin and job.user_id != user_id:
return jsonify({'error': 'Not authorized to delete this job'}), 403
# Only allow deletion of upcoming jobs
now = datetime.utcnow()
if job.start_at <= now and not is_admin:
return jsonify({'error': 'Cannot delete an active or completed job'}), 400
db.session.delete(job)
db.session.commit()
return jsonify({'message': 'Job deleted successfully'})
@bp.route('/jobs/<job_id>/remaining-time', methods=['GET'])
def get_remaining_time(job_id):
"""Get remaining time for a job (public endpoint)"""
job = PrintJob.query.get_or_404(job_id)
remaining_seconds = job.get_remaining_time()
return jsonify({
'job_id': job.id,
'remaining_seconds': remaining_seconds,
'is_active': job.is_active()
})

View File

@@ -0,0 +1,177 @@
from flask import request, jsonify
from app import db
from app.api import bp
from app.models import Printer, PrintJob
from app.auth.routes import token_required, admin_required
from datetime import datetime
@bp.route('/printers', methods=['GET'])
def get_printers():
"""Get all printers"""
printers = Printer.query.all()
result = []
for printer in printers:
# Get active job for the printer if any
now = datetime.utcnow()
active_job = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(PrintJob.start_at <= now) \
.filter(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) > now) \
.first()
printer_data = {
'id': printer.id,
'name': printer.name,
'description': printer.description,
'status': printer.status,
'is_available': printer.status == 0 and active_job is None,
'active_job': active_job.to_dict() if active_job else None
}
result.append(printer_data)
return jsonify(result)
@bp.route('/printers/<printer_id>', methods=['GET'])
def get_printer(printer_id):
"""Get a specific printer"""
printer = Printer.query.get_or_404(printer_id)
# Get active job for the printer if any
now = datetime.utcnow()
active_job = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(PrintJob.start_at <= now) \
.filter(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) > now) \
.first()
# Get upcoming jobs
upcoming_jobs = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(PrintJob.start_at > now) \
.order_by(PrintJob.start_at) \
.limit(5) \
.all()
result = {
'id': printer.id,
'name': printer.name,
'description': printer.description,
'status': printer.status,
'is_available': printer.status == 0 and active_job is None,
'active_job': active_job.to_dict() if active_job else None,
'upcoming_jobs': [job.to_dict() for job in upcoming_jobs]
}
return jsonify(result)
@bp.route('/printers', methods=['POST'])
@admin_required
def create_printer():
"""Create a new printer (admin only)"""
data = request.get_json() or {}
required_fields = ['name', 'description']
for field in required_fields:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
printer = Printer(
name=data['name'],
description=data['description'],
status=data.get('status', 0)
)
db.session.add(printer)
db.session.commit()
return jsonify({
'id': printer.id,
'name': printer.name,
'description': printer.description,
'status': printer.status
}), 201
@bp.route('/printers/<printer_id>', methods=['PUT'])
@admin_required
def update_printer(printer_id):
"""Update a printer (admin only)"""
printer = Printer.query.get_or_404(printer_id)
data = request.get_json() or {}
if 'name' in data:
printer.name = data['name']
if 'description' in data:
printer.description = data['description']
if 'status' in data:
printer.status = data['status']
db.session.commit()
return jsonify({
'id': printer.id,
'name': printer.name,
'description': printer.description,
'status': printer.status
})
@bp.route('/printers/<printer_id>', methods=['DELETE'])
@admin_required
def delete_printer(printer_id):
"""Delete a printer (admin only)"""
printer = Printer.query.get_or_404(printer_id)
# Check if the printer has active jobs
now = datetime.utcnow()
active_jobs = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(PrintJob.start_at <= now) \
.filter(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) > now) \
.all()
if active_jobs:
return jsonify({'error': 'Cannot delete printer with active jobs'}), 400
db.session.delete(printer)
db.session.commit()
return jsonify({'message': 'Printer deleted successfully'})
@bp.route('/printers/availability', methods=['GET'])
def get_availability():
"""Get availability information for all printers"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if not start_date or not end_date:
return jsonify({'error': 'start_date and end_date are required'}), 400
try:
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
except ValueError:
return jsonify({'error': 'Invalid date format'}), 400
if start >= end:
return jsonify({'error': 'start_date must be before end_date'}), 400
printers = Printer.query.all()
result = []
for printer in printers:
# Get all jobs for this printer in the date range
jobs = PrintJob.query.filter_by(printer_id=printer.id, aborted=False) \
.filter(
(PrintJob.start_at <= end) &
(PrintJob.start_at.op('+')(PrintJob.duration_in_minutes * 60) >= start)
) \
.order_by(PrintJob.start_at) \
.all()
# Convert to availability slots
availability = {
'printer_id': printer.id,
'printer_name': printer.name,
'status': printer.status,
'jobs': [job.to_dict() for job in jobs]
}
result.append(availability)
return jsonify(result)

View File

@@ -0,0 +1,139 @@
from flask import request, jsonify
from app import db
from app.api import bp
from app.models import User, PrintJob
from app.auth.routes import admin_required, token_required
@bp.route('/users', methods=['GET'])
@admin_required
def get_users():
"""Get all users (admin only)"""
users = User.query.all()
result = []
for user in users:
# Count jobs
total_jobs = PrintJob.query.filter_by(user_id=user.id).count()
active_jobs = PrintJob.query.filter_by(user_id=user.id, aborted=False).count()
user_data = {
'id': user.id,
'github_id': user.github_id,
'username': user.username,
'display_name': user.display_name,
'email': user.email,
'role': user.role,
'job_count': total_jobs,
'active_job_count': active_jobs
}
result.append(user_data)
return jsonify(result)
@bp.route('/users/<user_id>', methods=['GET'])
@admin_required
def get_user(user_id):
"""Get a specific user (admin only)"""
user = User.query.get_or_404(user_id)
# Count jobs
total_jobs = PrintJob.query.filter_by(user_id=user.id).count()
active_jobs = PrintJob.query.filter_by(user_id=user.id, aborted=False).count()
result = {
'id': user.id,
'github_id': user.github_id,
'username': user.username,
'display_name': user.display_name,
'email': user.email,
'role': user.role,
'job_count': total_jobs,
'active_job_count': active_jobs
}
return jsonify(result)
@bp.route('/users/<user_id>', methods=['PUT'])
@admin_required
def update_user(user_id):
"""Update a user (admin only)"""
user = User.query.get_or_404(user_id)
data = request.get_json() or {}
if 'role' in data and data['role'] in ['admin', 'user', 'guest']:
user.role = data['role']
if 'display_name' in data:
user.display_name = data['display_name']
db.session.commit()
return jsonify({
'id': user.id,
'github_id': user.github_id,
'username': user.username,
'display_name': user.display_name,
'email': user.email,
'role': user.role
})
@bp.route('/users/<user_id>', methods=['DELETE'])
@admin_required
def delete_user(user_id):
"""Delete a user (admin only)"""
user = User.query.get_or_404(user_id)
# Check if user has active jobs
active_jobs = PrintJob.query.filter_by(user_id=user.id, aborted=False).first()
if active_jobs:
return jsonify({'error': 'Cannot delete user with active jobs'}), 400
db.session.delete(user)
db.session.commit()
return jsonify({'message': 'User deleted successfully'})
@bp.route('/me', methods=['GET'])
@token_required
def get_current_user():
"""Get the current user's profile"""
user = User.query.get(request.user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
result = {
'id': user.id,
'github_id': user.github_id,
'username': user.username,
'display_name': user.display_name,
'email': user.email,
'role': user.role
}
return jsonify(result)
@bp.route('/me', methods=['PUT'])
@token_required
def update_current_user():
"""Update the current user's profile"""
user = User.query.get(request.user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json() or {}
if 'display_name' in data:
user.display_name = data['display_name']
db.session.commit()
result = {
'id': user.id,
'github_id': user.github_id,
'username': user.username,
'display_name': user.display_name,
'email': user.email,
'role': user.role
}
return jsonify(result)

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('auth', __name__)
from app.auth import routes

View File

@@ -0,0 +1,156 @@
from flask import request, jsonify, current_app
from app import db
from app.auth import bp
from app.models import User, Session
from datetime import datetime, timedelta
import time
import functools
import re
@bp.route('/register', methods=['POST'])
def register():
"""Register a new user"""
data = request.get_json() or {}
# Validate required fields
required_fields = ['username', 'email', 'password']
for field in required_fields:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Validate email format
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_regex, data['email']):
return jsonify({'error': 'Invalid email format'}), 400
# Validate password strength (at least 8 characters)
if len(data['password']) < 8:
return jsonify({'error': 'Password must be at least 8 characters long'}), 400
# Check if username already exists
if User.query.filter_by(username=data['username']).first():
return jsonify({'error': 'Username already exists'}), 400
# Check if email already exists
if User.query.filter_by(email=data['email']).first():
return jsonify({'error': 'Email already exists'}), 400
# Create new user
user = User(
username=data['username'],
email=data['email'],
display_name=data.get('display_name', data['username']),
role='user' # Default role
)
user.set_password(data['password'])
db.session.add(user)
db.session.commit()
return jsonify({
'id': user.id,
'username': user.username,
'email': user.email,
'display_name': user.display_name,
'role': user.role
}), 201
@bp.route('/login', methods=['POST'])
def login():
"""Login a user with username/email and password"""
data = request.get_json() or {}
# Validate required fields
if 'password' not in data:
return jsonify({'error': 'Password is required'}), 400
if 'username' not in data and 'email' not in data:
return jsonify({'error': 'Username or email is required'}), 400
# Find user by username or email
user = None
if 'username' in data:
user = User.query.filter_by(username=data['username']).first()
else:
user = User.query.filter_by(email=data['email']).first()
# Check if user exists and verify password
if not user or not user.check_password(data['password']):
return jsonify({'error': 'Invalid credentials'}), 401
# Create a session for the user
expires_at = int((datetime.utcnow() + timedelta(days=7)).timestamp())
session = Session(
user_id=user.id,
expires_at=expires_at
)
db.session.add(session)
db.session.commit()
# Generate JWT token
token = user.generate_token()
return jsonify({
'token': token,
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'display_name': user.display_name,
'role': user.role
}
})
@bp.route('/logout', methods=['POST'])
def logout():
"""Log out a user by invalidating their session"""
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Authorization header required'}), 401
token = auth_header.split(' ')[1]
payload = User.verify_token(token)
if not payload:
return jsonify({'error': 'Invalid token'}), 401
# Delete all sessions for this user
Session.query.filter_by(user_id=payload['user_id']).delete()
db.session.commit()
return jsonify({'message': 'Successfully logged out'})
def token_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({'error': 'Authorization header required'}), 401
token = auth_header.split(' ')[1]
payload = User.verify_token(token)
if not payload:
return jsonify({'error': 'Invalid token'}), 401
# Check if user has an active session
user_id = payload['user_id']
current_time = int(time.time())
session = Session.query.filter_by(user_id=user_id).filter(Session.expires_at > current_time).first()
if not session:
return jsonify({'error': 'No active session found'}), 401
# Add user to request context
request.user_id = user_id
request.user_role = payload['role']
return f(*args, **kwargs)
return decorated
def admin_required(f):
@functools.wraps(f)
@token_required
def decorated(*args, **kwargs):
if request.user_role != 'admin':
return jsonify({'error': 'Admin privileges required'}), 403
return f(*args, **kwargs)
return decorated

View File

@@ -0,0 +1,124 @@
from app import db
import uuid
from datetime import datetime, timedelta
import jwt
from config import Config
import bcrypt
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = db.Column(db.String(64), index=True, unique=True, nullable=False)
display_name = db.Column(db.String(120))
email = db.Column(db.String(120), index=True, unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
role = db.Column(db.String(20), default='user')
print_jobs = db.relationship('PrintJob', backref='user', lazy='dynamic', cascade='all, delete-orphan')
sessions = db.relationship('Session', backref='user', lazy='dynamic', cascade='all, delete-orphan')
def set_password(self, password):
"""Hash and set the user's password"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
self.password_hash = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
def check_password(self, password):
"""Check if the provided password matches the stored hash"""
password_bytes = password.encode('utf-8')
stored_hash = self.password_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, stored_hash)
def generate_token(self):
"""Generate a JWT token for this user"""
payload = {
'user_id': self.id,
'username': self.username,
'email': self.email,
'role': self.role,
'exp': datetime.utcnow() + timedelta(seconds=Config.JWT_ACCESS_TOKEN_EXPIRES)
}
return jwt.encode(payload, Config.JWT_SECRET, algorithm='HS256')
@staticmethod
def verify_token(token):
"""Verify and decode a JWT token"""
try:
payload = jwt.decode(token, Config.JWT_SECRET, algorithms=['HS256'])
return payload
except:
return None
class Session(db.Model):
__tablename__ = 'session'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = db.Column(db.String(36), db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
expires_at = db.Column(db.Integer, nullable=False)
class Printer(db.Model):
__tablename__ = 'printer'
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=False)
status = db.Column(db.Integer, nullable=False, default=0) # 0: OPERATIONAL, 1: OUT_OF_ORDER
print_jobs = db.relationship('PrintJob', backref='printer', lazy='dynamic', cascade='all, delete-orphan')
class PrintJob(db.Model):
__tablename__ = 'printJob'
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', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.String(36), db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False)
start_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
duration_in_minutes = db.Column(db.Integer, nullable=False)
comments = db.Column(db.Text)
aborted = db.Column(db.Boolean, nullable=False, default=False)
abort_reason = db.Column(db.Text)
def get_end_time(self):
return self.start_at + timedelta(minutes=self.duration_in_minutes)
def is_active(self):
now = datetime.utcnow()
return (not self.aborted and
self.start_at <= now and
now < self.get_end_time())
def get_remaining_time(self):
if self.aborted:
return 0
now = datetime.utcnow()
if now < self.start_at:
# Job hasn't started yet
return self.duration_in_minutes * 60
end_time = self.get_end_time()
if now >= end_time:
# Job has ended
return 0
# Job is ongoing
remaining_seconds = (end_time - now).total_seconds()
return int(remaining_seconds)
def to_dict(self):
return {
'id': self.id,
'printer_id': self.printer_id,
'user_id': self.user_id,
'start_at': self.start_at.isoformat(),
'duration_in_minutes': self.duration_in_minutes,
'comments': self.comments,
'aborted': self.aborted,
'abort_reason': self.abort_reason,
'remaining_time': self.get_remaining_time(),
'is_active': self.is_active()
}

View File

@@ -0,0 +1,13 @@
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET = os.environ.get('JWT_SECRET') or 'jwt-secret-key'
JWT_ACCESS_TOKEN_EXPIRES = 3600 # 1 hour in seconds

View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
flask-backend:
build:
context: .
dockerfile: Dockerfile
restart: always
ports:
- "5000:5000"
environment:
- SECRET_KEY=your-secret-key
- DATABASE_URL=sqlite:///app.db
- JWT_SECRET=your-jwt-secret
volumes:
- ./instance:/app/instance
command: >
bash -c "python -m flask db upgrade &&
python scripts/init_db.py &&
gunicorn --bind 0.0.0.0:5000 wsgi:app"

View File

@@ -0,0 +1,89 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[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
[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

View File

@@ -0,0 +1,91 @@
from __future__ import with_statement
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')
# 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',
str(current_app.extensions['migrate'].db.get_engine().url).replace(
'%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# 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 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=target_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.')
connectable = current_app.extensions['migrate'].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_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,75 @@
"""Initial migration
Revision ID: initial_migration
Revises:
Create Date: 2025-03-06 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'initial_migration'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table('user',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('display_name', sa.String(length=120), nullable=True),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('password_hash', sa.String(length=128), nullable=False),
sa.Column('role', sa.String(length=20), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
# Create session table
op.create_table('session',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('expires_at', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create printer table
op.create_table('printer',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('status', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# Create printJob table
op.create_table('printJob',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('printer_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('start_at', sa.DateTime(), nullable=False),
sa.Column('duration_in_minutes', sa.Integer(), nullable=False),
sa.Column('comments', sa.Text(), nullable=True),
sa.Column('aborted', sa.Boolean(), nullable=False),
sa.Column('abort_reason', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['printer_id'], ['printer.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('printJob')
op.drop_table('printer')
op.drop_table('session')
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

View File

@@ -0,0 +1,9 @@
Flask==2.3.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
Flask-CORS==4.0.0
python-dotenv==1.0.0
SQLAlchemy==2.0.25
pyjwt==2.8.0
bcrypt==4.1.2
gunicorn==21.2.0

View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Initialize virtual environment if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Install dependencies
echo "Installing dependencies..."
pip install -r requirements.txt
# Initialize database
echo "Initializing database..."
flask db upgrade
python scripts/init_db.py
# Run the application
echo "Starting Flask application..."
python wsgi.py

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python
from app import create_app, db
from app.models import User, Printer
import uuid
def init_db():
app = create_app()
with app.app_context():
# Create tables
db.create_all()
# Check if we already have an admin user
admin = User.query.filter_by(role='admin').first()
if not admin:
# Create admin user
admin = User(
id=str(uuid.uuid4()),
username='admin',
display_name='Administrator',
email='admin@example.com',
role='admin'
)
admin.set_password('admin123') # Default password, change in production!
db.session.add(admin)
print("Created admin user with username 'admin' and password 'admin123'")
# Check if we have any printers
printer_count = Printer.query.count()
if printer_count == 0:
# Create sample printers
printers = [
Printer(
name='Printer 1',
description='3D Printer for general use',
status=0 # OPERATIONAL
),
Printer(
name='Printer 2',
description='High resolution printer for detailed work',
status=0 # OPERATIONAL
),
Printer(
name='Printer 3',
description='Large format printer for big projects',
status=0 # OPERATIONAL
)
]
db.session.add_all(printers)
print("Created sample printers")
db.session.commit()
print("Database initialized successfully!")
if __name__ == '__main__':
init_db()

View File

@@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

48
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Flask
instance/
.webassets-cache
# Logs
logs/
*.log
# SQLite Datenbank-Dateien
*.db
*.db-journal
# Virtuelle Umgebungen
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Betriebssystem
.DS_Store
Thumbs.db

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p logs
ENV FLASK_APP=app.py
ENV PYTHONUNBUFFERED=1
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

1719
backend/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
# MYP Backend Cron-Jobs
# Installiere mit: crontab crontab-example
# Prüfe alle 5 Minuten auf abgelaufene Reservierungen und schalte Steckdosen aus
*/5 * * * * cd /pfad/zum/projektarbeit-myp/backend && /pfad/zur/venv/bin/flask check-jobs >> /pfad/zum/projektarbeit-myp/backend/logs/cron.log 2>&1
# Tägliche Sicherung der Datenbank um 3:00 Uhr
0 3 * * * cd /pfad/zum/projektarbeit-myp/backend && cp instance/myp.db instance/backups/myp-$(date +\%Y\%m\%d).db

View File

@@ -0,0 +1,84 @@
#!/bin/bash
# MYP Datenbank Initialisierungs-Skript
# Dieses Skript erstellt die erforderlichen Datenbanktabellen für das MYP Backend
echo "=== MYP Datenbank Initialisierung ==="
echo ""
# Prüfe, ob sqlite3 installiert ist
if ! command -v sqlite3 &> /dev/null; then
echo "FEHLER: sqlite3 ist nicht installiert."
echo "Bitte installiere sqlite3 mit deinem Paketmanager, z.B. 'apt install sqlite3'"
exit 1
fi
# Erstelle Instance-Ordner, falls nicht vorhanden
echo "Erstelle instance-Ordner, falls nicht vorhanden..."
mkdir -p instance/backups
# Prüfen, ob die Datenbank bereits existiert
if [ -f "instance/myp.db" ]; then
echo "Datenbank existiert bereits."
echo "Erstelle Backup in instance/backups..."
cp instance/myp.db "instance/backups/myp_$(date '+%Y%m%d_%H%M%S').db"
fi
# Erstelle die Datenbank und ihre Tabellen
echo "Erstelle neue Datenbank..."
sqlite3 instance/myp.db <<EOF
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT UNIQUE,
role TEXT DEFAULT 'user'
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS socket (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
status INTEGER DEFAULT 0,
ip_address TEXT
);
CREATE TABLE IF NOT EXISTS job (
id TEXT PRIMARY KEY,
socket_id TEXT NOT NULL,
user_id TEXT NOT NULL,
start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
duration_in_minutes INTEGER NOT NULL,
comments TEXT,
aborted INTEGER DEFAULT 0,
abort_reason TEXT,
FOREIGN KEY (socket_id) REFERENCES socket (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
EOF
# Setze Berechtigungen für die Datenbankdatei
chmod 644 instance/myp.db
echo ""
echo "=== Datenbank-Initialisierung abgeschlossen ==="
echo ""
echo "Du kannst jetzt einen Admin-Benutzer über die Web-Oberfläche registrieren."
echo "Der erste registrierte Benutzer wird automatisch zum Admin."
echo ""
echo "Starte den Server mit:"
echo "python app.py"
echo ""
echo "Alternativ kannst du einen Admin-Benutzer über die API erstellen mit:"
echo "curl -X POST http://localhost:5000/api/create-initial-admin -H \"Content-Type: application/json\" -d '{\"username\":\"admin\",\"password\":\"password\",\"displayName\":\"Administrator\"}'"
echo ""

View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Installation Script für MYP Backend
echo "=== MYP Backend Installation ==="
echo ""
# Prüfe Python-Version
python_version=$(python3 --version 2>&1 | awk '{print $2}')
echo "Python-Version: $python_version"
# Prüfe, ob die Python-Version mindestens 3.8 ist
required_version="3.8.0"
if [[ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]]; then
echo "FEHLER: Python $required_version oder höher wird benötigt"
exit 1
fi
# Prüfe, ob sqlite3 installiert ist
if ! command -v sqlite3 &> /dev/null; then
echo "FEHLER: sqlite3 ist nicht installiert."
echo "Bitte installiere sqlite3 mit deinem Paketmanager, z.B. 'apt install sqlite3'"
exit 1
fi
# Erstelle virtuelle Umgebung
echo ""
echo "Erstelle virtuelle Python-Umgebung..."
python3 -m venv venv
source venv/bin/activate
# Installiere Abhängigkeiten
echo ""
echo "Installiere Abhängigkeiten..."
pip install --upgrade pip
pip install -r requirements.txt
# Erstelle .env-Datei
echo ""
echo "Erstelle .env-Datei..."
if [ ! -f .env ]; then
cp .env.example .env
echo "Die .env-Datei wurde aus der Beispieldatei erstellt."
echo "Bitte passe die Konfiguration an, falls nötig."
else
echo ".env-Datei existiert bereits."
fi
# Erstelle Logs-Ordner
echo ""
echo "Erstelle logs-Ordner..."
mkdir -p logs
# Initialisiere die Datenbank
echo ""
echo "Initialisiere die Datenbank..."
bash initialize_myp_database.sh
echo ""
echo "=== Installation abgeschlossen ==="
echo ""
echo "Wichtige Schritte vor dem Start:"
echo "1. Passe die Konfigurationen in der .env-Datei an"
echo "2. Konfiguriere die Tapo-Steckdosen-Zugangsdaten in der .env-Datei (optional)"
echo "3. Passe die crontab-example an und installiere den Cron-Job (optional)"
echo ""
echo "Starte den Server mit:"
echo "source venv/bin/activate"
echo "python app.py"
echo ""
echo "Oder mit Gunicorn für Produktion:"
echo "gunicorn --bind 0.0.0.0:5000 app:app"
echo ""

View File

@@ -0,0 +1,95 @@
import requests
import json
# Basis-URL inkl. Token
url = "http://192.168.0.102:80/app?token=9DFAC92C53CEC92E67A9CB2E00B3CB2F"
# HTTP-Header wie in der Originalanfrage
headers = {
"Referer": "http://192.168.0.102:80",
"Accept": "application/json",
"requestByApp": "true",
"Content-Type": "application/json; charset=UTF-8",
"Host": "192.168.0.102",
"Connection": "Keep-Alive",
"Accept-Encoding": "gzip",
"User-Agent": "okhttp/3.14.9"
}
# Liste der Payloads (als Python-Dictionaries)
payloads = [
{
"method": "securePassthrough",
"params": {
"request": (
"ZC4CHp6bbfBO1rtmuH6I+TStBIiFRfQpayYPwet5NBmL35dib5xXHeEeLM7c0OSQSyxO6fnbXrC1\n"
"gXdfowwwq4Fum9ispgt8yT7cgbDcqnoVrhxEtHIDfuwLh8YAGmDSfTMo/JlsGspWPYMKd1EWXtb5\n"
"gP9FA9LHnV2kxKsNSPQ=\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"k111EbfCcfVzAouNbu1vyos9Ltsg+a97n4xUUQMviQVJfhqxvKOhv1SrvEk2LvpD0LwNVUNPZdwU\n"
"6pH5E/NOwdc1WzTPeqHiY760GpUuqn0tToHEHEyO2HaSKdrAYnw2gN410bvHb0pM3gYWS43eOA==\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"7/uYVDwyNfFhg9y7rHyp+4AGKBYQPyaBN6cFMl9j4ER/JpJTcGBdaUteSmx8P8Fkz+b2kkNLjYa2\n"
"wQr2gA3m6vEq9jpnAF2V3fv9c4Yg9gja9MlTIZqM6EdMi7YbfbhLme34Bh8kMcohDR3u1F4DwFDz\n"
"hNZPckf/CegbY9KGFeGwT4rWyX3BTk9+FE7ldtJn\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"EjWZb+YYS9tihgLdX4x+Wwx7q+e5X/ZHicr4jOnYmpFToDANzpm5ZpzD49BITcTCdQMOHlJBis85\n"
"9GX6Hv8j66OITyH0XmfG9dQo2tgIykyagCZIofr/BpAWYX4aRaOkU4z14mVa2XpDtHJQjc+pXYkh\n"
"JuWvLE+h01U5RoyPtvE=\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"OwyTsm5HdB/ReJMhVRrkjnV0NLTanw6iXOxVrDDexT456edWuwKiBOsZUyBHmUyJKgiPQzOXqyWWi220bX8IjLX4q8YNgPwRlj+7nRbfzpC/I57wBZBTWIt626pSdIH0vpiuPq84KMfPD5BB2p78/LjsqlzyeLGYzkSsGRBMT8TnLMDFzZE864nfDUZ9muH2kk8NRMN9l6xoCXBJqGA9q8XxIWRTpsl0kTx52kUszY69hYlfFSrrCDIls1ykul14/T1NtOVF8KOgiwaSGOZf7L4QlbhYvRj9kkVVkrxhlwt8jtMqfJKEqq+CIPh3Mp4440WYMLRo6VNIEJ3pWjplkJmc+htnYC4FwVgT7mHZ8eeGGKBvsJz+78gTaHnGBnwZ26I8UdFparyp6QXpOhK9zFmGVh0yapiTHo6jOOI+4Q3Ru+aPnidX/ZASPmR7CZO70CUpvv9zIKJnrAaoTMmH7A6+kcmCRLgLFaTaM+4DFmiz6JGP+4W7MmVPJxxvn0IFlo1P/xwNDuL3T6GLUIEVNk89JG5roBm7AdchUZJO38dGZ0eFiiTK/NhKPvjj+fk9A4FGh7EDshXZhL2u50cdLcdUtcP/CAMDjgWlMm4Kk3vxMQO+UGE+jsB7NkaulmTW1jcl+PSnAE5P71oqVVQ0ng==\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"7/uYVDwyNfFhg9y7rHyp+4AGKBYQPyaBN6cFMl9j4ER/JpJTcGBdaUteSmx8P8FkURmv/LWV1FpO\n"
"M3RWvsiC5UAsei2G+vwTVuQpOPjKKAx+qwftr9Qs2mSkPNjNLpWHK68EZkIw+h04TQkt0Q99Dirg\n"
"0BcrPgHTVKjiK8mdZ6w6gcld/h/FOKYMqJrP0Z+2\n"
)
}
},
{
"method": "securePassthrough",
"params": {
"request": (
"ZE/+XlUmTA9D3DFfp4x3xhS3vdsQ+60tz4TOodtZDby/4DPoqk9EBvJZ1JtUCr5c0AHuv/sfwcvN\n"
"Vx1zJP9RkltrAKVTWoaESAeewLozpXt/x0s/jkYC1rh7eTrxm+nYTZ5LJgNtcQq8yJxhEPez1w==\n"
)
}
}
]
# Sende die Payloads sequenziell per POST-Anfrage
for idx, payload in enumerate(payloads, start=1):
response = requests.post(url, headers=headers, data=json.dumps(payload))
print(f"Anfrage {idx}:")
print("Status Code:", response.status_code)
print("Response Text:", response.text)
print("-" * 60)

Binary file not shown.

View File

@@ -0,0 +1,128 @@
import requests
import json
# Constants from the Wireshark capture
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCMl89OZsjqE8yZ9TQhUb9h539WTX3U8Y5YCNdp
OhuXvLFYcAT5mvC074VFROmD0xhvw5hrwESOisqpPPU9r78JpLuYUKd+/aidvykqBT8OW5rDLb6d
O9FO6Gc+bV8L8ttHVlBFoX69EqiRhcreGPG6FQz4JqGJF4T1nFi0EvALXwIDAQAB
-----END PUBLIC KEY-----"""
# Vorbereitete verschlüsselte Befehle (aus Wireshark extrahiert)
COMMAND_ON = """ps0Puxc37EK4PhfcevceL3lyyDrjwLT1+443DDXNbcNRsltlgCQ6+oXgsrE2Pl5OhV73ZI/oM5Nj
37cWEaHpXPiHdr1W0cD3aJ5qJ55TfTRkHP9xcMNQJHCn6aWPEHpR7xvvXW9WbJWfShnE2Xdvmw==
"""
COMMAND_OFF = """FlO5i3DRcrUmu2ZwIIv8b68EisGu8VCuqfGOydaR+xCA0n3f2W/EcqVj8MurRBFXYTrZ/uwa1W26
ftCfvhdXNebBRwHr9Rj3id4bVfltJ8eT5/R3xY8kputklW2mrw9UfdISzAJqOPp9KZcU4K9p8g==
"""
class TapoP115Controller:
def __init__(self, device_ip):
self.device_ip = device_ip
self.session_id = None
self.token = None
def perform_handshake(self):
"""Führt den ersten Handshake durch und speichert die Session-ID"""
handshake_data = {
"method": "handshake",
"params": {
"key": PUBLIC_KEY
},
"requestTimeMils": 0
}
headers = {
"Referer": f"http://{self.device_ip}:80",
"Accept": "application/json",
"requestByApp": "true",
"Content-Type": "application/json; charset=UTF-8"
}
response = requests.post(
f"http://{self.device_ip}/app",
json=handshake_data,
headers=headers
)
if response.status_code == 200:
data = response.json()
if data["error_code"] == 0:
# Session-ID aus dem Cookie extrahieren
self.session_id = response.cookies.get("TP_SESSIONID")
print(f"Handshake erfolgreich, Session-ID: {self.session_id}")
# In einem echten Szenario würden wir hier den verschlüsselten Schlüssel entschlüsseln
# Da wir keinen privaten Schlüssel haben, speichern wir nur die Antwort
encrypted_key = data["result"]["key"]
print(f"Verschlüsselter Schlüssel: {encrypted_key}")
return True
print("Handshake fehlgeschlagen")
return False
def send_command(self, encrypted_command):
"""Sendet einen vorbereiteten verschlüsselten Befehl"""
if not self.session_id:
print("Keine Session-ID. Bitte zuerst Handshake durchführen.")
return None
# Token aus der Wireshark-Aufnahme (könnte sich ändern, oder vom Gerät abhängen)
token = "9DFAC92C53CEC92E67A9CB2E00B3CB2F"
secure_data = {
"method": "securePassthrough",
"params": {
"request": encrypted_command
}
}
headers = {
"Referer": f"http://{self.device_ip}:80",
"Accept": "application/json",
"requestByApp": "true",
"Content-Type": "application/json; charset=UTF-8",
"Cookie": f"TP_SESSIONID={self.session_id}"
}
response = requests.post(
f"http://{self.device_ip}/app?token={token}",
json=secure_data,
headers=headers
)
if response.status_code == 200:
data = response.json()
if data["error_code"] == 0:
# In einem echten Szenario würden wir die Antwort entschlüsseln
encrypted_response = data["result"]["response"]
print("Befehl erfolgreich gesendet")
return encrypted_response
print("Fehler beim Senden des Befehls")
return None
def turn_on(self):
"""Schaltet die Steckdose ein"""
return self.send_command(COMMAND_ON)
def turn_off(self):
"""Schaltet die Steckdose aus"""
return self.send_command(COMMAND_OFF)
# Verwendungsbeispiel
if __name__ == "__main__":
controller = TapoP115Controller("192.168.0.102")
# Handshake durchführen
if controller.perform_handshake():
# Steckdose einschalten
controller.turn_on()
# Kurze Pause (im echten Code mit time.sleep)
print("Steckdose ist eingeschaltet")
# Steckdose ausschalten
controller.turn_off()
print("Steckdose ist ausgeschaltet")

View File

@@ -0,0 +1,9 @@
from PyP100 import PyP100
p100 = PyP100.P100("192.168.0.102", "till.tomczak@mercedes-benz.com", "Agent045") #Creates a P100 plug object
p100.handshake() #Creates the cookies required for further methods
p100.login() #Sends credentials to the plug and creates AES Key and IV for further methods
p100.turnOn() #Turns the connected plug on
p100.turnOff() #Turns the connected plug off

View File

@@ -0,0 +1,253 @@
import unittest
import json
import os
import tempfile
from datetime import datetime, timedelta
from app import app, db, User, Printer, PrintJob
class MYPBackendTestCase(unittest.TestCase):
def setUp(self):
# Temporäre Datenbank für Tests
self.db_fd, app.config['DATABASE'] = tempfile.mkstemp()
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE']
app.config['TESTING'] = True
self.app = app.test_client()
# Datenbank-Tabellen erstellen und Test-Daten einfügen
with app.app_context():
db.create_all()
# Admin-Benutzer erstellen
admin = User(username='admin_test', email='admin@test.com', role='admin')
admin.set_password('admin')
db.session.add(admin)
# Normaler Benutzer erstellen
user = User(username='user_test', email='user@test.com', role='user')
user.set_password('user')
db.session.add(user)
# Drucker erstellen
printer1 = Printer(name='Printer 1', location='Room A', type='3D',
status='available', description='Test printer 1')
printer2 = Printer(name='Printer 2', location='Room B', type='3D',
status='busy', description='Test printer 2')
db.session.add(printer1)
db.session.add(printer2)
# Job erstellen
start_time = datetime.utcnow()
end_time = start_time + timedelta(minutes=60)
job = PrintJob(title='Test Job', start_time=start_time, end_time=end_time,
duration=60, status='active', comments='Test job',
user_id=2, printer_id=2)
db.session.add(job)
db.session.commit()
def tearDown(self):
# Aufräumen nach dem Test
os.close(self.db_fd)
os.unlink(app.config['DATABASE'])
def get_token(self, username, password):
response = self.app.post('/api/auth/login',
data=json.dumps({'username': username, 'password': password}),
content_type='application/json')
data = json.loads(response.data)
return data.get('token')
def test_login(self):
# Test: Erfolgreicher Login
response = self.app.post('/api/auth/login',
data=json.dumps({'username': 'admin_test', 'password': 'admin'}),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('token', data)
self.assertIn('user', data)
# Test: Fehlgeschlagener Login (falsches Passwort)
response = self.app.post('/api/auth/login',
data=json.dumps({'username': 'admin_test', 'password': 'wrong'}),
content_type='application/json')
self.assertEqual(response.status_code, 401)
def test_register(self):
# Test: Erfolgreiche Registrierung
response = self.app.post('/api/auth/register',
data=json.dumps({
'username': 'new_user',
'email': 'new@test.com',
'password': 'password'
}),
content_type='application/json')
self.assertEqual(response.status_code, 201)
# Test: Doppelte Registrierung
response = self.app.post('/api/auth/register',
data=json.dumps({
'username': 'new_user',
'email': 'another@test.com',
'password': 'password'
}),
content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_get_printers(self):
# Test: Drucker abrufen
response = self.app.get('/api/printers')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 2)
def test_get_single_printer(self):
# Test: Einzelnen Drucker abrufen
response = self.app.get('/api/printers/1')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['name'], 'Printer 1')
def test_create_printer(self):
# Als Admin einen Drucker erstellen
token = self.get_token('admin_test', 'admin')
response = self.app.post('/api/printers',
headers={'Authorization': f'Bearer {token}'},
data=json.dumps({
'name': 'New Printer',
'location': 'Room C',
'type': '3D',
'description': 'New test printer'
}),
content_type='application/json')
self.assertEqual(response.status_code, 201)
data = json.loads(response.data)
self.assertEqual(data['name'], 'New Printer')
def test_update_printer(self):
# Als Admin einen Drucker aktualisieren
token = self.get_token('admin_test', 'admin')
response = self.app.put('/api/printers/1',
headers={'Authorization': f'Bearer {token}'},
data=json.dumps({
'name': 'Updated Printer',
'location': 'Room D'
}),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['name'], 'Updated Printer')
self.assertEqual(data['location'], 'Room D')
def test_delete_printer(self):
# Als Admin einen Drucker löschen
token = self.get_token('admin_test', 'admin')
response = self.app.delete('/api/printers/1',
headers={'Authorization': f'Bearer {token}'})
self.assertEqual(response.status_code, 200)
# Überprüfen, ob der Drucker wirklich gelöscht wurde
response = self.app.get('/api/printers/1')
self.assertEqual(response.status_code, 404)
def test_get_jobs_as_admin(self):
# Als Admin alle Jobs abrufen
token = self.get_token('admin_test', 'admin')
response = self.app.get('/api/jobs',
headers={'Authorization': f'Bearer {token}'})
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 1)
def test_get_jobs_as_user(self):
# Als normaler Benutzer nur eigene Jobs abrufen
token = self.get_token('user_test', 'user')
response = self.app.get('/api/jobs',
headers={'Authorization': f'Bearer {token}'})
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 1) # Der Benutzer hat einen Job
def test_create_job(self):
# Als Benutzer einen Job erstellen
token = self.get_token('user_test', 'user')
response = self.app.post('/api/jobs',
headers={'Authorization': f'Bearer {token}'},
data=json.dumps({
'title': 'New Job',
'printer_id': 1,
'duration': 30,
'comments': 'Test job creation'
}),
content_type='application/json')
self.assertEqual(response.status_code, 201)
data = json.loads(response.data)
self.assertEqual(data['title'], 'New Job')
self.assertEqual(data['duration'], 30)
def test_update_job(self):
# Als Benutzer den eigenen Job aktualisieren
token = self.get_token('user_test', 'user')
response = self.app.put('/api/jobs/1',
headers={'Authorization': f'Bearer {token}'},
data=json.dumps({
'comments': 'Updated comments',
'duration': 15 # Verlängerung
}),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['comments'], 'Updated comments')
self.assertEqual(data['duration'], 75) # 60 + 15
def test_complete_job(self):
# Als Benutzer einen Job als abgeschlossen markieren
token = self.get_token('user_test', 'user')
response = self.app.put('/api/jobs/1',
headers={'Authorization': f'Bearer {token}'},
data=json.dumps({
'status': 'completed'
}),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['status'], 'completed')
# Überprüfen, ob der Drucker wieder verfügbar ist
response = self.app.get('/api/printers/2')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['status'], 'available')
def test_get_remaining_time(self):
# Test: Verbleibende Zeit für einen aktiven Job abrufen
response = self.app.get('/api/job/1/remaining-time')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('remaining_minutes', data)
# Der genaue Wert kann nicht überprüft werden, da er von der Zeit abhängt
def test_stats(self):
# Als Admin Statistiken abrufen
token = self.get_token('admin_test', 'admin')
response = self.app.get('/api/stats',
headers={'Authorization': f'Bearer {token}'})
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('printers', data)
self.assertIn('jobs', data)
self.assertIn('users', data)
self.assertEqual(data['printers']['total'], 2)
self.assertEqual(data['jobs']['total'], 1)
self.assertEqual(data['users']['total'], 2)
def test_test_endpoint(self):
# Test: API-Test-Endpunkt
response = self.app.get('/api/test')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data['message'], 'MYP Backend API funktioniert!')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,15 @@
version: '3'
services:
backend:
build: .
container_name: myp-backend
ports:
- "5000:5000"
environment:
- SECRET_KEY=change_me_in_production
- DATABASE_URL=sqlite:///myp.db
volumes:
- ./logs:/app/logs
- ./instance:/app/instance
restart: unless-stopped

647
backend/docs/API_DOCS.md Normal file
View File

@@ -0,0 +1,647 @@
# MYP Backend API-Dokumentation
Dieses Dokument beschreibt detailliert die API-Endpunkte des MYP (Manage Your Printer) Backend-Systems.
## Basis-URL
Die Basis-URL für alle API-Anfragen ist: `http://localhost:5000` (Entwicklungsumgebung) oder die URL, unter der die Anwendung gehostet wird.
## Authentifizierung
Die meisten Endpunkte erfordern eine Authentifizierung. Diese erfolgt über Cookies/Sessions, die bei der Anmeldung erstellt werden. Die Session wird für 7 Tage gespeichert.
### Benutzerregistrierung
**Endpunkt:** `POST /auth/register`
**Beschreibung:** Registriert einen neuen Benutzer im System.
**Request-Body:**
```json
{
"username": "string", // Erforderlich
"password": "string", // Erforderlich
"displayName": "string", // Optional (Standard: username)
"email": "string" // Optional
}
```
**Erfolgsantwort:**
```json
{
"message": "Registrierung erfolgreich!",
"user": {
"id": "string",
"username": "string",
"displayName": "string",
"email": "string",
"role": "string"
}
}
```
**Fehlerantwort:**
```json
{
"message": "Benutzername bereits vergeben!"
}
```
### Benutzeranmeldung
**Endpunkt:** `POST /auth/login`
**Beschreibung:** Meldet einen Benutzer an und erstellt eine Session.
**Request-Body:**
```json
{
"username": "string", // Erforderlich
"password": "string" // Erforderlich
}
```
**Erfolgsantwort:**
```json
{
"message": "Anmeldung erfolgreich!",
"user": {
"id": "string",
"username": "string",
"displayName": "string",
"email": "string",
"role": "string"
}
}
```
**Fehlerantwort:**
```json
{
"message": "Ungültiger Benutzername oder Passwort!"
}
```
### Initialer Administrator
**Endpunkt:** `POST /api/create-initial-admin`
**Beschreibung:** Erstellt einen initialen Admin-Benutzer, falls noch keiner existiert.
**Request-Body:**
```json
{
"username": "string", // Erforderlich
"password": "string", // Erforderlich
"displayName": "string", // Optional (Standard: username)
"email": "string" // Optional
}
```
**Erfolgsantwort:**
```json
{
"message": "Administrator wurde erfolgreich erstellt!",
"user": {
"id": "string",
"username": "string",
"displayName": "string",
"email": "string",
"role": "string"
}
}
```
**Fehlerantwort:**
```json
{
"message": "Es existiert bereits ein Administrator!"
}
```
## Benutzer-Endpunkte
### Alle Benutzer abrufen (Admin)
**Endpunkt:** `GET /api/users`
**Beschreibung:** Gibt eine Liste aller Benutzer zurück.
**Erforderliche Rechte:** Admin
**Erfolgsantwort:**
```json
[
{
"id": 1,
"username": "string",
"email": "string",
"role": "string"
}
]
```
### Benutzer abrufen (Admin)
**Endpunkt:** `GET /api/users/{userId}`
**Beschreibung:** Gibt die Details eines bestimmten Benutzers zurück.
**Erforderliche Rechte:** Admin
**Erfolgsantwort:**
```json
{
"id": 1,
"username": "string",
"email": "string",
"role": "string"
}
```
**Fehlerantwort:**
```json
{
"message": "Nicht gefunden!"
}
```
### Benutzer aktualisieren (Admin)
**Endpunkt:** `PUT /api/users/{userId}`
**Beschreibung:** Aktualisiert die Daten eines Benutzers.
**Erforderliche Rechte:** Admin
**Request-Body:**
```json
{
"username": "string",
"email": "string",
"password": "string",
"role": "string"
}
```
**Erfolgsantwort:**
```json
{
"id": 1,
"username": "string",
"email": "string",
"role": "string"
}
```
**Fehlerantwort:**
```json
{
"message": "Benutzername bereits vergeben!"
}
```
### Benutzer löschen (Admin)
**Endpunkt:** `DELETE /api/users/{userId}`
**Beschreibung:** Löscht einen Benutzer.
**Erforderliche Rechte:** Admin
**Erfolgsantwort:**
```json
{
"message": "Benutzer gelöscht!"
}
```
## Drucker-Endpunkte
### Alle Drucker abrufen
**Endpunkt:** `GET /api/printers`
**Beschreibung:** Gibt eine Liste aller Drucker (Steckdosen) zurück.
**Erfolgsantwort:**
```json
[
{
"id": "uuid-string",
"name": "string",
"description": "string",
"status": 0, // 0 = available, 1 = busy
"latestJob": {
// Job-Objekt oder null, wenn kein aktiver Job
}
}
]
```
### Drucker hinzufügen (Admin)
**Endpunkt:** `POST /api/printers`
**Beschreibung:** Fügt einen neuen Drucker hinzu.
**Erforderliche Rechte:** Admin
**Request-Body:**
```json
{
"name": "string",
"description": "string",
"ipAddress": "string" // IP-Adresse der Tapo-Steckdose
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"name": "string",
"description": "string",
"status": 0, // 0 = available, 1 = busy
"latestJob": null
}
```
### Drucker abrufen
**Endpunkt:** `GET /api/printers/{printerId}`
**Beschreibung:** Gibt die Details eines bestimmten Druckers zurück.
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"name": "string",
"description": "string",
"status": 0, // 0 = available, 1 = busy
"latestJob": {
// Job-Objekt oder null, wenn kein aktiver Job
}
}
```
**Fehlerantwort:**
```json
{
"message": "Nicht gefunden!"
}
```
### Drucker aktualisieren (Admin)
**Endpunkt:** `PUT /api/printers/{printerId}`
**Beschreibung:** Aktualisiert die Daten eines Druckers.
**Erforderliche Rechte:** Admin
**Request-Body:**
```json
{
"name": "string",
"description": "string",
"ipAddress": "string", // IP-Adresse der Tapo-Steckdose
"status": 0 // 0 = available, 1 = busy
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"name": "string",
"description": "string",
"status": 0, // 0 = available, 1 = busy
"latestJob": {
// Job-Objekt oder null, wenn kein aktiver Job
}
}
```
### Drucker löschen (Admin)
**Endpunkt:** `DELETE /api/printers/{printerId}`
**Beschreibung:** Löscht einen Drucker.
**Erforderliche Rechte:** Admin
**Erfolgsantwort:**
```json
{
"message": "Drucker gelöscht!"
}
```
## Druckauftrags-Endpunkte
### Alle Druckaufträge abrufen
**Endpunkt:** `GET /api/jobs`
**Beschreibung:** Gibt eine Liste aller Druckaufträge zurück (für Admins) oder der eigenen Druckaufträge (für Benutzer).
**Erfolgsantwort:**
```json
[
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 30
}
]
```
### Druckauftrag erstellen
**Endpunkt:** `POST /api/jobs`
**Beschreibung:** Erstellt einen neuen Druckauftrag.
**Request-Body:**
```json
{
"printerId": "uuid-string",
"durationInMinutes": 60,
"comments": "string"
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 60
}
```
**Fehlerantwort:**
```json
{
"message": "Drucker ist nicht verfügbar!"
}
```
### Druckauftrag abrufen
**Endpunkt:** `GET /api/jobs/{jobId}`
**Beschreibung:** Gibt die Details eines bestimmten Druckauftrags zurück.
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 30
}
```
**Fehlerantwort:**
```json
{
"message": "Nicht gefunden!"
}
```
### Druckauftrag Kommentare aktualisieren
**Endpunkt:** `PUT /api/jobs/{jobId}/comments`
**Beschreibung:** Aktualisiert die Kommentare eines Druckauftrags.
**Request-Body:**
```json
{
"comments": "string"
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 30
}
```
### Druckauftrag abbrechen
**Endpunkt:** `POST /api/jobs/{jobId}/abort`
**Beschreibung:** Bricht einen laufenden Druckauftrag ab.
**Request-Body:**
```json
{
"reason": "string" // Optional
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": true,
"abortReason": "string",
"remainingMinutes": 0
}
```
### Druckauftrag vorzeitig beenden
**Endpunkt:** `POST /api/jobs/{jobId}/finish`
**Beschreibung:** Beendet einen laufenden Druckauftrag vorzeitig.
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 45, // Tatsächliche Dauer bis zum Beenden
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 0
}
```
### Druckauftrag verlängern
**Endpunkt:** `POST /api/jobs/{jobId}/extend`
**Beschreibung:** Verlängert die Laufzeit eines Druckauftrags.
**Request-Body:**
```json
{
"minutes": 30, // Zusätzliche Minuten
"hours": 0 // Zusätzliche Stunden (optional)
}
```
**Erfolgsantwort:**
```json
{
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 90, // Aktualisierte Gesamtdauer
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 60
}
```
### Druckauftrag löschen
**Endpunkt:** `DELETE /api/jobs/{jobId}`
**Beschreibung:** Löscht einen Druckauftrag.
**Erfolgsantwort:**
```json
{
"message": "Druckauftrag gelöscht!"
}
```
### Verbleibende Zeit eines Druckauftrags abrufen
**Endpunkt:** `GET /api/job/{jobId}/remaining-time`
**Beschreibung:** Gibt die verbleibende Zeit eines aktiven Druckauftrags in Minuten zurück.
**Erfolgsantwort:**
```json
{
"remaining_minutes": 30,
"job_status": "active", // active, completed
"socket_status": "busy" // busy, available
}
```
### Status eines Druckauftrags abrufen
**Endpunkt:** `GET /api/job/{jobId}/status`
**Beschreibung:** Gibt detaillierte Statusinformationen zu einem Druckauftrag zurück.
**Erfolgsantwort:**
```json
{
"job": {
"id": "uuid-string",
"socketId": "uuid-string",
"userId": "uuid-string",
"startAt": "string (ISO 8601)",
"durationInMinutes": 60,
"comments": "string",
"aborted": false,
"abortReason": null,
"remainingMinutes": 30
},
"status": "active", // active, completed, aborted
"socketStatus": "busy", // busy, available
"remainingMinutes": 30
}
```
## Statistik-Endpunkte
### Systemstatistiken abrufen (Admin)
**Endpunkt:** `GET /api/stats`
**Beschreibung:** Gibt Statistiken zu Druckern, Aufträgen und Benutzern zurück.
**Erforderliche Rechte:** Admin
**Erfolgsantwort:**
```json
{
"printers": {
"total": 10,
"available": 5,
"utilization_rate": 0.5
},
"jobs": {
"total": 100,
"active": 5,
"completed": 90,
"avg_duration": 120
},
"users": {
"total": 50
}
}
```
## Test-Endpunkt
### API-Test
**Endpunkt:** `GET /api/test`
**Beschreibung:** Testet, ob die API funktioniert.
**Erfolgsantwort:**
```json
{
"message": "MYP Backend API funktioniert!"
}
```
## Fehlercodes
| Statuscode | Beschreibung |
|------------|-----------------------------|
| 200 | OK - Anfrage erfolgreich |
| 201 | Created - Ressource erstellt |
| 400 | Bad Request - Ungültige Anfrage |
| 401 | Unauthorized - Authentifizierung erforderlich |
| 403 | Forbidden - Unzureichende Rechte |
| 404 | Not Found - Ressource nicht gefunden |
| 500 | Internal Server Error - Serverfehler |

View File

@@ -0,0 +1,213 @@
# MYP - Projektdokumentation für das IHK-Abschlussprojekt
## Projektübersicht
**Projektname:** MYP (Manage Your Printer)
**Projekttyp:** IHK-Abschlussprojekt für Fachinformatiker für digitale Vernetzung
**Zeitraum:** [Dein Projektzeitraum]
**Team:** 2 Personen (Frontend- und Backend-Entwicklung)
## Projektziel
Das Ziel des Projektes ist die Entwicklung einer Reservierungs- und Steuerungsplattform für 3D-Drucker, die es Benutzern ermöglicht, Drucker zu reservieren und deren Stromversorgung automatisch über WLAN-Steckdosen (Tapo P115) zu steuern. Die Plattform soll eine einfache Verwaltung der Drucker und ihrer Auslastung bieten sowie den Stromverbrauch optimieren, indem Drucker nur während aktiver Reservierungen mit Strom versorgt werden.
## Aufgabenbeschreibung
Als Fachinformatiker für digitale Vernetzung besteht meine Aufgabe in der Entwicklung des Backend-Systems, das folgende Funktionen bereitstellt:
1. **API-Backend für das Frontend**: Entwicklung einer RESTful API, die mit dem Frontend kommuniziert und alle notwendigen Daten bereitstellt.
2. **Authentifizierungssystem**: Integration einer OAuth-Authentifizierung über GitHub, um Benutzer zu identifizieren und Zugriffskontrolle zu gewährleisten.
3. **Datenbankverwaltung**: Erstellung und Verwaltung der Datenbankmodelle für Benutzer, Drucker und Reservierungen.
4. **Steckdosensteuerung**: Implementierung einer Schnittstelle zu Tapo P115 WLAN-Steckdosen, um die Stromversorgung der Drucker basierend auf Reservierungen zu steuern.
5. **Automatisierung**: Entwicklung von Mechanismen zur automatischen Überwachung von Reservierungen und Steuerung der Steckdosen.
6. **Sicherheit**: Implementierung von Sicherheitsmaßnahmen zum Schutz der Anwendung und der Daten.
7. **Dokumentation**: Erstellung einer umfassenden Dokumentation für Entwicklung, Installation und Nutzung des Systems.
## Technische Umsetzung
### Backend (Mein Verantwortungsbereich)
#### Verwendete Technologien
- **Programmiersprache**: Python 3.11
- **Web-Framework**: Flask 2.3.3
- **Datenbank-ORM**: SQLAlchemy 3.1.1
- **Datenbank**: SQLite (für Entwicklung), erweiterbar auf PostgreSQL für Produktion
- **Authentifizierung**: Authlib für GitHub OAuth
- **Steckdosen-Steuerung**: Tapo Python Library
- **Container-Technologie**: Docker und Docker Compose
#### Architektur
Die Backend-Anwendung folgt einer klassischen dreischichtigen Architektur:
1. **Datenmodell-Schicht**: SQLAlchemy ORM-Modelle für Benutzer, Sessions, Drucker und Druckaufträge
2. **Business-Logic-Schicht**: Implementierung der Geschäftslogik für Reservierungsverwaltung und Steckdosensteuerung
3. **API-Schicht**: RESTful API-Endpunkte, die vom Frontend konsumiert werden
Zusätzlich wurden folgende Features implementiert:
- **OAuth-Authentifizierung**: Implementierung einer sicheren Authentifizierung über GitHub
- **Session-Management**: Server-seitige Session-Verwaltung für Benutzerauthentifizierung
- **Steckdosensteuerung**: Asynchrone Steuerung der Tapo P115 WLAN-Steckdosen
- **CLI-Befehle**: Flask CLI-Befehle für automatisierte Aufgaben wie die Überprüfung abgelaufener Reservierungen
#### Datenmodell
Das Datenmodell besteht aus vier Hauptentitäten:
1. **User**: Benutzer mit GitHub-Authentifizierung und Rollenverwaltung
2. **Session**: Sitzungsdaten für die Authentifizierung
3. **Printer**: Drucker mit Status und IP-Adresse der zugehörigen Steckdose
4. **PrintJob**: Reservierungen mit Start- und Endzeit, Dauer und Status
#### API-Endpunkte
Die API wurde speziell entwickelt, um nahtlos mit dem bestehenden Frontend zusammenzuarbeiten. Sie bietet Endpunkte für:
- Authentifizierung und Benutzerverwaltung
- Druckerverwaltung
- Reservierungsverwaltung (Erstellen, Abbrechen, Verlängern)
- Statusinformationen wie verbleibende Zeit
#### Steckdosensteuerung
Die Steuerung der Tapo P115 WLAN-Steckdosen erfolgt über die Tapo Python Library. Das System:
- Schaltet Steckdosen bei Erstellung einer Reservierung ein
- Schaltet Steckdosen bei Abbruch oder Beendigung einer Reservierung aus
- Überprüft regelmäßig abgelaufene Reservierungen und schaltet die entsprechenden Steckdosen aus
#### Automatisierung
Das System implementiert mehrere Automatisierungsmechanismen:
- **Automatische Steckdosensteuerung**: Ein- und Ausschalten der Steckdosen basierend auf Reservierungsstatus
- **Job-Überprüfung**: CLI-Befehl `flask check-jobs` zur regelmäßigen Überprüfung abgelaufener Reservierungen
- **Logging**: Automatische Protokollierung aller Aktionen zur Fehlerdiagnose
### Frontend (Verantwortungsbereich des Teampartners)
Das Frontend wurde von meinem Teampartner entwickelt und besteht aus:
- Next.js-Anwendung mit React-Komponenten
- Tailwind CSS für das Styling
- Serverless Functions für API-Integrationen
- Responsive Design für Desktop- und Mobile-Nutzung
## Projektergebnisse
Das Projekt hat erfolgreich eine funktionsfähige Reservierungs- und Steuerungsplattform für 3D-Drucker geschaffen, die es Benutzern ermöglicht:
1. Sich über GitHub zu authentifizieren
2. Verfügbare Drucker zu sehen und zu reservieren
3. Ihre Reservierungen zu verwalten (verlängern, abbrechen, kommentieren)
4. Als Administrator Drucker und Benutzer zu verwalten
Technische Errungenschaften:
1. Nahtlose Integration mit dem Frontend
2. Erfolgreiche Implementierung der Steckdosensteuerung
3. Sichere Authentifizierung über GitHub OAuth
4. Optimierte Stromnutzung durch automatische Steckdosensteuerung
## Herausforderungen und Lösungen
### Herausforderung 1: GitHub OAuth-Integration
Die Integration der GitHub-Authentifizierung, insbesondere mit GitHub Enterprise, erforderte eine sorgfältige Konfiguration der OAuth-Einstellungen und URL-Anpassungen.
**Lösung:** Implementierung mit Authlib und anpassbaren Konfigurationsoptionen für verschiedene GitHub-Instanzen.
### Herausforderung 2: Tapo P115 Steuerung
Die Kommunikation mit den Tapo P115 WLAN-Steckdosen erforderte eine zuverlässige und asynchrone Implementierung.
**Lösung:** Verwendung der Tapo Python Library mit asynchronem Handling und robusten Fehlerbehandlungsmechanismen.
### Herausforderung 3: Kompatibilität mit bestehendem Frontend
Das Backend musste mit dem bereits entwickelten Frontend kompatibel sein, was eine genaue Anpassung der API-Endpunkte und Datenstrukturen erforderte.
**Lösung:** Sorgfältige Analyse des Frontend-Codes, um die erwarteten API-Strukturen zu verstehen und das Backend entsprechend zu implementieren.
### Herausforderung 4: Automatische Steckdosensteuerung
Die zuverlässige Steuerung der Steckdosen bei abgelaufenen Reservierungen war eine Herausforderung.
**Lösung:** Implementierung eines CLI-Befehls, der regelmäßig durch Cron-Jobs ausgeführt werden kann, um abgelaufene Reservierungen zu überprüfen.
## Fachliche Reflexion
Das Projekt erforderte ein breites Spektrum an Fähigkeiten aus dem Bereich der digitalen Vernetzung:
1. **Netzwerkkommunikation**: Implementierung der Kommunikation zwischen Backend, Frontend und WLAN-Steckdosen über verschiedene Protokolle.
2. **Systemintegration**: Integration verschiedener Systeme (GitHub OAuth, Datenbank, Tapo-Steckdosen) zu einer kohärenten Anwendung.
3. **API-Design**: Entwicklung einer RESTful API, die den Anforderungen des Frontends entspricht und zukunftssicher ist.
4. **Datenbankentwurf**: Erstellung eines optimierten Datenbankschemas für die Anwendung.
5. **Sicherheitskonzepte**: Implementierung von Sicherheitsmaßnahmen wie OAuth, Session-Management und Zugriffskontrollen.
6. **Automatisierung**: Entwicklung von Automatisierungsprozessen für die Steckdosensteuerung und Job-Überwachung.
Diese Aspekte entsprechen direkt den Kernkompetenzen des Berufsbildes "Fachinformatiker für digitale Vernetzung" und zeigen die praktische Anwendung dieser Fähigkeiten in einem realen Projekt.
## Ausblick und Weiterentwicklung
Das System bietet verschiedene Möglichkeiten zur Weiterentwicklung:
1. **Erweiterung der Steckdosenunterstützung**: Integration weiterer Smart-Home-Geräte neben Tapo P115.
2. **Benachrichtigungssystem**: Implementierung von E-Mail- oder Push-Benachrichtigungen für Reservierungserinnerungen.
3. **Erweiterte Statistiken**: Detailliertere Nutzungsstatistiken und Visualisierungen für Administratoren.
4. **Mobile App**: Entwicklung einer nativen mobilen App für iOS und Android.
5. **Verbesserte Automatisierung**: Integration mit weiteren Systemen wie 3D-Drucker-APIs für direktes Monitoring des Druckstatus.
## Fazit
Das MYP-Projekt zeigt erfolgreich, wie moderne Webtechnologien und IoT-Geräte kombiniert werden können, um eine praktische Lösung für die Verwaltung von 3D-Druckern zu schaffen.
Als angehender Fachinformatiker für digitale Vernetzung konnte ich meine Fähigkeiten in den Bereichen Programmierung, Systemintegration, Netzwerkkommunikation und Automatisierung anwenden und erweitern.
Die Zusammenarbeit im Team mit klarer Aufgabenteilung (Frontend/Backend) hat zu einem erfolgreichen Projektergebnis geführt, das die gestellten Anforderungen erfüllt und einen praktischen Nutzen bietet.
---
## Anhang
### Installation und Einrichtung
Detaillierte Anweisungen zur Installation und Einrichtung des Backend-Systems finden sich in der README.md-Datei.
### Wichtige Konfigurationsparameter
Die folgenden Umgebungsvariablen müssen konfiguriert werden:
- `SECRET_KEY`: Geheimer Schlüssel für die Session-Verschlüsselung
- `DATABASE_URL`: URL zur Datenbank
- `OAUTH_CLIENT_ID`: GitHub OAuth Client ID
- `OAUTH_CLIENT_SECRET`: GitHub OAuth Client Secret
- `GITHUB_API_BASE_URL`, `GITHUB_AUTHORIZE_URL`, `GITHUB_TOKEN_URL`: URLs für GitHub OAuth
- `TAPO_USERNAME`: Benutzername für die Tapo-Steckdosen
- `TAPO_PASSWORD`: Passwort für die Tapo-Steckdosen
- `TAPO_DEVICES`: JSON-Objekt mit der Zuordnung von Drucker-IDs zu IP-Adressen
### Cron-Job-Einrichtung
Für die automatische Überprüfung abgelaufener Jobs kann folgender Cron-Job eingerichtet werden:
```
*/5 * * * * cd /pfad/zum/projekt && /pfad/zur/venv/bin/flask check-jobs >> /pfad/zum/projekt/logs/cron.log 2>&1
```

185
backend/docs/README.md Normal file
View File

@@ -0,0 +1,185 @@
# MYP Backend-Steuerungsplattform
Dies ist das Backend für das MYP (Manage Your Printer) Projekt, ein IHK-Abschlussprojekt für Fachinformatiker für digitale Vernetzung. Die Plattform ist mit Python und Flask implementiert und stellt eine RESTful API zur Verfügung, die es ermöglicht, 3D-Drucker zu verwalten, zu reservieren und über WLAN-Steckdosen (Tapo P115) zu steuern.
## Funktionen
- Lokales Authentifizierungssystem (Offline-fähig)
- Rollen-basierte Zugriffskontrolle (Admin/User/Guest)
- Druckerverwaltung (Hinzufügen, Bearbeiten, Löschen)
- Reservierungsverwaltung (Erstellen, Abbrechen, Verlängern)
- Fernsteuerung von WLAN-Steckdosen (Tapo P115)
- Statistikerfassung und -anzeige
- RESTful API für die Kommunikation mit dem Frontend
## Technologie-Stack
- **Python**: Programmiersprache
- **Flask**: Web-Framework
- **SQLite**: Integrierte Datenbank (kann für Produktion durch PostgreSQL ersetzt werden)
- **PyP100**: Python-Bibliothek zur Steuerung der Tapo P115 WLAN-Steckdosen
- **Gunicorn**: WSGI HTTP Server für die Produktionsumgebung
- **Docker**: Containerisierung der Anwendung
## Projekt-Struktur
- `app.py`: Hauptanwendungsdatei mit allen Routen und Modellen
- `requirements.txt`: Liste aller Python-Abhängigkeiten
- `Dockerfile`: Docker-Konfiguration
- `docker-compose.yml`: Docker Compose Konfiguration für einfaches Deployment
- `.env.example`: Beispiel für die Umgebungsvariablen
- `logs/`: Logdateien (automatisch erstellt)
- `instance/`: SQLite-Datenbank (automatisch erstellt)
## Installation und Ausführung
### Lokal (Entwicklung)
1. Python 3.8 oder höher installieren
2. Repository klonen
3. Ins Projektverzeichnis wechseln
4. Virtuelle Umgebung erstellen (optional, aber empfohlen)
```
python -m venv venv
source venv/bin/activate # Unter Windows: venv\Scripts\activate
```
5. Abhängigkeiten installieren
```
pip install -r requirements.txt
```
6. `.env.example` nach `.env` kopieren und anpassen
```
cp .env.example .env
```
7. Anwendung starten
```
python app.py
```
Die Anwendung ist dann unter http://localhost:5000 erreichbar.
### Mit Docker
1. Docker und Docker Compose installieren
2. Ins Projektverzeichnis wechseln
3. `.env.example` nach `.env` kopieren und anpassen
```
cp .env.example .env
```
4. Anwendung starten
```
docker-compose up -d
```
Die Anwendung ist dann unter http://localhost:5000 erreichbar.
## API-Endpunkte
### Authentifizierung
- `POST /auth/register`: Neuen Benutzer registrieren
- `POST /auth/login`: Benutzer anmelden
- `POST /auth/logout`: Abmelden und Session beenden
- `POST /api/create-initial-admin`: Initialen Administrator erstellen
- `GET /api/me`: Aktuelle Benutzerinformationen abrufen
### Benutzer
- `GET /api/users`: Liste aller Benutzer (Admin)
- `GET /api/users/<id>`: Details zu einem Benutzer (Admin)
- `PUT /api/users/<id>`: Benutzer aktualisieren (Admin)
- `DELETE /api/users/<id>`: Benutzer löschen (Admin)
### Drucker
- `GET /api/printers`: Liste aller Drucker
- `POST /api/printers`: Drucker hinzufügen (Admin)
- `GET /api/printers/<id>`: Details zu einem Drucker
- `PUT /api/printers/<id>`: Drucker aktualisieren (Admin)
- `DELETE /api/printers/<id>`: Drucker löschen (Admin)
### Druckaufträge
- `GET /api/jobs`: Liste aller Druckaufträge (Admin) oder eigener Druckaufträge (Benutzer)
- `POST /api/jobs`: Druckauftrag erstellen
- `GET /api/jobs/<id>`: Details zu einem Druckauftrag
- `POST /api/jobs/<id>/abort`: Druckauftrag abbrechen
- `POST /api/jobs/<id>/finish`: Druckauftrag vorzeitig beenden
- `POST /api/jobs/<id>/extend`: Druckauftrag verlängern
- `PUT /api/jobs/<id>/comments`: Kommentare aktualisieren
- `GET /api/job/<id>/remaining-time`: Verbleibende Zeit für einen aktiven Druckauftrag
### Statistiken
- `GET /api/stats`: Statistiken zu Druckern, Aufträgen und Benutzern (Admin)
## Datenmodell
### Benutzer (User)
- id (String UUID, Primary Key)
- username (String, Unique)
- password_hash (String)
- display_name (String)
- email (String, Unique)
- role (String, 'admin', 'user' oder 'guest')
### Session
- id (String UUID, Primary Key)
- user_id (String UUID, Foreign Key zu User)
- expires_at (DateTime)
### Drucker (Printer)
- id (String UUID, Primary Key)
- name (String)
- description (Text)
- status (Integer, 0=available, 1=busy, 2=maintenance)
- ip_address (String, IP-Adresse der Tapo-Steckdose)
### Druckauftrag (PrintJob)
- id (String UUID, Primary Key)
- printer_id (String UUID, Foreign Key zu Printer)
- user_id (String UUID, Foreign Key zu User)
- start_at (DateTime)
- duration_in_minutes (Integer)
- comments (Text)
- aborted (Boolean)
- abort_reason (Text)
## Steckdosensteuerung
Die Anwendung steuert Tapo P115 WLAN-Steckdosen, um die Drucker basierend auf Reservierungen ein- und auszuschalten:
- Bei Erstellung eines Druckauftrags wird die Steckdose des zugehörigen Druckers automatisch eingeschaltet
- Bei Abbruch oder vorzeitiger Beendigung eines Druckauftrags wird die Steckdose ausgeschaltet
- Nach Ablauf der Reservierungszeit wird die Steckdose automatisch ausgeschaltet
- Ein CLI-Befehl `flask check-jobs` überprüft regelmäßig abgelaufene Jobs und schaltet Steckdosen aus
## Sicherheit
- Die Anwendung verwendet ein lokales Authentifizierungssystem mit Passwort-Hashing
- Sitzungsdaten werden in Server-Side-Sessions gespeichert
- Zugriffskontrollen sind implementiert, um sicherzustellen, dass Benutzer nur auf ihre eigenen Daten zugreifen können
- Admin-Benutzer haben Zugriff auf alle Daten und können Systemkonfigurationen ändern
- Der erste registrierte Benutzer wird automatisch zum Administrator
## Logging
Die Anwendung protokolliert Aktivitäten in rotierenden Logdateien in einem `logs` Verzeichnis. Dies hilft bei der Fehlersuche und Überwachung der Anwendung im Betrieb.
## Umgebungsvariablen
Die folgenden Umgebungsvariablen müssen konfiguriert werden:
- `SECRET_KEY`: Geheimer Schlüssel für die Session-Verschlüsselung
- `DATABASE_PATH`: Pfad zur Datenbank (Standard: SQLite-Datenbank im Instance-Verzeichnis)
- `TAPO_USERNAME`: Benutzername für die Tapo-Steckdosen
- `TAPO_PASSWORD`: Passwort für die Tapo-Steckdosen
- `PRINTERS`: JSON-Objekt mit der Zuordnung von Drucker-Namen zu IP-Adressen der Steckdosen im Format: `{"Printer 1": {"ip": "192.168.1.100"}, "Printer 2": {"ip": "192.168.1.101"}, ...}`
## Automatisierung
Die Anwendung beinhaltet einen CLI-Befehl `flask check-jobs`, der regelmäßig ausgeführt werden sollte (z.B. als Cron-Job), um abgelaufene Druckaufträge zu überprüfen und die zugehörigen Steckdosen auszuschalten.
## Kompatibilität mit dem Frontend
Das Backend wurde speziell für die Kompatibilität mit dem bestehenden Frontend entwickelt, welches in `/packages/reservation-platform` zu finden ist. Die API-Endpunkte und Datenstrukturen sind so gestaltet, dass sie nahtlos mit dem Frontend zusammenarbeiten.

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
backend/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,42 @@
"""Add waiting_approval column to job table
Revision ID: add_waiting_approval
Revises: af3faaa3844c
Create Date: 2025-03-12 14:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_waiting_approval'
down_revision = 'af3faaa3844c'
branch_labels = None
depends_on = None
def upgrade():
# Füge die neue Spalte waiting_approval zur job-Tabelle hinzu
with op.batch_alter_table('job', schema=None) as batch_op:
batch_op.add_column(sa.Column('waiting_approval', sa.Integer(), server_default='0', nullable=False))
# SQLite-kompatible Migration für die print_job-Tabelle, falls diese existiert
try:
with op.batch_alter_table('print_job', schema=None) as batch_op:
batch_op.add_column(sa.Column('waiting_approval', sa.Boolean(), server_default='0', nullable=False))
except Exception as e:
print(f"Migration für print_job-Tabelle übersprungen: {e}")
def downgrade():
# Entferne die waiting_approval-Spalte aus der job-Tabelle
with op.batch_alter_table('job', schema=None) as batch_op:
batch_op.drop_column('waiting_approval')
# SQLite-kompatible Migration für die print_job-Tabelle, falls diese existiert
try:
with op.batch_alter_table('print_job', schema=None) as batch_op:
batch_op.drop_column('waiting_approval')
except Exception as e:
print(f"Downgrade für print_job-Tabelle übersprungen: {e}")

View File

@@ -0,0 +1,81 @@
"""empty message
Revision ID: af3faaa3844c
Revises:
Create Date: 2025-03-11 11:16:04.961964
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'af3faaa3844c'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('printer',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('status', sa.Integer(), nullable=True),
sa.Column('ip_address', sa.String(length=15), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('printer', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_printer_name'), ['name'], unique=False)
op.create_table('user',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('username', sa.String(length=64), nullable=True),
sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.Column('display_name', sa.String(length=100), nullable=True),
sa.Column('email', sa.String(length=120), nullable=True),
sa.Column('role', sa.String(length=20), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True)
batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True)
op.create_table('print_job',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('printer_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('start_at', sa.DateTime(), nullable=True),
sa.Column('duration_in_minutes', sa.Integer(), nullable=False),
sa.Column('comments', sa.Text(), nullable=True),
sa.Column('aborted', sa.Boolean(), nullable=True),
sa.Column('abort_reason', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['printer_id'], ['printer.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('session',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('session')
op.drop_table('print_job')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_username'))
batch_op.drop_index(batch_op.f('ix_user_email'))
op.drop_table('user')
with op.batch_alter_table('printer', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_printer_name'))
op.drop_table('printer')
# ### end Alembic commands ###

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
flask==2.3.3
flask-cors==4.0.0
pyjwt==2.8.0
python-dotenv==1.0.0
werkzeug==2.3.7
gunicorn==21.2.0
PyP100==0.0.19

Binary file not shown.

Binary file not shown.

Binary file not shown.

169
backend/templates/base.html Normal file
View File

@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MYP API Tester{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<style>
.sidebar {
min-height: calc(100vh - 56px);
background-color: #f8f9fa;
}
.api-response {
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
}
.nav-link.active {
background-color: #0d6efd;
color: white !important;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">MYP API Tester</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if active_page == 'home' %}active{% endif %}" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'printers' %}active{% endif %}" href="/admin/printers">Drucker</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'jobs' %}active{% endif %}" href="/admin/jobs">Druckaufträge</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'users' %}active{% endif %}" href="/admin/users">Benutzer</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'stats' %}active{% endif %}" href="/admin/stats">Statistiken</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
{% if current_user %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
{{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/logout">Abmelden</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="/login">Anmelden</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container-fluid py-3">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
function formatJson(jsonString) {
try {
const obj = JSON.parse(jsonString);
return JSON.stringify(obj, null, 2);
} catch (e) {
return jsonString;
}
}
document.addEventListener('DOMContentLoaded', function() {
// Format all response areas
document.querySelectorAll('.api-response').forEach(function(el) {
if (el.textContent) {
el.textContent = formatJson(el.textContent);
}
});
// Add event listener to show response areas
document.querySelectorAll('.api-form').forEach(function(form) {
form.addEventListener('submit', async function(e) {
e.preventDefault();
const url = this.getAttribute('data-url');
const method = this.getAttribute('data-method') || 'GET';
const responseArea = document.getElementById(this.getAttribute('data-response'));
const formData = new FormData(this);
const data = {};
formData.forEach((value, key) => {
if (value) {
try {
// Try to parse as JSON if it looks like JSON
if (value.trim().startsWith('{') || value.trim().startsWith('[')) {
data[key] = JSON.parse(value);
} else {
data[key] = value;
}
} catch (e) {
data[key] = value;
}
}
});
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
};
if (method !== 'GET' && method !== 'HEAD') {
options.body = JSON.stringify(data);
}
try {
responseArea.textContent = 'Sending request...';
const response = await fetch(url, options);
const responseText = await response.text();
try {
const formatted = formatJson(responseText);
responseArea.textContent = formatted;
} catch (e) {
responseArea.textContent = responseText;
}
if (this.hasAttribute('data-reload') && response.ok) {
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (err) {
responseArea.textContent = 'Error: ' + err.message;
}
});
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}Dashboard - MYP API Tester{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0">Willkommen, {{ current_user.display_name }}</h4>
</div>
<div class="card-body">
<p>Benutzerdetails:</p>
<ul>
<li><strong>ID:</strong> {{ current_user.id }}</li>
<li><strong>Benutzername:</strong> {{ current_user.username }}</li>
<li><strong>E-Mail:</strong> {{ current_user.email or "Nicht angegeben" }}</li>
<li><strong>Rolle:</strong> {{ current_user.role }}</li>
</ul>
<div class="mt-3">
<a href="/admin/printers" class="btn btn-primary me-2">Drucker verwalten</a>
<a href="/admin/jobs" class="btn btn-success me-2">Druckaufträge verwalten</a>
{% if current_user.role == 'admin' %}
<a href="/admin/users" class="btn btn-info me-2">Benutzer verwalten</a>
<a href="/admin/stats" class="btn btn-secondary">Statistiken</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Aktive Druckaufträge</h5>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/jobs" data-method="GET" data-response="jobsResponse">
<button type="submit" class="btn btn-primary">Aktualisieren</button>
</form>
<div id="activeJobsContainer">
<div class="alert alert-info">Lade Druckaufträge...</div>
</div>
<div class="d-none">
<h6>API-Antwort:</h6>
<pre class="api-response" id="jobsResponse"></pre>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Verfügbare Drucker</h5>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/printers" data-method="GET" data-response="printersResponse">
<button type="submit" class="btn btn-primary">Aktualisieren</button>
</form>
<div id="availablePrintersContainer">
<div class="alert alert-info">Lade Drucker...</div>
</div>
<div class="d-none">
<h6>API-Antwort:</h6>
<pre class="api-response" id="printersResponse"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- Job freischalten Modal -->
<div class="modal fade" id="approveJobModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Druckauftrag freischalten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie diesen Druckauftrag jetzt freischalten und starten?</p>
<p><strong>Hinweis:</strong> Der Drucker muss verfügbar sein, damit der Auftrag gestartet werden kann.</p>
<form id="approveJobForm" class="api-form" data-method="POST" data-response="approveJobResponse" data-reload="true">
<input type="hidden" id="approveJobId" name="jobId">
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="approveJobResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="approveJobForm" class="btn btn-success">Freischalten</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Aufträge und Drucker laden
document.querySelector('form[data-url="/api/jobs"]').dispatchEvent(new Event('submit'));
document.querySelector('form[data-url="/api/printers"]').dispatchEvent(new Event('submit'));
// Tabellen aktualisieren, wenn Daten geladen werden
const jobsResponse = document.getElementById('jobsResponse');
const printersResponse = document.getElementById('printersResponse');
// Observer für Jobs
const jobsObserver = new MutationObserver(function(mutations) {
try {
const jobs = JSON.parse(jobsResponse.textContent);
updateActiveJobs(jobs);
} catch (e) {
console.error('Fehler beim Parsen der Auftrags-Daten:', e);
}
});
jobsObserver.observe(jobsResponse, { childList: true, characterData: true, subtree: true });
// Observer für Drucker
const printersObserver = new MutationObserver(function(mutations) {
try {
const printers = JSON.parse(printersResponse.textContent);
updateAvailablePrinters(printers);
} catch (e) {
console.error('Fehler beim Parsen der Drucker-Daten:', e);
}
});
printersObserver.observe(printersResponse, { childList: true, characterData: true, subtree: true });
// Approve-Modal vorbereiten
document.getElementById('approveJobModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
document.getElementById('approveJobId').value = jobId;
document.getElementById('approveJobForm').setAttribute('data-url', `/api/jobs/${jobId}/approve`);
});
// Automatische Aktualisierung alle 60 Sekunden
setInterval(() => {
document.querySelector('form[data-url="/api/jobs"]').dispatchEvent(new Event('submit'));
document.querySelector('form[data-url="/api/printers"]').dispatchEvent(new Event('submit'));
}, 60000);
});
function updateActiveJobs(jobs) {
const container = document.getElementById('activeJobsContainer');
// Filter für aktive und wartende Jobs
const activeJobs = jobs.filter(job => !job.aborted && job.remainingMinutes > 0 && !job.waitingApproval);
const waitingJobs = jobs.filter(job => !job.aborted && job.waitingApproval);
if (activeJobs.length === 0 && waitingJobs.length === 0) {
container.innerHTML = '<div class="alert alert-info">Keine aktiven Druckaufträge vorhanden.</div>';
return;
}
let html = '';
// Aktive Jobs anzeigen
if (activeJobs.length > 0) {
html += '<h6 class="mt-3">Laufende Aufträge</h6>';
html += '<div class="list-group mb-3">';
activeJobs.forEach(job => {
// Prozentsatz der abgelaufenen Zeit berechnen
const totalDuration = job.durationInMinutes;
const elapsed = totalDuration - job.remainingMinutes;
const percentage = Math.round((elapsed / totalDuration) * 100);
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between">
<div>
<strong>Job ${job.id.substring(0, 8)}...</strong> (${job.durationInMinutes} Min)
<div class="small text-muted">Verbleibend: ${job.remainingMinutes} Min</div>
</div>
<div>
<span class="badge bg-warning">Aktiv</span>
</div>
</div>
<div class="progress mt-2" style="height: 10px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: ${percentage}%;"
aria-valuenow="${percentage}"
aria-valuemin="0"
aria-valuemax="100">
${percentage}%
</div>
</div>
</div>
`;
});
html += '</div>';
}
// Wartende Jobs anzeigen
if (waitingJobs.length > 0) {
html += '<h6 class="mt-3">Wartende Aufträge</h6>';
html += '<div class="list-group">';
waitingJobs.forEach(job => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between">
<div>
<strong>Job ${job.id.substring(0, 8)}...</strong> (${job.durationInMinutes} Min)
<div class="small text-muted">Drucker: ${job.socketId.substring(0, 8)}...</div>
</div>
<div>
<span class="badge bg-info">Wartet</span>
</div>
</div>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-success"
data-bs-toggle="modal"
data-bs-target="#approveJobModal"
data-job-id="${job.id}">
Freischalten
</button>
</div>
</div>
`;
});
html += '</div>';
}
container.innerHTML = html;
}
function updateAvailablePrinters(printers) {
const container = document.getElementById('availablePrintersContainer');
// Filter für verfügbare Drucker
const availablePrinters = printers.filter(printer => printer.status === 0);
if (availablePrinters.length === 0) {
container.innerHTML = '<div class="alert alert-warning">Keine verfügbaren Drucker gefunden.</div>';
return;
}
let html = '<div class="list-group">';
availablePrinters.forEach(printer => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${printer.name}</strong>
<div class="small text-muted">${printer.description}</div>
</div>
<div>
<span class="badge bg-success">Verfügbar</span>
</div>
</div>
<div class="mt-2">
<a href="/admin/jobs" class="btn btn-sm btn-primary">Auftrag erstellen</a>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
// Gesamtstatistik hinzufügen
const busyPrinters = printers.filter(printer => printer.status === 1).length;
const totalPrinters = printers.length;
if (totalPrinters > 0) {
const statsHtml = `
<div class="mt-3">
<div class="d-flex justify-content-between">
<small>Verfügbar: ${availablePrinters.length} / ${totalPrinters}</small>
<small>Belegt: ${busyPrinters} / ${totalPrinters}</small>
</div>
<div class="progress mt-1" style="height: 5px;">
<div class="progress-bar bg-success" style="width: ${(availablePrinters.length / totalPrinters) * 100}%"></div>
<div class="progress-bar bg-warning" style="width: ${(busyPrinters / totalPrinters) * 100}%"></div>
</div>
</div>
`;
container.innerHTML += statsHtml;
}
}
</script>
{% endblock %}

443
backend/templates/jobs.html Normal file
View File

@@ -0,0 +1,443 @@
{% extends "base.html" %}
{% block title %}Druckaufträge - MYP API Tester{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Druckaufträge verwalten</h4>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#newJobForm">
Neuen Auftrag erstellen
</button>
</div>
<div class="collapse" id="newJobForm">
<div class="card-body border-bottom">
<form class="api-form" data-url="/api/jobs" data-method="POST" data-response="createJobResponse" data-reload="true">
<div class="mb-3">
<label for="jobPrinterId" class="form-label">Drucker</label>
<select class="form-control" id="jobPrinterId" name="printerId" required>
<option value="">Drucker auswählen...</option>
<!-- Wird dynamisch gefüllt -->
</select>
</div>
<div class="mb-3">
<label for="jobDuration" class="form-label">Dauer (Minuten)</label>
<input type="number" class="form-control" id="jobDuration" name="durationInMinutes" min="1" required>
</div>
<div class="mb-3">
<label for="jobComments" class="form-label">Kommentare</label>
<textarea class="form-control" id="jobComments" name="comments" rows="3"></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="allowQueuedJobs" name="allowQueuedJobs" value="true">
<label class="form-check-label" for="allowQueuedJobs">
Auftrag in Warteschlange erlauben (wenn Drucker belegt ist)
</label>
</div>
<button type="submit" class="btn btn-success">Auftrag erstellen</button>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="createJobResponse"></pre>
</div>
</div>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/jobs" data-method="GET" data-response="jobsResponse">
<button type="submit" class="btn btn-primary">Aufträge aktualisieren</button>
</form>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Drucker</th>
<th>Benutzer</th>
<th>Start</th>
<th>Dauer (Min)</th>
<th>Verbleibend (Min)</th>
<th>Status</th>
<th>Kommentare</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<!-- Wird dynamisch gefüllt -->
</tbody>
</table>
</div>
<div>
<h6>API-Antwort:</h6>
<pre class="api-response" id="jobsResponse"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- Job abbrechen Modal -->
<div class="modal fade" id="abortJobModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Auftrag abbrechen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie den Auftrag wirklich abbrechen?</p>
<form id="abortJobForm" class="api-form" data-method="POST" data-response="abortJobResponse" data-reload="true">
<input type="hidden" id="abortJobId" name="jobId">
<div class="mb-3">
<label for="abortReason" class="form-label">Abbruchgrund</label>
<textarea class="form-control" id="abortReason" name="reason" rows="3"></textarea>
</div>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="abortJobResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="abortJobForm" class="btn btn-danger">Auftrag abbrechen</button>
</div>
</div>
</div>
</div>
<!-- Job beenden Modal -->
<div class="modal fade" id="finishJobModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Auftrag beenden</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie den Auftrag als beendet markieren?</p>
<form id="finishJobForm" class="api-form" data-method="POST" data-response="finishJobResponse" data-reload="true">
<input type="hidden" id="finishJobId" name="jobId">
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="finishJobResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="finishJobForm" class="btn btn-success">Auftrag beenden</button>
</div>
</div>
</div>
</div>
<!-- Job verlängern Modal -->
<div class="modal fade" id="extendJobModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Auftrag verlängern</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="extendJobForm" class="api-form" data-method="POST" data-response="extendJobResponse" data-reload="true">
<input type="hidden" id="extendJobId" name="jobId">
<div class="mb-3">
<label for="extendHours" class="form-label">Stunden</label>
<input type="number" class="form-control" id="extendHours" name="hours" min="0" value="0">
</div>
<div class="mb-3">
<label for="extendMinutes" class="form-label">Minuten</label>
<input type="number" class="form-control" id="extendMinutes" name="minutes" min="0" max="59" value="30">
</div>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="extendJobResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="extendJobForm" class="btn btn-primary">Auftrag verlängern</button>
</div>
</div>
</div>
</div>
<!-- Job Kommentare bearbeiten Modal -->
<div class="modal fade" id="editCommentsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Kommentare bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editCommentsForm" class="api-form" data-method="PUT" data-response="editCommentsResponse" data-reload="true">
<input type="hidden" id="editCommentsJobId" name="jobId">
<div class="mb-3">
<label for="editJobComments" class="form-label">Kommentare</label>
<textarea class="form-control" id="editJobComments" name="comments" rows="3"></textarea>
</div>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="editCommentsResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="editCommentsForm" class="btn btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Job freischalten Modal -->
<div class="modal fade" id="approveJobModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Druckauftrag freischalten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie diesen Druckauftrag jetzt freischalten und starten?</p>
<p><strong>Hinweis:</strong> Der Drucker muss verfügbar sein, damit der Auftrag gestartet werden kann.</p>
<form id="approveJobForm" class="api-form" data-method="POST" data-response="approveJobResponse" data-reload="true">
<input type="hidden" id="approveJobId" name="jobId">
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="approveJobResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="approveJobForm" class="btn btn-success">Freischalten</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Drucker für Dropdown laden
loadPrinters();
// Aufträge laden
document.querySelector('form[data-url="/api/jobs"]').dispatchEvent(new Event('submit'));
// Tabelle aktualisieren, wenn Aufträge geladen werden
const jobsResponse = document.getElementById('jobsResponse');
const observer = new MutationObserver(function(mutations) {
try {
const jobs = JSON.parse(jobsResponse.textContent);
updateJobsTable(jobs);
} catch (e) {
console.error('Fehler beim Parsen der Auftrags-Daten:', e);
}
});
observer.observe(jobsResponse, { childList: true, characterData: true, subtree: true });
// Abort-Modal vorbereiten
document.getElementById('abortJobModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
document.getElementById('abortJobId').value = jobId;
document.getElementById('abortJobForm').setAttribute('data-url', `/api/jobs/${jobId}/abort`);
});
// Finish-Modal vorbereiten
document.getElementById('finishJobModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
document.getElementById('finishJobId').value = jobId;
document.getElementById('finishJobForm').setAttribute('data-url', `/api/jobs/${jobId}/finish`);
});
// Extend-Modal vorbereiten
document.getElementById('extendJobModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
document.getElementById('extendJobId').value = jobId;
document.getElementById('extendJobForm').setAttribute('data-url', `/api/jobs/${jobId}/extend`);
});
// Edit-Comments-Modal vorbereiten
document.getElementById('editCommentsModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
const comments = button.getAttribute('data-job-comments');
document.getElementById('editCommentsJobId').value = jobId;
document.getElementById('editCommentsForm').setAttribute('data-url', `/api/jobs/${jobId}/comments`);
document.getElementById('editJobComments').value = comments || '';
});
// Approve-Modal vorbereiten
document.getElementById('approveJobModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const jobId = button.getAttribute('data-job-id');
document.getElementById('approveJobId').value = jobId;
document.getElementById('approveJobForm').setAttribute('data-url', `/api/jobs/${jobId}/approve`);
});
});
async function loadPrinters() {
try {
const response = await fetch('/api/printers');
const printers = await response.json();
const selectElement = document.getElementById('jobPrinterId');
selectElement.innerHTML = '<option value="">Drucker auswählen...</option>';
// Drucker anzeigen (alle, da man jetzt auch für belegte Drucker Jobs erstellen kann)
printers.forEach(printer => {
const option = document.createElement('option');
option.value = printer.id;
// Status-Information zum Drucker hinzufügen
const statusText = printer.status === 0 ? '(Verfügbar)' : '(Belegt)';
option.textContent = `${printer.name} - ${printer.description} ${statusText}`;
// Belegte Drucker visuell unterscheiden
if (printer.status !== 0) {
option.classList.add('text-muted');
}
selectElement.appendChild(option);
});
// Hinweis auf die Checkbox für Warteschlange anzeigen oder verstecken
const allowQueuedJobsCheckbox = document.getElementById('allowQueuedJobs');
const queueCheckboxContainer = allowQueuedJobsCheckbox.closest('.form-check');
// Prüfen, ob es belegte Drucker gibt
const hasBusyPrinters = printers.some(printer => printer.status !== 0);
queueCheckboxContainer.style.display = hasBusyPrinters ? 'block' : 'none';
// Event-Listener für die Druckerauswahl hinzufügen
selectElement.addEventListener('change', function() {
const selectedPrinterId = this.value;
const selectedPrinter = printers.find(p => p.id === selectedPrinterId);
if (selectedPrinter && selectedPrinter.status !== 0) {
// Wenn ein belegter Drucker ausgewählt wird, Checkbox für Warteschlange anzeigen
queueCheckboxContainer.style.display = 'block';
allowQueuedJobsCheckbox.checked = true;
} else if (selectedPrinter && selectedPrinter.status === 0) {
// Wenn ein verfügbarer Drucker ausgewählt wird, Checkbox für Warteschlange verstecken
allowQueuedJobsCheckbox.checked = false;
}
});
} catch (e) {
console.error('Fehler beim Laden der Drucker:', e);
}
}
function updateJobsTable(jobs) {
const tableBody = document.getElementById('jobsTableBody');
tableBody.innerHTML = '';
jobs.forEach(job => {
const row = document.createElement('tr');
const startDate = new Date(job.startAt);
const formattedStart = startDate.toLocaleString();
const isActive = !job.aborted && job.remainingMinutes > 0 && !job.waitingApproval;
const isWaiting = !job.aborted && job.waitingApproval;
let statusText = '';
let statusClass = '';
if (job.aborted) {
statusText = 'Abgebrochen';
statusClass = 'text-danger';
} else if (job.waitingApproval) {
statusText = 'Wartet auf Freischaltung';
statusClass = 'text-info';
} else if (job.remainingMinutes <= 0) {
statusText = 'Abgeschlossen';
statusClass = 'text-success';
} else {
statusText = 'Aktiv';
statusClass = 'text-warning';
}
// Zeige die verbleibende Zeit an
const remainingTime = job.waitingApproval ? '-' : job.remainingMinutes;
row.innerHTML = `
<td>${job.id}</td>
<td>${job.printerId}</td>
<td>${job.userId}</td>
<td>${formattedStart}</td>
<td>${job.durationInMinutes}</td>
<td>${remainingTime}</td>
<td><span class="${statusClass}">${statusText}</span></td>
<td>${job.comments || '-'}</td>
<td>
${isActive ? `
<button type="button" class="btn btn-sm btn-danger mb-1"
data-bs-toggle="modal"
data-bs-target="#abortJobModal"
data-job-id="${job.id}">
Abbrechen
</button>
<button type="button" class="btn btn-sm btn-success mb-1"
data-bs-toggle="modal"
data-bs-target="#finishJobModal"
data-job-id="${job.id}">
Beenden
</button>
<button type="button" class="btn btn-sm btn-primary mb-1"
data-bs-toggle="modal"
data-bs-target="#extendJobModal"
data-job-id="${job.id}">
Verlängern
</button>
` : ''}
${isWaiting ? `
<button type="button" class="btn btn-sm btn-success mb-1"
data-bs-toggle="modal"
data-bs-target="#approveJobModal"
data-job-id="${job.id}">
Freischalten
</button>
<button type="button" class="btn btn-sm btn-danger mb-1"
data-bs-toggle="modal"
data-bs-target="#abortJobModal"
data-job-id="${job.id}">
Abbrechen
</button>
` : ''}
<button type="button" class="btn btn-sm btn-secondary mb-1"
data-bs-toggle="modal"
data-bs-target="#editCommentsModal"
data-job-id="${job.id}"
data-job-comments="${job.comments || ''}">
Kommentare
</button>
</td>
`;
tableBody.appendChild(row);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Anmelden - MYP API Tester{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">Anmelden</h4>
</div>
<div class="card-body">
<form class="api-form" data-url="/auth/login" data-method="POST" data-response="loginResponse">
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
<div class="mt-3">
<p>Noch kein Konto? <a href="/register">Registrieren</a></p>
</div>
<div class="mt-3">
<h5>Antwort:</h5>
<pre class="api-response" id="loginResponse"></pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,280 @@
{% extends "base.html" %}
{% block title %}Drucker - MYP API Tester{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Drucker verwalten</h4>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#newPrinterForm">
Neuen Drucker hinzufügen
</button>
</div>
<div class="collapse" id="newPrinterForm">
<div class="card-body border-bottom">
<form class="api-form" data-url="/api/printers" data-method="POST" data-response="createPrinterResponse" data-reload="true">
<div class="mb-3">
<label for="printerName" class="form-label">Name</label>
<input type="text" class="form-control" id="printerName" name="name" required>
</div>
<div class="mb-3">
<label for="printerDescription" class="form-label">Beschreibung</label>
<textarea class="form-control" id="printerDescription" name="description" rows="3" required></textarea>
</div>
<div class="mb-3">
<label for="printerStatus" class="form-label">Status</label>
<select class="form-control" id="printerStatus" name="status">
<option value="0">Verfügbar (0)</option>
<option value="1">Besetzt (1)</option>
<option value="2">Wartung (2)</option>
</select>
</div>
<div class="mb-3">
<label for="printerIpAddress" class="form-label">IP-Adresse (Tapo Steckdose)</label>
<input type="text" class="form-control" id="printerIpAddress" name="ipAddress" placeholder="z.B. 192.168.1.100">
</div>
<button type="submit" class="btn btn-success">Drucker erstellen</button>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="createPrinterResponse"></pre>
</div>
</div>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/printers" data-method="GET" data-response="printersResponse">
<button type="submit" class="btn btn-primary">Drucker aktualisieren</button>
</form>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Beschreibung</th>
<th>Status</th>
<th>IP-Adresse</th>
<th>Aktueller Job</th>
<th>Wartende Jobs</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="printersTableBody">
<!-- Wird dynamisch gefüllt -->
</tbody>
</table>
</div>
<div>
<h6>API-Antwort:</h6>
<pre class="api-response" id="printersResponse"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- Drucker bearbeiten Modal -->
<div class="modal fade" id="editPrinterModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Drucker bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editPrinterForm" class="api-form" data-method="PUT" data-response="editPrinterResponse" data-reload="true">
<input type="hidden" id="editPrinterId" name="printerId">
<div class="mb-3">
<label for="editPrinterName" class="form-label">Name</label>
<input type="text" class="form-control" id="editPrinterName" name="name" required>
</div>
<div class="mb-3">
<label for="editPrinterDescription" class="form-label">Beschreibung</label>
<textarea class="form-control" id="editPrinterDescription" name="description" rows="3" required></textarea>
</div>
<div class="mb-3">
<label for="editPrinterStatus" class="form-label">Status</label>
<select class="form-control" id="editPrinterStatus" name="status">
<option value="0">Verfügbar (0)</option>
<option value="1">Besetzt (1)</option>
<option value="2">Wartung (2)</option>
</select>
</div>
<div class="mb-3">
<label for="editPrinterIpAddress" class="form-label">IP-Adresse (Tapo Steckdose)</label>
<input type="text" class="form-control" id="editPrinterIpAddress" name="ipAddress" placeholder="z.B. 192.168.1.100">
</div>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="editPrinterResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="editPrinterForm" class="btn btn-primary">Änderungen speichern</button>
</div>
</div>
</div>
</div>
<!-- Drucker löschen Modal -->
<div class="modal fade" id="deletePrinterModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Drucker löschen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie den Drucker <span id="deletePrinterName"></span> wirklich löschen?</p>
<form id="deletePrinterForm" class="api-form" data-method="DELETE" data-response="deletePrinterResponse" data-reload="true">
<input type="hidden" id="deletePrinterId" name="printerId">
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="deletePrinterResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="deletePrinterForm" class="btn btn-danger">Löschen</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Drucker laden
document.querySelector('form[data-url="/api/printers"]').dispatchEvent(new Event('submit'));
// Tabelle aktualisieren, wenn Drucker geladen werden
const printersResponse = document.getElementById('printersResponse');
const observer = new MutationObserver(function(mutations) {
try {
const printers = JSON.parse(printersResponse.textContent);
updatePrintersTable(printers);
} catch (e) {
console.error('Fehler beim Parsen der Drucker-Daten:', e);
}
});
observer.observe(printersResponse, { childList: true, characterData: true, subtree: true });
// Edit-Modal vorbereiten
document.getElementById('editPrinterModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const printerId = button.getAttribute('data-printer-id');
const printerName = button.getAttribute('data-printer-name');
const printerDescription = button.getAttribute('data-printer-description');
const printerStatus = button.getAttribute('data-printer-status');
const printerIpAddress = button.getAttribute('data-printer-ip');
document.getElementById('editPrinterId').value = printerId;
document.getElementById('editPrinterForm').setAttribute('data-url', `/api/printers/${printerId}`);
document.getElementById('editPrinterName').value = printerName;
document.getElementById('editPrinterDescription').value = printerDescription;
document.getElementById('editPrinterStatus').value = printerStatus;
document.getElementById('editPrinterIpAddress').value = printerIpAddress || '';
});
// Delete-Modal vorbereiten
document.getElementById('deletePrinterModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const printerId = button.getAttribute('data-printer-id');
const printerName = button.getAttribute('data-printer-name');
document.getElementById('deletePrinterId').value = printerId;
document.getElementById('deletePrinterForm').setAttribute('data-url', `/api/printers/${printerId}`);
document.getElementById('deletePrinterName').textContent = printerName;
});
});
function updatePrintersTable(printers) {
const tableBody = document.getElementById('printersTableBody');
tableBody.innerHTML = '';
printers.forEach(printer => {
const row = document.createElement('tr');
const statusText = {
0: 'Verfügbar',
1: 'Besetzt',
2: 'Wartung'
}[printer.status] || 'Unbekannt';
const statusClass = {
0: 'text-success',
1: 'text-warning',
2: 'text-danger'
}[printer.status] || '';
// Informationen zum aktuellen Job
let currentJobInfo = '-';
if (printer.latestJob && printer.status === 1) {
// Verbleibende Zeit berechnen
const remainingTime = printer.latestJob.remainingMinutes || 0;
currentJobInfo = `
<div class="small">
<strong>ID:</strong> ${printer.latestJob.id.substring(0, 8)}...<br>
<strong>Dauer:</strong> ${printer.latestJob.durationInMinutes} Min<br>
<strong>Verbleibend:</strong> ${remainingTime} Min
</div>
`;
}
// Wartende Jobs anzeigen
let waitingJobsInfo = '-';
if (printer.waitingJobs && printer.waitingJobs.length > 0) {
const waitingJobsCount = printer.waitingJobs.length;
waitingJobsInfo = `
<div class="small">
<strong>${waitingJobsCount} Job${waitingJobsCount !== 1 ? 's' : ''} in Warteschlange</strong><br>
${printer.waitingJobs.map((job, index) =>
`<span>${index + 1}. Job ${job.id.substring(0, 8)}... (${job.durationInMinutes} Min)</span>`
).join('<br>')}
</div>
`;
}
row.innerHTML = `
<td>${printer.id}</td>
<td>${printer.name}</td>
<td>${printer.description}</td>
<td><span class="${statusClass}">${statusText} (${printer.status})</span></td>
<td>${printer.ipAddress || '-'}</td>
<td>${currentJobInfo}</td>
<td>${waitingJobsInfo}</td>
<td>
<button type="button" class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#editPrinterModal"
data-printer-id="${printer.id}"
data-printer-name="${printer.name}"
data-printer-description="${printer.description}"
data-printer-status="${printer.status}"
data-printer-ip="${printer.ipAddress || ''}">
Bearbeiten
</button>
<button type="button" class="btn btn-sm btn-danger"
data-bs-toggle="modal"
data-bs-target="#deletePrinterModal"
data-printer-id="${printer.id}"
data-printer-name="${printer.name}">
Löschen
</button>
</td>
`;
tableBody.appendChild(row);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Registrieren - MYP API Tester{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">Registrieren</h4>
</div>
<div class="card-body">
<form class="api-form" data-url="/auth/register" data-method="POST" data-response="registerResponse">
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="displayName" class="form-label">Anzeigename</label>
<input type="text" class="form-control" id="displayName" name="displayName">
</div>
<div class="mb-3">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<button type="submit" class="btn btn-primary">Registrieren</button>
</form>
<div class="mt-3">
<p>Bereits registriert? <a href="/login">Anmelden</a></p>
</div>
<div class="mt-3">
<h5>Antwort:</h5>
<pre class="api-response" id="registerResponse"></pre>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,395 @@
{% extends "base.html" %}
{% block title %}Statistiken - MYP API Tester{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0">Systemstatistiken</h4>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/stats" data-method="GET" data-response="statsResponse">
<button type="submit" class="btn btn-primary">Statistiken aktualisieren</button>
</form>
<div class="row" id="statsContainer">
<!-- Wird dynamisch gefüllt -->
</div>
<!-- Problem-Drucker-Bereich -->
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">Drucker mit Verbindungsproblemen</h5>
</div>
<div class="card-body" id="problemPrintersContainer">
<div class="alert alert-info">Keine Verbindungsprobleme festgestellt.</div>
</div>
</div>
</div>
</div>
<!-- Uptime-Grafik -->
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">Steckdosen-Verfügbarkeit</h5>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/uptime" data-method="GET" data-response="uptimeResponse">
<button type="submit" class="btn btn-primary">Uptime-Daten laden</button>
</form>
<canvas id="uptimeChart" width="100%" height="300"></canvas>
</div>
</div>
</div>
</div>
<!-- API-Antworten -->
<div class="row mt-4">
<div class="col-md-6">
<h6>Stats API-Antwort:</h6>
<pre class="api-response" id="statsResponse"></pre>
</div>
<div class="col-md-6">
<h6>Uptime API-Antwort:</h6>
<pre class="api-response" id="uptimeResponse"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- Chart.js für Diagramme -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
let uptimeChart;
document.addEventListener('DOMContentLoaded', function() {
// Statistiken laden
document.querySelector('form[data-url="/api/stats"]').dispatchEvent(new Event('submit'));
document.querySelector('form[data-url="/api/uptime"]').dispatchEvent(new Event('submit'));
// Statistiken aktualisieren, wenn API-Antwort geladen wird
const statsResponse = document.getElementById('statsResponse');
const statsObserver = new MutationObserver(function(mutations) {
try {
const stats = JSON.parse(statsResponse.textContent);
updateStatsDisplay(stats);
updateProblemPrinters(stats);
} catch (e) {
console.error('Fehler beim Parsen der Statistik-Daten:', e);
}
});
statsObserver.observe(statsResponse, { childList: true, characterData: true, subtree: true });
// Uptime-Daten aktualisieren, wenn API-Antwort geladen wird
const uptimeResponse = document.getElementById('uptimeResponse');
const uptimeObserver = new MutationObserver(function(mutations) {
try {
const uptime = JSON.parse(uptimeResponse.textContent);
updateUptimeChart(uptime);
} catch (e) {
console.error('Fehler beim Parsen der Uptime-Daten:', e);
}
});
uptimeObserver.observe(uptimeResponse, { childList: true, characterData: true, subtree: true });
// Periodische Aktualisierung
setInterval(function() {
document.querySelector('form[data-url="/api/stats"]').dispatchEvent(new Event('submit'));
document.querySelector('form[data-url="/api/uptime"]').dispatchEvent(new Event('submit'));
}, 60000); // Alle 60 Sekunden aktualisieren
});
function updateStatsDisplay(stats) {
const container = document.getElementById('statsContainer');
container.innerHTML = '';
// Drucker-Statistiken
const printerStats = document.createElement('div');
printerStats.className = 'col-md-4 mb-3';
printerStats.innerHTML = `
<div class="card h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Drucker</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>Gesamt:</span>
<span>${stats.printers.total}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Verfügbar:</span>
<span>${stats.printers.available}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Auslastung:</span>
<span>${Math.round(stats.printers.utilization_rate * 100)}%</span>
</div>
<div class="progress mt-3 mb-3">
<div class="progress-bar" role="progressbar"
style="width: ${Math.round(stats.printers.utilization_rate * 100)}%">
${Math.round(stats.printers.utilization_rate * 100)}%
</div>
</div>
<hr />
<div class="d-flex justify-content-between mb-2">
<span>Online:</span>
<span>${stats.printers.online}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Offline:</span>
<span>${stats.printers.offline}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Verbindungsrate:</span>
<span>${Math.round(stats.printers.connectivity_rate * 100)}%</span>
</div>
<div class="progress mt-3">
<div class="progress-bar bg-success" role="progressbar"
style="width: ${Math.round(stats.printers.connectivity_rate * 100)}%">
${Math.round(stats.printers.connectivity_rate * 100)}%
</div>
</div>
</div>
</div>
`;
// Job-Statistiken
const jobStats = document.createElement('div');
jobStats.className = 'col-md-4 mb-3';
jobStats.innerHTML = `
<div class="card h-100">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Druckaufträge</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>Gesamt:</span>
<span>${stats.jobs.total}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Aktiv:</span>
<span>${stats.jobs.active}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Abgeschlossen:</span>
<span>${stats.jobs.completed}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Durchschnittliche Dauer:</span>
<span>${stats.jobs.avg_duration} Minuten</span>
</div>
</div>
</div>
`;
// Benutzer- und Uptime-Statistiken
const userStats = document.createElement('div');
userStats.className = 'col-md-4 mb-3';
userStats.innerHTML = `
<div class="card h-100">
<div class="card-header bg-info text-white">
<h5 class="mb-0">System</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>Benutzer:</span>
<span>${stats.users.total}</span>
</div>
<hr />
<div class="d-flex justify-content-between mb-2">
<span>Verbindungsausfälle (7 Tage):</span>
<span>${stats.uptime.outages_last_7_days}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Aktuelle Probleme:</span>
<span>${stats.uptime.problem_printers.length}</span>
</div>
</div>
</div>
`;
container.appendChild(printerStats);
container.appendChild(jobStats);
container.appendChild(userStats);
}
function updateProblemPrinters(stats) {
const container = document.getElementById('problemPrintersContainer');
const problemPrinters = stats.uptime.problem_printers;
if (problemPrinters.length === 0) {
container.innerHTML = '<div class="alert alert-info">Keine Verbindungsprobleme festgestellt.</div>';
return;
}
let html = '<div class="table-responsive"><table class="table table-striped">';
html += '<thead><tr><th>Drucker</th><th>Status</th><th>Offline seit</th><th>Dauer</th></tr></thead>';
html += '<tbody>';
problemPrinters.forEach(printer => {
let offlineSince = 'Unbekannt';
let duration = 'Unbekannt';
if (printer.last_seen) {
try {
const lastSeen = new Date(printer.last_seen);
const now = new Date();
const diffSeconds = Math.floor((now - lastSeen) / 1000);
const hours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
offlineSince = lastSeen.toLocaleString();
duration = `${hours}h ${minutes}m`;
} catch (e) {
console.error('Fehler beim Berechnen der Offline-Zeit:', e);
}
}
html += `<tr>
<td>${printer.name}</td>
<td><span class="badge bg-danger">Offline</span></td>
<td>${offlineSince}</td>
<td>${duration}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function updateUptimeChart(uptimeData) {
// Wenn keine Daten vorhanden sind, nichts tun
if (!uptimeData || !uptimeData.sockets || uptimeData.sockets.length === 0) {
return;
}
// Daten für das Diagramm vorbereiten
const socketNames = [];
const datasets = [];
const colors = {
online: 'rgba(40, 167, 69, 0.7)',
offline: 'rgba(220, 53, 69, 0.7)',
unknown: 'rgba(108, 117, 125, 0.7)'
};
// Zeitraum für das Diagramm (letzten 7 Tage)
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
// Für jede Steckdose
uptimeData.sockets.forEach(socket => {
socketNames.push(socket.name);
// Sortiere Ereignisse nach Zeitstempel
if (socket.events) {
socket.events.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// Erstelle einen Datensatz für diese Steckdose
const data = [];
// Füge Ereignisse zum Datensatz hinzu
socket.events.forEach(event => {
data.push({
x: new Date(event.timestamp),
y: event.status === 'online' ? 1 : 0,
status: event.status,
duration: event.duration_seconds ?
formatDuration(event.duration_seconds) : null
});
});
// Füge aktuellen Status hinzu
if (socket.current_status) {
data.push({
x: new Date(),
y: socket.current_status.connection_status === 'online' ? 1 : 0,
status: socket.current_status.connection_status,
duration: null
});
}
datasets.push({
label: socket.name,
data: data,
stepped: true,
borderColor: colors[socket.current_status?.connection_status || 'unknown'],
backgroundColor: colors[socket.current_status?.connection_status || 'unknown'],
fill: false
});
}
});
// Chart.js Konfiguration
const ctx = document.getElementById('uptimeChart').getContext('2d');
// Wenn Chart bereits existiert, zerstöre ihn
if (uptimeChart) {
uptimeChart.destroy();
}
// Erstelle neuen Chart
uptimeChart = new Chart(ctx, {
type: 'line',
data: {
datasets: datasets
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const point = context.raw;
let label = context.dataset.label || '';
label += ': ' + (point.status === 'online' ? 'Online' : 'Offline');
if (point.duration) {
label += ' (Dauer: ' + point.duration + ')';
}
return label;
}
}
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'day'
},
min: startDate,
max: endDate
},
y: {
min: -0.1,
max: 1.1,
ticks: {
callback: function(value) {
return value === 0 ? 'Offline' : value === 1 ? 'Online' : '';
}
}
}
}
}
});
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,238 @@
{% extends "base.html" %}
{% block title %}Benutzer - MYP API Tester{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0">Benutzer verwalten</h4>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#newUserForm">
Neuen Benutzer hinzufügen
</button>
</div>
<div class="collapse" id="newUserForm">
<div class="card-body border-bottom">
<form class="api-form" data-url="/auth/register" data-method="POST" data-response="createUserResponse" data-reload="true">
<div class="mb-3">
<label for="userName" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="userName" name="username" required>
</div>
<div class="mb-3">
<label for="userPassword" class="form-label">Passwort</label>
<input type="password" class="form-control" id="userPassword" name="password" required>
</div>
<div class="mb-3">
<label for="userDisplayName" class="form-label">Anzeigename</label>
<input type="text" class="form-control" id="userDisplayName" name="displayName">
</div>
<div class="mb-3">
<label for="userEmail" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="userEmail" name="email">
</div>
<button type="submit" class="btn btn-success">Benutzer erstellen</button>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="createUserResponse"></pre>
</div>
</div>
</div>
<div class="card-body">
<form class="api-form mb-3" data-url="/api/users" data-method="GET" data-response="usersResponse">
<button type="submit" class="btn btn-primary">Benutzer aktualisieren</button>
</form>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Benutzername</th>
<th>Anzeigename</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="usersTableBody">
<!-- Wird dynamisch gefüllt -->
</tbody>
</table>
</div>
<div>
<h6>API-Antwort:</h6>
<pre class="api-response" id="usersResponse"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- Benutzer bearbeiten Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Benutzer bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editUserForm" class="api-form" data-method="PUT" data-response="editUserResponse" data-reload="true">
<input type="hidden" id="editUserId" name="userId">
<div class="mb-3">
<label for="editUserName" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="editUserName" name="username" required>
</div>
<div class="mb-3">
<label for="editUserDisplayName" class="form-label">Anzeigename</label>
<input type="text" class="form-control" id="editUserDisplayName" name="displayName">
</div>
<div class="mb-3">
<label for="editUserEmail" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="editUserEmail" name="email">
</div>
<div class="mb-3">
<label for="editUserRole" class="form-label">Rolle</label>
<select class="form-control" id="editUserRole" name="role">
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
<option value="guest">Gast</option>
</select>
</div>
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="editUserResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="editUserForm" class="btn btn-primary">Änderungen speichern</button>
</div>
</div>
</div>
</div>
<!-- Benutzer löschen Modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Benutzer löschen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Möchten Sie den Benutzer <span id="deleteUserName"></span> wirklich löschen?</p>
<form id="deleteUserForm" class="api-form" data-method="DELETE" data-response="deleteUserResponse" data-reload="true">
<input type="hidden" id="deleteUserId" name="userId">
</form>
<div class="mt-3">
<h6>Antwort:</h6>
<pre class="api-response" id="deleteUserResponse"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" form="deleteUserForm" class="btn btn-danger">Löschen</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Benutzer laden
document.querySelector('form[data-url="/api/users"]').dispatchEvent(new Event('submit'));
// Tabelle aktualisieren, wenn Benutzer geladen werden
const usersResponse = document.getElementById('usersResponse');
const observer = new MutationObserver(function(mutations) {
try {
const users = JSON.parse(usersResponse.textContent);
updateUsersTable(users);
} catch (e) {
console.error('Fehler beim Parsen der Benutzer-Daten:', e);
}
});
observer.observe(usersResponse, { childList: true, characterData: true, subtree: true });
// Edit-Modal vorbereiten
document.getElementById('editUserModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const userId = button.getAttribute('data-user-id');
const userName = button.getAttribute('data-user-name');
const userDisplayName = button.getAttribute('data-user-displayname');
const userEmail = button.getAttribute('data-user-email');
const userRole = button.getAttribute('data-user-role');
document.getElementById('editUserId').value = userId;
document.getElementById('editUserForm').setAttribute('data-url', `/api/users/${userId}`);
document.getElementById('editUserName').value = userName;
document.getElementById('editUserDisplayName').value = userDisplayName || '';
document.getElementById('editUserEmail').value = userEmail || '';
document.getElementById('editUserRole').value = userRole;
});
// Delete-Modal vorbereiten
document.getElementById('deleteUserModal').addEventListener('show.bs.modal', function(event) {
const button = event.relatedTarget;
const userId = button.getAttribute('data-user-id');
const userName = button.getAttribute('data-user-name');
document.getElementById('deleteUserId').value = userId;
document.getElementById('deleteUserForm').setAttribute('data-url', `/api/users/${userId}`);
document.getElementById('deleteUserName').textContent = userName;
});
});
function updateUsersTable(users) {
const tableBody = document.getElementById('usersTableBody');
tableBody.innerHTML = '';
users.forEach(user => {
const row = document.createElement('tr');
const roleClass = {
'admin': 'text-danger',
'user': 'text-primary',
'guest': 'text-secondary'
}[user.role] || '';
row.innerHTML = `
<td>${user.id}</td>
<td>${user.username}</td>
<td>${user.displayName || user.username}</td>
<td>${user.email || '-'}</td>
<td><span class="${roleClass}">${user.role}</span></td>
<td>
<button type="button" class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#editUserModal"
data-user-id="${user.id}"
data-user-name="${user.username}"
data-user-displayname="${user.displayName || ''}"
data-user-email="${user.email || ''}"
data-user-role="${user.role}">
Bearbeiten
</button>
<button type="button" class="btn btn-sm btn-danger"
data-bs-toggle="modal"
data-bs-target="#deleteUserModal"
data-user-id="${user.id}"
data-user-name="${user.username}">
Löschen
</button>
</td>
`;
tableBody.appendChild(row);
});
}
</script>
{% endblock %}

1
docs/.gitkeep Normal file
View File

@@ -0,0 +1 @@

53
docs/Aktueller Stand.md Normal file
View File

@@ -0,0 +1,53 @@
- HTTP Broadcast funktioniert nicht -> können IP nicht finden
- Backend im Unternehmen, Frontend auf GitHub funktioniert nicht -> Frontend hat HTTPS und Backend nur HTTP, self-signed certificate würde Fehler werfen
---
[Knowledge Base - [PG Netzwerk] DHCP/DNS-Services: Anfragen und Änderungen von IP-Adressen/DNS (mercedes-benz.com)](https://servicenow.i.mercedes-benz.com/esc?id=kb_article&table=kb_knowledge&sysparm_article=KB0426678)
Statische IP beantragen
Alias Zuordnung (druckerapp.xyz.corpintra.net)
!!! SSL Zertifikat???
-> [Server-Zertifikat - Employee Center (mercedes-benz.com)](https://servicenow.i.mercedes-benz.com/esc?id=sc_cat_item&table=sc_cat_item&sys_id=7ef47a1c1b0d5450c4f43113dd4bcbf5)
8€ (wahrscheinlich einmalig)
benötigt Anwenderservice
[Application Service (Erstellen) - Employee Center (mercedes-benz.com)](https://servicenow.i.mercedes-benz.com/esc?id=sc_cat_item&table=sc_cat_item&sys_id=ab095def1bf68810c4f43113dd4bcb06)
-> [ServiceNow-Berechtigungen - Employee Center (mercedes-benz.com)](https://servicenow.i.mercedes-benz.com/esc?id=sc_cat_item&table=sc_cat_item&sys_id=8d645a30db3dc4501754ccd40596192c)
Sollten Sie für Ihre Applikation / Ihr Endgerät eine statisch zugewiesene IP Adresse (S-DHCP), einen manuellen IP-Pool (M-DHCP) oder eine entsprechende neue oder angepasste DNS-Zuordnung benötigen, nutzen Sie bitte die nachfolgenden SNOW-Templates (Hinweis zur Nutzung der Ticket-Templates benötigen Sie die **Service Now - Rechte eines "Agent Fulfilers"**. Die Beantragung für diese Rechte ist über das SNOW Employee Service Portal im Social Intranet möglich: [ServiceNow Permissions - Employee Service Center (mercedes-benz.com))](https://servicenow.i.mercedes-benz.com/esc?id=sc_cat_item&table=sc_cat_item&sys_id=8d645a30db3dc4501754ccd40596192c).
-> Martin diesbezüglich Fragen, ob er Sie für sich bestellt?
---
- Martin Rechte SNOW
- Martin Termin Volker Otto
[Examples · Diagrams (mingrammer.com)](https://diagrams.mingrammer.com/docs/getting-started/examples)
Hier ist eine Antwort, die ich mit Microsoft Copilot erhalten habe, der weltweit ersten KI-gestützten Antwort-Engine. Wählen Sie diese Option aus, um die vollständige Antwort anzuzeigen, oder probieren Sie sie selbst aus. https://sl.bing.net/iv24ZLcukjk
- use devcontainer !!!
https://x.com/tremorlabs/status/1779873516533547064?s=46
use mix of shadcn ui and tremor.so blocks
Bestellliste:
- 1x Switch
- 1x wlan access point
- 2x Raspberry Pi (4B / 5)
(- Xx LAN-Kabel)
- Adapter für 3D-Drucker oder Schaltbare Steckdosen
-> Datensicherung?!?!?!
make it a user thing
download db copy
import/export
OR Zugang zu CORP O über Anwendungsservice??
Move TRILLUM to GITHUB WIKI

24
docs/Dokumentation_IHK.md Normal file
View File

@@ -0,0 +1,24 @@
Notizen:
- Wollten zuerst OpenSUSE, haben uns dagegen entschieden, weil NixOS einfacher zu konfigurieren ist und besser geeignet für diesen Einsatzzweck
- Mussten eine IP-Adresse und eine Domain organisieren von unserer IT-Abteilung
- haben ein Netzwerkplan gemacht
- haben uns die akutellen Prozesse und Konventionen bei der Organisation einer internen Domain angeguckt
- haben uns für Raspberrys "entschieden", stand aber mehr oder weniger schon fest weil diese einfach perfekt für den Einsatzzweck sind
- Da Till digitale Vernetzung hat macht er Backend, weil die Schnittstelle der Vernetzung zum cyberphysischen System dort lag
- für die Dokumentation: Daten (Datums) müssen stimmen!
python schnittstelle funktionierte nicht
nach etlichem rumprobieren festgestellt: geht nicht so einfach
wireshark mitschnitt gemacht → auffällig: immer die selben responses bei verschlüsselter verbindung
ohne erfolg beim simulieren einzelner anfragen
dann: geistesblitz: anfragensequenz muss es sein!
hat funktioniert → es hat klick gemacht!! .
verbindung verschlüsselt und mit temporärem cookie
→ proprietäre Verschlüsselung
wie wird die verbindung ausgehandelt?
------
11.09 : Teile bestellt im internen Technikshop
12.09 : DNS Alias festlegen / beantragen

1
docs/Infrastruktur.tldr Normal file

File diff suppressed because one or more lines are too long

360
docs/LICENSE.md Normal file
View File

@@ -0,0 +1,360 @@
# Mercedes-Benz Inner Source License 1.0 ("ISL")
Copyright &copy; 2022 Mercedes-Benz Group AG
SPDX-License-Identifier: LicenseRef-MB-ISL-1.0
## 0. Preamble
0.0 This Mercedes-Benz Inner Source License 1.0 succeeds the
Daimler Inner Source License 1.0 as a later version and is similar in spirit.
0.1 Mercedes-Benz Group AG ("Mercedes-Benz") provides a platform for collaborative
development ("SCR") and use of source code and associated data (e.g. parameters,
documentation) for use by and for purposes of companies of the Mercedes-Benz
Group (§§ 15 et seq. Aktiengesetz, German Stock Companies Act; all such
companies together "Mercedes-Benz Companies"). This also includes the use by
third parties only as far as these are commissioned directly by a
Mercedes-Benz Company with performances for purposes of Mercedes-Benz Companies
("Commissioned Third Parties").
0.2 The SCR and cooperation model is named "Mercedes-Benz
Inner Source Platform". Inner Source
follows the pattern of Open Source software development, is however restricted to
use by and for purposes of Mercedes-Benz Companies due to technical, security,
relevance and majority reasons.
0.3 Source code uploads to repositories ("Projects") on the SCR come
with the expectation that other parties in this cooperation model will
improve such Projects through comments, suggestions, source code to
provide benefits of the collaboration for all involved parties.
## 1. Scope of ISL
1.1 The ISL is an agreement by a Mercedes-Benz Company or a Commissioned Third
Party, which contributes to or uses content on the SCR ("Participant"),
with Mercedes-Benz or another Mercedes-Benz Company also for the direct benefit of
other Mercedes-Benz Companies and Commissioned Third Parties. The direct
benefit of Commissioned Third Parties is limited to grant of rights
according to Section 4.4.
1.2 The ISL applies to Projects uploaded to or available on the SCR,
especially source code and data, that contain this `LICENSE.md` file
incorporating the unmodified ISL or that expressly refer to the ISL e.g.
by stating "Licensed under Mercedes-Benz Inner Source License 1.0" (all
together "Content").
1.3 The ISL applies to any and all handling and use of Content on the
SCR, including copying, integration, making and/or making available
modifications as well as any possible use of Content in foreseen or
unforeseen manner by any means.
## 2. Consent to and Prevalence of ISL
2.1 The ISL is accepted by a Mercedes-Benz Company by accessing or making use
of Content on the SCR through its employees or vicarious agents.
2.2 The ISL is accepted by a Commissioned Third Party by accessing or
making use of Content on the SCR through its employees or vicarious
agents. Subsidiarily, a user acting for a Commissioned Third Party
accepts the ISL by such action for himself/herself and on behalf of such
Commissioned Third Party.
2.3 Subsidiarily to Sections 2.1 and 2.2, the regulations in this ISL
for rights to Content in Sections 3 and 4 are accepted by and apply
analogously to any party, which contributes Content to the SCR, in
respect of the offer of concession of rights according to Section 3.1
and the scope of rights granted according to Section 4. Such party is
not able to accept an offer in respect of Content (see Section 3.2).
2.4 The ISL is prevalent for any Content on the SCR. Any exception from
the ISL is only valid if and applicable as far as (i) the ISL itself
expressly provides for other stipulations or such is expressly agreed
upon in written form for each individual case and (ii) in any case the
Content to which an exception applies is to be transparently marked as
underlying different stipulations.
2.5 The ISL itself can only be terminated extraordinarily for good cause
by notice in written form by one affected party to the other affected
parties in respect of specific affected Content. Any obligations which
arose before or are caused by such termination remain unaffected. The
stipulations on termination of rights according Section 6 of this ISL remain unaffected.
## 3. Offer of and Consent to Concession of Rights to Content available on SCR
3.1 The Mercedes-Benz Company or Commissioned Third Party which makes Content
available on the SCR thereby offers to any other Mercedes-Benz Company and
Commissioned Third Party the rights to Content described in Section 4.
This applies irrespective of the means of making available Content on
the SCR (e.g. upload, pull request, etc.). Contributions, e.g. through
pull request, to Content and/or Projects under ISL shall state,
"Licensed under Mercedes-Benz Inner Source License 1.0 and any later version
similar in spirit". Such offer is valid as long as such Content or a
modification of such Content is available on the SCR.
3.2 Any Mercedes-Benz Company can accept such offer in respect of Content
available on the SCR by accessing or using such Content in any way by
itself or through Commissioned Third Parties as agreed between the
Mercedes-Benz Company and the Commissioned Third Party in the course of
commissioning. A Commissioned Third Party can accept such offer only
within the scope of its duties commissioned by a Mercedes-Benz Company and
subject to Section 4.4 by accessing or using such Content. Prerequisite
for an acceptance is a valid agreement of the ISL. Any restriction or
reservation for acceptance of the ISL makes acceptance of the offer null
and void.
## 4. Rights granted to Content
4.1 Upon acceptance of an offer to Content,
the rights to such Content are granted directly by the Participant to
the accepting entity non-exclusively.
4.2 The rights to Content encompass any and all possible handling and
use of such Content for any purposes of Mercedes-Benz Companies in any
possible foreseen and unforeseen manner by any means and in any form.
This especially encompasses any reproduction, translation, adaptation,
arrangement and other modifications as well as the reproduction of the
results thereof, any form of distribution of Content or modified Content
including rental. This also encompasses any use without attribution to
any authors of Content as far as legally possible. The rights do not
entitle to remove any copyright information or legal notice unless
otherwise expressly entitled under this ISL and especially Section 5.5.
Any communication of Content to the public is subject to the additional
provisions of Section 5.
4.3 The rights to Content are granted to a Mercedes-Benz Company for the
duration of its membership to the Mercedes-Benz Companies.
4.4 The rights to Content granted to Commissioned Third Parties are
limited to the necessary use of Content for the benefit of performances
for purposes of Mercedes-Benz Companies as far as and as long as such
Commissioned Third Party is commissioned with such performances. For the
avoidance of doubt, rights to Content granted to Commissioned Third
Parties do also terminate with the end of the membership of its
commissioning Mercedes-Benz Company to the Mercedes-Benz Companies.
4.5 Any grant of rights to Content beyond the scope of this Section 4 by
a holder of rights by separate means remains
unaffected. As far as a Commissioned Third Party has granted exclusive
rights to specific content to a Mercedes-Benz Company, this Mercedes-Benz Company
consents to the grant of rights according to this Section 4 by ordering
or allowing the Commissioned Third Party to make such content available
on the SCR. Any grant of rights according to this ISL does not restrict
rights of a Mercedes-Benz Company to Content except as expressly stated in
this Section 4.
4.6 Any rights granted to specific Content according to this Section 4
remain unaffected if the availability of the respective Content on the
SCR ends.
4.7 Any license terms and conditions pertaining to Free and Open Source
Software (FOSS) as parts of Content or parts of Content from third
parties shall prevail over this Section 4. Any obligations of a
Participant in connection with FOSS remain unaffected, especially any
obligations of a Commissioned Third Parties in the context of its
commission.
4.8 Use of licensed Content under a particular version of the ISL, may
continue under the terms of that version of the ISL notwithstanding the
application of a later ISL version to a repository. Any party
contributing to a repository accepts and grants rights to Content
including such later application of a later ISL version in accordance
with Section 3.1.
4.9 Any Participant who makes Content available on a repository in the
SCR entitles the employee responsible for a Project ("Repository Owner")
of such repository to decide on external use of such Content according
to Section 5 in his/her own discretion as far as entitled by its respective
Mercedes-Benz Company.
## 5. External Use of Content
5.1 Any use of Content beyond the internal purposes of Mercedes-Benz Companies
requires the prior express consent of the Repository Owner as far as
entitled by its respective Mercedes-Benz Company in each individual case. Use
beyond internal purposes of Mercedes-Benz Companies in particular applies to
communication of Content to the public, including making available
Content or modified Content to the public ("External Use"), whether on a
restricted basis or by way of open source software. Consent to External
Use is deemed to be given with upload of Content to the SCR, subject to
the following provisions. This prerequisite of consent must not be
circumvented in any way (e.g. by forking a repository).
5.2 The rights to Content granted to Commissioned Third Parties are
limited according to this ISL and do not
include the rights to External Use.
5.3 The External Use in form of publication under an open source license
is limited to the Repository Owner, as far as entitled by the employing
Mercedes-Benz Company.
5.4 For any Content intended for External Use, all current standards,
especially for source code, of Mercedes-Benz Companies apply irrespective of
the ISL. The Repository Owner has to decide on any External Use
including its respective scope in each individual case, taking into
account the foreseen benefit for Mercedes-Benz Companies of External
Use as well as the inevitable loss of confidentiality of externally
disclosed Content. The Repository Owner has to withdraw
consent to External Use if its prerequisites lapse. Any Repository Owner
shall adequately document any decision in respect of External Use of
Content in the repository including the decision and date of decision.
5.5 As long as the necessary consent of the Repository Owner is not
withdrawn, the consent includes the External Use of any later
contributions to Content of such repository, irrespective of the means
of a contribution (e.g. upload, pull request, etc.). Any rights already
granted for External Use of specific Content remain unaffected by a
later withdrawal of necessary consent to External Use.
5.6 Any External Use of Content requires prior relicensing of the
Content, e.g. using a Mercedes-Benz proprietary license. For the avoidance of
doubt, this ISL shall not be used in externally disclosed Content.
5.7 Any Participant has to instruct and entitle its Repository Owners
according to this Section 5.
## 6. Termination of Rights granted to Content
6.1 Any rights to any Content granted to a recipient according to
Section 4 terminate automatically and completely if the recipient of
such rights
(i) declares a termination of the ISL, except within the scope and
under the prerequisites of Section 2.5,
(ii) violates the scope of rights granted by this ISL by acts or
omissions by itself or through third parties, in case of a Mercedes-Benz
Company only after a reasonable cure period expressly set has lapsed
without effect, or
(iii) is a legal entity whose membership to the Mercedes-Benz Companies
ends. In case of termination of membership of a Mercedes-Benz Company to the
Mercedes-Benz Companies "Former Member", the Former Member requires a
separate agreement with the Content providing Mercedes-Benz Companies for
the further use of Content.
For the avoidance of doubt, any rights granted to Content provided by
a Former Member do not terminate by this Section 6.
6.2 Any rights to specific Content granted to a Commissioned Third Party
terminate completely and automatically if the commissioning with the
performances for Mercedes-Benz Companies, which require the use of the
affected Content, ends for whatsoever reason.
6.3 If the ISL is terminated according to Section 2.5, any and all
rights to affected Content granted by the terminating party to the
affected parties terminate completely and automatically.
6.4 If rights to Content are terminated according to Section 6.1(ii) the
recipient may request a license to Content from the offering Mercedes-Benz
Company at a later point in time. It is solely up to the discretion of
the offering Mercedes-Benz Company to grant rights to such party under the ISL
again.
## 7. Prerequisites for Use of Content available on SCR
7.1 It lies completely in the sole responsibility of any recipient of
any Content to assess and verify if and to which extent such Content is
suitable and technically qualified to be used for a particular purpose
and a particular application intended by such recipient. Any contractual
obligations and duties of Commissioned Third Parties and commissioned
Mercedes-Benz Companies (a Mercedes-Benz Company that is commissioned by another
Mercedes-Benz Company), especially based on any contract with a Mercedes-Benz
Company, remain unaffected and are not restricted.
7.2 The user is solely and completely responsible for any use of Content
by itself and on its behalf.
7.3 The user is solely and completely responsible for the fulfillment of
any obligations in connection with Content, especially arising out of
Free and Open Source licenses or licenses for content from third
parties.
## 8. Remuneration, Warranty and Liability
8.1 Content is made available and rights to Content are granted without
remuneration but with respect to Section 0.3.
8.2 Any Content is made available on the SCR "as is" without warranty.
There is no warranty provided by any Participant beyond mandatory
statutory obligations and subject to Section 8.4.
8.3 No Participant assumes any liability exceeding mandatory statutory
obligations (intentional damage; damage to life and/or body; statutory
product liability). This especially applies to any obligations of care
or indemnification in connection with Content.
8.4 Any warranty and liability of Commissioned Third Parties and
commissioned Mercedes-Benz Companies, especially based on any contract with a
Mercedes-Benz Company, remain unaffected and are not restricted by Sections
8.2 and 8.3.
## 9. Confidentiality of Content
9.1 As any Content is made available for purposes of Mercedes-Benz Companies
only, any user of the SCR is obliged to take adequate precautions
against unintended or unauthorized access or disclosure of Content to
third parties.
9.2 Any Content not expressly marked or declared as "public", according
to Mercedes-Benz Information Classification, (by and within Mercedes-Benz Companies)
is to be treated as confidential and business secret of Mercedes-Benz
Companies by any Mercedes-Benz Company and any Commissioned Third Party. Any
such party has to take reasonable precautions, at least as for its own
business secrets, to keep such Content secret and protect it against
unauthorized disclosure to third parties, especially if a third party is
not bound to adequate secrecy. Mercedes-Benz Companies may be entitled to use
such Content for External Use in adherence with Section 5. For the
avoidance of doubt, the SCR function "public" is by no means to be
misunderstood to be such a declaration or marking of "public".
## 10. Final Provisions
10.1 Any party, which accesses, uses or downloads Content, is solely
responsible for adherence to all applicable regulations and requirements
including but not limited to the regulations and requirements in
connection with export control.
10.2 Any later or new version of the ISL must be approved by the FOSS
Center of Competence or any comparable successor thereof.
10.3 Except where otherwise expressly stated, this ISL does not
encompass grant of rights to trade marks, patents or similar
intellectual property rights.
10.4 This ISL constitutes the complete agreement on its subject matter.
Any modifications shall be agreed upon in written form. If any
stipulation of this ISL should be held to be invalid or unenforceable,
this does not affect the remaining stipulations. The affected parties
will substitute an invalid or unenforceable stipulation with a provision
that comes as close as possible to the intent and effect of the affected
stipulation.
10.5 Irrespective the regulations on dispute resolution in intra-group
agreements, the following applies: In case of conflict over
applicability and/or interpretation of the ISL between more than one
Mercedes-Benz Company, the FOSS Center of Competence, or any comparable
successor thereof, shall be addressed by one or the parties jointly to
resolve the conflict through an ultimate decision. In the event that
such ultimate decision cannot be made, the parties adhere to the
regulations on dispute resolution in intra-group agreements if
concluded.
10.6 The ISL is governed by applicable German Law for domestic business
under exclusion of the UN-purchase rules (CISG). Sole legal venue for
any conflict arising in connection with the ISL is the appropriate civil
courts in Stuttgart (Mitte), Germany; mandatory statutory jurisdiction
remains unaffected.
10.7 Any and all Participants on the SCR are obliged to desist from all
practices which may lead to penal liability due to fraud or
embezzlement, insolvency crimes, crimes in violation of competition,
guaranteeing advantages, bribery, acceptance of bribes or other
corruption crimes on the part of persons employed by the respective
Participant or other third parties. In the event of violation of the
above, Mercedes-Benz or the right granting Mercedes-Benz Company has the right to
immediately withdraw from or terminate all legal transactions existing
with the respective Participant and the right to cancel all
negotiations. The above notwithstanding, any Participant is obliged to
adhere to all laws and regulations applicable to both itself and the
commercial relationship with Mercedes-Benz Companies.

60
docs/MYP.dbml Normal file
View File

@@ -0,0 +1,60 @@
Table "Printer" {
"id" int [unique, pk]
"name" text
"description" text
"status" int [note: '0: OPERATIONAL\n1: OUT_OF_ORDER']
"created_at" timestamp
"updated_at" timestamp
}
Table "PrintJob" {
"id" int [unique, pk]
"printer_id" int
"user_id" int
"start_at" timestamp
"duration_in_minutes" int
"comments" text
"aborted" bool [default: false]
"abort_reason" text [note: 'Error code displayed on printer']
"created_at" timestamp
"updated_at" timestamp
}
Table "Account" {
"id" text [unique, pk]
"userId" text
"type" text
"provider" text
"providerAccountId" text
"refresh_token" text
"access_token" text
"expires_at" int
"token_type" text
"scope" text
"id_token" text
"session_state" text
}
Table "Session" {
"id" text [unique, pk]
"sessionToken" text [unique]
"userId" text
"expires" datetime
}
Table "User" {
"id" text [pk]
"name" text
"email" text [unique]
"role" text [note: 'ADMIN,USER,GUEST']
}
Ref:"PrintJob"."user_id" < "User"."id"
Ref:"Account"."userId" < "User"."id"
Ref:"Session"."userId" < "User"."id"
Ref:"PrintJob"."printer_id" < "Printer"."id"

BIN
docs/MYP.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

57
docs/MYP.sql Normal file
View File

@@ -0,0 +1,57 @@
CREATE TABLE `Printer` (
`id` int UNIQUE PRIMARY KEY,
`name` text,
`description` text,
`status` int COMMENT '0: OPERATIONAL
1: OUT_OF_ORDER',
`created_at` timestamp,
`updated_at` timestamp
);
CREATE TABLE `PrintJob` (
`id` int UNIQUE PRIMARY KEY,
`printer_id` int,
`user_id` int,
`start_at` timestamp,
`duration_in_minutes` int,
`comments` text,
`aborted` boolean DEFAULT false,
`abort_reason` text COMMENT 'Error code displayed on printer'
);
CREATE TABLE `Account` (
`id` text UNIQUE PRIMARY KEY,
`userId` text,
`type` text,
`provider` text,
`providerAccountId` text,
`refresh_token` text,
`access_token` text,
`expires_at` int,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text
);
CREATE TABLE `Session` (
`id` text UNIQUE PRIMARY KEY,
`sessionToken` text UNIQUE,
`userId` text,
`expires` datetime
);
CREATE TABLE `User` (
`id` text PRIMARY KEY,
`name` text,
`email` text UNIQUE,
`role` text COMMENT 'ADMIN,USER,GUEST'
);
ALTER TABLE `User` ADD FOREIGN KEY (`id`) REFERENCES `PrintJob` (`user_id`);
ALTER TABLE `User` ADD FOREIGN KEY (`id`) REFERENCES `Account` (`userId`);
ALTER TABLE `User` ADD FOREIGN KEY (`id`) REFERENCES `Session` (`userId`);
ALTER TABLE `Printer` ADD FOREIGN KEY (`id`) REFERENCES `PrintJob` (`printer_id`);

22
frontend-aenderungen.md Normal file
View File

@@ -0,0 +1,22 @@
# Notwendige Frontend-Änderungen
1. Frontend-Authentifizierung anpassen:
- GitHub OAuth durch lokale Authentifizierung ersetzen
- Login-Komponenten für Benutzername/Passwort erstellen
- Registrierungs-Formular implementieren
- API-Routen für Login- und Registrierungsprozess anpassen
2. Datenbankschema:
- Users-Tabelle anpassen um Passwort-Hash zu unterstützen
- GitHub-ID entfernen oder optional machen
3. Auth-System:
- Lucia.js: Anpassung von OAuth auf Formular-basierte Authentifizierung
- Session-Management beibehalten
4. API-Endpunktanpassungen:
- Neue Login und Register-Endpunkte erstellen
- Route für initialen Admin-Setup
Die Änderungen im Frontend sind umfangreicher, da das aktuelle System stark auf GitHub OAuth ausgerichtet ist und komplett umgestellt werden muss.

View File

@@ -0,0 +1,27 @@
# Build and utility assets
docker/
scripts/
# Ignore node_modules as they will be installed in the container
node_modules
# Ignore build artifacts
.next
# Ignore runtime data
db/
# Ignore local configuration files
.env
.env.example
# Ignore version control files
.git
.gitignore
# Ignore IDE/editor specific files
*.log
*.tmp
*.DS_Store
.vscode/
.idea/

View File

@@ -0,0 +1,3 @@
# OAuth Configuration
OAUTH_CLIENT_ID=client_id
OAUTH_CLIENT_SECRET=client_secret

View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# db folder
db/
# Env file
.env
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,34 @@
FROM node:20-bookworm-slim
# Create application directory
RUN mkdir -p /usr/src/app
# Set environment variables
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /usr/src/app
# Copy package.json and pnpm-lock.yaml
COPY package.json /usr/src/app
COPY pnpm-lock.yaml /usr/src/app
# Install pnpm
RUN corepack enable pnpm
# Install dependencies
RUN pnpm install
# Copy the rest of the application code
COPY . /usr/src/app
# Initialize Database, if it not already exists
RUN pnpm run db
# Build the application
RUN pnpm run build
EXPOSE 3000
# Start the application
CMD ["/bin/sh", "-c", "if [ ! -f ./db/sqlite.db ]; then pnpm db; fi && pnpm start"]

View File

@@ -0,0 +1,32 @@
# MYP - Manage Your Printer
MYP (Manage Your Printer) ist eine Webanwendung zur Reservierung von 3D-Druckern.
Sie wurde im Rahmen des Abschlussprojektes der Fachinformatiker Ausbildung für Daten- und Prozessanalyse für die Technische Berufsausbildung des Mercedes-Benz Werkes Berlin-Marienfelde entwickelt.
## Deployment
### Voraussetzungen
- Netzwerk auf Raspberry Pi ist eingerichtet
- Docker ist installiert
### Schritte
1. Docker-Container bauen (docker/build.sh)
2. Docker-Container speichern (docker/save.sh caddy:2.8 myp-rp:latest)
3. Docker-Container auf Raspberry Pi bereitstellen (docker/deploy.sh)
## Entwicklerinformationen
### Raspberry Pi Einstellungen
Auf dem Raspberry Pi wurde Raspbian Lite installiert.
Unter /srv/* sind die Projektdateien zu finden.
### Anmeldedaten
```
Benutzer: myp
Passwort: (persönlich bekannt)
```

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://biomejs.dev/schemas/1.7.0/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utils/styles"
}
}

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Define image name
MYP_RP_IMAGE_NAME="myp-rp"
# Function to build Docker image
build_image() {
local image_name=$1
local dockerfile=$2
local platform=$3
echo "Building $image_name Docker image for $platform..."
docker buildx build --platform $platform -t ${image_name}:latest -f $dockerfile --load .
if [ $? -eq 0 ]; then
echo "$image_name Docker image built successfully"
else
echo "Error occurred while building $image_name Docker image"
exit 1
fi
}
# Create and use a builder instance (if not already created)
BUILDER_NAME="myp-rp-arm64-builder"
docker buildx create --name $BUILDER_NAME --use || docker buildx use $BUILDER_NAME
# Build myp-rp image
build_image "$MYP_RP_IMAGE_NAME" "$PWD/Dockerfile" "linux/arm64"
# Remove the builder instance
docker buildx rm $BUILDER_NAME

View File

@@ -0,0 +1,8 @@
{
debug
}
m040tbaraspi001.de040.corpintra.net, m040tbaraspi001.de040.corpinter.net {
reverse_proxy myp-rp:3000
tls internal
}

View File

@@ -0,0 +1,19 @@
services:
caddy:
image: caddy:2.8
container_name: caddy
restart: unless-stopped
ports:
- 80:80
- 443:443
volumes:
- ./caddy/data:/data
- ./caddy/config:/config
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
myp-rp:
image: myp-rp:latest
container_name: myp-rp
env_file: "/srv/myp-env/github.env"
volumes:
- /srv/MYP-DB:/usr/src/app/db
restart: unless-stopped

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Directory containing the Docker images
IMAGE_DIR="docker/images"
# Load all Docker images from the tar.xz files in the IMAGE_DIR
echo "Loading Docker images from $IMAGE_DIR..."
for image_file in "$IMAGE_DIR"/*.tar.xz; do
if [ -f "$image_file" ]; then
echo "Loading Docker image from $image_file..."
docker load -i "$image_file"
# Check if the image loading was successful
if [ $? -ne 0 ]; then
echo "Error occurred while loading Docker image from $image_file"
exit 1
fi
else
echo "No Docker image tar.xz files found in $IMAGE_DIR."
fi
done
# Execute docker compose
echo "Running docker compose..."
docker compose -f "docker/compose.yml" up -d
# Check if the operation was successful
if [ $? -eq 0 ]; then
echo "Docker compose executed successfully"
else
echo "Error occurred while executing docker compose"
exit 1
fi
echo "Deployment completed successfully"

View File

@@ -0,0 +1,2 @@
caddy_2.8.tar.xz filter=lfs diff=lfs merge=lfs -text
myp-rp_latest.tar.xz filter=lfs diff=lfs merge=lfs -text

BIN
packages/reservation-platform/docker/images/caddy_2.8.tar.xz (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Get image name as argument
IMAGE_NAME=$1
PLATFORM="linux/arm64"
# Define paths
IMAGE_DIR="docker/images"
IMAGE_FILE="${IMAGE_DIR}/${IMAGE_NAME//[:\/]/_}.tar"
COMPRESSED_FILE="${IMAGE_FILE}.xz"
# Function to pull the image
pull_image() {
local image=$1
if [[ $image == arm64v8/* ]]; then
echo "Pulling image $image without platform specification..."
docker pull $image
else
echo "Pulling image $image for platform $PLATFORM..."
docker pull --platform $PLATFORM $image
fi
return $?
}
# Pull the image if it is not available locally
if ! docker image inspect ${IMAGE_NAME} &>/dev/null; then
if pull_image ${IMAGE_NAME}; then
echo "Image $IMAGE_NAME pulled successfully."
else
echo "Error occurred while pulling $IMAGE_NAME for platform $PLATFORM"
echo "Trying to pull $IMAGE_NAME without platform specification..."
# Attempt to pull again without platform
if pull_image ${IMAGE_NAME}; then
echo "Image $IMAGE_NAME pulled successfully without platform."
else
echo "Error occurred while pulling $IMAGE_NAME without platform."
echo "Trying to pull arm64v8/${IMAGE_NAME} instead..."
# Construct new image name
NEW_IMAGE_NAME="arm64v8/${IMAGE_NAME}"
if pull_image ${NEW_IMAGE_NAME}; then
echo "Image $NEW_IMAGE_NAME pulled successfully."
IMAGE_NAME=${NEW_IMAGE_NAME} # Update IMAGE_NAME to use the new one
else
echo "Error occurred while pulling $NEW_IMAGE_NAME"
exit 1
fi
fi
fi
else
echo "Image $IMAGE_NAME found locally. Skipping pull."
fi
# Save the Docker image
echo "Saving $IMAGE_NAME Docker image..."
docker save ${IMAGE_NAME} > $IMAGE_FILE
# Compress the Docker image (overwriting if file exists)
echo "Compressing $IMAGE_FILE..."
xz -z --force $IMAGE_FILE
if [ $? -eq 0 ]; then
echo "$IMAGE_NAME Docker image saved and compressed successfully as $COMPRESSED_FILE"
else
echo "Error occurred while compressing $IMAGE_NAME Docker image"
exit 1
fi

View File

@@ -0,0 +1,116 @@
# **Detaillierte Dokumentation des Admin-Dashboards**
In diesem Abschnitt werde ich die Funktionen und Nutzung des Admin-Dashboards genauer beschreiben, einschließlich der verschiedenen Module, Diagramme und deren Zweck.
---
## **1. Überblick über das Admin-Dashboard**
Das Admin-Dashboard ist der zentrale Verwaltungsbereich für Administratoren. Es bietet Funktionen wie die Verwaltung von Druckern, Benutzern und Druckaufträgen sowie detaillierte Statistiken und Analysen.
### **1.1. Navigation**
Das Dashboard enthält ein Sidebar-Menü mit den folgenden Hauptbereichen:
1. **Dashboard:** Übersicht der wichtigsten Statistiken.
2. **Benutzer:** Verwaltung von Benutzerkonten.
3. **Drucker:** Hinzufügen, Bearbeiten und Verwalten von Druckern.
4. **Druckaufträge:** Einsicht in alle Druckaufträge und deren Status.
5. **Einstellungen:** Konfiguration der Anwendung.
6. **Über MYP:** Informationen über das Projekt und den Entwickler.
Die Sidebar wird in der Datei `src/app/admin/admin-sidebar.tsx` definiert und dynamisch basierend auf der aktuellen Seite hervorgehoben.
---
## **2. Funktionen des Admin-Dashboards**
### **2.1. Benutzerverwaltung**
- **Datei:** `src/app/admin/users/page.tsx`
- **Beschreibung:** Ermöglicht das Anzeigen, Bearbeiten und Löschen von Benutzerkonten.
- **Funktionen:**
- Anzeige einer Liste aller registrierten Benutzer.
- Bearbeiten von Benutzerrollen (z. B. „admin“ oder „user“).
- Deaktivieren oder Löschen von Benutzerkonten.
---
### **2.2. Druckerverwaltung**
- **Datei:** `src/app/admin/printers/page.tsx`
- **Beschreibung:** Verwaltung der Drucker, einschließlich Hinzufügen, Bearbeiten und Deaktivieren.
- **Funktionen:**
- Statusanzeige der Drucker (aktiv/inaktiv).
- Hinzufügen neuer Drucker mit Namen und Beschreibung.
- Löschen oder Bearbeiten bestehender Drucker.
---
### **2.3. Druckaufträge**
- **Datei:** `src/app/admin/jobs/page.tsx`
- **Beschreibung:** Übersicht aller Druckaufträge, einschließlich Details wie Startzeit, Dauer und Status.
- **Funktionen:**
- Filtern nach Benutzern, Druckern oder Status (abgeschlossen, abgebrochen).
- Anzeigen von Abbruchgründen und Fehlermeldungen.
- Sortieren nach Zeit oder Benutzer.
---
### **2.4. Einstellungen**
- **Datei:** `src/app/admin/settings/page.tsx`
- **Beschreibung:** Konfigurationsseite für die Anwendung.
- **Funktionen:**
- Ändern von globalen Einstellungen wie Standardzeiten oder Fehlerrichtlinien.
- Download von Daten (z. B. Export der Druckhistorie).
---
## **3. Statistiken und Diagramme**
Das Admin-Dashboard enthält interaktive Diagramme, die wichtige Statistiken visualisieren. Hier einige der zentralen Diagramme:
### **3.1. Abbruchgründe**
- **Datei:** `src/app/admin/charts/printer-error-chart.tsx`
- **Beschreibung:** Zeigt die Häufigkeit der Abbruchgründe für Druckaufträge in einem Balkendiagramm.
- **Nutzen:** Identifiziert häufige Probleme wie Materialmangel oder Düsenverstopfungen.
---
### **3.2. Fehlerraten**
- **Datei:** `src/app/admin/charts/printer-error-rate.tsx`
- **Beschreibung:** Zeigt die prozentuale Fehlerrate für jeden Drucker in einem Balkendiagramm.
- **Nutzen:** Ermöglicht die Überwachung und Identifizierung von problematischen Druckern.
---
### **3.3. Druckvolumen**
- **Datei:** `src/app/admin/charts/printer-volume.tsx`
- **Beschreibung:** Zeigt das Druckvolumen für heute, diese Woche und diesen Monat.
- **Nutzen:** Vergleich des Druckeroutputs über verschiedene Zeiträume.
---
### **3.4. Prognostizierte Nutzung**
- **Datei:** `src/app/admin/charts/printer-forecast.tsx`
- **Beschreibung:** Ein Bereichsdiagramm zeigt die erwartete Druckernutzung pro Wochentag.
- **Nutzen:** Hilft bei der Planung von Wartungsarbeiten oder Ressourcenzuweisungen.
---
### **3.5. Druckerauslastung**
- **Datei:** `src/app/admin/charts/printer-utilization.tsx`
- **Beschreibung:** Zeigt die aktuelle Nutzung eines Druckers in Prozent in einem Kreisdiagramm.
- **Nutzen:** Überwacht die Auslastung und identifiziert ungenutzte Ressourcen.
---
## **4. Rollenbasierte Zugriffssteuerung**
Das Admin-Dashboard ist nur für Benutzer mit der Rolle „admin“ zugänglich. Nicht berechtigte Benutzer werden auf die Startseite umgeleitet. Die Zugriffssteuerung erfolgt durch folgende Logik:
- **Datei:** `src/app/admin/layout.tsx`
- **Funktion:** `validateRequest` prüft die Rolle des aktuellen Benutzers.
- **Umleitung:** Falls die Rolle unzureichend ist, wird der Benutzer automatisch umgeleitet:
```typescript
if (guard(user, IS_NOT, UserRole.ADMIN)) {
redirect("/");
}
```
Nächster Schritt: [=> API-Endpunkte und deren Nutzung](./API.md)

View File

@@ -0,0 +1,79 @@
# **Technische Architektur und Codeaufbau**
In diesem Abschnitt erläutere ich die Architektur und Struktur des MYP-Projekts sowie die Funktionalitäten der zentralen Komponenten.
---
## **1. Technische Architektur**
### **1.1. Architekturübersicht**
MYP basiert auf einer modernen Webanwendungsarchitektur:
- **Frontend:** Entwickelt mit React und Next.js. Stellt die Benutzeroberfläche bereit.
- **Backend:** Nutzt Node.js und Drizzle ORM für die Datenbankinteraktion und Geschäftslogik.
- **Datenbank:** SQLite zur Speicherung von Nutzerdaten, Druckaufträgen und Druckerkonfigurationen.
- **Containerisierung:** Docker wird verwendet, um die Anwendung in isolierten Containern bereitzustellen.
- **Webserver:** Caddy dient als Reverse Proxy mit HTTPS-Unterstützung.
### **1.2. Modulübersicht**
- **Datenfluss:** Die Anwendung ist stark datengetrieben. API-Routen werden genutzt, um Daten zwischen Frontend und Backend auszutauschen.
- **Rollenbasierter Zugriff:** Über ein Berechtigungssystem können Administratoren und Benutzer unterschiedliche Funktionen nutzen.
---
## **2. Codeaufbau**
### **2.1. Ordnerstruktur**
Die Datei `repomix-output.txt` zeigt eine strukturierte Übersicht des Projekts. Nachfolgend einige wichtige Verzeichnisse:
| **Verzeichnis** | **Inhalt** |
|--------------------------|---------------------------------------------------------------------------|
| `src/app` | Next.js-Seiten und Komponenten für Benutzer und Admins. |
| `src/components` | Wiederverwendbare UI-Komponenten wie Karten, Diagramme, Buttons etc. |
| `src/server` | Backend-Logik, Authentifizierung und Datenbankinteraktionen. |
| `src/utils` | Hilfsfunktionen für Analysen, Validierungen und Datenbankzugriffe. |
| `drizzle` | Datenbank-Migrationsdateien und Metadaten. |
| `docker` | Docker-Konfigurations- und Bereitstellungsskripte. |
---
### **2.2. Hauptdateien**
#### **Frontend**
- **`src/app/page.tsx`:** Startseite der Anwendung.
- **`src/app/admin/`:** Admin-spezifische Seiten, z. B. Druckerverwaltung oder Fehlerstatistiken.
- **`src/components/ui/`:** UI-Komponenten wie Dialoge, Formulare und Tabellen.
#### **Backend**
- **`src/server/auth/`:** Authentifizierung und Benutzerrollenmanagement.
- **`src/server/actions/`:** Funktionen zur Interaktion mit Druckaufträgen und Druckern.
- **`src/utils/`:** Analyse und Verarbeitung von Druckdaten (z. B. Fehlerquoten und Auslastung).
#### **Datenbank**
- **`drizzle/0000_overjoyed_strong_guy.sql`:** SQLite-Datenbankschema mit Tabellen für Drucker, Benutzer und Druckaufträge.
- **`drizzle.meta/`:** Metadaten zur Datenbankmigration.
---
### **2.3. Datenbankschema**
Das Schema enthält vier Haupttabellen:
1. **`user`:** Speichert Benutzerinformationen, einschließlich Rollen und E-Mail-Adressen.
2. **`printer`:** Beschreibt die Drucker, ihren Status und ihre Eigenschaften.
3. **`printJob`:** Zeichnet Druckaufträge auf, einschließlich Startzeit, Dauer und Abbruchgrund.
4. **`session`:** Verwaltert Benutzer-Sitzungen und Ablaufzeiten.
---
## **3. Wichtige Funktionen**
### **3.1. Authentifizierung**
Das System nutzt OAuth zur Anmeldung. Benutzerrollen werden in der Tabelle `user` gespeichert und im Backend überprüft.
### **3.2. Statistiken**
- **Fehlerrate:** Berechnet die Häufigkeit von Abbrüchen für jeden Drucker.
- **Auslastung:** Prozentuale Nutzung der Drucker, basierend auf geplanten und abgeschlossenen Druckaufträgen.
- **Prognosen:** Verwenden historische Daten, um zukünftige Drucknutzungen vorherzusagen.
### **3.3. API-Endpunkte**
- **`src/app/api/printers/`:** Zugriff auf Druckerkonfigurationsdaten.
- **`src/app/api/job/[jobId]/`:** Verwaltung einzelner Druckaufträge.
Nächster Schritt: [=> Datenbank und Analytik-Funktionen](./Datenbank.md)

View File

@@ -0,0 +1,150 @@
# **Bereitstellungsdetails und Best Practices**
In diesem Abschnitt erläutere ich, wie das MYP-Projekt auf einem Server bereitgestellt wird, sowie empfohlene Praktiken zur Verwaltung und Optimierung des Systems.
---
## **1. Bereitstellungsschritte**
### **1.1. Voraussetzungen**
- **Server:** Raspberry Pi mit installiertem Raspbian Lite.
- **Docker:** Docker und Docker Compose müssen vorab installiert sein.
- **Netzwerk:** Der Server muss über eine statische IP-Adresse oder einen DNS-Namen erreichbar sein.
### **1.2. Vorbereitung**
#### **1.2.1. Docker-Images erstellen und speichern**
Führen Sie die folgenden Schritte auf dem Entwicklungssystem aus:
1. **Images erstellen:**
```bash
bash docker/build.sh
```
2. **Images exportieren und komprimieren:**
```bash
bash docker/save.sh <image-name>
```
Dies speichert die Docker-Images im Verzeichnis `docker/images/`.
#### **1.2.2. Übertragung auf den Server**
Kopieren Sie die erzeugten `.tar.xz`-Dateien auf den Raspberry Pi:
```bash
scp docker/images/*.tar.xz <username>@<server-ip>:/path/to/destination/
```
---
### **1.3. Images auf dem Server laden**
Loggen Sie sich auf dem Server ein und laden Sie die Docker-Images:
```bash
docker load -i /path/to/destination/<image-name>.tar.xz
```
---
### **1.4. Starten der Anwendung**
Führen Sie das Bereitstellungsskript aus:
```bash
bash docker/deploy.sh
```
Dieses Skript:
- Startet die Docker-Container mithilfe von `docker compose`.
- Verbindet den Reverse Proxy (Caddy) mit der Anwendung.
Die Anwendung sollte unter `http://<server-ip>` oder der konfigurierten Domain erreichbar sein.
---
## **2. Best Practices**
### **2.1. Sicherheit**
1. **Umgebungsvariablen schützen:**
- Stellen Sie sicher, dass die Datei `.env` nicht versehentlich in ein öffentliches Repository hochgeladen wird.
- Verwenden Sie geeignete Zugriffsrechte:
```bash
chmod 600 .env
```
2. **HTTPS aktivieren:**
- Der Caddy-Webserver unterstützt automatisch HTTPS. Stellen Sie sicher, dass eine gültige Domain konfiguriert ist.
3. **Zugriffsrechte beschränken:**
- Verwenden Sie Benutzerrollen („admin“, „guest“), um den Zugriff auf kritische Funktionen zu steuern.
---
### **2.2. Performance**
1. **Docker-Container optimieren:**
- Reduzieren Sie die Größe der Docker-Images, indem Sie unnötige Dateien in `.dockerignore` ausschließen.
2. **Datenbankwartung:**
- Führen Sie regelmäßige Backups der SQLite-Datenbank durch:
```bash
cp db/sqlite.db /path/to/backup/location/
```
- Optimieren Sie die Datenbank regelmäßig:
```sql
VACUUM;
```
3. **Skalierung:**
- Bei hoher Last kann die Anwendung mit Kubernetes oder einer Cloud-Lösung (z. B. AWS oder Azure) skaliert werden.
---
### **2.3. Fehlerbehebung**
1. **Logs überprüfen:**
- Docker-Logs können wichtige Debug-Informationen liefern:
```bash
docker logs <container-name>
```
2. **Health Checks:**
- Integrieren Sie Health Checks in die Docker Compose-Datei, um sicherzustellen, dass die Dienste korrekt laufen.
3. **Fehlerhafte Drucker deaktivieren:**
- Deaktivieren Sie Drucker mit einer hohen Fehlerrate über das Admin-Dashboard, um die Benutzererfahrung zu verbessern.
---
### **2.4. Updates**
1. **Neue Funktionen hinzufügen:**
- Aktualisieren Sie die Anwendung und erstellen Sie neue Docker-Images:
```bash
git pull origin main
bash docker/build.sh
```
- Stellen Sie die aktualisierten Images bereit:
```bash
bash docker/deploy.sh
```
2. **Datenbankmigrationen:**
- Führen Sie neue Migrationsskripte mit folgendem Befehl aus:
```bash
pnpm run db:migrate
```
---
## **3. Backup und Wiederherstellung**
### **3.1. Backups erstellen**
Sichern Sie wichtige Dateien und Datenbanken regelmäßig:
- **SQLite-Datenbank:**
```bash
cp db/sqlite.db /backup/location/sqlite-$(date +%F).db
```
- **Docker-Images:**
```bash
docker save myp-rp:latest | gzip > /backup/location/myp-rp-$(date +%F).tar.gz
```
### **3.2. Wiederherstellung**
- **Datenbank wiederherstellen:**
```bash
cp /backup/location/sqlite-<date>.db db/sqlite.db
```
- **Docker-Images importieren:**
```bash
docker load < /backup/location/myp-rp-<date>.tar.gz
```
Nächster Schritt: [=> Admin-Dashboard](./Admin-Dashboard.md)

View File

@@ -0,0 +1,153 @@
# **Datenbank und Analytik-Funktionen**
Dieser Abschnitt konzentriert sich auf die Struktur der Datenbank sowie die Analyse- und Prognosefunktionen, die im Projekt verwendet werden.
---
## **1. Datenbankstruktur**
Das Datenbankschema wurde mit **Drizzle ORM** definiert und basiert auf SQLite. Die wichtigsten Tabellen und ihre Zwecke sind:
### **1.1. Tabellenübersicht**
#### **`user`**
- Speichert Benutzerinformationen.
- Enthält Rollen wie „admin“ oder „guest“ zur Verwaltung von Berechtigungen.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige ID des Benutzers. |
| `github_id` | `integer` | ID des Benutzers aus dem OAuth-Dienst. |
| `name` | `text` | Benutzername. |
| `displayName` | `text` | Angezeigter Name. |
| `email` | `text` | E-Mail-Adresse. |
| `role` | `text` | Benutzerrolle, Standardwert: „guest“. |
---
#### **`printer`**
- Beschreibt verfügbare Drucker und deren Status.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige Drucker-ID. |
| `name` | `text` | Name des Druckers. |
| `description` | `text` | Beschreibung oder Spezifikationen. |
| `status` | `integer` | Betriebsstatus (0 = inaktiv, 1 = aktiv). |
---
#### **`printJob`**
- Speichert Informationen zu Druckaufträgen.
| **Feld** | **Typ** | **Beschreibung** |
|-----------------------|---------------|-------------------------------------------------------|
| `id` | `text` | Eindeutige Auftrags-ID. |
| `printerId` | `text` | Verweis auf die ID des Druckers. |
| `userId` | `text` | Verweis auf die ID des Benutzers. |
| `startAt` | `integer` | Startzeit des Druckauftrags (Unix-Timestamp). |
| `durationInMinutes` | `integer` | Dauer des Druckauftrags in Minuten. |
| `comments` | `text` | Zusätzliche Kommentare. |
| `aborted` | `integer` | 1 = Abgebrochen, 0 = Erfolgreich abgeschlossen. |
| `abortReason` | `text` | Grund für den Abbruch (falls zutreffend). |
---
#### **`session`**
- Verwaltert Benutzer-Sitzungen und Ablaufzeiten.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige Sitzungs-ID. |
| `user_id` | `text` | Verweis auf die ID des Benutzers. |
| `expires_at` | `integer` | Zeitpunkt, wann die Sitzung abläuft. |
---
### **1.2. Relationen**
- `printer``printJob`: Druckaufträge sind an spezifische Drucker gebunden.
- `user``printJob`: Druckaufträge werden Benutzern zugewiesen.
- `user``session`: Sitzungen verknüpfen Benutzer mit Login-Details.
---
## **2. Analytik-Funktionen**
Das Projekt bietet verschiedene Analytik- und Prognosetools, um die Druckernutzung und Fehler zu überwachen.
### **2.1. Fehlerratenanalyse**
- Funktion: `calculatePrinterErrorRate` (in `src/utils/analytics/error-rate.ts`).
- Berechnet die prozentuale Fehlerrate für jeden Drucker basierend auf abgebrochenen Aufträgen.
Beispielausgabe:
```json
[
{ "name": "Drucker 1", "errorRate": 5.2 },
{ "name": "Drucker 2", "errorRate": 3.7 }
]
```
---
### **2.2. Abbruchgründe**
- Funktion: `calculateAbortReasonsCount` (in `src/utils/analytics/errors.ts`).
- Zählt die Häufigkeit der Abbruchgründe aus der Tabelle `printJob`.
Beispielausgabe:
```json
[
{ "abortReason": "Materialmangel", "count": 10 },
{ "abortReason": "Düsenverstopfung", "count": 7 }
]
```
---
### **2.3. Nutzung und Prognosen**
#### Nutzung:
- Funktion: `calculatePrinterUtilization` (in `src/utils/analytics/utilization.ts`).
- Berechnet die Nutzung der Drucker in Prozent.
Beispielausgabe:
```json
{ "printerId": "1", "utilizationPercentage": 85 }
```
#### Prognosen:
- Funktion: `forecastPrinterUsage` (in `src/utils/analytics/forecast.ts`).
- Nutzt historische Daten, um die erwartete Druckernutzung für kommende Tage/Wochen zu schätzen.
Beispielausgabe:
```json
[
{ "day": 1, "usageMinutes": 300 },
{ "day": 2, "usageMinutes": 200 }
]
```
---
### **2.4. Druckvolumen**
- Funktion: `calculatePrintVolumes` (in `src/utils/analytics/volume.ts`).
- Vergleicht die Anzahl der abgeschlossenen Druckaufträge für heute, diese Woche und diesen Monat.
Beispielausgabe:
```json
{
"today": 15,
"thisWeek": 90,
"thisMonth": 300
}
```
---
## **3. Datenbankinitialisierung**
Die Datenbank wird über Skripte in der `package.json` initialisiert:
```bash
pnpm run db:clean # Datenbank und Migrationsordner löschen
pnpm run db:generate # Neues Schema generieren
pnpm run db:migrate # Migrationsskripte ausführen
```
Nächster Schritt: [=> Bereitstellungsdetails und Best Practices](./Bereitstellungsdetails.md)

Some files were not shown because too many files have changed in this diff Show More