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 ───────────────────────────────────────────────────────────────
+8
View File
@@ -9,6 +9,14 @@ services:
- 3d-pricing-data:/data
environment:
- DATABASE_PATH=/data/pricing.db
- SECRET_KEY=${SECRET_KEY:-changeme_generate_with_openssl_rand_hex_32}
# ── OIDC Authelia (laisser vide pour désactiver) ──────────────────
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
# URL de fin de session Authelia (optionnel)
# ex: https://auth.tondomain.com/api/oidc/end-session
- OIDC_END_SESSION_URL=${OIDC_END_SESSION_URL:-}
volumes:
3d-pricing-data:
+2
View File
@@ -1 +1,3 @@
flask>=3.0.0
authlib>=1.3.0
requests>=2.31.0
+16
View File
@@ -104,7 +104,23 @@
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
<i class="bi bi-sliders"></i> Paramètres
</a>
<a href="{{ url_for('mobile_quick') }}" class="{% if request.endpoint=='mobile_quick' %}active{% endif %}">
<i class="bi bi-phone"></i> Vue mobile
</a>
</nav>
{% if session.user %}
<div style="padding:.75rem 1.25rem;border-top:1px solid #2e323d;margin-top:auto">
<div style="font-size:.75rem;color:#9ba3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
title="{{ session.user.email }}">
<i class="bi bi-person-circle me-1"></i>{{ session.user.name }}
</div>
<a href="{{ url_for('logout') }}"
style="font-size:.72rem;color:#6b7280;text-decoration:none;display:block;margin-top:.25rem">
<i class="bi bi-box-arrow-left me-1"></i>Déconnexion
</a>
</div>
{% endif %}
</div>
<div id="main">
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Suivi commande — {{ job.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background: #f4f6f9; }
.topbar { background: #1a1d23; color: #fff; padding: 1rem 1.5rem; }
.topbar .brand { font-weight: 800; font-size: 1.2rem; }
.topbar .brand span { color: #ff6b35; }
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-val { font-size: 1.5rem; font-weight: 700; }
.timeline { position: relative; padding-left: 2rem; }
.timeline::before { content: ''; position: absolute; left: .6rem; top: 0; bottom: 0;
width: 2px; background: #dee2e6; }
.timeline-item { position: relative; margin-bottom: 1.2rem; }
.timeline-item::before { content: ''; position: absolute; left: -1.55rem; top: .3rem;
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;
box-shadow: 0 0 0 2px #dee2e6; background: #6b7280; }
.timeline-item.done::before { background: #22c55e; box-shadow: 0 0 0 2px #22c55e; }
.timeline-item.failed::before { background: #ef4444; box-shadow: 0 0 0 2px #ef4444; }
.timeline-item.planned::before{ background: #3b82f6; box-shadow: 0 0 0 2px #3b82f6; }
</style>
</head>
<body>
<div class="topbar d-flex align-items-center justify-content-between">
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
<small class="text-secondary">Suivi de commande</small>
</div>
<div class="container py-4" style="max-width:720px">
<!-- En-tête commande -->
<div class="card mb-4">
<div class="card-body">
<h4 class="fw-bold mb-1">{{ job.name }}</h4>
{% if job.client_name %}
<div class="text-muted small mb-3"><i class="bi bi-building me-1"></i>{{ job.client_name }}</div>
{% endif %}
<div class="row g-3">
<div class="col-6 col-md-3 text-center">
{% set status_map = {'planned':'En cours','done':'Terminé','failed':'Échec','cancelled':'Annulé'} %}
{% set slot_statuses = slots | map(attribute='status') | list %}
{% if 'done' in slot_statuses %}
<span class="badge bg-success fs-6 px-3 py-2">✅ Terminé</span>
{% elif 'failed' in slot_statuses %}
<span class="badge bg-danger fs-6 px-3 py-2">❌ Incident</span>
{% elif slots %}
<span class="badge bg-primary fs-6 px-3 py-2">🖨️ En cours</span>
{% else %}
<span class="badge bg-secondary fs-6 px-3 py-2">📋 Planifié</span>
{% endif %}
</div>
<div class="col-6 col-md-3 text-center">
<div class="text-muted small">Pièces commandées</div>
<div class="stat-val">{{ job.order_qty or 1 }}</div>
</div>
<div class="col-6 col-md-3 text-center">
<div class="text-muted small">Matière</div>
<div class="fw-semibold">
{% if job.material_name %}
{% if job.material_color %}
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
background:{{ job.material_color | css_color }};border:1px solid #ccc;
vertical-align:middle;margin-right:4px"></span>
{% endif %}
{{ job.material_name }}
{% else %}—{% endif %}
</div>
</div>
<div class="col-6 col-md-3 text-center">
<div class="text-muted small">Créé le</div>
<div class="fw-semibold">{{ job.created_at[:10] }}</div>
</div>
</div>
{% if job.description %}
<hr class="my-3">
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{{ job.description }}</p>
{% endif %}
</div>
</div>
<!-- Timeline créneaux -->
{% if slots %}
<div class="card mb-4">
<div class="card-body">
<h6 class="fw-bold mb-3"><i class="bi bi-printer me-2"></i>Suivi d'impression</h6>
<div class="timeline">
{% for s in slots %}
<div class="timeline-item {{ s.status }}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold">{{ s.printer_name }}</div>
<div class="small text-muted">
{{ s.planned_start[:16].replace('T', ' ') }} →
{{ s.planned_end[11:16] }}
</div>
{% if s.failure_note %}
<div class="small text-danger mt-1">
<i class="bi bi-exclamation-triangle me-1"></i>{{ s.failure_note }}
</div>
{% endif %}
</div>
<span class="badge
{% if s.status == 'done' %}bg-success
{% elif s.status == 'failed' %}bg-danger
{% elif s.status == 'planned' %}bg-primary
{% else %}bg-secondary{% endif %} ms-2">
{{ {'done':'Terminé','failed':'Échec','planned':'Planifié','cancelled':'Annulé'}.get(s.status, s.status) }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="card mb-4">
<div class="card-body text-center text-muted py-4">
<i class="bi bi-calendar-plus fs-2 d-block mb-2"></i>
Impression non encore planifiée.
</div>
</div>
{% endif %}
<div class="text-center text-muted small">
<i class="bi bi-shield-lock me-1"></i>
Cette page est partagée uniquement pour le suivi de votre commande.
Les informations de tarification ne sont pas visibles.
</div>
</div>
</body>
</html>
+249 -2
View File
@@ -9,7 +9,20 @@
</a>
<h1 class="mt-1">{{ job.name }}</h1>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="shareBtn"
title="{{ 'Lien client actif — cliquer pour copier' if job.client_token else 'Générer un lien client' }}">
{% if job.client_token %}
<i class="bi bi-share-fill me-1 text-success"></i>Lien client
{% else %}
<i class="bi bi-share me-1"></i>Partager
{% endif %}
</button>
{% if job.client_token %}
<button class="btn btn-sm btn-outline-danger" id="revokeBtn" title="Révoquer le lien">
<i class="bi bi-x-circle"></i>
</button>
{% endif %}
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-copy me-1"></i>Dupliquer
</a>
@@ -38,7 +51,20 @@
</td>
</tr>
<tr><th class="text-muted fw-normal">Fichier source</th>
<td class="small font-monospace">{{ job.source_file or '—' }}</td></tr>
<td class="small">
{% if job.source_file %}
<span class="font-monospace">{{ job.source_file.split('/')[-1] }}</span>
{% set ext = job.source_file.rsplit('.',1)[-1].lower() %}
{% if ext in ['stl','3mf','step','stp','obj'] %}
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
style="font-size:.7rem"
data-bs-toggle="modal" data-bs-target="#viewerModal"
data-file="{{ job.source_file }}" data-ext="{{ ext }}">
<i class="bi bi-box me-1"></i>Voir
</button>
{% endif %}
{% else %}—{% endif %}
</td></tr>
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
@@ -207,4 +233,225 @@
</div>
</div>
</div>
<!-- ── Modale viewer 3D ──────────────────────────────────────────────────── -->
<div class="modal fade" id="viewerModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" style="background:#1a1d23">
<div class="modal-header border-0 pb-0">
<span class="text-white fw-semibold" id="viewerTitle">Viewer 3D</span>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-light" id="viewerWire">Wireframe</button>
<button class="btn btn-sm btn-outline-light" id="viewerReset">Reset vue</button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0 position-relative" style="height:75vh">
<canvas id="viewerCanvas" style="width:100%;height:100%;display:block"></canvas>
<div id="viewerLoader" class="position-absolute top-50 start-50 translate-middle text-white text-center">
<div class="spinner-border mb-2"></div><br>Chargement du modèle…
</div>
<div id="viewerError" class="position-absolute top-50 start-50 translate-middle text-danger text-center" style="display:none">
<i class="bi bi-exclamation-triangle fs-1 d-block mb-2"></i>
<span id="viewerErrorMsg"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
const modal = document.getElementById('viewerModal');
const canvas = document.getElementById('viewerCanvas');
const loader = document.getElementById('viewerLoader');
const errDiv = document.getElementById('viewerError');
const errMsg = document.getElementById('viewerErrorMsg');
const title = document.getElementById('viewerTitle');
let renderer, scene, camera, controls, modelMesh;
let wireMode = false;
function initRenderer() {
if (renderer) return;
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x1a1d23, 1);
renderer.shadowMap.enabled = true;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// Lumières
const amb = new THREE.AmbientLight(0xffffff, 0.5);
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
dir.position.set(5, 10, 7);
scene.add(amb, dir);
// Grille
const grid = new THREE.GridHelper(300, 30, 0x444444, 0x333333);
scene.add(grid);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
}
animate();
}
function fitCamera() {
if (!modelMesh) return;
const box = new THREE.Box3().setFromObject(modelMesh);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 1.5);
controls.target.copy(center);
controls.update();
}
function loadModel(path, ext) {
loader.style.display = '';
errDiv.style.display = 'none';
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
const url = '/api/nextcloud/file?path=' + encodeURIComponent(path);
const mat = new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
const onError = (e) => {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = 'Erreur chargement : ' + (e.message || e);
};
if (ext === 'stl') {
new STLLoader().load(url, geo => {
geo.computeVertexNormals();
modelMesh = new THREE.Mesh(geo, mat);
modelMesh.rotation.x = -Math.PI / 2;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else if (ext === '3mf') {
new ThreeMFLoader().load(url, obj => {
obj.traverse(child => {
if (child.isMesh) child.material = mat.clone();
});
modelMesh = obj;
scene.add(modelMesh);
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else {
loader.style.display = 'none';
errDiv.style.display = '';
errMsg.textContent = `Format .${ext} non supporté dans le viewer (STL et 3MF uniquement).`;
}
}
// Ouvrir la modale
modal.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const path = btn.dataset.file;
const ext = btn.dataset.ext;
title.textContent = path.split('/').pop();
initRenderer();
loadModel(path, ext);
});
// Fermer → libérer
modal.addEventListener('hidden.bs.modal', () => {
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
});
// Wireframe toggle
document.getElementById('viewerWire').addEventListener('click', function () {
wireMode = !wireMode;
this.classList.toggle('active', wireMode);
if (modelMesh) modelMesh.traverse(c => {
if (c.isMesh) c.material.wireframe = wireMode;
});
});
// Reset vue
document.getElementById('viewerReset').addEventListener('click', fitCamera);
</script>
<script>
// ── Partage client ───────────────────────────────────────────────────────────
const jobId = {{ job.id }};
const shareBtn = document.getElementById('shareBtn');
const revokeBtn= document.getElementById('revokeBtn');
let currentToken = {{ '"' + job.client_token + '"' if job.client_token else 'null' }};
if (shareBtn) {
shareBtn.addEventListener('click', async () => {
if (currentToken) {
// Copier l'URL existante
const url = location.origin + '/portal/' + currentToken;
await navigator.clipboard.writeText(url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2000);
} else {
// Générer un nouveau token
const r = await fetch(`/jobs/${jobId}/generate-token`, {method:'POST'});
const d = await r.json();
currentToken = d.token;
await navigator.clipboard.writeText(d.url);
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Lien copié !';
setTimeout(() => {
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
}, 2500);
// Ajouter bouton révoquer
if (!document.getElementById('revokeBtn')) {
const rb = document.createElement('button');
rb.id = 'revokeBtn';
rb.className = 'btn btn-sm btn-outline-danger';
rb.title = 'Révoquer le lien';
rb.innerHTML = '<i class="bi bi-x-circle"></i>';
shareBtn.insertAdjacentElement('afterend', rb);
rb.addEventListener('click', revokeLink);
}
}
});
}
async function revokeLink() {
if (!confirm('Révoquer le lien client ? Il ne sera plus accessible.')) return;
await fetch(`/jobs/${jobId}/revoke-token`, {method:'POST'});
currentToken = null;
shareBtn.innerHTML = '<i class="bi bi-share me-1"></i>Partager';
shareBtn.title = 'Générer un lien client';
document.getElementById('revokeBtn')?.remove();
}
if (revokeBtn) revokeBtn.addEventListener('click', revokeLink);
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion — 3D Pricing</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background: #f4f6f9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { background: #fff; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,.08); padding: 2.5rem 2rem; width: 100%; max-width: 380px; text-align: center; }
.brand { font-size: 1.6rem; font-weight: 800; color: #1a1d23; margin-bottom: .25rem; }
.brand span { color: #ff6b35; }
.subtitle { color: #6b7280; font-size: .875rem; margin-bottom: 2rem; }
.btn-authelia { background: #ff6b35; color: #fff; border: none; border-radius: 8px; padding: .75rem 1.5rem; font-size: 1rem; font-weight: 600; width: 100%; transition: background .15s; }
.btn-authelia:hover { background: #e85d2d; color: #fff; }
.powered { font-size: .75rem; color: #9ca3af; margin-top: 1.5rem; }
</style>
</head>
<body>
<div class="login-card">
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
<div class="subtitle">Gestion & tarification d'impressions 3D</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} mb-3 text-start">{{ msg }}</div>
{% endfor %}
{% endwith %}
<a href="{{ url_for('login') }}" class="btn btn-authelia">
<i class="bi bi-shield-lock me-2"></i>Se connecter avec Authelia
</a>
<div class="powered">
Authentification sécurisée via SSO
</div>
</div>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>3D Pricing — Mobile</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
body { background: #f4f6f9; padding-bottom: 2rem; }
.topbar {
background: #1a1d23; color: #fff; padding: .75rem 1rem;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
}
.topbar .brand { font-weight: 700; font-size: 1.1rem; }
.topbar .brand span { color: #ff6b35; }
.section-title {
font-size: .7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: #6b7280; padding: 1rem 1rem .4rem;
}
.slot-card {
background: #fff; border-radius: 10px; padding: .9rem 1rem;
margin: .4rem .75rem; box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
.slot-card .job-name { font-weight: 600; font-size: .95rem; }
.slot-card .meta { font-size: .78rem; color: #6b7280; }
.slot-card .time { font-size: .82rem; color: #374151; }
.tab-bar {
display: flex; background: #fff; border-bottom: 2px solid #f0f0f0;
position: sticky; top: 53px; z-index: 99;
}
.tab-bar button {
flex: 1; border: none; background: none; padding: .7rem .5rem;
font-size: .8rem; color: #6b7280; font-weight: 600;
border-bottom: 2px solid transparent; margin-bottom: -2px;
transition: color .15s, border-color .15s;
}
.tab-bar button.active { color: #ff6b35; border-bottom-color: #ff6b35; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
.form-card { background: #fff; border-radius: 10px; margin: .75rem; padding: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.btn-primary-orange { background: #ff6b35; color: #fff; border: none; border-radius: 8px; }
.btn-primary-orange:hover { background: #e85d2d; color: #fff; }
</style>
</head>
<body>
<div class="topbar">
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
<a href="/" class="btn btn-sm btn-outline-light btn-sm">
<i class="bi bi-grid me-1"></i>Bureau
</a>
</div>
<!-- Tabs -->
<div class="tab-bar">
<button class="active" onclick="showTab('planning', this)">
<i class="bi bi-calendar3 d-block mb-1"></i>Planning
</button>
<button onclick="showTab('block', this)">
<i class="bi bi-calendar-x d-block mb-1"></i>Indispo
</button>
<button onclick="showTab('shift', this)">
<i class="bi bi-arrow-left-right d-block mb-1"></i>Décaler
</button>
<button onclick="showTab('fail', this)">
<i class="bi bi-exclamation-triangle d-block mb-1"></i>Échec
</button>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} mx-3 mt-2 mb-0 py-2 small">{{ msg }}</div>
{% endfor %}
{% endwith %}
<!-- ── Tab : Planning ─────────────────────────────────── -->
<div class="tab-pane active" id="tab-planning">
<div class="section-title">Prochaines impressions</div>
{% if upcoming %}
{% for s in upcoming %}
<div class="slot-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="job-name">{{ s.job_name }}</div>
<div class="meta">
<i class="bi bi-printer me-1"></i>{{ s.printer_name }}
{% if s.client_name %} · {{ s.client_name }}{% endif %}
</div>
<div class="time mt-1">
<i class="bi bi-clock me-1"></i>
{{ s.planned_start[:16].replace('T',' ') }} →
{{ s.planned_end[11:16] }}
</div>
</div>
<span class="badge {% if s.status=='done' %}bg-success{% elif s.status=='failed' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ s.status }}
</span>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-check fs-1 d-block mb-2"></i>
Aucune impression planifiée.
</div>
{% endif %}
</div>
<!-- ── Tab : Indisponibilité ──────────────────────────── -->
<div class="tab-pane" id="tab-block">
<div class="section-title">Ajouter une indisponibilité</div>
<div class="form-card">
<form method="POST">
<input type="hidden" name="action" value="block">
<div class="mb-3">
<label class="form-label fw-semibold">Date</label>
<input type="date" name="date" class="form-control" value="{{ today }}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Type</label>
<select name="type" class="form-select">
<option value="off">🔴 Jour off (toutes machines)</option>
<option value="maintenance">🔧 Maintenance</option>
<option value="other">📌 Autre</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Machine (optionnel)</label>
<select name="printer_id" class="form-select">
<option value="">Toutes les machines</option>
{% for p in printers %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Note</label>
<input type="text" name="notes" class="form-control" placeholder="Raison…">
</div>
<button type="submit" class="btn btn-primary-orange w-100">
<i class="bi bi-calendar-plus me-1"></i>Ajouter
</button>
</form>
</div>
</div>
<!-- ── Tab : Décaler ──────────────────────────────────── -->
<div class="tab-pane" id="tab-shift">
<div class="section-title">Décaler un créneau</div>
<div class="form-card">
<form method="POST">
<input type="hidden" name="action" value="shift">
<div class="mb-3">
<label class="form-label fw-semibold">Créneau à décaler</label>
<select name="slot_id" class="form-select" required>
<option value="">Choisir…</option>
{% for s in upcoming %}
<option value="{{ s.id }}">
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Nouveau début</label>
<input type="datetime-local" name="new_start" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary-orange w-100">
<i class="bi bi-arrow-left-right me-1"></i>Décaler
</button>
</form>
</div>
</div>
<!-- ── Tab : Échec ────────────────────────────────────── -->
<div class="tab-pane" id="tab-fail">
<div class="section-title">Signaler un échec d'impression</div>
<div class="form-card">
<form method="POST">
<input type="hidden" name="action" value="fail">
<div class="mb-3">
<label class="form-label fw-semibold">Créneau concerné</label>
<select name="slot_id" class="form-select" required>
<option value="">Choisir…</option>
{% for s in upcoming %}
<option value="{{ s.id }}">
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description du problème</label>
<textarea name="failure_note" class="form-control" rows="3"
placeholder="Détachement, sous-extrusion, bourrage…"></textarea>
</div>
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-exclamation-triangle me-1"></i>Signaler l'échec
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showTab(name, btn) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
btn.classList.add('active');
}
</script>
</body>
</html>
+183 -13
View File
@@ -50,9 +50,16 @@
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Fichier source</label>
<input type="text" name="source_file" id="sourceFile" class="form-control"
placeholder="nom_fichier.3mf"
value="{{ prefill.source_file if prefill else '' }}">
<div class="input-group">
<input type="text" name="source_file" id="sourceFile" class="form-control"
placeholder="nom_fichier.3mf"
value="{{ prefill.source_file if prefill else '' }}">
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
title="Parcourir Nextcloud">
<i class="bi bi-cloud-arrow-down"></i>
</button>
</div>
</div>
</div>
<div class="row g-3 mb-3">
@@ -282,35 +289,109 @@
</div>
</div>
</div>
<!-- Modale Nextcloud File Picker -->
<div class="modal fade" id="ncPickerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<div class="flex-fill">
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0 small" id="ncBreadcrumb"></ol>
</nav>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0" id="ncPickerBody" style="max-height:60vh;overflow-y:auto">
<div class="text-center text-muted py-5">
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
</div>
</div>
<div class="modal-footer py-2">
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── 3MF parser ────────────────────────────────────────────────────────────────
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
let _3mfPlates = [];
function parse3mf() {
const file = document.getElementById('file3mf').files[0];
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
const status = document.getElementById('parseStatus');
status.textContent = 'Analyse...';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse';
const fd = new FormData();
fd.append('file', file);
fetch('/api/parse-3mf', {method:'POST', body:fd})
.then(r => r.json())
.then(d => {
let filled = [];
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
if (d.hours != null) { document.getElementById('hours').value = d.hours; filled.push('heures'); }
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
if (d.filename) {
document.getElementById('sourceFile').value = d.filename;
if (!document.getElementById('jobName').value)
document.getElementById('jobName').value = d.filename;
}
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
recalc();
// ── Cas Bambu avec plaques détectées ──
if (d.plates && d.plates.length > 1) {
_3mfPlates = d.plates;
renderPlateSelector(d.plates);
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
return;
}
// ── Plaque unique ou fallback ──
applyPlateData(d.weight_g, d.hours, d.minutes);
const ok = d.weight_g || d.hours != null;
status.innerHTML = ok
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées</span>';
})
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
}
function applyPlateData(weight_g, hours, minutes) {
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
if (hours != null) document.getElementById('hours').value = hours;
if (minutes != null) document.getElementById('minutes').value = minutes;
recalc();
}
function renderPlateSelector(plates) {
// Supprimer un éventuel sélecteur précédent
document.getElementById('plateSelectorWrap')?.remove();
const wrap = document.createElement('div');
wrap.id = 'plateSelectorWrap';
wrap.className = 'mt-3 border rounded p-3 bg-light';
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
const grid = document.createElement('div');
grid.className = 'd-flex flex-wrap gap-2';
plates.forEach(p => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-warning';
// Pastilles filaments
const dots = p.filaments.map(f =>
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
).join('');
btn.innerHTML = `${dots}<strong>Plateau ${p.index}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m`;
btn.addEventListener('click', () => {
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
btn.classList.replace('btn-outline-warning','btn-warning');
applyPlateData(p.weight_g, Math.floor(p.print_time_s/3600), Math.floor((p.print_time_s%3600)/60));
document.getElementById('parseStatus').innerHTML =
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
});
grid.appendChild(btn);
});
wrap.appendChild(grid);
// Insérer après le card d'import
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
}
// ── Coefficient design ────────────────────────────────────────────────────────
@@ -595,5 +676,94 @@ const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value
updateDMLabel(_initDM);
updateMarginDisplay(_initMargin);
if (document.getElementById('weight_g').value) recalc();
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
(function () {
const modal = document.getElementById('ncPickerModal');
const body = document.getElementById('ncPickerBody');
const bread = document.getElementById('ncBreadcrumb');
const sfInput = document.getElementById('sourceFile');
if (!modal) return;
let currentPath = '';
async function browse(path) {
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
try {
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
const d = await r.json();
if (d.error) {
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
return;
}
currentPath = d.path;
renderBreadcrumb(d.breadcrumb);
renderItems(d.items);
} catch (e) {
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
}
}
function renderBreadcrumb(crumbs) {
bread.innerHTML = crumbs.map((c, i) =>
i < crumbs.length - 1
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
: `<li class="breadcrumb-item active">${c.name}</li>`
).join('');
bread.querySelectorAll('.nc-crumb').forEach(a =>
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
}
const EXT_ICONS = {
'3mf': 'bi-box-seam text-warning',
'stl': 'bi-triangle text-info',
'step': 'bi-bezier2 text-success',
'stp': 'bi-bezier2 text-success',
'obj': 'bi-grid-3x3 text-secondary',
'gcode':'bi-code-square text-danger',
'dir': 'bi-folder-fill text-warning',
};
function renderItems(items) {
if (!items.length) {
body.innerHTML = '<div class="text-center text-muted py-4">Dossier vide.</div>';
return;
}
body.innerHTML = '';
const list = document.createElement('div');
list.className = 'list-group list-group-flush';
items.forEach(item => {
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
const size = item.is_dir ? '' : ` <small class="text-muted">(${(item.size/1024/1024).toFixed(1)} Mo)</small>`;
const el = document.createElement('button');
el.type = 'button';
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
<span class="flex-fill text-start">${item.name}${size}</span>
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
if (item.is_dir) {
el.addEventListener('click', () => browse(item.path));
} else {
el.addEventListener('click', () => selectFile(item));
}
list.appendChild(el);
});
body.appendChild(list);
}
function selectFile(item) {
sfInput.value = item.path;
// Auto-remplir le nom de commande si vide
const nameField = document.getElementById('jobName');
if (!nameField.value) {
nameField.value = item.name.replace(/\.[^.]+$/, '');
}
bootstrap.Modal.getInstance(modal).hide();
}
// Ouvrir = charger le dossier racine
modal.addEventListener('show.bs.modal', () => { if (!currentPath) browse(''); });
})();
</script>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Commande introuvable</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>body { background:#f4f6f9; display:flex; align-items:center; justify-content:center; min-height:100vh; }</style>
</head>
<body>
<div class="text-center">
<i class="bi bi-question-circle fs-1 text-muted d-block mb-3"></i>
<h4>Commande introuvable</h4>
<p class="text-muted">Ce lien de suivi est invalide ou a été révoqué.</p>
</div>
</body>
</html>
+105
View File
@@ -194,6 +194,83 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header py-3"><i class="bi bi-cloud me-2"></i>Nextcloud — Intégration fichiers 3D</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">URL Nextcloud</label>
<input type="url" name="nc_url" class="form-control"
placeholder="https://nextcloud.tondomain.com"
value="{{ settings.get('nc_url','') }}">
<div class="form-text">Sans slash final.</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Identifiant</label>
<input type="text" name="nc_username" class="form-control"
placeholder="ton_login"
value="{{ settings.get('nc_username','') }}">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Mot de passe d'application</label>
<input type="password" name="nc_app_password" class="form-control"
placeholder="••••••••••••"
value="{{ settings.get('nc_app_password','') }}">
<div class="form-text">Générer dans Nextcloud → Paramètres → Sécurité.</div>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Dossier racine 3D</label>
<input type="text" name="nc_root_path" class="form-control"
placeholder="/Impressions3D"
value="{{ settings.get('nc_root_path','/3D') }}">
<div class="form-text">Chemin de base pour l'explorateur de fichiers.</div>
</div>
<div class="col-md-8 d-flex align-items-end">
<button type="button" class="btn btn-sm btn-outline-secondary" id="testNcBtn">
<i class="bi bi-plug me-1"></i>Tester la connexion
</button>
<span id="testNcResult" class="ms-3 small"></span>
</div>
</div>
</div>
</div>
</div>
<!-- iCal -->
<div class="col-12">
<div class="card">
<div class="card-header py-3"><i class="bi bi-calendar-check me-2"></i>Calendrier iCal — Abonnement Android / iOS</div>
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label fw-semibold">Token secret (optionnel)</label>
<input type="text" name="ical_token" class="form-control font-monospace"
placeholder="Laisser vide = pas de protection"
value="{{ settings.get('ical_token','') }}">
<div class="form-text">Si renseigné, l'URL contiendra <code>?token=xxx</code>.</div>
</div>
<div class="col-md-8">
<label class="form-label fw-semibold">URL d'abonnement</label>
<div class="input-group">
<input type="text" id="icalUrl" class="form-control font-monospace small" readonly
value="{{ request.host_url }}calendar.ics{% if settings.get('ical_token','') %}?token={{ settings.get('ical_token','') }}{% endif %}">
<button class="btn btn-outline-secondary" type="button" id="copyIcal" title="Copier">
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="form-text">
Dans Google Calendar → Autres agendas → Depuis une URL.
Samsung / iOS : Paramètres → Comptes → Ajouter → Calendrier.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
@@ -203,3 +280,31 @@
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('copyIcal').addEventListener('click', function () {
const url = document.getElementById('icalUrl').value;
navigator.clipboard.writeText(url).then(() => {
this.innerHTML = '<i class="bi bi-check text-success"></i>';
setTimeout(() => { this.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
});
});
document.getElementById('testNcBtn').addEventListener('click', async function () {
const res = document.getElementById('testNcResult');
res.textContent = 'Test en cours…';
try {
const r = await fetch('/api/nextcloud/browse');
const d = await r.json();
if (d.error) {
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
} else {
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${d.items.length} élément(s) dans ${d.path}</span>`;
}
} catch (e) {
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
}
});
</script>
{% endblock %}