整理文件夹及架构,加入打印机页面,octo反代有问题
This commit is contained in:
131
app/routes/admin_routes.py
Normal file
131
app/routes/admin_routes.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
# Guest User Middleware
|
||||
@admin_bp.before_request
|
||||
def require_admin():
|
||||
if not current_user.is_authenticated or not current_user.is_admin:
|
||||
flash('Admin access required', 'danger')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
@admin_bp.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
if request.method == 'POST':
|
||||
# concurrent_slices = request.form.get('concurrent_slices')
|
||||
offset_x = request.form.get('offset_x', '0')
|
||||
offset_y = request.form.get('offset_y', '0')
|
||||
proxy_skip_size_mb = request.form.get('proxy_skip_size_mb', '5.0')
|
||||
default_infill = request.form.get('default_infill', '20')
|
||||
default_support = request.form.get('default_support', 'false')
|
||||
default_support_pattern = request.form.get('default_support_pattern', 'tree')
|
||||
default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg')
|
||||
|
||||
# update or create config entries
|
||||
config_items = [
|
||||
('offset_x', offset_x),
|
||||
('offset_y', offset_y),
|
||||
('proxy_skip_size_mb', proxy_skip_size_mb),
|
||||
('default_infill', default_infill),
|
||||
('default_support', default_support),
|
||||
('default_support_pattern', default_support_pattern),
|
||||
('default_quality', default_quality)
|
||||
]
|
||||
for key, val in config_items:
|
||||
conf = SystemConfig.query.filter_by(key=key).first()
|
||||
if not conf:
|
||||
conf = SystemConfig(key=key)
|
||||
db.session.add(conf)
|
||||
conf.value = val
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': True}), 200
|
||||
flash('Settings updated successfully', 'success')
|
||||
return redirect(url_for('admin.settings'))
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
presets = get_quality_presets()
|
||||
return render_template('admin/settings.html', configs=configs, presets=presets)
|
||||
|
||||
@admin_bp.route('/users')
|
||||
def users():
|
||||
all_users = User.query.order_by(User.created_at.desc()).all()
|
||||
return render_template('admin/users.html', users=all_users)
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
|
||||
def delete_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.id == current_user.id:
|
||||
flash('You cannot delete yourself.', 'danger')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
print_files = PrintFile.query.filter_by(user_id=user.id).all()
|
||||
for print_file in print_files:
|
||||
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
proxy_path = stl_path + '.proxy.stl'
|
||||
|
||||
if os.path.exists(stl_path):
|
||||
try: os.remove(stl_path)
|
||||
except: pass
|
||||
if os.path.exists(proxy_path):
|
||||
try: os.remove(proxy_path)
|
||||
except: pass
|
||||
if os.path.exists(gcode_path):
|
||||
try: os.remove(gcode_path)
|
||||
except: pass
|
||||
|
||||
db.session.delete(print_file)
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f'User {user.username} and all their files have been deleted.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
def get_bed_dimensions():
|
||||
try:
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
w = data['overrides']['machine_width']['default_value']
|
||||
h = data['overrides']['machine_depth']['default_value']
|
||||
hd = data['overrides']['machine_height']['default_value']
|
||||
return w, h, hd
|
||||
except:
|
||||
return 200, 200, 200
|
||||
|
||||
def get_quality_presets():
|
||||
try:
|
||||
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
presets = []
|
||||
for f in files:
|
||||
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
|
||||
name = f.replace('.inst.cfg', '')
|
||||
presets.append((f, name))
|
||||
presets.sort(key=lambda x: x[1])
|
||||
return presets
|
||||
except:
|
||||
return []
|
||||
|
||||
46
app/routes/auth_routes.py
Normal file
46
app/routes/auth_routes.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
# Guest User Middleware
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username, is_guest=False).first()
|
||||
if user and check_password_hash(user.password_hash, password):
|
||||
login_user(user)
|
||||
return redirect(url_for('main.index'))
|
||||
flash('Invalid username or password', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
response = make_response(redirect(url_for('main.index')))
|
||||
response.delete_cookie('guest_id') # Optionally clear guest cookie
|
||||
return response
|
||||
|
||||
# --- Admin Routes ---
|
||||
|
||||
416
app/routes/main_routes.py
Normal file
416
app/routes/main_routes.py
Normal file
@@ -0,0 +1,416 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
# Guest User Middleware
|
||||
@main_bp.before_app_request
|
||||
def assign_guest_cookie():
|
||||
if not current_user.is_authenticated:
|
||||
guest_id = request.cookies.get('guest_id')
|
||||
if not guest_id:
|
||||
guest_id = str(uuid.uuid4())
|
||||
user = User(username=f'guest_{guest_id[:8]}', is_guest=True, guest_cookie_id=guest_id)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
# We will set the cookie in the response after request, see below
|
||||
request.guest_id_to_set = guest_id
|
||||
else:
|
||||
user = User.query.filter_by(guest_cookie_id=guest_id).first()
|
||||
if user:
|
||||
login_user(user)
|
||||
|
||||
@main_bp.after_app_request
|
||||
def set_guest_cookie(response):
|
||||
if hasattr(request, 'guest_id_to_set'):
|
||||
response.set_cookie('guest_id', request.guest_id_to_set, max_age=60*60*24*365) # 1 year
|
||||
return response
|
||||
|
||||
# --- Main Routes ---
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
return render_template('slice/index.html')
|
||||
|
||||
@main_bp.route('/set_language/<lang>')
|
||||
def set_language(lang):
|
||||
|
||||
if lang not in i18n_dict:
|
||||
lang = 'en'
|
||||
# return to previous page
|
||||
response = make_response(redirect(request.referrer or url_for('main.index')))
|
||||
response.set_cookie('lang', lang, max_age=60*60*24*365)
|
||||
return response
|
||||
|
||||
@main_bp.route('/files', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def files():
|
||||
if request.method == 'POST':
|
||||
if 'file' not in request.files:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'error': 'No file part'}), 400
|
||||
flash('No file part', 'danger')
|
||||
return redirect(request.url)
|
||||
|
||||
uploaded_files = request.files.getlist('file')
|
||||
success_count = 0
|
||||
|
||||
for file in uploaded_files:
|
||||
if file.filename == '':
|
||||
continue
|
||||
if file and file.filename.lower().endswith('.stl'):
|
||||
original_filename = file.filename # Do not use secure_filename to keep Chinese characters
|
||||
ext = os.path.splitext(original_filename)[1].lower()
|
||||
if not ext:
|
||||
ext = '.stl'
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
unique_filename = f"{timestamp}_{uuid.uuid4().hex}_{success_count}{ext}"
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
|
||||
file.save(filepath)
|
||||
|
||||
print_file = PrintFile(
|
||||
filename=unique_filename,
|
||||
original_filename=original_filename,
|
||||
file_type='stl',
|
||||
user_id=current_user.id,
|
||||
status='simplifying' # Set to simplifying while proxy is generated
|
||||
)
|
||||
db.session.add(print_file)
|
||||
db.session.commit()
|
||||
|
||||
# Start background simplification
|
||||
simplify_stl_task(print_file.id, filepath)
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': True, 'count': success_count})
|
||||
flash(f'{success_count} file(s) uploaded successfully!', 'success')
|
||||
else:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'error': 'No valid files uploaded'}), 400
|
||||
flash('No valid files uploaded', 'danger')
|
||||
|
||||
return redirect(url_for('main.files'))
|
||||
|
||||
# Order by newest first
|
||||
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
|
||||
return render_template('slice/files.html', files=user_files)
|
||||
|
||||
@main_bp.route('/api/files_status')
|
||||
@login_required
|
||||
def files_status():
|
||||
files = PrintFile.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify({str(f.id): f.status for f in files})
|
||||
|
||||
@main_bp.route('/download/<int:file_id>')
|
||||
@login_required
|
||||
def download_gcode(file_id):
|
||||
print_file = PrintFile.query.get_or_404(file_id)
|
||||
if print_file.user_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
if print_file.status != 'sliced':
|
||||
flash('File is not ready yet.', 'warning')
|
||||
return redirect(url_for('main.files'))
|
||||
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
|
||||
if os.path.exists(filepath):
|
||||
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
|
||||
return send_file(filepath, as_attachment=True, download_name=safe_name)
|
||||
flash('GCode file not found. It might have been deleted.', 'danger')
|
||||
return redirect(url_for('main.files'))
|
||||
|
||||
@main_bp.route('/preview_gcode/<int:file_id>')
|
||||
@login_required
|
||||
def preview_gcode(file_id):
|
||||
print_file = PrintFile.query.get_or_404(file_id)
|
||||
if print_file.user_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
|
||||
content = "File not found or not ready."
|
||||
line_count = 0
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r') as f:
|
||||
lines = f.readlines()
|
||||
line_count = len(lines)
|
||||
content = "".join(lines[:500]) # Preview first 500 lines
|
||||
if line_count > 500:
|
||||
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
|
||||
|
||||
w, h, hd = get_bed_dimensions()
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
offset_x = float(configs.get('offset_x', '0.0'))
|
||||
offset_y = float(configs.get('offset_y', '0.0'))
|
||||
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y)
|
||||
|
||||
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
|
||||
@login_required
|
||||
def delete_file(file_id):
|
||||
print_file = PrintFile.query.get_or_404(file_id)
|
||||
if print_file.user_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
|
||||
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
proxy_path = stl_path + '.proxy.stl'
|
||||
|
||||
if os.path.exists(stl_path):
|
||||
os.remove(stl_path)
|
||||
if os.path.exists(proxy_path):
|
||||
os.remove(proxy_path)
|
||||
if os.path.exists(gcode_path):
|
||||
os.remove(gcode_path)
|
||||
|
||||
db.session.delete(print_file)
|
||||
db.session.commit()
|
||||
flash(f"Deleted {print_file.original_filename} successfully.", 'success')
|
||||
return redirect(url_for('main.files'))
|
||||
|
||||
# --- Auth Routes ---
|
||||
|
||||
def get_bed_dimensions():
|
||||
try:
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
w = data['overrides']['machine_width']['default_value']
|
||||
h = data['overrides']['machine_depth']['default_value']
|
||||
hd = data['overrides']['machine_height']['default_value']
|
||||
return w, h, hd
|
||||
except:
|
||||
return 200, 200, 200
|
||||
|
||||
def get_quality_presets():
|
||||
try:
|
||||
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
presets = []
|
||||
for f in files:
|
||||
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
|
||||
name = f.replace('.inst.cfg', '')
|
||||
presets.append((f, name))
|
||||
presets.sort(key=lambda x: x[1])
|
||||
return presets
|
||||
except:
|
||||
return []
|
||||
|
||||
@main_bp.route('/plater')
|
||||
@login_required
|
||||
def plater():
|
||||
w, h, hd = get_bed_dimensions()
|
||||
presets = get_quality_presets()
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
offset_x = float(configs.get('offset_x', '0.0'))
|
||||
offset_y = float(configs.get('offset_y', '0.0'))
|
||||
|
||||
default_infill = configs.get('default_infill', '20')
|
||||
default_support = configs.get('default_support', 'false')
|
||||
default_support_pattern = configs.get('default_support_pattern', 'tree')
|
||||
default_quality = configs.get('default_quality', 'base_global_standard.inst.cfg')
|
||||
|
||||
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
|
||||
models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files]
|
||||
return render_template('slice/plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern)
|
||||
|
||||
@main_bp.route('/file/<int:file_id>')
|
||||
@login_required
|
||||
def serve_file(file_id):
|
||||
f = PrintFile.query.get_or_404(file_id)
|
||||
if f.user_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
||||
return send_file(path)
|
||||
|
||||
@main_bp.route('/proxy/<int:file_id>')
|
||||
@login_required
|
||||
def serve_proxy_file(file_id):
|
||||
f = PrintFile.query.get_or_404(file_id)
|
||||
if f.user_id != current_user.id and not current_user.is_admin:
|
||||
abort(403)
|
||||
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
||||
proxy_path = path + '.proxy.stl'
|
||||
if os.path.exists(proxy_path):
|
||||
return send_file(proxy_path)
|
||||
return send_file(path)
|
||||
|
||||
@main_bp.route('/api/merge_and_slice', methods=['POST'])
|
||||
@login_required
|
||||
def merge_and_slice():
|
||||
data = request.json
|
||||
pieces = data.get('pieces', [])
|
||||
quality = data.get('quality', 'base_global_standard.inst.cfg')
|
||||
infill_density = data.get('infill', '20')
|
||||
support_enable = data.get('support', 'false')
|
||||
support_pattern = data.get('support_pattern', 'lines')
|
||||
|
||||
if not pieces:
|
||||
return jsonify({'error': 'No pieces provided'}), 400
|
||||
|
||||
inputs = []
|
||||
# Build a combined name
|
||||
names = []
|
||||
for p in pieces[:3]: # Cap names at 3 to avoid super long string
|
||||
f = PrintFile.query.get(p['file_id'])
|
||||
if f and (f.user_id == current_user.id or current_user.is_admin):
|
||||
names.append(f.original_filename.replace('.stl', ''))
|
||||
|
||||
combined_name = ", ".join(names)
|
||||
if len(pieces) > 3:
|
||||
combined_name += "等合并切片"
|
||||
elif len(pieces) > 1:
|
||||
combined_name += "合并切片"
|
||||
else:
|
||||
combined_name += " 单独切片"
|
||||
|
||||
is_edit = data.get('is_edit', False)
|
||||
|
||||
for p in pieces:
|
||||
f = PrintFile.query.get(p['file_id'])
|
||||
if f and (f.user_id == current_user.id or current_user.is_admin):
|
||||
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
||||
inputs.append((path, p['matrix']))
|
||||
# 只有在单一编辑模式才修改原模型的矩阵 (如果多模型/新建模式,我们不修改原模型,而是后续记录到新的包含实体上)
|
||||
if 'raw_matrix' in p and is_edit and len(pieces) == 1:
|
||||
f.transform_matrix = json.dumps({
|
||||
"is_composite": False,
|
||||
"matrix": p['raw_matrix'],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
}
|
||||
})
|
||||
db.session.add(f)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
target_file_id = data.get('target_file_id')
|
||||
|
||||
if is_edit and target_file_id:
|
||||
print_file = PrintFile.query.get(target_file_id)
|
||||
if not print_file:
|
||||
return jsonify({'error': 'Original file not found'}), 404
|
||||
print_file.status = 'merging'
|
||||
|
||||
if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix:
|
||||
composite_data = {
|
||||
"is_composite": True,
|
||||
"parts": [],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
}
|
||||
}
|
||||
for p in pieces:
|
||||
pf = PrintFile.query.get(p['file_id'])
|
||||
if pf:
|
||||
composite_data['parts'].append({
|
||||
"file_id": pf.id,
|
||||
"name": pf.original_filename,
|
||||
"url": url_for('main.serve_proxy_file', file_id=pf.id),
|
||||
"raw_matrix": p.get('raw_matrix', p['matrix'])
|
||||
})
|
||||
print_file.transform_matrix = json.dumps(composite_data)
|
||||
elif len(pieces) == 1:
|
||||
print_file.transform_matrix = json.dumps({
|
||||
"is_composite": False,
|
||||
"matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']),
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
}
|
||||
})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl"
|
||||
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
|
||||
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
elif len(inputs) == 1 and is_edit:
|
||||
target_file_id = pieces[0]['file_id']
|
||||
print_file = PrintFile.query.get(target_file_id)
|
||||
if not print_file:
|
||||
return jsonify({'error': 'Original file not found'}), 404
|
||||
print_file.status = 'merging'
|
||||
db.session.commit()
|
||||
|
||||
# We still need to apply transforms to a temporary STL to generate correct GCode
|
||||
temp_filename = f"temp_{uuid.uuid4().hex}.stl"
|
||||
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
|
||||
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
else:
|
||||
# Multiple models, create a new "Merged Slice" PrintFile entry to keep track of combination
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl"
|
||||
merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
|
||||
|
||||
# 构建组合文件元数据树 (is_composite: true)
|
||||
composite_data = {
|
||||
"is_composite": True,
|
||||
"parts": [],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
}
|
||||
}
|
||||
for p in pieces:
|
||||
pf = PrintFile.query.get(p['file_id'])
|
||||
if pf:
|
||||
composite_data['parts'].append({
|
||||
"file_id": pf.id,
|
||||
"name": pf.original_filename,
|
||||
"url": url_for('main.serve_proxy_file', file_id=pf.id),
|
||||
"raw_matrix": p.get('raw_matrix', p['matrix'])
|
||||
})
|
||||
|
||||
print_file = PrintFile(
|
||||
filename=unique_filename,
|
||||
original_filename=f"{combined_name}.stl",
|
||||
file_type='stl',
|
||||
user_id=current_user.id,
|
||||
status='merging',
|
||||
transform_matrix=json.dumps(composite_data)
|
||||
)
|
||||
db.session.add(print_file)
|
||||
db.session.commit()
|
||||
|
||||
merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=False)
|
||||
|
||||
return jsonify({'success': True, 'message': 'Plater slice queued!'})
|
||||
|
||||
327
app/routes/printer_routes.py
Normal file
327
app/routes/printer_routes.py
Normal file
@@ -0,0 +1,327 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
|
||||
from flask_login import login_required, current_user
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
import websockets.exceptions
|
||||
import threading
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from app.models import SystemConfig, db
|
||||
from app.utils.octoprint_client import OctoPrintClient
|
||||
|
||||
printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
|
||||
|
||||
def get_octo_client():
|
||||
url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
apikey = SystemConfig.query.filter_by(key='octoprint_apikey').first()
|
||||
if url and url.value and apikey and apikey.value:
|
||||
return OctoPrintClient(url.value, apikey.value)
|
||||
return None
|
||||
|
||||
@printer_bp.route('/status')
|
||||
@login_required
|
||||
def status():
|
||||
client = get_octo_client()
|
||||
status_data = None
|
||||
job_data = None
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
job_data = client.get_job_info()
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
|
||||
return render_template('printer/status.html', status=status_data, job=job_data, error=error)
|
||||
|
||||
@printer_bp.route('/prepare')
|
||||
@login_required
|
||||
def prepare():
|
||||
client = get_octo_client()
|
||||
files = []
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
res = client.get_files()
|
||||
files = res.get('files', [])
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
return render_template('printer/prepare.html', files=files, error=error)
|
||||
|
||||
@printer_bp.route('/api/print_file', methods=['POST'])
|
||||
@login_required
|
||||
def api_print_file():
|
||||
path = request.json.get('path')
|
||||
location = request.json.get('origin', 'local')
|
||||
client = get_octo_client()
|
||||
if client and path:
|
||||
try:
|
||||
client.select_file(location, path, print_after_select=True)
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
return jsonify({"success": False, "error": "Not configured or missing path"})
|
||||
|
||||
@printer_bp.route('/control')
|
||||
@login_required
|
||||
def control():
|
||||
client = get_octo_client()
|
||||
webcam_url = None
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
webcam_url = client.get_webcam_stream_url()
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
return render_template('printer/control.html', webcam_url=webcam_url, error=error)
|
||||
|
||||
@printer_bp.route('/api/command', methods=['POST'])
|
||||
@login_required
|
||||
def api_command():
|
||||
cmd = request.json.get('command')
|
||||
client = get_octo_client()
|
||||
if client and cmd:
|
||||
try:
|
||||
if cmd == 'home':
|
||||
client.home_axes()
|
||||
elif cmd == 'pause':
|
||||
client.pause_print()
|
||||
elif cmd == 'cancel':
|
||||
client.cancel_print()
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
return jsonify({"success": False, "error": "Invalid client or command"})
|
||||
|
||||
@printer_bp.route('/octo_config', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def octo_config():
|
||||
if not current_user.is_admin:
|
||||
flash("Admin access required", "danger")
|
||||
return redirect(url_for('printer.status'))
|
||||
|
||||
if request.method == 'POST':
|
||||
url = request.form.get('octoprint_url', '')
|
||||
apikey = request.form.get('octoprint_apikey', '')
|
||||
|
||||
conf_url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
if not conf_url:
|
||||
conf_url = SystemConfig(key='octoprint_url')
|
||||
db.session.add(conf_url)
|
||||
conf_url.value = url.rstrip('/')
|
||||
|
||||
conf_key = SystemConfig.query.filter_by(key='octoprint_apikey').first()
|
||||
if not conf_key:
|
||||
conf_key = SystemConfig(key='octoprint_apikey')
|
||||
db.session.add(conf_key)
|
||||
conf_key.value = apikey
|
||||
|
||||
db.session.commit()
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': True}), 200
|
||||
flash("OctoPrint settings updated", "success")
|
||||
return redirect(url_for('printer.octo_config'))
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
return render_template('printer/octo_config.html', configs=configs)
|
||||
|
||||
@printer_bp.route('/octo_embed')
|
||||
@login_required
|
||||
def octo_embed():
|
||||
if not current_user.is_admin:
|
||||
flash("Admin access required", "danger")
|
||||
return redirect(url_for('printer.status'))
|
||||
|
||||
url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
embed_url = url_for('printer.octo_proxy') if url and url.value else None
|
||||
return render_template('printer/octo_embed.html', embed_url=embed_url)
|
||||
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, websocket=True)
|
||||
@printer_bp.route('/proxy/<path:path>', websocket=True)
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@login_required
|
||||
def octo_proxy(path):
|
||||
if not current_user.is_admin:
|
||||
return "Unauthorized", 403
|
||||
|
||||
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
if not url_config or not url_config.value:
|
||||
return "OctoPrint URL not configured", 404
|
||||
|
||||
base_url = url_config.value.rstrip('/')
|
||||
|
||||
# print("----- REQUEST HEADERS -----")
|
||||
# for k, v in request.headers:
|
||||
# print(f"{k}: {v}")
|
||||
# print("----- END HEADERS -----")
|
||||
|
||||
# --- WebSocket Proxy Logic ---
|
||||
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
||||
from flask_sock import Server, ConnectionClosed
|
||||
|
||||
# Check if environment supports WebSockets
|
||||
try:
|
||||
ws = Server(request.environ)
|
||||
except Exception as e:
|
||||
env_keys = sorted(list(request.environ.keys()))
|
||||
print(f"FAILED. ENV KEYS: {env_keys}")
|
||||
return f"WebSocket Upgrade Failed: {str(e)}", 400
|
||||
|
||||
def handle_ws():
|
||||
if base_url.startswith('https://'):
|
||||
ws_base = base_url.replace('https://', 'wss://', 1)
|
||||
else:
|
||||
ws_base = base_url.replace('http://', 'ws://', 1)
|
||||
|
||||
target_url = f"{ws_base}/{path}"
|
||||
if request.query_string:
|
||||
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
|
||||
print(f"WS Proxy Query String: {request.query_string.decode('utf-8')}")
|
||||
# Copy most headers, especially Origin to pass CORS
|
||||
ws_headers = {}
|
||||
for k, v in request.headers:
|
||||
if k.lower() not in ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'content-length']:
|
||||
ws_headers[k] = v
|
||||
|
||||
# Match Tornado's expectations for Origin to avoid 400 Bad Request
|
||||
parsed_base = urlparse(base_url)
|
||||
ws_headers['Host'] = parsed_base.netloc
|
||||
if 'Origin' in request.headers:
|
||||
ws_headers['Origin'] = base_url
|
||||
if 'Referer' in request.headers:
|
||||
ws_headers['Referer'] = f"{base_url}/{path}"
|
||||
|
||||
ws_headers['X-Real-IP'] = request.remote_addr
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
ws_headers['X-Forwarded-Proto'] = request.scheme
|
||||
ws_headers['X-Forwarded-Host'] = request.host
|
||||
ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
ws_headers['X-Script-Name'] = '/printer/proxy'
|
||||
|
||||
print(f"WS Proxy Connecting to: {target_url}")
|
||||
try:
|
||||
remote_ws = ws_connect(target_url, additional_headers=ws_headers)
|
||||
print("WS Proxy connected to remote.")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"Remote WS Connection Error: {e}")
|
||||
ws.close(1011, str(e))
|
||||
return
|
||||
|
||||
def recv_loop():
|
||||
print("WS recv_loop started")
|
||||
try:
|
||||
for message in remote_ws:
|
||||
ws.send(message)
|
||||
except Exception as e:
|
||||
print("WS recv error:", e)
|
||||
finally:
|
||||
try: remote_ws.close()
|
||||
except: pass
|
||||
try: ws.close()
|
||||
except: pass
|
||||
print("WS recv_loop ended")
|
||||
|
||||
t = threading.Thread(target=recv_loop)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
print("WS Entering client receive loop")
|
||||
try:
|
||||
while True:
|
||||
data = ws.receive()
|
||||
if data is None:
|
||||
break
|
||||
remote_ws.send(data)
|
||||
except Exception as e:
|
||||
print("WS send error:", e)
|
||||
finally:
|
||||
try: remote_ws.close()
|
||||
except: pass
|
||||
try: ws.close()
|
||||
except: pass
|
||||
print("WS client loop ended")
|
||||
|
||||
try:
|
||||
handle_ws()
|
||||
except ConnectionClosed:
|
||||
print("WS Connection Closed")
|
||||
except Exception as e:
|
||||
print("WS Error in handle_ws:", e)
|
||||
finally:
|
||||
try: ws.close()
|
||||
except: pass
|
||||
|
||||
class WebSocketResponse(Response):
|
||||
def __call__(self, *args, **kwargs):
|
||||
print("WS Response __call__")
|
||||
if getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
|
||||
return super().__call__(*args, **kwargs)
|
||||
return []
|
||||
|
||||
return WebSocketResponse()
|
||||
|
||||
# --- Standard HTTP Proxy Logic ---
|
||||
# from urllib.parse import urlparse
|
||||
target_url = f"{base_url}/{path}"
|
||||
|
||||
if request.query_string:
|
||||
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
|
||||
|
||||
# Build headers for reverse proxy based on nginx config reference
|
||||
parsed_base = urlparse(base_url)
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
|
||||
|
||||
# NGINX equivalent proxy headers
|
||||
headers['Host'] = request.host
|
||||
headers['X-Real-IP'] = request.remote_addr
|
||||
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
|
||||
headers['X-Forwarded-Protocol'] = request.scheme
|
||||
headers['X-Script-Name'] = "/printer/proxy"
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
headers['REMOTE-HOST'] = request.remote_addr
|
||||
|
||||
if request.headers.get('Upgrade'):
|
||||
headers['Upgrade'] = request.headers.get('Upgrade')
|
||||
if request.headers.get('Connection'):
|
||||
headers['Connection'] = request.headers.get('Connection')
|
||||
|
||||
try:
|
||||
# proxy_connect_timeout 60s, proxy_read_timeout 600s
|
||||
resp = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
timeout=(60, 600)
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Proxy connection error: {str(e)}", 502
|
||||
|
||||
# Strip headers that might break the iframe or framing
|
||||
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy']
|
||||
response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers]
|
||||
|
||||
def generate():
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
return Response(generate(), resp.status_code, response_headers)
|
||||
|
||||
Reference in New Issue
Block a user