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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user