v0.0.7: documenten met versies bij leads/klanten en relaties (tabel documents, versies in files; oude bestanden worden bij het starten versie 1 van een eigen document); woning = document met versies, de viewer toont de nieuwste (oudere via keuzelijst), nieuwe versie uploaden met opmerking, PDF's per woningversie; kaart (kaart-app, /kaart/) onder de woning en bij een klantrelatie zodra het adres bekend is, met de kavel en getekende woningen uit een kaveltoets-lead; blokken zonder gegevens verborgen; knop Bewerken in Tectum (TECTUM_URL) en API /api/tectum/woningen (lijst, ontwerp, nieuwe versie met conflictcontrole) met sleutel TECTUM_CRM_KEY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
db3e26d258
commit
c69c3773dc
@@ -4,7 +4,7 @@ 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.6'
|
||||
__version__ = '0.0.7'
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from email.utils import getaddresses
|
||||
from urllib.parse import quote as url_quote, unquote, urlparse
|
||||
from urllib.parse import quote as url_quote, unquote, urlencode, urlparse
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
@@ -74,6 +74,13 @@ VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/co
|
||||
'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
|
||||
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen')
|
||||
|
||||
# Andere apps in de stack. Tectum: knop "Bewerken in Tectum" bij een woning (TECTUM_URL) en de API /api/tectum/
|
||||
# (woningen laden en als nieuwe versie bewaren; sleutel TECTUM_CRM_KEY, die Caddy namens Tectum meestuurt).
|
||||
# Kaart: de kaveltoets-kaart bij een adres (KAART_URL; in de stack /kaart/ op hetzelfde domein).
|
||||
TECTUM_URL = (os.environ.get('TECTUM_URL') or '').rstrip('/')
|
||||
TECTUM_KEY = os.environ.get('TECTUM_CRM_KEY') or ''
|
||||
KAART_URL = os.environ.get('KAART_URL') or '/kaart/'
|
||||
|
||||
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', 'allowed_origins', 'api_assign_user',
|
||||
@@ -90,7 +97,8 @@ MAILBOX_PURPOSES = [('inbox', 'Inbox'), ('facturen', 'Facturen'), ('nieuwsbrief'
|
||||
FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
|
||||
API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'}
|
||||
|
||||
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown', 'unsubscribe'}
|
||||
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown', 'unsubscribe',
|
||||
'api_tectum_woningen', 'api_tectum_woning', 'api_tectum_versie'}
|
||||
PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
|
||||
ADMIN_ONLY = {'settings', 'users', 'user_edit', 'mailbox_edit', 'routines', 'routine_edit', 'routine_run',
|
||||
'routine_delete'}
|
||||
@@ -182,9 +190,14 @@ MIGRATIONS = {
|
||||
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
|
||||
'relation_role': "TEXT DEFAULT ''",
|
||||
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL',
|
||||
# gekozen woning (document); design_file_id is van vóór de documenten
|
||||
'design_id': 'INTEGER REFERENCES documents(id) ON DELETE SET NULL',
|
||||
# nieuwsbrieven: afgemeld (afmeldlink) of onbestelbaar (bounce); beide vallen buiten elk segment
|
||||
'unsubscribed_at': 'TEXT', 'bounced_at': 'TEXT'},
|
||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'},
|
||||
'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER',
|
||||
'document_id': 'INTEGER REFERENCES documents(id) ON DELETE CASCADE', 'version': 'INTEGER NOT NULL DEFAULT 1',
|
||||
'note': "TEXT DEFAULT ''", 'user_id': 'INTEGER REFERENCES users(id) ON DELETE SET NULL',
|
||||
'source_file_id': 'INTEGER'},
|
||||
'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
|
||||
'users': {'dav_password_hash': 'TEXT', 'cal_sync': 'INTEGER NOT NULL DEFAULT 0'},
|
||||
'emails': {'archived': 'INTEGER NOT NULL DEFAULT 0', 'cc': "TEXT DEFAULT ''", 'attachments': "TEXT DEFAULT ''",
|
||||
@@ -192,6 +205,36 @@ MIGRATIONS = {
|
||||
}
|
||||
|
||||
|
||||
def migrate_documents(con):
|
||||
"""Bestanden van vóór de documenten (v0.0.7): elk bestand wordt versie 1 van een eigen document; een woningontwerp
|
||||
een woning, de PDF's (werk/klant) tekeningen van die woning, en het gekozen model de gekozen woning."""
|
||||
rows = con.execute('SELECT id, contact_id, original, title, kind, design_id, created_at FROM files '
|
||||
'WHERE document_id IS NULL AND contact_id IS NOT NULL ORDER BY id').fetchall()
|
||||
if not rows:
|
||||
return
|
||||
woning = {}
|
||||
add = lambda kind, title, cid, ts, design=None: con.execute(
|
||||
'INSERT INTO documents (contact_id, kind, title, design_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(cid, kind, title, design, ts, ts)).lastrowid
|
||||
for fid, cid, original, title, kind, _, ts in rows:
|
||||
if title and not kind and original.lower().endswith('.json'):
|
||||
woning[fid] = add('woning', title, cid, ts)
|
||||
con.execute('UPDATE files SET document_id=?, version=1 WHERE id=?', (woning[fid], fid))
|
||||
for fid, cid, original, title, kind, design, ts in rows:
|
||||
if fid in woning:
|
||||
continue
|
||||
if kind in ('werk', 'klant') and design in woning:
|
||||
name = con.execute('SELECT title FROM documents WHERE id=?', (woning[design],)).fetchone()[0]
|
||||
did = add(kind, f"{name} {'werktekening' if kind == 'werk' else 'klant-PDF'}", cid, ts, woning[design])
|
||||
con.execute('UPDATE files SET document_id=?, version=1, source_file_id=? WHERE id=?', (did, design, fid))
|
||||
else:
|
||||
did = add('bestand', original, cid, ts)
|
||||
con.execute('UPDATE files SET document_id=?, version=1 WHERE id=?', (did, fid))
|
||||
for fid, did in woning.items():
|
||||
con.execute('UPDATE contacts SET design_id=? WHERE design_file_id=? AND design_id IS NULL', (did, fid))
|
||||
print(f'{len(rows)} bestand(en) omgezet naar documenten met versies')
|
||||
|
||||
|
||||
def init_db():
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
with open(os.path.join(BASE, 'schema.sql')) as f:
|
||||
@@ -212,9 +255,11 @@ 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))
|
||||
con.execute('CREATE INDEX IF NOT EXISTS idx_files_document ON files(document_id, version)')
|
||||
# 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():
|
||||
"AND (title IS NULL OR title='') AND document_id IS NULL "
|
||||
"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)
|
||||
@@ -224,6 +269,7 @@ def init_db():
|
||||
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))
|
||||
migrate_documents(con)
|
||||
# vóór de mailboxen stond één mailbox in de instellingen: die wordt de eerste (gedeelde) mailbox, met de
|
||||
# bestaande e-mail erin (uid krijgt het mailbox-voorvoegsel, zoals do_sync het nu maakt)
|
||||
if not con.execute('SELECT COUNT(*) FROM mailboxes').fetchone()[0]:
|
||||
@@ -434,19 +480,84 @@ def convert_contact(contact_id, relation_id=None, new_relation=''):
|
||||
link_relation(contact_id, rid)
|
||||
|
||||
|
||||
def save_upload(fs, contact_id=None):
|
||||
def store_upload(fs):
|
||||
"""Sla een geüpload bestand op in de uploadmap; geeft de opgeslagen naam (of None zonder bestand)."""
|
||||
if not fs or not fs.filename:
|
||||
return None
|
||||
ext = os.path.splitext(secure_filename(fs.filename))[1].lower()
|
||||
stored = uuid.uuid4().hex + ext
|
||||
path = os.path.join(UPLOADS, stored)
|
||||
fs.save(path)
|
||||
if contact_id:
|
||||
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, cur.lastrowid)
|
||||
stored = uuid.uuid4().hex + os.path.splitext(secure_filename(fs.filename))[1].lower()
|
||||
fs.save(os.path.join(UPLOADS, stored))
|
||||
return stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- documenten en versies
|
||||
# Een document hoort bij een contact of een relatie en heeft een of meer versies (rijen in files, versie 1, 2, …;
|
||||
# de hoogste is de huidige). Soorten: zie DOC_KINDS. Een woning is een house-design-JSON uit Tectum; werk/klant zijn
|
||||
# de PDF's van een woning (design_id), elk gemaakt van een bepaalde woningversie (files.source_file_id).
|
||||
|
||||
DOC_KINDS = {'bestand': 'Bestand', 'woning': 'Woning', 'werk': 'Werktekening', 'klant': 'Klant-PDF'}
|
||||
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
|
||||
FILE_COLS = ('f.id, f.document_id, f.version, f.stored, f.original, f.mime, f.size, f.note, f.created_at, '
|
||||
'f.source_file_id, f.user_id')
|
||||
|
||||
|
||||
def create_document(kind, title, contact_id=None, relation_id=None, design_id=None):
|
||||
ts = now()
|
||||
return db().execute('INSERT INTO documents (contact_id, relation_id, kind, title, design_id, created_at, updated_at) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(contact_id, relation_id, kind, str(title or '')[:200], design_id, ts, ts)).lastrowid
|
||||
|
||||
|
||||
def add_version(doc_id, stored, original, mime='', note='', source_file_id=None):
|
||||
"""Nieuwe versie van een document (het bestand staat al in de uploadmap); geeft (file_id, versienummer)."""
|
||||
doc = q1('SELECT contact_id FROM documents WHERE id=?', doc_id)
|
||||
version = scalar('SELECT COALESCE(MAX(version), 0) + 1 FROM files WHERE document_id=?', doc_id)
|
||||
ts = now()
|
||||
cur = db().execute(
|
||||
'INSERT INTO files (contact_id, stored, original, mime, size, created_at, document_id, version, note, user_id, '
|
||||
'source_file_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(doc['contact_id'], stored, str(original or stored)[:200], mime or mimetypes.guess_type(stored)[0] or '',
|
||||
os.path.getsize(os.path.join(UPLOADS, stored)), ts, doc_id, version, str(note or '')[:500],
|
||||
current_user_id(), source_file_id))
|
||||
db().execute('UPDATE documents SET updated_at=? WHERE id=?', (ts, doc_id))
|
||||
return cur.lastrowid, version
|
||||
|
||||
|
||||
def latest_file(doc_id):
|
||||
return q1(f'SELECT {FILE_COLS} FROM files f WHERE f.document_id=? ORDER BY f.version DESC LIMIT 1', doc_id)
|
||||
|
||||
|
||||
def doc_log(doc, text):
|
||||
"""Regel in de tijdlijn van het contact van een document (bij een relatie: geen tijdlijn)."""
|
||||
if doc['contact_id']:
|
||||
log(doc['contact_id'], 'file', text)
|
||||
|
||||
|
||||
def save_upload(fs, contact_id=None, relation_id=None, document_id=None, note=''):
|
||||
"""Upload opslaan. Zonder contact/relatie/document alleen het bestand (productafbeelding). Met een document een
|
||||
nieuwe versie daarvan; anders een nieuw document (een woningontwerp uit Tectum wordt een woning).
|
||||
Geeft de opgeslagen naam, of None (geen bestand, of geen woningontwerp als nieuwe versie van een woning)."""
|
||||
stored = store_upload(fs)
|
||||
if not stored or not (contact_id or relation_id or document_id):
|
||||
return stored
|
||||
design = read_design(os.path.join(UPLOADS, stored)) if stored.endswith('.json') else None
|
||||
if document_id:
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', document_id)
|
||||
if not doc or (doc['kind'] == 'woning' and not design):
|
||||
remove_upload(stored)
|
||||
return None
|
||||
fid, version = add_version(doc['id'], stored, fs.filename, fs.mimetype, note)
|
||||
if doc['kind'] == 'woning':
|
||||
note_design(doc, design, version, note)
|
||||
else:
|
||||
doc_log(doc, f"{doc['title']}: versie {version}" + (f' ({note})' if note else ''))
|
||||
return stored
|
||||
if design:
|
||||
did = create_document('woning', design_title(design), contact_id, relation_id)
|
||||
add_version(did, stored, fs.filename, 'application/json', note)
|
||||
note_design(q1('SELECT * FROM documents WHERE id=?', did), design, 1, note)
|
||||
else:
|
||||
did = create_document('bestand', fs.filename, contact_id, relation_id)
|
||||
add_version(did, stored, fs.filename, fs.mimetype, note)
|
||||
return stored
|
||||
|
||||
|
||||
@@ -464,51 +575,105 @@ 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:
|
||||
def note_design(doc, design, version, note=''):
|
||||
"""Tijdlijn bij een woning(versie). Een nieuwe woning wordt de gekozen woning van het contact (de nieuwste telt);
|
||||
een nieuwe versie verandert de keuze niet."""
|
||||
if not doc['contact_id']:
|
||||
return
|
||||
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)',
|
||||
if version == 1:
|
||||
db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (doc['id'], now(), doc['contact_id']))
|
||||
head = f"Woning: {doc['title']} (gekozen woning)"
|
||||
else:
|
||||
head = f"Woning {doc['title']}: versie {version}"
|
||||
parts = [head + (f' — {note}' if note else ''),
|
||||
' '.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.get('opmerking') or '' if version == 1 else '']
|
||||
log(doc['contact_id'], 'file', '\n'.join(p for p in parts if p))
|
||||
|
||||
|
||||
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
|
||||
def save_design_json(design, contact_id=None, document_id=None, note='', relation_id=None, original=None):
|
||||
"""Woningontwerp (dict) bewaren: nieuwe woning, of een nieuwe versie van `document_id`. Geeft (doc_id, file_id, versie)."""
|
||||
stored = uuid.uuid4().hex + '.json'
|
||||
with open(os.path.join(UPLOADS, stored), 'w', encoding='utf-8') as f:
|
||||
json.dump(design, f, ensure_ascii=False)
|
||||
name = original or (secure_filename(design_title(design)) or 'ontwerp') + '.json'
|
||||
doc_id = document_id or create_document('woning', design_title(design), contact_id, relation_id)
|
||||
fid, version = add_version(doc_id, stored, name, 'application/json', note)
|
||||
note_design(q1('SELECT * FROM documents WHERE id=?', doc_id), design, version, note)
|
||||
return doc_id, fid, version
|
||||
|
||||
|
||||
def design_pdfs(design_ids):
|
||||
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
|
||||
def documents_of(contact_id=None, relation_id=None, contact_ids=None, kinds=None):
|
||||
"""Documenten met hun versies (nieuwste eerst): [{doc, latest, versions, …}], recentst gewijzigd eerst."""
|
||||
where, args = [], []
|
||||
if contact_id:
|
||||
where.append('d.contact_id=?'); args.append(contact_id)
|
||||
if relation_id:
|
||||
where.append('d.relation_id=?'); args.append(relation_id)
|
||||
if contact_ids is not None:
|
||||
where.append(f'd.contact_id IN {id_list(contact_ids or [0])}'); args += contact_ids or [0]
|
||||
sql = (f"SELECT d.*, c.name AS contact_name FROM documents d LEFT JOIN contacts c ON c.id=d.contact_id "
|
||||
f"WHERE ({' OR '.join(where) or '1=0'})")
|
||||
if kinds:
|
||||
sql += f' AND d.kind IN {id_list(kinds)}'
|
||||
args += list(kinds)
|
||||
docs = q(sql + ' ORDER BY d.updated_at DESC, d.id DESC', *args)
|
||||
files = {}
|
||||
if docs:
|
||||
for f in q(f'SELECT {FILE_COLS}, u.name AS user_name FROM files f LEFT JOIN users u ON u.id=f.user_id '
|
||||
f'WHERE f.document_id IN {id_list([d["id"] for d in docs])} ORDER BY f.version DESC',
|
||||
*[d['id'] for d in docs]):
|
||||
files.setdefault(f['document_id'], []).append(f)
|
||||
out = []
|
||||
for d in docs:
|
||||
versions = files.get(d['id'], [])
|
||||
if versions:
|
||||
out.append({**dict(d), 'latest': versions[0], 'versions': versions})
|
||||
return out
|
||||
|
||||
|
||||
def design_pdfs(design_ids, current_only=False):
|
||||
"""PDF's (werktekening, klant) per woning: {woning_id: {kind: {url, date, id, version, current}}}. current = gemaakt
|
||||
van de nieuwste woningversie; met current_only alleen die (de viewer maakt de rest dan opnieuw)."""
|
||||
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']}
|
||||
if not design_ids:
|
||||
return out
|
||||
latest = {d: (latest_file(d) or {'id': None})['id'] for d in design_ids}
|
||||
for d in q(f"SELECT * FROM documents WHERE kind IN ('werk', 'klant') AND design_id IN {id_list(design_ids)}",
|
||||
*design_ids):
|
||||
f = latest_file(d['id'])
|
||||
if not f:
|
||||
continue
|
||||
current = f['source_file_id'] == latest[d['design_id']]
|
||||
if current or not current_only:
|
||||
out.setdefault(d['design_id'], {})[d['kind']] = {
|
||||
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id'],
|
||||
'version': f['version'], 'current': current}
|
||||
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']]
|
||||
"""Alle woningversies bij een contact, nieuwste eerst (voor het bestek: id = de versie, title met versienummer)."""
|
||||
return q("SELECT f.id, f.stored, f.original, f.created_at, f.version, f.document_id, "
|
||||
"d.title || ' · versie ' || f.version AS title, d.title AS design_title FROM files f "
|
||||
"JOIN documents d ON d.id=f.document_id WHERE d.contact_id=? AND d.kind='woning' "
|
||||
"ORDER BY d.updated_at DESC, f.version DESC", contact_id)
|
||||
|
||||
|
||||
def save_design_json(design, contact_id):
|
||||
stored = uuid.uuid4().hex + '.json'
|
||||
path = os.path.join(UPLOADS, stored)
|
||||
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'
|
||||
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 chosen_design_file(contact):
|
||||
"""Nieuwste versie van de gekozen woning van een contact, of None."""
|
||||
return latest_file(contact['design_id']) if contact and contact['design_id'] else None
|
||||
|
||||
|
||||
def remove_document(doc_id):
|
||||
"""Document met alle versies en (bij een woning) zijn tekeningen weg, ook van schijf."""
|
||||
for f in q('SELECT f.stored FROM files f JOIN documents d ON d.id=f.document_id WHERE d.id=? OR d.design_id=?',
|
||||
doc_id, doc_id):
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE document_id IN (SELECT id FROM documents WHERE id=? OR design_id=?)',
|
||||
(doc_id, doc_id))
|
||||
db().execute('DELETE FROM documents WHERE id=? OR design_id=?', (doc_id, doc_id))
|
||||
|
||||
|
||||
def remove_upload(stored):
|
||||
@@ -795,7 +960,7 @@ def dashboard():
|
||||
# ---------------------------------------------------------------- contacten
|
||||
|
||||
CONTACT_FROM = ('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')
|
||||
'LEFT JOIN relations r ON r.id=c.relation_id LEFT JOIN documents f ON f.id=c.design_id')
|
||||
CONTACT_HIDDEN = ('portal_token', 'portal_password_hash')
|
||||
|
||||
|
||||
@@ -864,6 +1029,8 @@ def contacts_export():
|
||||
|
||||
|
||||
def delete_contact(cid):
|
||||
for d in q('SELECT id FROM documents WHERE contact_id=?', cid):
|
||||
remove_document(d['id'])
|
||||
for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM contacts WHERE id=?', (cid,))
|
||||
@@ -947,33 +1114,73 @@ def all_relations():
|
||||
return q('SELECT id, kind, type, name FROM relations ORDER BY kind, name')
|
||||
|
||||
|
||||
KAVEL_LINK = re.compile(r'https?://\S+?/(?:kavel|kaart)/\?(\S*\bid=[^\s]+)')
|
||||
|
||||
|
||||
def kaart_src(address='', link_query=''):
|
||||
"""Adres van de kaart (kaart-app) voor een kavellink uit een lead, of anders de eerste treffer voor het adres."""
|
||||
if link_query:
|
||||
return f'{KAART_URL}?{link_query}'
|
||||
return f'{KAART_URL}?{urlencode({"adres": address})}' if address else ''
|
||||
|
||||
|
||||
def full_address(row, design=None):
|
||||
"""Volledig adres (straat + nummer, postcode/plaats) van een contact of relatie; anders dat uit het
|
||||
woningontwerp. Leeg als het niet bekend is (dan geen kaart)."""
|
||||
street, place = (row['address'] or '').strip(), ' '.join(filter(None, [(row['postcode'] or '').strip(),
|
||||
(row['city'] or '').strip()]))
|
||||
if street and place and re.search(r'\d', street):
|
||||
return f'{street}, {place}'
|
||||
project = (design or {}).get('project') or {}
|
||||
street = ' '.join(filter(None, [str(project.get('straat') or '').strip(), str(project.get('nummer') or '').strip()]))
|
||||
place = ' '.join(filter(None, [str(project.get('postcode') or '').strip(), str(project.get('plaats') or '').strip()]))
|
||||
return f'{street}, {place}' if street and place and re.search(r'\d', street) else ''
|
||||
|
||||
|
||||
def contact_kavel_link(cid):
|
||||
"""Querystring van de nieuwste kavellink in de tijdlijn (lead uit de kaveltoets), met perceel en woningen."""
|
||||
for a in q("SELECT body FROM activities WHERE contact_id=? AND (body LIKE '%/kavel/?%' OR body LIKE '%/kaart/?%') "
|
||||
"ORDER BY created_at DESC, id DESC", cid):
|
||||
m = KAVEL_LINK.search(a['body'])
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ''
|
||||
|
||||
|
||||
def design_meta(design_file):
|
||||
d = read_design(os.path.join(UPLOADS, os.path.basename(design_file['stored']))) or {}
|
||||
project = d.get('project') or {}
|
||||
return d, {'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 ''}
|
||||
|
||||
|
||||
@app.route('/contacts/<int:cid>')
|
||||
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 ''}
|
||||
docs = documents_of(contact_id=cid)
|
||||
woningen = [d for d in docs if d['kind'] == 'woning']
|
||||
woning = next((d for d in woningen if d['id'] == c['design_id']), None)
|
||||
# welke versie in de viewer: ?versie=<file-id> van deze woning, anders de nieuwste
|
||||
shown = woning and next((v for v in woning['versions'] if v['id'] == request.args.get('versie', type=int)),
|
||||
woning['latest'])
|
||||
design, meta = design_meta(shown) if shown else ({}, {})
|
||||
return render_template(
|
||||
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
|
||||
mail_templates=all_mail_templates(), mail_vars=mail_vars(c),
|
||||
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
|
||||
'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid),
|
||||
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
|
||||
documents=docs, DOC_KINDS=DOC_KINDS,
|
||||
emails=q(f'SELECT * FROM emails e WHERE e.contact_id=? AND {visible_mail()} ORDER BY e.date DESC LIMIT 50', cid),
|
||||
mailboxes=user_mailboxes(sending=True),
|
||||
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,
|
||||
woningen=woningen, woning=woning, shown=shown, design_meta=meta,
|
||||
pdfs=design_pdfs([woning['id']]).get(woning['id'], {}) if woning else {}, PDF_KINDS=PDF_KINDS,
|
||||
tectum_url=f"{TECTUM_URL}/?woning={woning['id']}" if woning and TECTUM_URL else '',
|
||||
kaart=kaart_src(full_address(c, design), contact_kavel_link(cid)),
|
||||
quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
|
||||
appointments=calendar_items(datetime.combine(date.today(), datetime.min.time()),
|
||||
datetime.combine(date.today() + timedelta(days=366), datetime.min.time()),
|
||||
@@ -1042,9 +1249,10 @@ def contact_note(cid):
|
||||
@app.post('/contacts/<int:cid>/upload')
|
||||
def contact_upload(cid):
|
||||
get_or_404('contacts', cid)
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid))
|
||||
note = request.form.get('note', '').strip()
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid, note=note))
|
||||
if count:
|
||||
log(cid, 'file', f'{count} bestand(en) toegevoegd')
|
||||
log(cid, 'file', f'{count} document(en) toegevoegd' + (f' ({note})' if note else ''))
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=cid))
|
||||
|
||||
@@ -1118,45 +1326,100 @@ def upload_file(name):
|
||||
return serve_upload(name, f['original'] if f else None)
|
||||
|
||||
|
||||
def doc_back(doc):
|
||||
if doc['contact_id']:
|
||||
return redirect(url_for('contact_view', cid=doc['contact_id']))
|
||||
if doc['relation_id']:
|
||||
return redirect(url_for('relation_view', rid=doc['relation_id']))
|
||||
return redirect(back(url_for('dashboard')))
|
||||
|
||||
|
||||
def doc_or_404(did):
|
||||
return get_or_404('documents', did)
|
||||
|
||||
|
||||
@app.post('/documents/<int:did>/version')
|
||||
def document_version(did):
|
||||
"""Nieuwe versie van een document uploaden (bij een woning: een woningontwerp uit Tectum)."""
|
||||
doc = doc_or_404(did)
|
||||
if not save_upload(request.files.get('file'), document_id=did, note=request.form.get('note', '').strip()):
|
||||
flash('Kies een bestand' + (' met een woningontwerp uit Tectum (JSON).' if doc['kind'] == 'woning' else '.'),
|
||||
'error')
|
||||
else:
|
||||
flash('Nieuwe versie opgeslagen.')
|
||||
db().commit()
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/documents/<int:did>/delete')
|
||||
def document_delete(did):
|
||||
"""Document met alle versies verwijderen (bij een woning ook de tekeningen)."""
|
||||
doc = doc_or_404(did)
|
||||
remove_document(did)
|
||||
if doc['kind'] == 'woning' and doc['contact_id']:
|
||||
c = q1('SELECT design_id FROM contacts WHERE id=?', doc['contact_id'])
|
||||
if c and c['design_id'] is None:
|
||||
rest = q("SELECT id FROM documents WHERE contact_id=? AND kind='woning' ORDER BY updated_at DESC LIMIT 1",
|
||||
doc['contact_id'])
|
||||
db().execute('UPDATE contacts SET design_id=? WHERE id=?', (rest[0]['id'] if rest else None, doc['contact_id']))
|
||||
doc_log(doc, f"{DOC_KINDS.get(doc['kind'], 'Document')} verwijderd: {doc['title']}")
|
||||
db().commit()
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/files/<int:fid>/delete')
|
||||
def file_delete(fid):
|
||||
"""Eén versie verwijderen; de laatste versie weg = het hele document weg."""
|
||||
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):
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
|
||||
if not doc:
|
||||
remove_upload(f['stored'])
|
||||
db().execute('DELETE FROM files WHERE id=?', (fid,))
|
||||
db().commit()
|
||||
return redirect(back(url_for('dashboard')))
|
||||
if scalar('SELECT COUNT(*) FROM files WHERE document_id=?', doc['id']) <= 1:
|
||||
return document_delete(doc['id'])
|
||||
for pdf in q('SELECT stored, id FROM files WHERE source_file_id=?', fid):
|
||||
remove_upload(pdf['stored'])
|
||||
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
|
||||
db().execute('DELETE FROM files WHERE id=?', (pdf['id'],))
|
||||
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().execute("DELETE FROM documents WHERE kind IN ('werk', 'klant') AND design_id=? "
|
||||
"AND NOT EXISTS (SELECT 1 FROM files WHERE document_id=documents.id)", (doc['id'],))
|
||||
doc_log(doc, f"{doc['title']}: versie {f['version']} verwijderd")
|
||||
db().commit()
|
||||
return redirect(url_for('contact_view', cid=f['contact_id']))
|
||||
return doc_back(doc)
|
||||
|
||||
|
||||
@app.post('/contacts/<int:cid>/design')
|
||||
def contact_design(cid):
|
||||
"""Gekozen model: een van de woningontwerpen bij het contact (of geen)."""
|
||||
"""Gekozen woning: een van de woningen 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')
|
||||
did = request.form.get('document_id', type=int)
|
||||
d = q1("SELECT * FROM documents WHERE id=? AND contact_id=? AND kind='woning'", did, cid) if did else None
|
||||
if (d['id'] if d else None) != c['design_id']:
|
||||
db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (d['id'] if d else None, now(), cid))
|
||||
log(cid, 'status', f"Gekozen woning: {d['title']}" if d else 'Geen gekozen woning 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)."""
|
||||
"""3D-viewer van een woningversie (in een iframe op de contactpagina, of los). PDF's maken (en bij het openen
|
||||
vanzelf aanvullen) alleen bij de nieuwste versie."""
|
||||
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)}
|
||||
doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
|
||||
latest = doc and doc['kind'] == 'woning' and latest_file(doc['id'])['id'] == fid
|
||||
pdfs = design_pdfs([doc['id']], current_only=True).get(doc['id'], {}) if latest else {}
|
||||
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if latest else '', 'pdfs': pdfs,
|
||||
'auto': bool(latest) and len(pdfs) < len(PDF_KINDS)}
|
||||
name = (doc['title'] if doc else '') or design_title(design)
|
||||
if doc and doc['kind'] == 'woning':
|
||||
name += f" · versie {f['version']}"
|
||||
resp = make_response(render_template(
|
||||
'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg,
|
||||
'viewer.html', design=design, name=name, 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
|
||||
@@ -1164,29 +1427,32 @@ def design_viewer(fid):
|
||||
|
||||
@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."""
|
||||
"""PDF van een woningversie, gemaakt door de viewer (HousePdf van de configurator). Per woning een document
|
||||
werktekening (intern) en klant-PDF (ook in het portaal), met een versie per woningversie; opnieuw maken van
|
||||
dezelfde woningversie vervangt die versie."""
|
||||
f = get_or_404('files', fid)
|
||||
doc = q1("SELECT * FROM documents WHERE id=? AND kind='woning'", f['document_id']) if f['document_id'] else None
|
||||
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']:
|
||||
if kind not in PDF_KINDS or not fs or not doc:
|
||||
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):
|
||||
pdf_doc = q1('SELECT * FROM documents WHERE design_id=? AND kind=?', doc['id'], kind)
|
||||
pdf_id = pdf_doc['id'] if pdf_doc else create_document(kind, f"{doc['title']} {PDF_KINDS[kind]}", doc['contact_id'],
|
||||
doc['relation_id'], doc['id'])
|
||||
for old in q('SELECT * FROM files WHERE document_id=? AND source_file_id=?', pdf_id, fid):
|
||||
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']}")
|
||||
name = secure_filename(f"{doc['title']} v{f['version']} {PDF_KINDS[kind]}") or 'ontwerp'
|
||||
new_id, _ = add_version(pdf_id, stored, f'{name}.pdf', 'application/pdf', f"van versie {f['version']}", fid)
|
||||
doc_log(doc, f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van woning {doc['title']} "
|
||||
f"(versie {f['version']})")
|
||||
db().commit()
|
||||
row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid)
|
||||
row = q1('SELECT * FROM files WHERE id=?', new_id)
|
||||
return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id'])
|
||||
|
||||
|
||||
@@ -1294,9 +1560,14 @@ 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)
|
||||
chosen = {m['design_id'] for m in members if m['design_id']}
|
||||
# woningen van de contactpersonen (gekozen eerst) en documenten van de relatie zelf
|
||||
designs = sorted(documents_of(contact_ids=ids, relation_id=rid, kinds=('woning',)), key=lambda d: d['id'] not in chosen)
|
||||
for d in designs:
|
||||
d['chosen'] = d['id'] in chosen
|
||||
docs = documents_of(relation_id=rid)
|
||||
design = next((d for d in designs if d['chosen']), designs[0] if designs else None)
|
||||
design_data = read_design(os.path.join(UPLOADS, design['latest']['stored'])) if design else None
|
||||
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'),
|
||||
@@ -1307,7 +1578,20 @@ def relation_view(rid):
|
||||
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),
|
||||
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS)
|
||||
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS,
|
||||
documents=docs, DOC_KINDS=DOC_KINDS,
|
||||
kaart=kaart_src(full_address(r, design_data)) if r['kind'] == 'klant' else '')
|
||||
|
||||
|
||||
@app.post('/relations/<int:rid>/upload')
|
||||
def relation_upload(rid):
|
||||
get_or_404('relations', rid)
|
||||
note = request.form.get('note', '').strip()
|
||||
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, relation_id=rid, note=note))
|
||||
if count:
|
||||
db().commit()
|
||||
flash(f'{count} document(en) toegevoegd.')
|
||||
return redirect(url_for('relation_view', rid=rid))
|
||||
|
||||
|
||||
@app.post('/relations/<int:rid>/member')
|
||||
@@ -1329,6 +1613,8 @@ def relation_member_remove(rid, cid):
|
||||
|
||||
|
||||
def delete_relation(rid):
|
||||
for d in q('SELECT id FROM documents WHERE relation_id=?', rid):
|
||||
remove_document(d['id'])
|
||||
for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
|
||||
link_relation(c['id'], None, '')
|
||||
db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,))
|
||||
@@ -1808,7 +2094,7 @@ def mail_vars(contact=None, user=None):
|
||||
'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')}
|
||||
if contact:
|
||||
name = (contact['name'] or '').strip()
|
||||
design = q1('SELECT title FROM files WHERE id=?', contact['design_file_id']) if contact['design_file_id'] else None
|
||||
design = q1('SELECT title FROM documents WHERE id=?', contact['design_id']) if contact['design_id'] else None
|
||||
values.update(voornaam=name.split()[0] if name else '', naam=name, email=contact['email'] or '',
|
||||
telefoon=contact['phone'] or '', bedrijf=contact['company'] or '', adres=contact['address'] or '',
|
||||
postcode=contact['postcode'] or '', plaats=contact['city'] or '',
|
||||
@@ -2134,7 +2420,7 @@ def segment_where(f):
|
||||
where.append('c.created_at<?')
|
||||
args.append((date.fromisoformat(f['created_to']) + timedelta(days=1)).isoformat())
|
||||
if f.get('has_design'):
|
||||
where.append('c.design_file_id IS NOT NULL')
|
||||
where.append('c.design_id IS NOT NULL')
|
||||
return ' AND '.join(where), args
|
||||
|
||||
|
||||
@@ -2381,7 +2667,7 @@ def campaign_test(c):
|
||||
ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg else []
|
||||
contact = q1('SELECT * FROM contacts WHERE id=?', ids[0]) if ids else None
|
||||
contact = contact or {'id': 0, 'name': g.user['name'], 'email': g.user['email'], 'phone': '', 'company': '',
|
||||
'address': '', 'postcode': '', 'city': '', 'design_file_id': None}
|
||||
'address': '', 'postcode': '', 'city': '', 'design_id': None}
|
||||
cfg = mailbox_cfg(q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']))
|
||||
msg = campaign_message(c, cfg, contact, g.user['email'], g.user, request.url_root)
|
||||
msg.replace_header('Subject', f"[Test] {msg['Subject']}")
|
||||
@@ -2926,7 +3212,8 @@ 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)
|
||||
chosen = chosen_design_file(contact)
|
||||
want = request.args.get('file', type=int) or qt['design_file_id'] or (chosen['id'] if chosen 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')
|
||||
@@ -3059,7 +3346,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=? AND kind<>'werk' ORDER BY created_at DESC", cid),
|
||||
documents=[d for d in documents_of(contact_id=cid) if d['kind'] != 'werk'],
|
||||
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())
|
||||
@@ -3112,7 +3399,8 @@ def portal_message():
|
||||
|
||||
@app.route('/portal/files/<name>')
|
||||
def portal_file(name):
|
||||
f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id'])
|
||||
f = q1("SELECT f.* FROM files f JOIN documents d ON d.id=f.document_id WHERE f.stored=? AND d.contact_id=? "
|
||||
"AND d.kind<>'werk'", name, g.portal['id'])
|
||||
if f is None:
|
||||
abort(404)
|
||||
return serve_upload(name, f['original'])
|
||||
@@ -3297,6 +3585,88 @@ def search_groups(term, limit):
|
||||
return groups
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- API voor Tectum
|
||||
# Tectum (de configurator) laadt woningen van leads en klanten en bewaart wijzigingen als nieuwe versie. Tectum staat
|
||||
# achter het wachtwoord van Caddy; Caddy stuurt /api/crm/* van Tectum hierheen met X-API-Key = TECTUM_CRM_KEY.
|
||||
# Zonder sleutel (leeg) staat de API uit.
|
||||
|
||||
def tectum_auth():
|
||||
key = request.headers.get('X-API-Key') or ''
|
||||
if not TECTUM_KEY or not secrets.compare_digest(key, TECTUM_KEY):
|
||||
abort(make_response(jsonify(ok=False, error='Ongeldige sleutel'), 401))
|
||||
|
||||
|
||||
def tectum_woning(d, latest):
|
||||
return {'id': d['id'], 'titel': d['title'], 'versie': latest['version'], 'datum': latest['created_at'],
|
||||
'opmerking': latest['note'] or '',
|
||||
'contact': {'id': d['contact_id'], 'naam': d['contact_name'] or '', 'soort': d['contact_type'] or '',
|
||||
'plaats': d['contact_city'] or ''} if d['contact_id'] else None,
|
||||
'relatie': d['relation_name'] or ''}
|
||||
|
||||
|
||||
TECTUM_FROM = ("FROM documents d LEFT JOIN contacts c ON c.id=d.contact_id "
|
||||
"LEFT JOIN relations r ON r.id=COALESCE(d.relation_id, c.relation_id) ")
|
||||
TECTUM_COLS = ("SELECT d.*, c.name AS contact_name, c.type AS contact_type, c.city AS contact_city, "
|
||||
"r.name AS relation_name ")
|
||||
|
||||
|
||||
@app.get('/api/tectum/woningen')
|
||||
def api_tectum_woningen():
|
||||
"""Woningen van leads en klanten, recentst gewijzigd eerst; ?q= zoekt op woning, contact, relatie en plaats."""
|
||||
tectum_auth()
|
||||
term = request.args.get('q', '').strip()
|
||||
sql, args = TECTUM_COLS + TECTUM_FROM + "WHERE d.kind='woning'", []
|
||||
if term:
|
||||
sql += ' AND (d.title LIKE ? OR c.name LIKE ? OR r.name LIKE ? OR c.city LIKE ?)'
|
||||
args += [f'%{term}%'] * 4
|
||||
out = []
|
||||
for d in q(sql + ' ORDER BY d.updated_at DESC LIMIT 100', *args):
|
||||
latest = latest_file(d['id'])
|
||||
if latest:
|
||||
out.append(tectum_woning(d, latest))
|
||||
return jsonify(ok=True, woningen=out)
|
||||
|
||||
|
||||
@app.get('/api/tectum/woningen/<int:did>')
|
||||
def api_tectum_woning(did):
|
||||
"""Eén woning met het ontwerp (house-design) van de nieuwste versie, of ?versie=<n>, en de lijst van versies."""
|
||||
tectum_auth()
|
||||
d = q1(TECTUM_COLS + TECTUM_FROM + "WHERE d.id=? AND d.kind='woning'", did)
|
||||
if not d:
|
||||
return jsonify(ok=False, error='Woning niet gevonden'), 404
|
||||
versions = q(f'SELECT {FILE_COLS} FROM files f WHERE f.document_id=? ORDER BY f.version DESC', did)
|
||||
want = request.args.get('versie', type=int)
|
||||
f = next((v for v in versions if v['version'] == want), versions[0] if versions else None)
|
||||
design = read_design(os.path.join(UPLOADS, f['stored'])) if f else None
|
||||
if not design:
|
||||
return jsonify(ok=False, error='Ontwerp niet leesbaar'), 404
|
||||
return jsonify(ok=True, woning={**tectum_woning(d, versions[0]), 'geladen': f['version']}, ontwerp=design,
|
||||
versies=[{'versie': v['version'], 'datum': v['created_at'], 'opmerking': v['note'] or ''}
|
||||
for v in versions])
|
||||
|
||||
|
||||
@app.post('/api/tectum/woningen/<int:did>/versies')
|
||||
def api_tectum_versie(did):
|
||||
"""Nieuwe versie van een woning vanuit Tectum. Body: {ontwerp, opmerking, basis}; basis = de versie die Tectum
|
||||
geladen had. Is er intussen een nieuwere versie, dan 409 (tenzij forceer: true)."""
|
||||
tectum_auth()
|
||||
data = request.get_json(silent=True) or {}
|
||||
design = data.get('ontwerp')
|
||||
if not isinstance(design, dict) or design.get('type') != 'house-design':
|
||||
return jsonify(ok=False, error='Geen woningontwerp (house-design)'), 400
|
||||
d = q1("SELECT * FROM documents WHERE id=? AND kind='woning'", did)
|
||||
if not d:
|
||||
return jsonify(ok=False, error='Woning niet gevonden'), 404
|
||||
current = latest_file(did)['version']
|
||||
if not data.get('forceer') and isinstance(data.get('basis'), int) and data['basis'] < current:
|
||||
return jsonify(ok=False, error=f'Er is intussen een nieuwere versie ({current})', versie=current), 409
|
||||
note = str(data.get('opmerking') or '').strip()[:500] or 'vanuit Tectum'
|
||||
_, _, version = save_design_json(design, document_id=did, note=note,
|
||||
original=f"{secure_filename(d['title']) or 'woning'}-v{current + 1}.json")
|
||||
db().commit()
|
||||
return jsonify(ok=True, versie=version)
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
term = request.args.get('q', '').strip()[:100]
|
||||
|
||||
Reference in New Issue
Block a user