v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-15 11:58:52 +02:00
co-authored by Claude Opus 5.5
parent bffa053c05
commit 7d302dc405
48 changed files with 2769 additions and 698 deletions
+375 -24
View File
@@ -2,8 +2,9 @@
Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron)
Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
"""
__version__ = '0.0.1'
__version__ = '0.0.2'
import csv
import io
@@ -52,6 +53,18 @@ PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', '
'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'}
INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'}
# Viewer voor woningontwerpen: de engine van de configurator, gekopieerd met tools/configurator-sync.sh
# (zelfde volgorde als light.html, zonder PDF en light-UI). Demo-modellen: tools/export-models.js.
ENGINE = os.path.join(BASE, 'static', 'configurator')
VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/core/geometry.js',
'js/core/view-layers.js', 'js/core/ui-kit.js', 'js/core/skybox.js', 'js/wall/kozijn-model.js',
'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js',
'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js',
'js/wall/wall.js', 'js/floor/floor.js', 'js/unit/unit-model.js', 'js/room/room-model.js',
'js/interior/fixture-model.js', 'js/house/chimney-model.js', 'js/house/house-model.js',
'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer',
@@ -81,6 +94,12 @@ app = Flask(__name__)
app.secret_key = _secret_key()
app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax',
PERMANENT_SESSION_LIFETIME=timedelta(days=30))
# Achter nginx/Apache met HTTPS (CRM_PROXY=1): schema en host uit X-Forwarded-*, zodat links naar buiten
# (portaaluitnodiging, API-URL) https://domein zijn, en sessiecookies alleen over HTTPS.
if os.environ.get('CRM_PROXY'):
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
app.config.update(SESSION_COOKIE_SECURE=True)
def now():
@@ -109,7 +128,10 @@ MIGRATIONS = {
'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT',
'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'},
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
'relation_role': "TEXT DEFAULT ''"},
'relation_role': "TEXT DEFAULT ''",
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
}
@@ -133,6 +155,18 @@ def init_db():
"INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
(name, now(), now())).lastrowid
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name))
# woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model
for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' "
"AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall():
try:
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
design = json.load(f)
except (ValueError, OSError):
continue
if isinstance(design, dict) and design.get('type') == 'house-design':
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
con.execute('UPDATE files SET title=? WHERE id=?', (title, fid))
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid))
con.execute('PRAGMA journal_mode=WAL')
con.commit()
con.close()
@@ -337,27 +371,62 @@ def save_upload(fs, contact_id=None):
path = os.path.join(UPLOADS, stored)
fs.save(path)
if contact_id:
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
if ext == '.json':
note_design(contact_id, path)
note_design(contact_id, path, cur.lastrowid)
return stored
def note_design(contact_id, path):
"""Een woningontwerp uit de configurator krijgt een regel in de tijdlijn."""
def read_design(path):
"""Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None."""
try:
with open(path, encoding='utf-8-sig') as f:
design = json.load(f)
except (ValueError, OSError):
return None
return design if isinstance(design, dict) and design.get('type') == 'house-design' else None
def design_title(design):
return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
def note_design(contact_id, path, file_id):
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt),
met een regel in de tijdlijn."""
design = read_design(path)
if not design:
return
if isinstance(design, dict) and design.get('type') == 'house-design':
project = design.get('project') or {}
parts = [f"Woningontwerp: {project.get('naam') or 'zonder naam'}",
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
project.get('opmerking') or '']
log(contact_id, 'file', '\n'.join(p for p in parts if p))
project = design.get('project') or {}
title = design_title(design)
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
parts = [f'Woningontwerp: {title} (gekozen model)',
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
project.get('opmerking') or '']
log(contact_id, 'file', '\n'.join(p for p in parts if p))
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
def design_pdfs(design_ids):
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
out = {}
if design_ids:
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})",
*design_ids):
out.setdefault(f['design_id'], {})[f['kind']] = {
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']}
return out
def contact_designs(contact_id):
"""Alle woningontwerpen bij een contact, nieuwste eerst."""
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC',
contact_id, '%.json') if f['title']]
def save_design_json(design, contact_id):
@@ -366,9 +435,9 @@ def save_design_json(design, contact_id):
with open(path, 'w', encoding='utf-8') as f:
json.dump(design, f, ensure_ascii=False)
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp'
db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
note_design(contact_id, path)
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
note_design(contact_id, path, cur.lastrowid)
def remove_upload(stored):
@@ -637,8 +706,9 @@ def dashboard():
def contacts():
term = request.args.get('q', '').strip()
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')}
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
'LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id WHERE 1=1')
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'LEFT JOIN files f ON f.id=c.design_file_id WHERE 1=1')
params = []
if term:
sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?'
@@ -713,6 +783,16 @@ def contact_view(cid):
c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'WHERE c.id=?', cid) or abort(404)
designs = contact_designs(cid)
design = next((f for f in designs if f['id'] == c['design_file_id']), None)
design_meta = {}
if design:
d = read_design(os.path.join(UPLOADS, design['stored'])) or {}
project = d.get('project') or {}
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
project.get('plaats')])),
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
@@ -721,6 +801,8 @@ def contact_view(cid):
emails=q('SELECT * FROM emails WHERE contact_id=? ORDER BY date DESC LIMIT 50', cid),
tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id '
'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid),
designs=designs, design=design, design_meta=design_meta,
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '',
klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"),
@@ -864,12 +946,75 @@ def upload_file(name):
@app.post('/files/<int:fid>/delete')
def file_delete(fid):
f = get_or_404('files', fid)
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id'])
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid):
remove_upload(pdf['stored'])
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,))
if chosen:
rest = contact_designs(f['contact_id'])
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
db().commit()
return redirect(url_for('contact_view', cid=f['contact_id']))
@app.post('/contacts/<int:cid>/design')
def contact_design(cid):
"""Gekozen model: een van de woningontwerpen bij het contact (of geen)."""
c = get_or_404('contacts', cid)
fid = request.form.get('file_id', type=int)
f = next((d for d in contact_designs(cid) if d['id'] == fid), None)
if (f['id'] if f else None) != c['design_file_id']:
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid))
log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer')
db().commit()
return redirect(url_for('contact_view', cid=cid))
@app.route('/files/<int:fid>/viewer')
def design_viewer(fid):
"""3D-viewer van een woningontwerp (in een iframe op de contactpagina, of los)."""
f = get_or_404('files', fid)
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
pdfs = design_pdfs([fid]).get(fid, {})
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs,
'auto': len(pdfs) < len(PDF_KINDS)}
resp = make_response(render_template(
'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg,
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))))
resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
return resp
@app.post('/files/<int:fid>/pdf')
def design_pdf(fid):
"""PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening
(intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige."""
f = get_or_404('files', fid)
kind, fs = request.form.get('kind'), request.files.get('pdf')
if kind not in PDF_KINDS or not fs or not f['contact_id'] or not f['title']:
return jsonify(ok=False, error='Ongeldige PDF'), 400
head = fs.stream.read(5)
fs.stream.seek(0)
if head != b'%PDF-':
return jsonify(ok=False, error='Geen PDF'), 400
for old in q('SELECT * FROM files WHERE design_id=? AND kind=?', fid, kind):
remove_upload(old['stored'])
db().execute('DELETE FROM files WHERE id=?', (old['id'],))
stored = uuid.uuid4().hex + '.pdf'
fs.save(os.path.join(UPLOADS, stored))
name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp'
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
(f['contact_id'], stored, f'{name}.pdf', 'application/pdf',
os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid))
log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}")
db().commit()
row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid)
return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id'])
# ---------------------------------------------------------------- relaties
def relation_form_data():
@@ -969,6 +1114,9 @@ def relation_view(rid):
'WHERE c.relation_id=? ORDER BY c.name', rid)
ids = [m['id'] for m in members] or [0]
marks = ', '.join('?' * len(ids))
designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f '
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' "
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids)
return render_template(
'relation.html', r=r, members=members,
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'),
@@ -978,7 +1126,8 @@ def relation_view(rid):
f'WHERE a.contact_id IN ({marks}) ORDER BY a.created_at DESC LIMIT 30', *ids),
tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id '
f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids),
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid))
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid),
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS)
@app.post('/relations/<int:rid>/member')
@@ -1442,6 +1591,8 @@ def quote_new():
if cid:
log(cid, 'quote', f'Offerte {number} aangemaakt')
db().commit()
if request.form.get('bestek'):
return redirect(url_for('quote_bestek', qid=cur.lastrowid))
return redirect(url_for('quote_edit', qid=cur.lastrowid))
@@ -1473,7 +1624,8 @@ def quote_edit(qid):
flash('Offerte opgeslagen.')
return redirect(url_for('quote_edit', qid=qid))
return render_template(
'quote_edit.html', qt=qt,
'quote_edit.html', qt=qt, bestek=quote_bestek_data(qt),
designs=contact_designs(qt['contact_id']) if qt['contact_id'] else [],
lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid),
contacts=q('SELECT id, name, company FROM contacts ORDER BY name'),
products=q('SELECT * FROM products WHERE active=1 ORDER BY name'),
@@ -1485,7 +1637,7 @@ def render_quote(qt, portal=False):
contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c '
'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None
return render_template('quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
s=settings_dict(), portal=portal)
s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt))
@app.route('/quotes/<int:qid>/print')
@@ -1493,6 +1645,160 @@ def quote_print(qid):
return render_quote(get_or_404('quotes', qid))
# Bestek uit het woningontwerp: de berekening draait in de browser op de engine van de configurator
# (static/bestek.js); hier worden het bestek bewaard en de offerteregels gemaakt.
def quote_bestek_data(qt):
try:
data = json.loads(qt['bestek'] or 'null')
except ValueError:
return None
return data if isinstance(data, dict) and isinstance(data.get('chapters'), list) else None
def fmt_qty(value, unit=''):
value = round(float(value or 0), 2 if unit == 'm³' else 1)
return f'{value:g}'.replace('.', ',')
def clean_bestek(raw):
"""Bestek uit het formulier controleren en tot de bekende velden terugbrengen."""
try:
data = json.loads(raw or '')
except ValueError:
return None
if not isinstance(data, dict) or not isinstance(data.get('chapters'), list):
return None
def product(p):
if not isinstance(p, dict) or not str(p.get('sku') or '').strip():
return None
return {k: str(p.get(k) or '').strip()[:limit] for k, limit in
(('sku', 80), ('name', 200), ('description', 1000), ('supplier', 200), ('article', 80))}
def line(l):
out = {'label': str(l.get('label') or '')[:300], 'qty': num(l.get('qty')), 'unit': str(l.get('unit') or '')[:20],
'price': round(num(l.get('price')), 2), 'unitPrice': round(num(l.get('unitPrice')), 4)}
if product(l.get('product')):
out['product'] = product(l['product'])
return out
chapters = [{'key': str(ch.get('key') or '')[:40], 'name': str(ch.get('name') or '')[:120],
'price': round(num(ch.get('price')), 2),
'lines': [line(l) for l in ch.get('lines') or [] if isinstance(l, dict)],
'info': [line(l) for l in ch.get('info') or [] if isinstance(l, dict)]}
for ch in data['chapters'] if isinstance(ch, dict)]
if not chapters:
return None
totals = data.get('totals') if isinstance(data.get('totals'), dict) else {}
design = data.get('design') if isinstance(data.get('design'), dict) else {}
return {'version': 1, 'computedAt': str(data.get('computedAt') or now())[:30], 'vat': num(data.get('vat'), 21),
'units': int(num(data.get('units'))), 'configurator': str(data.get('configurator') or '')[:20],
'design': {'file_id': design.get('file_id'), 'title': str(design.get('title') or '')[:120]},
'totals': {k: round(num(totals.get(k)), 2) for k in ('exBtw', 'btw', 'inclBtw')}, 'chapters': chapters}
def bestek_rows(b, mode='post'):
"""Offerteregels uit een bestek: dicts met desc, qty, unit, price (per eenheid) en product (of None).
post: elke post met zijn hoeveelheid (42 spots = 42 st. × prijs per spot); hoofdstuk: één regel per hoofdstuk.
De prijs per eenheid is afgerond op centen; het verschil met het hoofdstuk gaat naar de laatste regel van
1 stuk/post zonder product (bevestiging en bewerking, plaatsen met kraan), wat dan nog over is naar de
laatste zo'n regel van de offerte, zodat het totaal precies dat van de configurator is."""
def absorb(rows, target):
diff = round(target - sum(r['qty'] * r['price'] for r in rows), 2)
spot = next((r for r in reversed(rows) if r['qty'] == 1 and not r['product']), None) or \
next((r for r in reversed(rows) if r['qty'] == 1), None)
if spot and diff:
spot['price'] = round(spot['price'] + diff, 2)
if mode == 'hoofdstuk':
rows = [{'desc': ch['name'] + '\n' + ', '.join(f"{l['label']} {fmt_qty(l['qty'], l['unit'])} {l['unit']}".strip()
for l in ch['lines']),
'qty': 1, 'unit': 'post', 'price': ch['price'], 'product': None} for ch in b['chapters']]
else:
rows = []
for ch in b['chapters']:
part = [{'desc': f"{l['label']}\n{ch['name']}", 'qty': l['qty'] or 1, 'unit': l['unit'],
'price': round(l.get('unitPrice') or (l['price'] / l['qty'] if l['qty'] else l['price']), 2),
'product': l.get('product')} for l in ch['lines']]
absorb(part, ch['price'])
rows += part
absorb(rows, b['totals'].get('exBtw') or sum(ch['price'] for ch in b['chapters']))
return rows
def bestek_product(row, vat, update_price, source):
"""Product bij een bestekpost (SKU = code uit de kosten van de configurator); bestaat het nog niet, dan wordt
het aangemaakt met de prijs per eenheid van deze berekening."""
p = row['product']
existing = find_product(p['sku'])
if existing is None:
description = '\n'.join(filter(None, [p['description'], f"Artikelnummer leverancier: {p['article']}" if p['article'] else '']))
return upsert_product({'sku': p['sku'], 'name': p['name'] or row['desc'].split('\n')[0], 'unit': row['unit'],
'price': row['price'], 'vat': vat, 'description': description, 'supplier': p['supplier']},
source)[2]
if update_price:
upsert_product({'sku': p['sku'], 'price': row['price']}, source, existing=existing)
return existing['id']
@app.route('/quotes/<int:qid>/bestek')
def quote_bestek(qid):
qt = get_or_404('quotes', qid)
contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None
designs = contact_designs(contact['id']) if contact else []
want = request.args.get('file', type=int) or qt['design_file_id'] or (contact['design_file_id'] if contact else None)
f = next((d for d in designs if d['id'] == want), designs[0] if designs else None)
if not f:
flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error')
return redirect(url_for('quote_edit', qid=qid))
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
return render_template('bestek.html', qt=qt, c=contact, design=design, design_file=f, designs=designs,
scripts=VIEWER_SCRIPTS, has_lines=scalar('SELECT COUNT(*) FROM quote_lines WHERE quote_id=?', qid),
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js')))
@app.post('/quotes/<int:qid>/bestek')
def quote_bestek_save(qid):
qt = get_or_404('quotes', qid)
b = clean_bestek(request.form.get('bestek'))
if not b:
flash('Het bestek kon niet worden overgenomen.', 'error')
return redirect(url_for('quote_bestek', qid=qid))
fid = request.form.get('file_id', type=int)
if qt['contact_id'] and fid not in {d['id'] for d in contact_designs(qt['contact_id'])}:
fid = None
vat = b['vat']
rows = bestek_rows(b, request.form.get('mode', 'post'))
if request.form.get('replace'):
db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,))
start = scalar('SELECT COALESCE(MAX(position), -1) + 1 FROM quote_lines WHERE quote_id=?', qid)
source, linked = f"Bestek offerte {qt['number']}", 0
for pos, r in enumerate(rows, start):
pid = bestek_product(r, vat, bool(request.form.get('update_prices')), source) if r['product'] else None
linked += bool(pid)
db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, pid, r['desc'], r['qty'], r['unit'], r['price'], vat))
title = qt['title'] or (f"Woning {b['design']['title']}" if b['design']['title'] else '')
db().execute('UPDATE quotes SET bestek=?, design_file_id=?, title=?, updated_at=? WHERE id=?',
(json.dumps(b, ensure_ascii=False), fid, title, now(), qid))
if qt['contact_id']:
log(qt['contact_id'], 'quote', f"Offerte {qt['number']}: bestek uit ontwerp {b['design']['title']} "
f"({euro(b['totals']['exBtw'])} excl. btw)")
db().commit()
flash(f'Bestek overgenomen: {len(rows)} regel(s), waarvan {linked} gekoppeld aan een product.')
return redirect(url_for('quote_edit', qid=qid))
@app.post('/quotes/<int:qid>/bestek/delete')
def quote_bestek_delete(qid):
get_or_404('quotes', qid)
db().execute("UPDATE quotes SET bestek='', design_file_id=NULL, updated_at=? WHERE id=?", (now(), qid))
db().commit()
flash('Bestek losgekoppeld; de offerteregels zijn gebleven.')
return redirect(url_for('quote_edit', qid=qid))
@app.post('/quotes/<int:qid>/delete')
def quote_delete(qid):
get_or_404('quotes', qid)
@@ -1573,7 +1879,7 @@ def portal_home():
return render_template(
'portal.html', c=g.portal,
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid),
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid),
messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
"ORDER BY created_at DESC LIMIT 20", cid),
s=settings_dict())
@@ -1626,7 +1932,7 @@ def portal_message():
@app.route('/portal/files/<name>')
def portal_file(name):
f = q1('SELECT * FROM files WHERE stored=? AND contact_id=?', name, g.portal['id'])
f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id'])
if f is None:
abort(404)
return serve_upload(name, f['original'])
@@ -1748,7 +2054,52 @@ def api_products():
return jsonify(ok=True, **result)
# ---------------------------------------------------------------- demo
DEMO_LEADS = [
('Lorem Ipsum', 'Utrecht', 'nieuw', 'amstel'),
('Dolor Sitamet', 'Amersfoort', 'contact', 'wetering'),
('Consectetur Adipiscing', 'Zwolle', 'nieuw', 'ijssel'),
('Sed Doeiusmod', 'Nijmegen', 'offerte', 'waal'),
('Tempor Incididunt', 'Maastricht', 'contact', 'maas'),
('Labore Etdolore', 'Arnhem', 'nieuw', 'rijn'),
('Magna Aliqua', 'Meppel', 'offerte', 'reest'),
('Enim Adminim', 'Breukelen', 'nieuw', 'vecht'),
]
DEMO_MESSAGE = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Wij zoeken een mantelzorgwoning '
'voor in de tuin; graag meer informatie over het model en de mogelijkheden.')
def seed_demo():
"""Demoleads met elk een standaardmodel als gekozen model (demo/modellen, uit tools/export-models.js).
Bestaat een demolead al (zelfde e-mailadres), dan wordt hij overgeslagen."""
admin = q1("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id")
made = 0
for i, (name, city, status, model) in enumerate(DEMO_LEADS):
path = os.path.join(DEMO_MODELS, f'{model}.json')
design = read_design(path)
if not design:
print(f'{model}.json ontbreekt in {DEMO_MODELS}; draai node tools/export-models.js')
continue
email = name.lower().replace(' ', '.') + '@example.nl'
cid, outcome = upsert_contact(
{'name': name, 'email': email, 'phone': f'06-{12345670 + i}', 'city': city, 'status': status},
{'model': design_title(design)}, update_existing=False,
defaults={'source': 'Demo', 'assigned': admin['id'] if admin else None})
if outcome != 'created':
continue
log(cid, 'form', f'Formulier ontvangen via Demo\n{DEMO_MESSAGE}')
save_design_json(design, cid)
made += 1
db().commit()
print(f'{made} demolead(s) aangemaakt')
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == 'demo':
with app.app_context():
seed_demo()
sys.exit(0)
if len(sys.argv) > 1 and sys.argv[1] == 'sync':
with app.app_context():
n, err = do_sync()