From c69c3773dc1fc04ed72442ffd9f353007f7c5876 Mon Sep 17 00:00:00 2001 From: Machiel Date: Fri, 16 Oct 2026 19:40:51 +0200 Subject: [PATCH] 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 --- README.md | 31 +- app.py | 574 +++++++++++++++++++++++++++++++------- schema.sql | 17 ++ static/style.css | 19 ++ templates/_documents.html | 47 ++++ templates/bestek.html | 2 +- templates/contact.html | 96 +++---- templates/portal.html | 7 +- templates/relation.html | 29 +- 9 files changed, 653 insertions(+), 169 deletions(-) create mode 100644 templates/_documents.html diff --git a/README.md b/README.md index adbee28..c4f03ba 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,34 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0 familie of bedrijf, met meerdere contactpersonen en hun rol) en **partners** met een type (leverancier, aannemer, installateur, ...; de typen pas je aan bij Instellingen). Een lead omzetten naar klant zet de persoon in een nieuwe of bestaande klantrelatie. Leveranciers van producten worden automatisch partners. -- **Gekozen model** per lead/contact: een woningontwerp uit de configurator (`house-design`-JSON, via het - formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp - kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator - (`/files//viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve - prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom. +- **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe + versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning + en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten + vanzelf versie 1 van een eigen document. +- **Woning** per lead/klant: een woningontwerp uit Tectum (`house-design`-JSON, via het formulier, de API, geüpload + bij Documenten of bewaard vanuit Tectum) is een woning met versies. Een nieuwe woning wordt de gekozen woning + (de nieuwste telt), een andere kies je op de contactpagina. Daar staat de nieuwste versie in een **3D-viewer** op + de engine van de configurator (`/files//viewer`, een oudere versie via de keuzelijst): 3D, doorsnede en + plattegrond, met maten, m², inhoud, gewicht en een indicatieve prijs (uit de kosten-CSV's van de configurator). + Zonder woning is het blok er niet. In de leadlijst staat de woning als kolom (Model). +- **Tectum** (in de stack): **Bewerken in Tectum** bij de woning opent hem op `tectum./?woning=`; daar + bewaart **Opslaan in CRM** een nieuwe versie. Tectum toont de woningen van leads en klanten ook bij Nieuw. De API + daarvoor is `/api/tectum/woningen` (lijst, `?q=`), `/api/tectum/woningen/` (ontwerp, `?versie=`) en `POST + /api/tectum/woningen//versies` (`{ontwerp, opmerking, basis}`; 409 als er intussen een nieuwere versie is), + alleen met de sleutel `TECTUM_CRM_KEY` (Caddy stuurt die namens Tectum mee; leeg = uit). `TECTUM_URL` = adres + van Tectum voor de knop. +- **Kaart** bij een lead/klant (onder de woning) en een klantrelatie zodra het adres bekend is (straat met + huisnummer en postcode of plaats, of anders het adres uit het woningontwerp): de kaveltoets van de kaart-app + (`KAART_URL`, standaard `/kaart/`, in de stack op hetzelfde domein). Kwam de lead uit de kaveltoets, dan opent de + kaart die kavel met de getekende woningen. Zonder adres is het blok er niet. Met **Lagen** in de viewer kies je een bouwfase (t/m regelwerk, elektra, beplating, …) of losse lagen (regelwerk, elektra, kozijnen, dak, …), zoals Lagen in de configurator, zodat de ploeg de opbouw kan bekijken. - **PDF's per ontwerp** (`HousePdf` van de configurator, gemaakt in de viewer en bewaard bij het contact): een **werktekening** (alle tekenbladen, elektra, zaaglijst en kosten; intern, niet in het klantportaal) en een **klant-PDF** zoals de light-configurator die maakt (`LightPdf`: voorblad met beeld, kerncijfers, keuzes, indicatieve - prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Ontbreken ze, dan maakt de viewer ze vanzelf bij - het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de - relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen). + prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken + ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave. + Op de contactpagina bij de woning en op de relatiepagina (Woningen en tekeningen, voor alle contactpersonen). - Op mobiel staan bij contact en relatie de gegevens bovenaan, de 3D-weergave verderop. - **Taken/opvolging** per contact met datum en eigenaar; "Mijn taken" op het dashboard. - **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden. diff --git a/app.py b/app.py index 2c4922d..41dc9dd 100644 --- a/app.py +++ b/app.py @@ -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/') 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= 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//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//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//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//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//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//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//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//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//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'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/') 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/') +def api_tectum_woning(did): + """Eén woning met het ontwerp (house-design) van de nieuwste versie, of ?versie=, 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//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] diff --git a/schema.sql b/schema.sql index 9e2a1e5..4d92953 100644 --- a/schema.sql +++ b/schema.sql @@ -68,6 +68,23 @@ CREATE TABLE IF NOT EXISTS activities ( created_at TEXT NOT NULL ); +-- Documenten bij een contact (lead/klant) of relatie, elk met een of meer versies (tabel files). Soort: bestand, +-- woning (house-design-JSON uit Tectum; de nieuwste versie telt), werk/klant (PDF's van een woning: design_id). +CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, + relation_id INTEGER REFERENCES relations(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'bestand', + title TEXT NOT NULL DEFAULT '', + design_id INTEGER REFERENCES documents(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_documents_contact ON documents(contact_id, kind); +CREATE INDEX IF NOT EXISTS idx_documents_relation ON documents(relation_id, kind); + +-- Bestanden: elke rij is één versie van een document (document_id, version). source_file_id bij een PDF = de +-- woningversie waarvan hij gemaakt is. title/kind/design_id zijn van vóór de documenten (alleen nog voor de migratie). CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, diff --git a/static/style.css b/static/style.css index c955263..7fb3789 100644 --- a/static/style.css +++ b/static/style.css @@ -248,6 +248,25 @@ dl.props dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; } .file .muted { padding: 0 0.5rem 0.35rem; font-size: 0.8rem; } .viewer-frame { aspect-ratio: 16 / 10; min-height: 320px; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); } .viewer-frame iframe { display: block; width: 100%; height: 100%; border: 0; } +.kaart-frame { height: 640px; max-height: 80vh; border: 1px solid var(--line); border-radius: var(--radius-md); overflow: hidden; background: var(--blue); } +.kaart-frame iframe { display: block; width: 100%; height: 100%; border: 0; } +/* documenten met versies (contact, relatie, portaal) */ +.docs { margin-bottom: 1rem; } +.doc { border-top: 1px solid var(--line); } +.doc:first-child { border-top: 0; } +.doc > summary { list-style: none; display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; font: inherit; } +.doc > summary::-webkit-details-marker { display: none; } +.doc[open] > summary { margin-bottom: 0.25rem; } +.doc .doc-icon { flex: 0 0 40px; height: 40px; border-radius: 10px; background: var(--blue); display: flex; align-items: center; justify-content: center; overflow: hidden; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; color: var(--ink); } +.doc .doc-icon img { width: 100%; height: 100%; object-fit: cover; } +.doc .doc-main { min-width: 0; flex: 1; } +.doc .doc-title { display: block; color: var(--ink); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.doc .doc-main .muted { display: block; font-size: 0.8rem; } +.doc .versions { list-style: none; padding: 0 0 0 52px; margin: 0 0 0.5rem; font-size: 0.85rem; } +.doc .versions li { display: flex; flex-wrap: wrap; gap: 0 0.5rem; align-items: baseline; padding: 0.2rem 0; } +.doc .versions form { margin-left: auto; } +.doc .doc-forms { padding: 0 0 0.75rem 52px; } +.doc .doc-forms .actions { margin-top: 0.5rem; } details summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; } details[open] summary { margin-bottom: 1rem; } .mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; } diff --git a/templates/_documents.html b/templates/_documents.html new file mode 100644 index 0000000..af07495 --- /dev/null +++ b/templates/_documents.html @@ -0,0 +1,47 @@ +{# Documenten met versies (contact en relatie). docs = documents_of(…); upload_url = waar nieuwe documenten heen gaan; + chosen_id = gekozen woning (alleen bij een contact, dan ook "Kies als woning"). #} +{% macro documents_list(docs, upload_url, chosen_id=None, choose_url=None) %} +
+ {% for d in docs %}{% set v0 = d.latest %} +
+ + {% if v0.mime.startswith('image/') %}{% elif d.kind == 'woning' %}3D{% else %}{{ v0.original.rsplit('.', 1)[-1][:4] if '.' in v0.original else 'doc' }}{% endif %} + {{ d.title }} + {{ DOC_KINDS.get(d.kind, 'Document') }} · versie {{ v0.version }} · {{ v0.created_at|dt('%d-%m-%Y') }}{% if d.kind == 'werk' %} · intern, niet in portaal{% endif %}{% if chosen_id and d.id == chosen_id %} · gekozen woning{% endif %}{% if d.contact_name and not chosen_id %} · {{ d.contact_name }}{% endif %} + +
    + {% for v in d.versions %} +
  • Versie {{ v.version }} + ↓ + {{ v.created_at|dt }}{% if v.user_name %} · {{ v.user_name }}{% endif %}{% if v.note %} · {{ v.note }}{% endif %} +
    +
  • + {% endfor %} +
+
+ {% if d.kind in ('bestand', 'woning') %} +
+
+
+
+
+ {% endif %} +
+ {% if choose_url and d.kind == 'woning' and d.id != chosen_id %} +
+ {% endif %} +
+
+
+
+ {% else %} +

Nog geen documenten.

+ {% endfor %} +
+
+ + +
+

Elk bestand wordt een nieuw document; een woningontwerp uit Tectum (JSON) wordt een woning. Een nieuwe versie van een bestaand document voeg je toe bij dat document.

+
+{% endmacro %} diff --git a/templates/bestek.html b/templates/bestek.html index a6a8a79..756ff40 100644 --- a/templates/bestek.html +++ b/templates/bestek.html @@ -67,7 +67,7 @@ {% block scripts %} {% if engine %} - + {% for src in scripts %} {% endfor %} {% endif %} diff --git a/templates/contact.html b/templates/contact.html index 9961961..5b427d9 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -1,5 +1,6 @@ {% extends 'base.html' %} {% from '_compose.html' import compose %} +{% from '_documents.html' import documents_list with context %} {% block title %}{{ c.name }}{% endblock %} {% block content %}
@@ -28,15 +29,24 @@
+ {% if woningen %}
-

Gekozen model

- {% if design %} +

Woning

- {% if designs|length > 1 %} + {% if woningen|length > 1 or not woning %}
- + {% if not woning %}{% endif %} + {% for d in woningen %}{% endfor %} + +
+ {% endif %} + {% if woning %} + {% if woning.versions|length > 1 %} +
+
{% endif %} @@ -44,19 +54,22 @@ - Openen - JSON + {% if tectum_url %}Bewerken in Tectum{% endif %} + Openen + JSON + {% endif %}
- {% endif %}
- {% if design %} -
-

{{ design.title }} + {% if woning %} +

+

{{ woning.title }} + · versie {{ shown.version }}{% if shown.id != woning.latest.id %} (nieuwste: {{ woning.latest.version }}){% endif %}{% if shown.note %} · {{ shown.note }}{% endif %} · {{ design_meta.units }} unit{{ 's' if design_meta.units != 1 }}{% if design_meta.address %} · {{ design_meta.address }}{% endif %} - · ontvangen {{ design.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}

+ · {{ shown.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}

{% if design_meta.note %}

{{ design_meta.note }}

{% endif %} - @@ -68,18 +81,28 @@ if (w) w.hidden = true; }); - {% elif designs %} -

Er is geen model gekozen.

-
- - -
+ {% endif %} +
+ Nieuwe versie uploaden +
+
+
+
+
+
{% else %} -

Nog geen model. Een ontwerp uit de configurator (Bestand › Opslaan, JSON) komt hier vanzelf als je het bij Bestanden uploadt of als het met het formulier meekomt.

+

Er is geen woning gekozen.

{% endif %}
+ {% endif %} + + {% if kaart %} +
+

Kavel

+
+
+
+ {% endif %}

Taken

@@ -196,31 +219,8 @@
-

Bestanden

- {% if files %} -
- {% for f in files %} -
- - {% if f.mime.startswith('image/') %}{{ f.original }} - {% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} - -
{{ f.title or f.original }}
- {% if f.kind == 'werk' %}
Intern, niet in portaal
{% endif %} - {% if f.title %}{% if c.design_file_id == f.id %}
Gekozen model
{% else %} -
- {% endif %}{% endif %} -
- -
-
- {% endfor %} -
- {% endif %} -
- -
-
+

Documenten

+ {{ documents_list(documents, url_for('contact_upload', cid=c.id), c.design_id or -1, url_for('contact_design', cid=c.id)) }}
diff --git a/templates/portal.html b/templates/portal.html index 340b897..7db470a 100644 --- a/templates/portal.html +++ b/templates/portal.html @@ -37,15 +37,16 @@

Bestanden

- {% if files %} + {% if documents %}
- {% for f in files %} + {% for d in documents %}{% set f = d.latest %}
{% if f.mime.startswith('image/') %}{{ f.original }} {% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} -
{{ f.original }}
+
{{ d.title }}
+ {% if d.versions|length > 1 %}
versie {{ f.version }}
{% endif %}
{% endfor %}
diff --git a/templates/relation.html b/templates/relation.html index a97181a..fd1a1e0 100644 --- a/templates/relation.html +++ b/templates/relation.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% from '_documents.html' import documents_list with context %} {% block title %}{{ r.name }}{% endblock %} {% block content %}
@@ -58,17 +59,18 @@ {% if designs %}
-

Ontwerpen en tekeningen

+

Woningen en tekeningen

- + {% for d in designs %}{% set p = pdfs.get(d.id, {}) %} - - - - + + + + + {% endfor %} @@ -76,6 +78,14 @@ {% endif %} + {% if kaart %} +
+

Kavel

+
+
+
+ {% endif %} + {% if r.kind == 'partner' %}

Producten van deze leverancier

@@ -127,6 +137,11 @@ {% if r.notes %}

Notities

{{ r.notes }}

{% endif %}
+
+

Documenten van de relatie

+ {{ documents_list(documents, url_for('relation_upload', rid=r.id)) }} +
+ {% if tasks %}

Open taken

OntwerpContactTekeningenOntvangen
WoningContactVersieTekeningenGewijzigd
{{ d.title }}{% if d.chosen %} Gekozen model{% endif %}{{ d.contact_name }}{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}{{ (label[:1]|upper) ~ label[1:] }}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %} - {% if not p %}Wordt gemaakt bij openen van het contact{% endif %}{{ d.created_at|dt('%d-%m-%Y') }}{{ d.title }}{% if d.chosen %} Gekozen woning{% endif %}{% if d.contact_id %}{{ d.contact_name }}{% else %}relatie{% endif %}{{ d.latest.version }}{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}{{ (label[:1]|upper) ~ label[1:] }}{% if not p[k].current %} (v{{ p[k].version }}, ouder){% endif %}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %} + {% if not p %}Wordt gemaakt bij openen van de woning{% endif %}{{ d.latest.created_at|dt('%d-%m-%Y') }}