feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 08:58:12 +02:00
parent e410bfbc16
commit 7b65c4b2e2
11 changed files with 1508 additions and 69 deletions
+530 -54
View File
@@ -1,10 +1,75 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response
import sqlite3, csv, io, os, zipfile, re, math
from flask import Flask, render_template, request, redirect, url_for, jsonify, Response, session, flash
import sqlite3, csv, io, os, zipfile, re, math, secrets, functools
from datetime import datetime, timedelta, date as date_cls
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
DATABASE = os.environ.get('DATABASE_PATH', '/data/pricing.db')
# ─── OIDC ─────────────────────────────────────────────────────────────────────
OIDC_ENABLED = bool(os.environ.get('OIDC_CLIENT_ID'))
oauth = OAuth(app)
if OIDC_ENABLED:
oauth.register(
name='authelia',
client_id=os.environ.get('OIDC_CLIENT_ID'),
client_secret=os.environ.get('OIDC_CLIENT_SECRET'),
server_metadata_url=os.environ.get('OIDC_DISCOVERY_URL'),
client_kwargs={
'scope': 'openid profile email',
'code_challenge_method': 'S256', # PKCE
},
)
def login_required(f):
@functools.wraps(f)
def decorated(*args, **kwargs):
if OIDC_ENABLED and 'user' not in session:
session['next'] = request.url
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated
@app.route('/login')
def login():
if not OIDC_ENABLED:
return redirect(url_for('dashboard'))
redirect_uri = url_for('auth_callback', _external=True)
return oauth.authelia.authorize_redirect(redirect_uri)
@app.route('/auth/callback')
def auth_callback():
token = oauth.authelia.authorize_access_token()
userinfo = token.get('userinfo') or oauth.authelia.userinfo()
session['user'] = {
'sub': userinfo.get('sub', ''),
'name': userinfo.get('name') or userinfo.get('preferred_username', 'Utilisateur'),
'email': userinfo.get('email', ''),
}
next_url = session.pop('next', None)
return redirect(next_url or url_for('dashboard'))
@app.route('/logout')
def logout():
session.clear()
if OIDC_ENABLED:
end_session = os.environ.get('OIDC_END_SESSION_URL', '')
if end_session:
post_logout = url_for('login', _external=True)
return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}")
return redirect(url_for('login'))
# Routes accessibles sans login
_OPEN_ROUTES = {'login', 'auth_callback', 'logout', 'static', 'client_portal', 'calendar_ics'}
@app.before_request
def require_login_global():
if OIDC_ENABLED and request.endpoint not in _OPEN_ROUTES and 'user' not in session:
session['next'] = request.url
return redirect(url_for('login'))
def get_db():
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
@@ -150,6 +215,10 @@ def init_db():
('cooldown_minutes','15'),
('failure_buffer_pct','10'),
('max_simultaneous_printers','5'),
('nc_url',''),
('nc_username',''),
('nc_app_password',''),
('nc_root_path','/3D'),
]
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
@@ -186,6 +255,9 @@ def init_db():
'ALTER TABLE clients ADD COLUMN default_material_profile_id INTEGER',
'ALTER TABLE clients ADD COLUMN default_pricing_profile_id INTEGER',
'ALTER TABLE jobs ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL',
'ALTER TABLE print_slots ADD COLUMN wasted_g REAL DEFAULT 0',
'ALTER TABLE materials ADD COLUMN total_wasted_g REAL DEFAULT 0',
'ALTER TABLE jobs ADD COLUMN client_token TEXT DEFAULT NULL',
]
for sql in migrations:
try:
@@ -199,49 +271,100 @@ def get_settings():
conn = get_db()
rows = conn.execute('SELECT key,value FROM settings').fetchall()
conn.close()
return {r['key']: float(r['value']) for r in rows}
result = {}
for r in rows:
try:
result[r['key']] = float(r['value'])
except (ValueError, TypeError):
result[r['key']] = r['value'] or ''
return result
def parse_3mf(file_stream):
result = {'weight_g': None, 'hours': None, 'minutes': None, 'filename': None}
"""
Parse un fichier .3mf Bambu Studio.
Retourne un dict avec :
- plates : liste de plaques [{index, weight_g, print_time_s, filaments:[{type,color,used_g}]}]
- fallback : {weight_g, hours, minutes} si slice_info absent
"""
result = {'plates': [], 'weight_g': None, 'hours': None, 'minutes': None}
try:
with zipfile.ZipFile(file_stream) as z:
for name in z.namelist():
if not any(name.endswith(e) for e in ['.xml','.model','.config','.json','.txt']):
continue
names = z.namelist()
# ── 1. Bambu slice_info.config (format XML par plaque) ──────────
slice_info_candidates = [n for n in names if 'slice_info' in n.lower() and n.endswith('.config')]
if slice_info_candidates:
raw = z.read(slice_info_candidates[0]).decode('utf-8', errors='ignore')
try:
raw = z.read(name).decode('utf-8', errors='ignore')
root = ET.fromstring(raw)
for plate_el in root.findall('plate'):
plate = {}
meta = {m.get('key'): m.get('value') for m in plate_el.findall('metadata')}
plate['index'] = int(meta.get('index', 0))
plate['print_time_s'] = int(meta.get('prediction', 0))
filaments = []
total_g = 0.0
for fil in plate_el.findall('filament'):
used_g = float(fil.get('used_g', 0) or 0)
total_g += used_g
filaments.append({
'id': fil.get('id', ''),
'type': fil.get('type', ''),
'color': fil.get('color', ''),
'used_g': round(used_g, 2),
'used_m': round(float(fil.get('used_m', 0) or 0), 2),
})
plate['weight_g'] = round(total_g, 2)
plate['filaments'] = filaments
# Libellé lisible
h = plate['print_time_s'] // 3600
mn = (plate['print_time_s'] % 3600) // 60
plate['label'] = f"Plateau {plate['index']}{plate['weight_g']} g — {h}h{mn:02d}m"
result['plates'].append(plate)
result['plates'].sort(key=lambda p: p['index'])
except Exception:
continue
if result['weight_g'] is None:
for pat in [
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
result['weight_g'] = float(m.group(1).replace(',','.'))
break
except ValueError:
pass
if result['hours'] is None:
for pat in [
r'(\d+)h\s*(\d+)m',
r'(\d+)\s*hours?\s*(\d+)\s*min',
r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
h, mn = int(m.group(1)), int(m.group(2))
if 0 <= h <= 500 and 0 <= mn <= 59:
result['hours'] = h
result['minutes'] = mn
pass
# ── 2. Fallback regex si slice_info absent ──────────────────────
if not result['plates']:
for name in names:
if not any(name.endswith(e) for e in ['.xml', '.model', '.config', '.json', '.txt']):
continue
try:
raw = z.read(name).decode('utf-8', errors='ignore')
except Exception:
continue
if result['weight_g'] is None:
for pat in [
r'filament[_\s]*weight[^>]*?["\s](\d+[\.,]\d+)',
r'used\s*\[g\][^"]*"\s*value\s*=\s*"(\d+[\.,]\d+)',
r'"weight"\s*[:=]\s*"?(\d+[\.,]\d+)',
r'(\d+[\.,]\d+)\s*g\b(?!\s*[/])',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
result['weight_g'] = float(m.group(1).replace(',', '.'))
break
except (ValueError, IndexError):
pass
except ValueError:
pass
if result['hours'] is None:
for pat in [
r'(\d+)h\s*(\d+)m',
r'(\d+)\s*hours?\s*(\d+)\s*min',
r'printing.time[^"]*"\s*value\s*=\s*"(\d+)h\s*(\d+)',
]:
m = re.search(pat, raw, re.IGNORECASE)
if m:
try:
h, mn = int(m.group(1)), int(m.group(2))
if 0 <= h <= 500 and 0 <= mn <= 59:
result['hours'] = h
result['minutes'] = mn
break
except (ValueError, IndexError):
pass
except Exception:
pass
return result
@@ -1209,6 +1332,117 @@ def delete_calendar_block(id):
conn.commit(); conn.close()
return redirect(url_for('calendar_blocks'))
# ─── Page mobile saisie rapide ────────────────────────────────────────────────
@app.route('/mobile', methods=['GET', 'POST'])
def mobile_quick():
conn = get_db()
if request.method == 'POST':
action = request.form.get('action')
if action == 'block':
conn.execute(
'INSERT INTO calendar_blocks(date,type,printer_id,notes) VALUES(?,?,?,?)',
(request.form['date'], request.form.get('type', 'off'),
request.form.get('printer_id') or None,
request.form.get('notes', '')))
conn.commit()
elif action == 'shift':
slot_id = request.form.get('slot_id')
new_start = request.form.get('new_start')
if slot_id and new_start:
slot = conn.execute('SELECT * FROM print_slots WHERE id=?', (slot_id,)).fetchone()
if slot:
try:
orig_s = datetime.fromisoformat(slot['planned_start'])
orig_e = datetime.fromisoformat(slot['planned_end'])
new_s = datetime.fromisoformat(new_start)
delta = new_s - orig_s
new_e = (orig_e + delta).isoformat()
conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?',
(new_s.isoformat(), new_e, slot_id))
conn.commit()
except Exception:
pass
elif action == 'fail':
slot_id = request.form.get('slot_id')
note = request.form.get('failure_note', '')
if slot_id:
conn.execute("UPDATE print_slots SET status='failed',failure_note=? WHERE id=?",
(note, slot_id))
conn.commit()
conn.close()
return redirect(url_for('mobile_quick'))
printers = conn.execute('SELECT * FROM printers WHERE is_active=1 ORDER BY id').fetchall()
today = date_cls.today().isoformat()
upcoming = conn.execute('''
SELECT ps.*, j.name as job_name, p.name as printer_name,
c.name as client_name
FROM print_slots ps
JOIN jobs j ON ps.job_id=j.id
JOIN printers p ON ps.printer_id=p.id
LEFT JOIN clients c ON j.client_id=c.id
WHERE ps.status='planned' AND date(ps.planned_start) >= ?
ORDER BY ps.planned_start LIMIT 20''', (today,)).fetchall()
conn.close()
return render_template('mobile.html', printers=printers,
upcoming=upcoming, today=today)
# ─── Gestion échecs impression ────────────────────────────────────────────────
@app.route('/api/slots/<int:id>/fail', methods=['POST'])
def fail_slot(id):
"""Marque un créneau comme échoué, log le gaspillage filament."""
conn = get_db()
slot = conn.execute('''
SELECT ps.*, j.weight_g, j.material_id, j.pieces_per_plate
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
WHERE ps.id=?''', (id,)).fetchone()
if not slot:
conn.close()
return jsonify({'error': 'Créneau introuvable'}), 404
note = request.json.get('note', '') if request.is_json else request.form.get('note', '')
wasted_g = float(request.json.get('wasted_g', 0) if request.is_json else request.form.get('wasted_g', 0) or 0)
# Si wasted_g non précisé, estimer = poids plateau complet
if wasted_g == 0 and slot['weight_g']:
wasted_g = slot['weight_g']
conn.execute("""UPDATE print_slots
SET status='failed', failure_note=?, wasted_g=?
WHERE id=?""", (note, wasted_g, id))
# Déduire du stock matière si lié
if slot['material_id'] and wasted_g > 0:
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?), total_wasted_g = COALESCE(total_wasted_g,0) + ? WHERE id=?',
(wasted_g, wasted_g, slot['material_id']))
conn.commit(); conn.close()
return jsonify({'ok': True, 'wasted_g': wasted_g})
@app.route('/api/slots/<int:id>/done', methods=['POST'])
def done_slot(id):
"""Marque un créneau comme terminé avec le poids réel consommé."""
conn = get_db()
slot = conn.execute('''
SELECT ps.*, j.material_id FROM print_slots ps
JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?''', (id,)).fetchone()
if not slot:
conn.close()
return jsonify({'error': 'Créneau introuvable'}), 404
actual_g = float(request.json.get('actual_g', 0) if request.is_json else request.form.get('actual_g', 0) or 0)
conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (id,))
# Déduire le poids réel consommé du stock
if slot['material_id'] and actual_g > 0:
conn.execute('UPDATE materials SET stock_g = MAX(0, stock_g - ?) WHERE id=?',
(actual_g, slot['material_id']))
conn.commit(); conn.close()
return jsonify({'ok': True})
# ─── API ──────────────────────────────────────────────────────────────────────
@app.route('/api/calculate', methods=['POST'])
@@ -1297,6 +1531,12 @@ def api_parse_3mf():
return jsonify({'error': 'Format .3mf requis'}), 400
result = parse_3mf(f.stream)
result['filename'] = os.path.splitext(f.filename)[0]
# Si une seule plaque, pré-sélectionner automatiquement
if result['plates'] and len(result['plates']) == 1:
p = result['plates'][0]
result['weight_g'] = p['weight_g']
result['hours'] = p['print_time_s'] // 3600
result['minutes'] = (p['print_time_s'] % 3600) // 60
return jsonify(result)
@app.route('/api/slots')
@@ -1417,12 +1657,25 @@ def api_availability():
# ─── iCal Export ──────────────────────────────────────────────────────────────
def _ical_escape(s):
return str(s).replace('\\','\\\\').replace('\n','\\n').replace(',','\\,').replace(';','\\;')
def _ical_dt(dt_str):
return dt_str.replace('-','').replace(':','').replace(' ','T').split('.')[0]
@app.route('/calendar.ics')
def calendar_ics():
# Token optionnel : si ical_token est défini dans les settings, vérifier ?token=xxx
conn = get_db()
s_row = conn.execute("SELECT value FROM settings WHERE key='ical_token'").fetchone()
ical_token = s_row['value'] if s_row else ''
if ical_token and request.args.get('token', '') != ical_token:
conn.close()
return Response('Non autorisé', status=401)
slots = conn.execute('''
SELECT ps.*, j.name as job_name, j.print_time_s,
j.order_qty, j.pieces_per_plate, j.weight_g,
SELECT ps.*, j.name as job_name, j.order_qty, j.pieces_per_plate,
j.weight_g, j.notes as job_notes,
p.name as printer_name, c.name as client_name
FROM print_slots ps
JOIN jobs j ON ps.job_id=j.id
@@ -1430,8 +1683,15 @@ def calendar_ics():
LEFT JOIN clients c ON j.client_id=c.id
WHERE ps.status NOT IN ('cancelled')
ORDER BY ps.planned_start''').fetchall()
blocks = conn.execute('''
SELECT cb.*, p.name as printer_name
FROM calendar_blocks cb
LEFT JOIN printers p ON cb.printer_id=p.id
ORDER BY cb.date''').fetchall()
conn.close()
now_str = datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
@@ -1440,33 +1700,249 @@ def calendar_ics():
'METHOD:PUBLISH',
'X-WR-CALNAME:Planning Impression 3D',
'X-WR-TIMEZONE:Europe/Paris',
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
]
# Créneaux d'impression
for s in slots:
start = s['planned_start'].replace('-','').replace(':','').replace(' ','T')
if '.' in start:
start = start.split('.')[0]
end = s['planned_end'].replace('-','').replace(':','').replace(' ','T')
if '.' in end:
end = end.split('.')[0]
if 'T' not in start:
start += 'T000000'
if 'T' not in end:
end += 'T000000'
start = _ical_dt(s['planned_start'])
end = _ical_dt(s['planned_end'])
client_part = f"{s['client_name']}" if s['client_name'] else ''
desc_parts = [
f"Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau",
f"Statut: {s['status']}",
]
if s['failure_note']:
desc_parts.append(f"Note: {s['failure_note']}")
if s['job_notes']:
desc_parts.append(s['job_notes'])
lines += [
'BEGIN:VEVENT',
f"DTSTART:{start}",
f"DTEND:{end}",
f"SUMMARY:{s['job_name']}{client_part} [{s['printer_name']}]",
f"DESCRIPTION:Qty: {s['order_qty']} pcs | {s['pieces_per_plate']} pcs/plateau | Statut: {s['status']}",
f"DTSTAMP:{now_str}",
f"SUMMARY:{_ical_escape(s['job_name'] + client_part + ' [' + s['printer_name'] + ']')}",
f"DESCRIPTION:{_ical_escape(' | '.join(desc_parts))}",
f"UID:slot-{s['id']}@3d-pricing",
f"STATUS:{'CONFIRMED' if s['status']=='done' else 'TENTATIVE'}",
f"CATEGORIES:{'Terminé' if s['status']=='done' else 'Planifié'}",
'END:VEVENT',
]
# Jours indisponibles / bloqués
for b in blocks:
date_val = b['date'].replace('-', '')
# Date suivante pour DTEND all-day
d_obj = datetime.strptime(b['date'], '%Y-%m-%d') + timedelta(days=1)
end_val = d_obj.strftime('%Y%m%d')
label_map = {'off': '🔴 Jour off', 'maintenance': '🔧 Maintenance', 'other': '📌 Bloqué'}
label = label_map.get(b['type'], b['type'])
if b['printer_name']:
label += f"{b['printer_name']}"
notes = b['notes'] or ''
lines += [
'BEGIN:VEVENT',
f"DTSTART;VALUE=DATE:{date_val}",
f"DTEND;VALUE=DATE:{end_val}",
f"DTSTAMP:{now_str}",
f"SUMMARY:{_ical_escape(label)}",
f"DESCRIPTION:{_ical_escape(notes)}",
f"UID:block-{b['id']}@3d-pricing",
'TRANSP:TRANSPARENT',
'STATUS:CONFIRMED',
'END:VEVENT',
]
lines.append('END:VCALENDAR')
ical = '\r\n'.join(lines)
return Response(ical, mimetype='text/calendar',
headers={'Content-Disposition': 'attachment; filename=planning_3d.ics'})
return Response(ical, mimetype='text/calendar; charset=utf-8',
headers={'Content-Disposition': 'inline; filename=planning_3d.ics'})
# ─── Nextcloud WebDAV ────────────────────────────────────────────────────────
import xml.etree.ElementTree as ET
import requests as _requests
from urllib.parse import quote, unquote
def _nc_session():
"""Retourne (session_requests, base_url, username) ou None si non configuré."""
s = get_settings()
url = str(s.get('nc_url', '')).rstrip('/')
user = str(s.get('nc_username', ''))
pwd = str(s.get('nc_app_password', ''))
if not url or not user or not pwd:
return None, None, None
sess = _requests.Session()
sess.auth = (user, pwd)
sess.headers['OCS-APIRequest'] = 'true'
return sess, url, user
def _dav_url(base_url, username, path):
"""Construit l'URL WebDAV pour un chemin Nextcloud."""
clean = path.lstrip('/')
return f"{base_url}/remote.php/dav/files/{quote(username)}/{quote(clean, safe='/')}"
def _parse_propfind(xml_bytes, base_url, username, current_path):
"""Parse la réponse PROPFIND et retourne liste de dicts {name, path, is_dir, size, mtime}."""
ns = {'d': 'DAV:'}
root = ET.fromstring(xml_bytes)
items = []
dav_prefix = f"/remote.php/dav/files/{username}/"
for resp in root.findall('d:response', ns):
href = resp.findtext('d:href', '', ns)
# Enlever le préfixe DAV pour obtenir le chemin relatif
rel = unquote(href).replace(f"/remote.php/dav/files/{username}", '').rstrip('/')
if not rel:
rel = '/'
# Ignorer l'entrée courante elle-même
norm_current = ('/' + current_path.strip('/')).rstrip('/')
if rel == norm_current or (rel + '/') == norm_current:
continue
propstat = resp.find('d:propstat/d:prop', ns)
is_dir = propstat.find('d:resourcetype/d:collection', ns) is not None if propstat is not None else False
size = int(propstat.findtext('d:getcontentlength', '0', ns) or 0) if propstat is not None else 0
mtime = propstat.findtext('d:getlastmodified', '', ns) if propstat is not None else ''
name = rel.split('/')[-1] or rel
# Filtrer : dossiers ou extensions 3D
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
if not is_dir and ext not in ('3mf', 'stl', 'step', 'stp', 'obj', 'gcode'):
continue
items.append({'name': name, 'path': rel, 'is_dir': is_dir,
'size': size, 'mtime': mtime, 'ext': ext if not is_dir else 'dir'})
items.sort(key=lambda x: (0 if x['is_dir'] else 1, x['name'].lower()))
return items
@app.route('/api/nextcloud/browse')
def nc_browse():
path = request.args.get('path', '')
sess, base_url, username = _nc_session()
if not sess:
return jsonify({'error': 'Nextcloud non configuré'}), 503
# Si path vide, partir du nc_root_path
if not path:
s = get_settings()
path = str(s.get('nc_root_path', '/3D'))
dav_url = _dav_url(base_url, username, path)
try:
r = sess.request('PROPFIND', dav_url,
headers={'Depth': '1', 'Content-Type': 'application/xml'},
data='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop>'
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
'</d:prop></d:propfind>',
timeout=10)
if r.status_code == 404:
return jsonify({'error': f'Chemin introuvable : {path}'}), 404
if r.status_code not in (207, 200):
return jsonify({'error': f'Erreur WebDAV {r.status_code}'}), 502
items = _parse_propfind(r.content, base_url, username, path)
# Construire le breadcrumb
parts = [p for p in path.split('/') if p]
breadcrumb = [{'name': 'Nextcloud', 'path': '/'}]
for i, p in enumerate(parts):
breadcrumb.append({'name': p, 'path': '/' + '/'.join(parts[:i+1])})
return jsonify({'path': path, 'items': items, 'breadcrumb': breadcrumb})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/nextcloud/link')
def nc_link():
"""Génère un lien de partage Nextcloud pour un fichier via l'API OCS."""
path = request.args.get('path', '')
sess, base_url, username = _nc_session()
if not sess or not path:
return jsonify({'error': 'Paramètre manquant ou Nextcloud non configuré'}), 400
try:
r = sess.post(
f"{base_url}/ocs/v2.php/apps/files_sharing/api/v1/shares",
data={'path': path, 'shareType': 3, 'permissions': 1}, # 3=public link, 1=read
headers={'Accept': 'application/json'},
timeout=10)
data = r.json()
token = data.get('ocs', {}).get('data', {}).get('token', '')
if token:
return jsonify({'url': f"{base_url}/s/{token}"})
return jsonify({'error': 'Impossible de créer le lien'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── Portail client ───────────────────────────────────────────────────────────
@app.route('/jobs/<int:id>/generate-token', methods=['POST'])
def generate_client_token(id):
"""Génère ou regénère le token de partage client pour une commande."""
token = secrets.token_urlsafe(20)
conn = get_db()
conn.execute('UPDATE jobs SET client_token=? WHERE id=?', (token, id))
conn.commit(); conn.close()
return jsonify({'token': token,
'url': url_for('client_portal', token=token, _external=True)})
@app.route('/jobs/<int:id>/revoke-token', methods=['POST'])
def revoke_client_token(id):
conn = get_db()
conn.execute('UPDATE jobs SET client_token=NULL WHERE id=?', (id,))
conn.commit(); conn.close()
return jsonify({'ok': True})
@app.route('/portal/<token>')
def client_portal(token):
"""Vue publique (sans prix) pour le client."""
conn = get_db()
job = conn.execute('''
SELECT j.*, c.name as client_name, m.name as material_name, m.type as material_type,
m.color as material_color
FROM jobs j
LEFT JOIN clients c ON j.client_id=c.id
LEFT JOIN materials m ON j.material_id=m.id
WHERE j.client_token=?''', (token,)).fetchone()
if not job:
conn.close()
return render_template('portal_404.html'), 404
slots = conn.execute('''
SELECT ps.planned_start, ps.planned_end, ps.status, ps.failure_note,
p.name as printer_name
FROM print_slots ps
JOIN printers p ON ps.printer_id=p.id
WHERE ps.job_id=? ORDER BY ps.planned_start''', (job['id'],)).fetchall()
conn.close()
return render_template('client_portal.html', job=job, slots=slots)
@app.route('/api/nextcloud/file')
def nc_file():
"""Proxy WebDAV : télécharge un fichier Nextcloud et le sert au navigateur."""
path = request.args.get('path', '')
if not path:
return Response('Paramètre path manquant', status=400)
sess, base_url, username = _nc_session()
if not sess:
return Response('Nextcloud non configuré', status=503)
dav_url = _dav_url(base_url, username, path)
try:
r = sess.get(dav_url, timeout=30, stream=True)
if r.status_code != 200:
return Response(f'Erreur WebDAV {r.status_code}', status=502)
filename = path.split('/')[-1]
ext = filename.rsplit('.', 1)[-1].lower()
mime_map = {
'stl': 'model/stl',
'3mf': 'model/3mf',
'step': 'application/step',
'stp': 'application/step',
'obj': 'model/obj',
}
mime = mime_map.get(ext, 'application/octet-stream')
return Response(r.content, mimetype=mime,
headers={'Content-Disposition': f'inline; filename="{filename}"',
'Access-Control-Allow-Origin': '*'})
except Exception as e:
return Response(str(e), status=500)
# ─── Paramètres ───────────────────────────────────────────────────────────────