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:
Machiel
2026-10-16 19:40:51 +02:00
co-authored by Claude Opus 5.5
parent db3e26d258
commit c69c3773dc
9 changed files with 653 additions and 169 deletions
+23 -8
View File
@@ -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, 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 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. 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 - **Documenten met versies** bij leads/klanten en bij relaties: elk document heeft een of meer versies (nieuwe
formulier, de API of geüpload bij Bestanden) wordt het gekozen model; het nieuwste telt, een ander ontwerp versie uploaden met een opmerking, oude versies blijven te openen en te verwijderen). Soorten: bestand, woning
kies je op de contactpagina. Daar staat het in een **3D-viewer** op de engine van de configurator en de tekeningen (werktekening, klant-PDF) van een woning. Bestanden van vóór v0.0.7 worden bij het starten
(`/files/<id>/viewer`): 3D, doorsnede en plattegrond, met maten, m², inhoud, gewicht en een indicatieve vanzelf versie 1 van een eigen document.
prijs (uit de kosten-CSV's van de configurator). In de leadlijst staat het model als kolom. - **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/<id>/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.<domein>/?woning=<id>`; 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/<id>` (ontwerp, `?versie=`) en `POST
/api/tectum/woningen/<id>/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 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. (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 - **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 **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 **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 prijs en QR-code, dan aanzichten en plattegrond en de werkwijze; wel in het portaal). Per woningversie: ontbreken
het eerste openen; opnieuw maken via PDF in de weergave. Op de contactpagina bij het gekozen model en op de ze voor de nieuwste versie, dan maakt de viewer ze vanzelf bij het openen; opnieuw maken via PDF in de weergave.
relatiepagina (Ontwerpen en tekeningen, voor alle contactpersonen). 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. - 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. - **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. - **API voor formulieren** (`POST /api/leads`): JSON, gewone formulieren en multipart met bestanden.
+472 -102
View File
@@ -4,7 +4,7 @@ Starten: python app.py
E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron) 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) Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model)
""" """
__version__ = '0.0.6' __version__ = '0.0.7'
import csv import csv
import hashlib import hashlib
@@ -20,7 +20,7 @@ import sys
import uuid import uuid
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from email.utils import getaddresses 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.etree import ElementTree as ET
from xml.sax.saxutils import escape as xml_escape 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'] 'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js']
DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen') 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', SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone',
'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban', 'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban',
'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', 'allowed_origins', 'api_assign_user', '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) FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES)
API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'} 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'} PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
ADMIN_ONLY = {'settings', 'users', 'user_edit', 'mailbox_edit', 'routines', 'routine_edit', 'routine_run', ADMIN_ONLY = {'settings', 'users', 'user_edit', 'mailbox_edit', 'routines', 'routine_edit', 'routine_run',
'routine_delete'} 'routine_delete'}
@@ -182,9 +190,14 @@ MIGRATIONS = {
'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL', 'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL',
'relation_role': "TEXT DEFAULT ''", 'relation_role': "TEXT DEFAULT ''",
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL', '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 # nieuwsbrieven: afgemeld (afmeldlink) of onbestelbaar (bounce); beide vallen buiten elk segment
'unsubscribed_at': 'TEXT', 'bounced_at': 'TEXT'}, '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'}, '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'}, '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 ''", '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(): def init_db():
con = sqlite3.connect(DB_PATH) con = sqlite3.connect(DB_PATH)
with open(os.path.join(BASE, 'schema.sql')) as f: 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', ?, ?, ?)", "INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES ('partner', 'Leverancier', ?, ?, ?)",
(name, now(), now())).lastrowid (name, now(), now())).lastrowid
con.execute('UPDATE products SET supplier_id=? WHERE supplier=? AND supplier_id IS NULL', (rid, name)) 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 # 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' " 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: try:
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f: with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
design = json.load(f) design = json.load(f)
@@ -224,6 +269,7 @@ def init_db():
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' 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 files SET title=? WHERE id=?', (title, fid))
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid)) con.execute('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 # 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) # 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]: 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) 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: if not fs or not fs.filename:
return None return None
ext = os.path.splitext(secure_filename(fs.filename))[1].lower() stored = uuid.uuid4().hex + os.path.splitext(secure_filename(fs.filename))[1].lower()
stored = uuid.uuid4().hex + ext fs.save(os.path.join(UPLOADS, stored))
path = os.path.join(UPLOADS, stored) return stored
fs.save(path)
if contact_id:
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) ' # ---------------------------------------------------------------- documenten en versies
'VALUES (?, ?, ?, ?, ?, ?)', # Een document hoort bij een contact of een relatie en heeft een of meer versies (rijen in files, versie 1, 2, …;
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now())) # de hoogste is de huidige). Soorten: zie DOC_KINDS. Een woning is een house-design-JSON uit Tectum; werk/klant zijn
if ext == '.json': # de PDF's van een woning (design_id), elk gemaakt van een bepaalde woningversie (files.source_file_id).
note_design(contact_id, path, cur.lastrowid)
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 return stored
@@ -464,51 +575,105 @@ def design_title(design):
return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
def note_design(contact_id, path, file_id): def note_design(doc, design, version, note=''):
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt), """Tijdlijn bij een woning(versie). Een nieuwe woning wordt de gekozen woning van het contact (de nieuwste telt);
met een regel in de tijdlijn.""" een nieuwe versie verandert de keuze niet."""
design = read_design(path) if not doc['contact_id']:
if not design:
return return
project = design.get('project') or {} project = design.get('project') or {}
title = design_title(design) if version == 1:
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id)) db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (doc['id'], now(), doc['contact_id']))
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id)) head = f"Woning: {doc['title']} (gekozen woning)"
parts = [f'Woningontwerp: {title} (gekozen model)', 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')])), ' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
project.get('opmerking') or ''] project.get('opmerking') or '' if version == 1 else '']
log(contact_id, 'file', '\n'.join(p for p in parts if p)) 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): def documents_of(contact_id=None, relation_id=None, contact_ids=None, kinds=None):
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}.""" """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 = {} out = {}
if design_ids: if not design_ids:
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(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): *design_ids):
out.setdefault(f['design_id'], {})[f['kind']] = { f = latest_file(d['id'])
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['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 return out
def contact_designs(contact_id): def contact_designs(contact_id):
"""Alle woningontwerpen bij een contact, nieuwste eerst.""" """Alle woningversies bij een contact, nieuwste eerst (voor het bestek: id = de versie, title met versienummer)."""
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC', return q("SELECT f.id, f.stored, f.original, f.created_at, f.version, f.document_id, "
contact_id, '%.json') if f['title']] "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): def chosen_design_file(contact):
stored = uuid.uuid4().hex + '.json' """Nieuwste versie van de gekozen woning van een contact, of None."""
path = os.path.join(UPLOADS, stored) return latest_file(contact['design_id']) if contact and contact['design_id'] else None
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' def remove_document(doc_id):
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)', """Document met alle versies en (bij een woning) zijn tekeningen weg, ook van schijf."""
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now())) 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=?',
note_design(contact_id, path, cur.lastrowid) 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): def remove_upload(stored):
@@ -795,7 +960,7 @@ def dashboard():
# ---------------------------------------------------------------- contacten # ---------------------------------------------------------------- contacten
CONTACT_FROM = ('FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id ' 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') CONTACT_HIDDEN = ('portal_token', 'portal_password_hash')
@@ -864,6 +1029,8 @@ def contacts_export():
def delete_contact(cid): 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): for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
remove_upload(f['stored']) remove_upload(f['stored'])
db().execute('DELETE FROM contacts WHERE id=?', (cid,)) 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') 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>') @app.route('/contacts/<int:cid>')
def contact_view(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 ' 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 ' '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) 'WHERE c.id=?', cid) or abort(404)
designs = contact_designs(cid) docs = documents_of(contact_id=cid)
design = next((f for f in designs if f['id'] == c['design_file_id']), None) woningen = [d for d in docs if d['kind'] == 'woning']
design_meta = {} woning = next((d for d in woningen if d['id'] == c['design_id']), None)
if design: # welke versie in de viewer: ?versie=<file-id> van deze woning, anders de nieuwste
d = read_design(os.path.join(UPLOADS, design['stored'])) or {} shown = woning and next((v for v in woning['versions'] if v['id'] == request.args.get('versie', type=int)),
project = d.get('project') or {} woning['latest'])
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19], design, meta = design_meta(shown) if shown else ({}, {})
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
project.get('plaats')])),
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
return render_template( return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'), 'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
mail_templates=all_mail_templates(), mail_vars=mail_vars(c), 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 ' 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), '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), 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), 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 ' 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), '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, woningen=woningen, woning=woning, shown=shown, design_meta=meta,
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS, 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), quotes=quotes_with_totals('WHERE qu.contact_id=?', cid),
appointments=calendar_items(datetime.combine(date.today(), datetime.min.time()), appointments=calendar_items(datetime.combine(date.today(), datetime.min.time()),
datetime.combine(date.today() + timedelta(days=366), 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') @app.post('/contacts/<int:cid>/upload')
def contact_upload(cid): def contact_upload(cid):
get_or_404('contacts', 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: 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() db().commit()
return redirect(url_for('contact_view', cid=cid)) 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) 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') @app.post('/files/<int:fid>/delete')
def file_delete(fid): def file_delete(fid):
"""Eén versie verwijderen; de laatste versie weg = het hele document weg."""
f = get_or_404('files', fid) f = get_or_404('files', fid)
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id']) doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid): if not doc:
remove_upload(pdf['stored'])
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
remove_upload(f['stored']) remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,)) db().execute('DELETE FROM files WHERE id=?', (fid,))
if chosen:
rest = contact_designs(f['contact_id'])
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
db().commit() db().commit()
return redirect(url_for('contact_view', cid=f['contact_id'])) 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 id=?', (pdf['id'],))
remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,))
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 doc_back(doc)
@app.post('/contacts/<int:cid>/design') @app.post('/contacts/<int:cid>/design')
def contact_design(cid): 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) c = get_or_404('contacts', cid)
fid = request.form.get('file_id', type=int) did = request.form.get('document_id', type=int)
f = next((d for d in contact_designs(cid) if d['id'] == fid), None) d = q1("SELECT * FROM documents WHERE id=? AND contact_id=? AND kind='woning'", did, cid) if did else None
if (f['id'] if f else None) != c['design_file_id']: if (d['id'] if d else None) != c['design_id']:
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid)) db().execute('UPDATE contacts SET design_id=?, updated_at=? WHERE id=?', (d['id'] if d else None, now(), cid))
log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer') log(cid, 'status', f"Gekozen woning: {d['title']}" if d else 'Geen gekozen woning meer')
db().commit() db().commit()
return redirect(url_for('contact_view', cid=cid)) return redirect(url_for('contact_view', cid=cid))
@app.route('/files/<int:fid>/viewer') @app.route('/files/<int:fid>/viewer')
def design_viewer(fid): 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) f = get_or_404('files', fid)
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404) design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
pdfs = design_pdfs([fid]).get(fid, {}) doc = q1('SELECT * FROM documents WHERE id=?', f['document_id']) if f['document_id'] else None
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs, latest = doc and doc['kind'] == 'woning' and latest_file(doc['id'])['id'] == fid
'auto': len(pdfs) < len(PDF_KINDS)} 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( 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')))) engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))))
resp.headers['X-Frame-Options'] = 'SAMEORIGIN' resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
return resp return resp
@@ -1164,29 +1427,32 @@ def design_viewer(fid):
@app.post('/files/<int:fid>/pdf') @app.post('/files/<int:fid>/pdf')
def design_pdf(fid): def design_pdf(fid):
"""PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening """PDF van een woningversie, gemaakt door de viewer (HousePdf van de configurator). Per woning een document
(intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige.""" 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) 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') 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 return jsonify(ok=False, error='Ongeldige PDF'), 400
head = fs.stream.read(5) head = fs.stream.read(5)
fs.stream.seek(0) fs.stream.seek(0)
if head != b'%PDF-': if head != b'%PDF-':
return jsonify(ok=False, error='Geen PDF'), 400 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']) remove_upload(old['stored'])
db().execute('DELETE FROM files WHERE id=?', (old['id'],)) db().execute('DELETE FROM files WHERE id=?', (old['id'],))
stored = uuid.uuid4().hex + '.pdf' stored = uuid.uuid4().hex + '.pdf'
fs.save(os.path.join(UPLOADS, stored)) fs.save(os.path.join(UPLOADS, stored))
name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp' name = secure_filename(f"{doc['title']} v{f['version']} {PDF_KINDS[kind]}") or 'ontwerp'
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) ' new_id, _ = add_version(pdf_id, stored, f'{name}.pdf', 'application/pdf', f"van versie {f['version']}", fid)
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', doc_log(doc, f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van woning {doc['title']} "
(f['contact_id'], stored, f'{name}.pdf', 'application/pdf', f"(versie {f['version']})")
os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid))
log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}")
db().commit() 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']) 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) 'WHERE c.relation_id=? ORDER BY c.name', rid)
ids = [m['id'] for m in members] or [0] ids = [m['id'] for m in members] or [0]
marks = ', '.join('?' * len(ids)) 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 ' chosen = {m['design_id'] for m in members if m['design_id']}
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' " # woningen van de contactpersonen (gekozen eerst) en documenten van de relatie zelf
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids) 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( return render_template(
'relation.html', r=r, members=members, 'relation.html', r=r, members=members,
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'), 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 ' 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), f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids),
products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid), products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid),
designs=designs, pdfs=design_pdfs([d['id'] for d in designs]), PDF_KINDS=PDF_KINDS) 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') @app.post('/relations/<int:rid>/member')
@@ -1329,6 +1613,8 @@ def relation_member_remove(rid, cid):
def delete_relation(rid): 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): for c in q('SELECT id FROM contacts WHERE relation_id=?', rid):
link_relation(c['id'], None, '') link_relation(c['id'], None, '')
db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,)) 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')} 'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')}
if contact: if contact:
name = (contact['name'] or '').strip() 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 '', 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 '', telefoon=contact['phone'] or '', bedrijf=contact['company'] or '', adres=contact['address'] or '',
postcode=contact['postcode'] or '', plaats=contact['city'] or '', postcode=contact['postcode'] or '', plaats=contact['city'] or '',
@@ -2134,7 +2420,7 @@ def segment_where(f):
where.append('c.created_at<?') where.append('c.created_at<?')
args.append((date.fromisoformat(f['created_to']) + timedelta(days=1)).isoformat()) args.append((date.fromisoformat(f['created_to']) + timedelta(days=1)).isoformat())
if f.get('has_design'): 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 return ' AND '.join(where), args
@@ -2381,7 +2667,7 @@ def campaign_test(c):
ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg else [] 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 = 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': '', 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'])) 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 = campaign_message(c, cfg, contact, g.user['email'], g.user, request.url_root)
msg.replace_header('Subject', f"[Test] {msg['Subject']}") msg.replace_header('Subject', f"[Test] {msg['Subject']}")
@@ -2926,7 +3212,8 @@ def quote_bestek(qid):
qt = get_or_404('quotes', qid) qt = get_or_404('quotes', qid)
contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None
designs = contact_designs(contact['id']) if contact else [] 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) f = next((d for d in designs if d['id'] == want), designs[0] if designs else None)
if not f: if not f:
flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error') 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( return render_template(
'portal.html', c=g.portal, 'portal.html', c=g.portal,
quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", cid), 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:%' " messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
"ORDER BY created_at DESC LIMIT 20", cid), "ORDER BY created_at DESC LIMIT 20", cid),
s=settings_dict()) s=settings_dict())
@@ -3112,7 +3399,8 @@ def portal_message():
@app.route('/portal/files/<name>') @app.route('/portal/files/<name>')
def portal_file(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: if f is None:
abort(404) abort(404)
return serve_upload(name, f['original']) return serve_upload(name, f['original'])
@@ -3297,6 +3585,88 @@ def search_groups(term, limit):
return groups 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') @app.route('/search')
def search(): def search():
term = request.args.get('q', '').strip()[:100] term = request.args.get('q', '').strip()[:100]
+17
View File
@@ -68,6 +68,23 @@ CREATE TABLE IF NOT EXISTS activities (
created_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
+19
View File
@@ -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; } .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 { 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; } .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 summary { cursor: pointer; font-family: var(--font-heading); font-size: 1.3rem; font-weight: 500; }
details[open] summary { margin-bottom: 1rem; } details[open] summary { margin-bottom: 1rem; }
.mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; } .mailbody { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px; line-height: 1.7; }
+47
View File
@@ -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) %}
<div class="docs">
{% for d in docs %}{% set v0 = d.latest %}
<details class="doc">
<summary>
<span class="doc-icon">{% if v0.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=v0.stored) }}" alt="" loading="lazy">{% elif d.kind == 'woning' %}3D{% else %}{{ v0.original.rsplit('.', 1)[-1][:4] if '.' in v0.original else 'doc' }}{% endif %}</span>
<span class="doc-main"><span class="doc-title" title="{{ d.title }}">{{ d.title }}</span>
<span class="muted">{{ 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 %}</span></span>
</summary>
<ul class="versions">
{% for v in d.versions %}
<li><a href="{{ url_for('design_viewer', fid=v.id) if d.kind == 'woning' else url_for('upload_file', name=v.stored) }}" target="_blank">Versie {{ v.version }}</a>
<a class="muted" href="{{ url_for('upload_file', name=v.stored) }}" download="{{ v.original }}" title="Downloaden">↓</a>
<span class="muted">{{ v.created_at|dt }}{% if v.user_name %} · {{ v.user_name }}{% endif %}{% if v.note %} · {{ v.note }}{% endif %}</span>
<form method="post" action="{{ url_for('file_delete', fid=v.id) }}" onsubmit="return confirm('Versie {{ v.version }} van {{ d.title|e }} verwijderen?{{ ' Dit is de enige versie: het document verdwijnt.' if d.versions|length == 1 }}')"><button class="linkbtn">Verwijderen</button></form>
</li>
{% endfor %}
</ul>
<div class="doc-forms">
{% if d.kind in ('bestand', 'woning') %}
<form method="post" action="{{ url_for('document_version', did=d.id) }}" enctype="multipart/form-data" class="row g-2">
<div class="col-12"><input class="form-control form-control-sm" type="file" name="file" required aria-label="Nieuwe versie"{% if d.kind == 'woning' %} accept=".json,application/json"{% endif %}></div>
<div class="col"><input class="form-control form-control-sm" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
<div class="col-auto"><button class="btn btn-outline-primary btn-sm">Nieuwe versie</button></div>
</form>
{% endif %}
<div class="actions">
{% if choose_url and d.kind == 'woning' and d.id != chosen_id %}
<form method="post" action="{{ choose_url }}" class="inline"><input type="hidden" name="document_id" value="{{ d.id }}"><button class="linkbtn">Kies als woning</button></form>
{% endif %}
<form method="post" action="{{ url_for('document_delete', did=d.id) }}" class="inline" onsubmit="return confirm('{{ d.title|e }} met alle versies{{ ' en tekeningen' if d.kind == 'woning' }} verwijderen?')"><button class="linkbtn">Document verwijderen</button></form>
</div>
</div>
</details>
{% else %}
<p class="empty">Nog geen documenten.</p>
{% endfor %}
</div>
<form method="post" action="{{ upload_url }}" enctype="multipart/form-data">
<input class="form-control" type="file" name="files" multiple required aria-label="Documenten kiezen">
<input class="form-control mt-2" name="note" placeholder="Opmerking (optioneel)" aria-label="Opmerking">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
<p class="muted" style="margin:.5rem 0 0;font-size:.8rem">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.</p>
</form>
{% endmacro %}
+1 -1
View File
@@ -67,7 +67,7 @@
{% block scripts %} {% block scripts %}
{% if engine %} {% if engine %}
<script type="application/json" id="design">{{ design|tojson }}</script> <script type="application/json" id="design">{{ design|tojson }}</script>
<script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script> <script type="application/json" id="bestek-meta">{{ {'design': {'file_id': design_file.id, 'title': design_file.design_title or design_file.title}, 'configurator': design.get('configurator') or ''}|tojson }}</script>
{% for src in scripts %}<script src="{{ src }}"></script> {% for src in scripts %}<script src="{{ src }}"></script>
{% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script> {% endfor %}<script src="{{ url_for('static', filename='bestek.js') }}"></script>
{% endif %} {% endif %}
+47 -47
View File
@@ -1,5 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from '_compose.html' import compose %} {% from '_compose.html' import compose %}
{% from '_documents.html' import documents_list with context %}
{% block title %}{{ c.name }}{% endblock %} {% block title %}{{ c.name }}{% endblock %}
{% block content %} {% block content %}
<div class="head"> <div class="head">
@@ -28,15 +29,24 @@
<div class="row gx-4 stack"> <div class="row gx-4 stack">
<div class="col-lg-7 col-xl-8"> <div class="col-lg-7 col-xl-8">
{% if woningen %}
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<div class="card-top"> <div class="card-top">
<h2>Gekozen model</h2> <h2>Woning</h2>
{% if design %}
<div class="actions"> <div class="actions">
{% if designs|length > 1 %} {% if woningen|length > 1 or not woning %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline"> <form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="file_id" onchange="this.form.submit()" aria-label="Ander ontwerp kiezen"> <select class="form-select form-select-sm w-auto" name="document_id" onchange="this.form.submit()" aria-label="Andere woning kiezen">
{% for d in designs %}<option value="{{ d.id }}" {{ 'selected' if d.id == design.id }}>{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %} {% if not woning %}<option value="">Kies een woning…</option>{% endif %}
{% for d in woningen %}<option value="{{ d.id }}" {{ 'selected' if woning and d.id == woning.id }}>{{ d.title }} · {{ d.updated_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select>
</form>
{% endif %}
{% if woning %}
{% if woning.versions|length > 1 %}
<form method="get" action="{{ url_for('contact_view', cid=c.id) }}" class="inline">
<select class="form-select form-select-sm w-auto" name="versie" onchange="this.form.submit()" aria-label="Versie">
{% for v in woning.versions %}<option value="{{ v.id }}" {{ 'selected' if v.id == shown.id }}>Versie {{ v.version }}{{ ' (nieuwste)' if loop.first }} · {{ v.created_at|dt('%d-%m-%Y') }}</option>{% endfor %}
</select> </select>
</form> </form>
{% endif %} {% endif %}
@@ -44,19 +54,22 @@
<input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1"> <input type="hidden" name="contact_id" value="{{ c.id }}"><input type="hidden" name="bestek" value="1">
<button class="btn btn-primary btn-sm">Offerte met bestek</button> <button class="btn btn-primary btn-sm">Offerte met bestek</button>
</form> </form>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=design.id) }}" target="_blank">Openen</a> {% if tectum_url %}<a class="btn btn-outline-primary btn-sm" href="{{ tectum_url }}" target="_blank" title="Openen in Tectum; Opslaan in CRM maakt een nieuwe versie">Bewerken in Tectum</a>{% endif %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=design.stored) }}" download="{{ design.original }}">JSON</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('design_viewer', fid=shown.id) }}" target="_blank">Openen</a>
</div> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('upload_file', name=shown.stored) }}" download="{{ shown.original }}">JSON</a>
{% endif %} {% endif %}
</div> </div>
{% if design %} </div>
<div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=design.id) }}" title="3D-weergave {{ design.title }}" loading="lazy" allowfullscreen></iframe></div> {% if woning %}
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ design.title }}</strong> <div class="viewer-frame"><iframe src="{{ url_for('design_viewer', fid=shown.id) }}" title="3D-weergave {{ woning.title }}" loading="lazy" allowfullscreen></iframe></div>
<p class="muted" style="margin:.75rem 0 0"><strong style="color:var(--ink)">{{ woning.title }}</strong>
· 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 %} · {{ 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 %}</p> · {{ shown.created_at|dt }}{% if design_meta.version %} · configurator {{ design_meta.version }}{% endif %}</p>
{% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %} {% if design_meta.note %}<p style="white-space:pre-line;margin:.5rem 0 0">{{ design_meta.note }}</p>{% endif %}
<p class="pdf-links" style="margin:.5rem 0 0">Tekeningen: {% if shown.id == woning.latest.id %}
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) %} <p class="pdf-links" style="margin:.5rem 0 0">Tekeningen van deze versie:
{% for k, label in PDF_KINDS.items() %}{% set p = pdfs.get(k) if pdfs.get(k) and pdfs.get(k).current else None %}
<a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %} <a data-pdf="{{ k }}" href="{{ p.url if p else '#' }}" target="_blank" {{ 'hidden' if not p }}>{{ (label[:1]|upper) ~ label[1:] }}</a><span class="muted" data-pdf-wait="{{ k }}" {{ 'hidden' if p }}>{{ label }} wordt gemaakt…</span>{% if k == 'werk' %} <span class="muted">(intern)</span> ·{% endif %}
{% endfor %} {% endfor %}
<span class="muted">· opnieuw maken via PDF in de weergave</span></p> <span class="muted">· opnieuw maken via PDF in de weergave</span></p>
@@ -68,18 +81,28 @@
if (w) w.hidden = true; if (w) w.hidden = true;
}); });
</script> </script>
{% elif designs %} {% endif %}
<p class="muted">Er is geen model gekozen.</p> <details style="margin-top:1rem">
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}" class="d-flex flex-wrap gap-2"> <summary style="font-size:1rem">Nieuwe versie uploaden</summary>
<select class="form-select w-auto" name="file_id" aria-label="Ontwerp"> <form method="post" action="{{ url_for('document_version', did=woning.id) }}" enctype="multipart/form-data" class="row g-2">
{% for d in designs %}<option value="{{ d.id }}">{{ d.title }} · {{ d.created_at|dt('%d-%m-%Y') }}</option>{% endfor %} <div class="col-md-6"><input class="form-control" type="file" name="file" accept=".json,application/json" required aria-label="Woningontwerp (JSON)"></div>
</select> <div class="col-md"><input class="form-control" name="note" placeholder="Wat is er veranderd?" aria-label="Opmerking"></div>
<button class="btn btn-outline-primary">Kiezen</button> <div class="col-md-auto"><button class="btn btn-outline-primary">Opslaan als versie {{ woning.latest.version + 1 }}</button></div>
</form> </form>
</details>
{% else %} {% else %}
<p class="empty">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.</p> <p class="muted" style="margin:0">Er is geen woning gekozen.</p>
{% endif %} {% endif %}
</div></div> </div></div>
{% endif %}
{% if kaart %}
<div class="card"><div class="card-body">
<div class="card-top"><h2>Kavel</h2>
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ kaart }}" target="_blank">Openen</a></div></div>
<div class="kaart-frame"><iframe src="{{ kaart }}" title="Kaart van de kavel" loading="lazy"></iframe></div>
</div></div>
{% endif %}
<div class="card o-2"><div class="card-body"> <div class="card o-2"><div class="card-body">
<h2>Taken</h2> <h2>Taken</h2>
@@ -196,31 +219,8 @@
</div></div> </div></div>
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Bestanden</h2> <h2>Documenten</h2>
{% if files %} {{ documents_list(documents, url_for('contact_upload', cid=c.id), c.design_id or -1, url_for('contact_design', cid=c.id)) }}
<div class="files">
{% for f in files %}
<div class="file">
<a href="{{ url_for('design_viewer', fid=f.id) if f.title else url_for('upload_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('upload_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% elif f.title %}3D{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a>
<div class="name" title="{{ f.original }}">{{ f.title or f.original }}</div>
{% if f.kind == 'werk' %}<div class="muted">Intern, niet in portaal</div>{% endif %}
{% if f.title %}{% if c.design_file_id == f.id %}<div class="muted">Gekozen model</div>{% else %}
<form method="post" action="{{ url_for('contact_design', cid=c.id) }}"><input type="hidden" name="file_id" value="{{ f.id }}"><button class="linkbtn">Kies als model</button></form>
{% endif %}{% endif %}
<form method="post" action="{{ url_for('file_delete', fid=f.id) }}" onsubmit="return confirm('Bestand verwijderen?')">
<button class="linkbtn">Verwijderen</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<form method="post" action="{{ url_for('contact_upload', cid=c.id) }}" enctype="multipart/form-data">
<input class="form-control" type="file" name="files" multiple required aria-label="Bestanden kiezen">
<div class="actions"><button class="btn btn-outline-primary btn-sm">Uploaden</button></div>
</form>
</div></div> </div></div>
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
+4 -3
View File
@@ -37,15 +37,16 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Bestanden</h2> <h2>Bestanden</h2>
{% if files %} {% if documents %}
<div class="files"> <div class="files">
{% for f in files %} {% for d in documents %}{% set f = d.latest %}
<div class="file"> <div class="file">
<a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb"> <a href="{{ url_for('portal_file', name=f.stored) }}" target="_blank" class="thumb">
{% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy"> {% if f.mime.startswith('image/') %}<img src="{{ url_for('portal_file', name=f.stored) }}" alt="{{ f.original }}" loading="lazy">
{% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %} {% else %}{{ f.original.rsplit('.', 1)[-1][:4] if '.' in f.original else 'bestand' }}{% endif %}
</a> </a>
<div class="name" title="{{ f.original }}">{{ f.original }}</div> <div class="name" title="{{ d.title }}">{{ d.title }}</div>
{% if d.versions|length > 1 %}<div class="muted">versie {{ f.version }}</div>{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+22 -7
View File
@@ -1,4 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from '_documents.html' import documents_list with context %}
{% block title %}{{ r.name }}{% endblock %} {% block title %}{{ r.name }}{% endblock %}
{% block content %} {% block content %}
<div class="head"> <div class="head">
@@ -58,17 +59,18 @@
{% if designs %} {% if designs %}
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Ontwerpen en tekeningen</h2> <h2>Woningen en tekeningen</h2>
<div class="table-responsive"><table class="table table-stack"> <div class="table-responsive"><table class="table table-stack">
<thead><tr><th>Ontwerp</th><th>Contact</th><th>Tekeningen</th><th>Ontvangen</th></tr></thead> <thead><tr><th>Woning</th><th>Contact</th><th>Versie</th><th>Tekeningen</th><th>Gewijzigd</th></tr></thead>
<tbody> <tbody>
{% for d in designs %}{% set p = pdfs.get(d.id, {}) %} {% for d in designs %}{% set p = pdfs.get(d.id, {}) %}
<tr> <tr>
<td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen model</span>{% endif %}</td> <td class="stack-title"><a href="{{ url_for('design_viewer', fid=d.latest.id) }}" target="_blank">{{ d.title }}</a>{% if d.chosen %} <span class="chip">Gekozen woning</span>{% endif %}</td>
<td data-label="Contact"><a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a></td> <td data-label="Contact">{% if d.contact_id %}<a href="{{ url_for('contact_view', cid=d.contact_id) }}">{{ d.contact_name }}</a>{% else %}<span class="muted">relatie</span>{% endif %}</td>
<td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not loop.last %} · {% endif %}{% endif %}{% endfor %} <td data-label="Versie">{{ d.latest.version }}</td>
{% if not p %}<span class="muted">Wordt gemaakt bij openen van het contact</span>{% endif %}</span></td> <td data-label="Tekeningen"><span>{% for k, label in PDF_KINDS.items() %}{% if p.get(k) %}<a href="{{ p[k].url }}" target="_blank">{{ (label[:1]|upper) ~ label[1:] }}</a>{% if not p[k].current %} <span class="muted">(v{{ p[k].version }}, ouder)</span>{% endif %}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
<td data-label="Ontvangen" class="muted">{{ d.created_at|dt('%d-%m-%Y') }}</td> {% if not p %}<span class="muted">Wordt gemaakt bij openen van de woning</span>{% endif %}</span></td>
<td data-label="Gewijzigd" class="muted">{{ d.latest.created_at|dt('%d-%m-%Y') }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -76,6 +78,14 @@
</div></div> </div></div>
{% endif %} {% endif %}
{% if kaart %}
<div class="card"><div class="card-body">
<div class="card-top"><h2>Kavel</h2>
<div class="actions"><a class="btn btn-outline-primary btn-sm" href="{{ kaart }}" target="_blank">Openen</a></div></div>
<div class="kaart-frame"><iframe src="{{ kaart }}" title="Kaart van de kavel" loading="lazy"></iframe></div>
</div></div>
{% endif %}
{% if r.kind == 'partner' %} {% if r.kind == 'partner' %}
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Producten van deze leverancier</h2> <h2>Producten van deze leverancier</h2>
@@ -127,6 +137,11 @@
{% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %} {% if r.notes %}<h3>Notities</h3><p style="white-space:pre-line;margin:0">{{ r.notes }}</p>{% endif %}
</div></div> </div></div>
<div class="card"><div class="card-body">
<h2>Documenten van de relatie</h2>
{{ documents_list(documents, url_for('relation_upload', rid=r.id)) }}
</div></div>
{% if tasks %} {% if tasks %}
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Open taken</h2> <h2>Open taken</h2>