feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
flask>=3.0.0
|
||||
authlib>=1.3.0
|
||||
requests>=2.31.0
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user