diff --git a/app.py b/app.py index 8d535d4..ed69b33 100644 --- a/app.py +++ b/app.py @@ -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//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//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='' + '' + '', + 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//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//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/') +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 ─────────────────────────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index c8fcb98..46fe652 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt index 805b0f1..13d7642 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ flask>=3.0.0 +authlib>=1.3.0 +requests>=2.31.0 diff --git a/templates/base.html b/templates/base.html index b21be72..7fe56a5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -104,7 +104,23 @@ Paramètres + + Vue mobile + + + {% if session.user %} +
+
+ {{ session.user.name }} +
+ + Déconnexion + +
+ {% endif %}
diff --git a/templates/client_portal.html b/templates/client_portal.html new file mode 100644 index 0000000..664ac89 --- /dev/null +++ b/templates/client_portal.html @@ -0,0 +1,139 @@ + + + + + + Suivi commande — {{ job.name }} + + + + + + +
+
3DPricing
+ Suivi de commande +
+ +
+ + +
+
+

{{ job.name }}

+ {% if job.client_name %} +
{{ job.client_name }}
+ {% endif %} + +
+
+ {% 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 %} + ✅ Terminé + {% elif 'failed' in slot_statuses %} + ❌ Incident + {% elif slots %} + 🖨️ En cours + {% else %} + 📋 Planifié + {% endif %} +
+
+
Pièces commandées
+
{{ job.order_qty or 1 }}
+
+
+
Matière
+
+ {% if job.material_name %} + {% if job.material_color %} + + {% endif %} + {{ job.material_name }} + {% else %}—{% endif %} +
+
+
+
Créé le
+
{{ job.created_at[:10] }}
+
+
+ + {% if job.description %} +
+

{{ job.description }}

+ {% endif %} +
+
+ + + {% if slots %} +
+
+
Suivi d'impression
+
+ {% for s in slots %} +
+
+
+
{{ s.printer_name }}
+
+ {{ s.planned_start[:16].replace('T', ' ') }} → + {{ s.planned_end[11:16] }} +
+ {% if s.failure_note %} +
+ {{ s.failure_note }} +
+ {% endif %} +
+ + {{ {'done':'Terminé','failed':'Échec','planned':'Planifié','cancelled':'Annulé'}.get(s.status, s.status) }} + +
+
+ {% endfor %} +
+
+
+ {% else %} +
+
+ + Impression non encore planifiée. +
+
+ {% endif %} + +
+ + Cette page est partagée uniquement pour le suivi de votre commande. + Les informations de tarification ne sont pas visibles. +
+
+ + diff --git a/templates/job_detail.html b/templates/job_detail.html index 75e5c46..827976e 100644 --- a/templates/job_detail.html +++ b/templates/job_detail.html @@ -9,7 +9,20 @@

{{ job.name }}

-
+
+ + {% if job.client_token %} + + {% endif %} Dupliquer @@ -38,7 +51,20 @@ Fichier source - {{ job.source_file or '—' }} + + {% if job.source_file %} + {{ job.source_file.split('/')[-1] }} + {% set ext = job.source_file.rsplit('.',1)[-1].lower() %} + {% if ext in ['stl','3mf','step','stp','obj'] %} + + {% endif %} + {% else %}—{% endif %} + Date{{ job.created_at[:10] }} Poids plateau{{ job.weight_g }} g Pieces / plateau{{ job.pieces_per_plate or 1 }} @@ -207,4 +233,225 @@
+ + + +{% endblock %} + +{% block scripts %} + + + + {% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..aada4f4 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,40 @@ + + + + + + Connexion — 3D Pricing + + + + + + + + diff --git a/templates/mobile.html b/templates/mobile.html new file mode 100644 index 0000000..336a364 --- /dev/null +++ b/templates/mobile.html @@ -0,0 +1,218 @@ + + + + + + + + 3D Pricing — Mobile + + + + + + +
+
3DPricing
+ + Bureau + +
+ + +
+ + + + +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +{% endwith %} + + +
+
Prochaines impressions
+ {% if upcoming %} + {% for s in upcoming %} +
+
+
+
{{ s.job_name }}
+
+ {{ s.printer_name }} + {% if s.client_name %} · {{ s.client_name }}{% endif %} +
+
+ + {{ s.planned_start[:16].replace('T',' ') }} → + {{ s.planned_end[11:16] }} +
+
+ + {{ s.status }} + +
+
+ {% endfor %} + {% else %} +
+ + Aucune impression planifiée. +
+ {% endif %} +
+ + +
+
Ajouter une indisponibilité
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
Décaler un créneau
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + +
+
Signaler un échec d'impression
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + + + + diff --git a/templates/new_job.html b/templates/new_job.html index 7c3a71e..38fb431 100644 --- a/templates/new_job.html +++ b/templates/new_job.html @@ -50,9 +50,16 @@
- +
+ + +
@@ -282,35 +289,109 @@
+ + + {% endblock %} {% block scripts %} {% endblock %} diff --git a/templates/portal_404.html b/templates/portal_404.html new file mode 100644 index 0000000..2b48679 --- /dev/null +++ b/templates/portal_404.html @@ -0,0 +1,18 @@ + + + + + + Commande introuvable + + + + + +
+ +

Commande introuvable

+

Ce lien de suivi est invalide ou a été révoqué.

+
+ + diff --git a/templates/settings.html b/templates/settings.html index f3c957a..5d629e6 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -194,6 +194,83 @@ + + +
+
+
Nextcloud — Intégration fichiers 3D
+
+
+
+ + +
Sans slash final.
+
+
+ + +
+
+ + +
Générer dans Nextcloud → Paramètres → Sécurité.
+
+
+ + +
Chemin de base pour l'explorateur de fichiers.
+
+
+ + +
+
+
+
+
+ + +
+
+
Calendrier iCal — Abonnement Android / iOS
+
+
+
+ + +
Si renseigné, l'URL contiendra ?token=xxx.
+
+
+ +
+ + +
+
+ Dans Google Calendar → Autres agendas → Depuis une URL. + Samsung / iOS : Paramètres → Comptes → Ajouter → Calendrier. +
+
+
+
+
+
+
@@ -203,3 +280,31 @@
{% endblock %} + +{% block scripts %} + +{% endblock %}