backend erstellt flask
This commit is contained in:
parent
fb2d584874
commit
b7fe9a036a
11
Dokumentation.md
Normal file
11
Dokumentation.md
Normal 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
|
||||||
|
|
3
packages/flask-backend/.env
Normal file
3
packages/flask-backend/.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
DATABASE_URL=sqlite:///app.db
|
||||||
|
JWT_SECRET=dev-jwt-secret-change-in-production
|
3
packages/flask-backend/.env.example
Normal file
3
packages/flask-backend/.env.example
Normal 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
|
20
packages/flask-backend/Dockerfile
Normal file
20
packages/flask-backend/Dockerfile
Normal 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"]
|
96
packages/flask-backend/README.md
Normal file
96
packages/flask-backend/README.md
Normal 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
|
||||||
|
```
|
32
packages/flask-backend/app/__init__.py
Normal file
32
packages/flask-backend/app/__init__.py
Normal 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
|
5
packages/flask-backend/app/api/__init__.py
Normal file
5
packages/flask-backend/app/api/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
|
from app.api import printers, jobs, users
|
219
packages/flask-backend/app/api/jobs.py
Normal file
219
packages/flask-backend/app/api/jobs.py
Normal 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()
|
||||||
|
})
|
177
packages/flask-backend/app/api/printers.py
Normal file
177
packages/flask-backend/app/api/printers.py
Normal 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)
|
139
packages/flask-backend/app/api/users.py
Normal file
139
packages/flask-backend/app/api/users.py
Normal 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)
|
5
packages/flask-backend/app/auth/__init__.py
Normal file
5
packages/flask-backend/app/auth/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
from app.auth import routes
|
156
packages/flask-backend/app/auth/routes.py
Normal file
156
packages/flask-backend/app/auth/routes.py
Normal 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
|
124
packages/flask-backend/app/models.py
Normal file
124
packages/flask-backend/app/models.py
Normal 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()
|
||||||
|
}
|
13
packages/flask-backend/config.py
Normal file
13
packages/flask-backend/config.py
Normal 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
|
20
packages/flask-backend/docker-compose.yml
Normal file
20
packages/flask-backend/docker-compose.yml
Normal 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"
|
89
packages/flask-backend/migrations/alembic.ini
Normal file
89
packages/flask-backend/migrations/alembic.ini
Normal 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
|
91
packages/flask-backend/migrations/env.py
Normal file
91
packages/flask-backend/migrations/env.py
Normal 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()
|
24
packages/flask-backend/migrations/script.py.mako
Normal file
24
packages/flask-backend/migrations/script.py.mako
Normal 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"}
|
@ -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')
|
9
packages/flask-backend/requirements.txt
Normal file
9
packages/flask-backend/requirements.txt
Normal 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
|
23
packages/flask-backend/run.sh
Executable file
23
packages/flask-backend/run.sh
Executable 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
|
55
packages/flask-backend/scripts/init_db.py
Normal file
55
packages/flask-backend/scripts/init_db.py
Normal 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()
|
6
packages/flask-backend/wsgi.py
Normal file
6
packages/flask-backend/wsgi.py
Normal 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)
|
Loading…
x
Reference in New Issue
Block a user