"""Van Emous CRM — eenvoudige CRM op Flask + SQLite. 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.7' import csv import hashlib import hmac import io import json import mimetypes import os import re import secrets import sqlite3 import sys import uuid from datetime import date, datetime, timedelta from email.utils import getaddresses 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 from flask import (Flask, abort, flash, g, jsonify, make_response, redirect, render_template, request, send_from_directory, session, url_for) from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.utils import secure_filename import ical import importer import jobs import mail BASE = os.path.dirname(os.path.abspath(__file__)) DATA = os.environ.get('CRM_DATA', os.path.join(BASE, 'data')) UPLOADS = os.path.join(DATA, 'uploads') IMPORTS = os.path.join(DATA, 'imports') DB_PATH = os.path.join(DATA, 'crm.db') for _dir in (UPLOADS, IMPORTS): os.makedirs(_dir, exist_ok=True) TYPES = [('lead', 'Lead'), ('klant', 'Klant'), ('partner', 'Partnercontact')] RELATION_KINDS = [('klant', 'Klant'), ('partner', 'Partner')] DEFAULT_RELATION_TYPES = {'klant': 'Huishouden / familie, Particulier, Zakelijk, Zorginstelling', 'partner': 'Leverancier, Aannemer, Installateur, Architect / adviseur, Gemeente, Overig'} RELATION_FIELDS = ['name', 'type', 'email', 'phone', 'website', 'address', 'postcode', 'city', 'kvk', 'notes'] STATUSES = [('nieuw', 'Nieuw'), ('contact', 'In behandeling'), ('offerte', 'Offerte'), ('gewonnen', 'Gewonnen'), ('verloren', 'Verloren'), ('vervallen', 'Vervallen')] CLOSED_STATUSES = ('gewonnen', 'verloren', 'vervallen') QUOTE_STATUSES = [('concept', 'Concept'), ('verzonden', 'Verzonden'), ('geaccepteerd', 'Geaccepteerd'), ('afgewezen', 'Afgewezen')] ROLES = [('admin', 'Beheerder'), ('user', 'Medewerker')] ACTIVITY_TYPES = {'note': 'Notitie', 'form': 'Formulier', 'email': 'E-mail', 'status': 'Status', 'quote': 'Offerte', 'file': 'Bestand', 'task': 'Taak', 'portal': 'Portaal', 'import': 'Import', 'event': 'Afspraak'} CONTACT_FIELDS = ['name', 'email', 'phone', 'company', 'address', 'postcode', 'city', 'source', 'notes'] PRODUCT_FIELDS = ['name', 'sku', 'description', 'unit', 'supplier', 'price', 'vat'] PRODUCT_LABELS = {'name': 'Naam', 'sku': 'SKU', 'description': 'Omschrijving', 'unit': 'Eenheid', 'supplier': 'Leverancier', 'price': 'Prijs', 'vat': 'Btw', 'active': 'Actief', 'created': 'Aangemaakt'} INLINE_MIME = {'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf'} # Viewer voor woningontwerpen: de engine van de configurator, gekopieerd met tools/configurator-sync.sh # (zelfde volgorde als light.html, zonder PDF en light-UI). Demo-modellen: tools/export-models.js. ENGINE = os.path.join(BASE, 'static', 'configurator') VIEWER_SCRIPTS = ['js/vendor/three.min.js', 'js/vendor/OrbitControls.js', 'js/core/geometry.js', 'js/core/view-layers.js', 'js/core/ui-kit.js', 'js/core/skybox.js', 'js/wall/kozijn-model.js', 'js/wall/wall-model.js', 'js/floor/floor-model.js', 'js/roof/roof-model.js', 'js/roof/post-model.js', 'js/lighting/lighting-model.js', 'js/elektra/elektra-model.js', 'js/sandwich/sandwich-model.js', 'js/wall/wall.js', 'js/floor/floor.js', 'js/unit/unit-model.js', 'js/room/room-model.js', 'js/interior/fixture-model.js', 'js/house/chimney-model.js', 'js/house/house-model.js', 'js/cost/cost-defaults.js', 'js/cost/cost-csv.js', 'js/cost/cost-model.js'] DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen') # 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', 'klant_types', 'partner_types'] # Mailboxen (tabel mailboxes): de verbindingsvelden heten zoals mail.py ze verwacht, zodat een rij direct de cfg is. MAILBOX_FIELDS = ['imap_host', 'imap_port', 'imap_security', 'imap_user', 'imap_folder', 'imap_days', 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_user', 'smtp_sent_folder', 'smtp_from'] MAILBOX_SECRETS = ['imap_pass', 'smtp_pass'] # Rol van een mailbox: bepaalt waar hij voor wordt aangeboden (nieuwsbrieven kiezen standaard een # nieuwsbriefmailbox); routines kunnen er later op sturen (bijv. facturen@ verwerken). MAILBOX_PURPOSES = [('inbox', 'Inbox'), ('facturen', 'Facturen'), ('nieuwsbrief', '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', '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'} def _secret_key(): path = os.path.join(DATA, 'secret.key') if not os.path.exists(path): with open(path, 'w') as f: f.write(secrets.token_hex(32)) with open(path) as f: return f.read().strip() app = Flask(__name__) app.secret_key = _secret_key() app.config.update(MAX_CONTENT_LENGTH=25 * 1024 * 1024, SESSION_COOKIE_SAMESITE='Lax', PERMANENT_SESSION_LIFETIME=timedelta(days=30)) # Achter nginx/Apache met HTTPS (CRM_PROXY=1): schema en host uit X-Forwarded-*, zodat links naar buiten # (portaaluitnodiging, API-URL) https://domein zijn, en sessiecookies alleen over HTTPS. if os.environ.get('CRM_PROXY'): from werkzeug.middleware.proxy_fix import ProxyFix app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) app.config.update(SESSION_COOKIE_SECURE=True) def now(): return datetime.now().strftime('%Y-%m-%d %H:%M:%S') def num(value, default=0.0): s = str(value if value is not None else '').strip().replace('€', '').replace(' ', '') if ',' in s: s = s.replace('.', '').replace(',', '.') try: return float(s) except ValueError: return default def back(default): ref = request.referrer or '' return ref if ref.startswith(request.host_url) else default def csv_cell(value): if value is None: return '' if isinstance(value, float): return fmt_number(value) return value def csv_response(name, header, rows): """CSV met ; en BOM, zodat Excel kolommen en tekens direct goed leest (en de import hem terugneemt).""" out = io.StringIO() writer = csv.writer(out, delimiter=';') writer.writerow(header) writer.writerows([csv_cell(v) for v in row] for row in rows) resp = make_response('\ufeff' + out.getvalue()) resp.headers['Content-Type'] = 'text/csv; charset=utf-8' resp.headers['Content-Disposition'] = f'attachment; filename={name}-{date.today()}.csv' return resp def table_columns(table, hidden=()): return [r[1] for r in q(f'PRAGMA table_info({table})') if r[1] not in hidden] def bulk_ids(): """De aangevinkte rijen van een massa-actie.""" return sorted({i for i in request.form.getlist('ids', type=int) if i}) def id_list(ids): return f"({', '.join('?' * len(ids))})" def bulk_back(default): return redirect(safe_next(request.form.get('next', '')) or default) # ---------------------------------------------------------------- database # Kolommen die later zijn bijgekomen; bestaande databases krijgen ze bij het starten. MIGRATIONS = { 'products': {'supplier': "TEXT DEFAULT ''", 'updated_at': 'TEXT', 'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'}, 'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL', 'relation_role': "TEXT DEFAULT ''", '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', '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 ''", 'mailbox_id': 'INTEGER REFERENCES mailboxes(id) ON DELETE SET NULL'}, } 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: con.executescript(f.read()) for table, cols in MIGRATIONS.items(): have = {r[1] for r in con.execute(f'PRAGMA table_info({table})')} for col, ddl in cols.items(): if col not in have: con.execute(f'ALTER TABLE {table} ADD COLUMN {col} {ddl}') try: con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sku ON products(sku COLLATE NOCASE) WHERE sku <> ''") except sqlite3.IntegrityError: print('Let op: dubbele SKU\'s in de producten; maak ze uniek zodat import op SKU betrouwbaar is.') # leveranciers die alleen als tekst bij producten staan, worden partners for (name,) in con.execute("SELECT DISTINCT supplier FROM products WHERE supplier<>'' AND supplier_id IS NULL").fetchall(): row = con.execute("SELECT id FROM relations WHERE kind='partner' AND name=? COLLATE NOCASE", (name,)).fetchone() rid = row[0] if row else con.execute( "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='') 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) except (ValueError, OSError): continue if isinstance(design, dict) and design.get('type') == 'house-design': title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' con.execute('UPDATE files SET title=? WHERE id=?', (title, fid)) con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid)) 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]: old = dict(con.execute('SELECT key, value FROM settings').fetchall()) if old.get('imap_host') or old.get('smtp_host'): cols = MAILBOX_FIELDS + MAILBOX_SECRETS mbid = con.execute( f'INSERT INTO mailboxes (name, last_sync, created_at, updated_at, {", ".join(cols)}) ' f'VALUES (?, ?, ?, ?, {", ".join("?" * len(cols))})', ((old.get('imap_user') or old.get('smtp_user') or 'e-mail').split('@')[0].capitalize(), old.get('last_sync'), now(), now(), *[old.get(c) or '' for c in cols])).lastrowid con.execute("UPDATE emails SET uid=? || '/' || uid WHERE uid IS NOT NULL AND mailbox_id IS NULL", (str(mbid),)) con.execute('UPDATE emails SET mailbox_id=? WHERE mailbox_id IS NULL', (mbid,)) con.execute('PRAGMA journal_mode=WAL') con.commit() con.close() init_db() def db(): if 'db' not in g: g.db = sqlite3.connect(DB_PATH, timeout=10) g.db.row_factory = sqlite3.Row g.db.execute('PRAGMA foreign_keys = ON') return g.db @app.teardown_appcontext def close_db(exc): con = g.pop('db', None) if con: con.close() def q(sql, *args): return db().execute(sql, args).fetchall() def q1(sql, *args): return db().execute(sql, args).fetchone() def scalar(sql, *args): return db().execute(sql, args).fetchone()[0] def get_or_404(table, row_id): row = q1(f'SELECT * FROM {table} WHERE id=?', row_id) if row is None: abort(404) return row def get_setting(key, default=''): row = q1('SELECT value FROM settings WHERE key=?', key) return row['value'] if row and row['value'] is not None else default def set_setting(key, value): db().execute('INSERT INTO settings (key, value) VALUES (?, ?) ' 'ON CONFLICT(key) DO UPDATE SET value=excluded.value', (key, value)) def settings_dict(): return {r['key']: r['value'] or '' for r in q('SELECT key, value FROM settings')} def current_user_id(): user = g.get('user') return user['id'] if user else None def log(contact_id, type_, body): db().execute('INSERT INTO activities (contact_id, type, body, user_id, created_at) VALUES (?, ?, ?, ?, ?)', (contact_id, type_, body, current_user_id(), now())) def link_emails(contact_id, address): if address: db().execute('UPDATE emails SET contact_id=? WHERE contact_id IS NULL AND from_addr=?', (contact_id, address.strip().lower())) def status_key(value): """Accepteer een statussleutel of -label (voor import en API).""" value = str(value or '').strip().lower() for key, label in STATUSES: if value in (key, label.lower()): return key return None def insert_contact(data, extra=None, status='nieuw', type_='lead', assigned=None, value=0.0): ts = now() values = [str(data.get(f) or '').strip() for f in CONTACT_FIELDS] cur = db().execute( f'INSERT INTO contacts ({", ".join(CONTACT_FIELDS)}, status, type, assigned_user_id, value, extra, ' f'created_at, updated_at) VALUES ({", ".join("?" * len(CONTACT_FIELDS))}, ?, ?, ?, ?, ?, ?, ?)', [*values, status, type_, assigned, value, json.dumps(extra or {}, ensure_ascii=False), ts, ts]) link_emails(cur.lastrowid, data.get('email')) return cur.lastrowid def relation_types(kind): return [t.strip() for t in get_setting(f'{kind}_types', DEFAULT_RELATION_TYPES[kind]).split(',') if t.strip()] def find_or_create_relation(name, kind, type_=None): name = str(name or '').strip() if not name or kind not in dict(RELATION_KINDS): return None row = q1('SELECT id FROM relations WHERE kind=? AND name=? COLLATE NOCASE', kind, name) if row: return row['id'] if type_ is None: types = relation_types(kind) type_ = next((t for t in types if t.lower().startswith('leverancier')), '') if kind == 'partner' else ( types[0] if types else '') ts = now() return db().execute('INSERT INTO relations (kind, type, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', (kind, type_, name, ts, ts)).lastrowid def link_relation(contact_id, relation_id, role=None): """Koppel een contactpersoon aan een relatie; bij een partner wordt het een partnercontact.""" rel = q1('SELECT * FROM relations WHERE id=?', relation_id) if relation_id else None old = q1('SELECT relation_id, type FROM contacts WHERE id=?', contact_id) db().execute('UPDATE contacts SET relation_id=?, relation_role=COALESCE(?, relation_role), updated_at=? WHERE id=?', (rel['id'] if rel else None, role, now(), contact_id)) if rel and rel['kind'] == 'partner': db().execute("UPDATE contacts SET type='partner' WHERE id=?", (contact_id,)) elif old and old['type'] == 'partner': db().execute("UPDATE contacts SET type=? WHERE id=?", ('klant' if rel else 'lead', contact_id)) if old and (rel['id'] if rel else None) != old['relation_id']: log(contact_id, 'status', f"Gekoppeld aan relatie {rel['name']}" if rel else 'Losgekoppeld van relatie') def upsert_contact(fields, extra=None, update_existing=True, defaults=None): """Maak een lead aan of vul een bestaande (zelfde e-mailadres) aan. Bestaande gegevens worden nooit overschreven, alleen lege velden aangevuld. Geeft (contact_id, 'created' | 'updated' | 'skipped') terug. """ fields = {k: str(v).strip() for k, v in fields.items() if v is not None and str(v).strip()} extra = extra or {} defaults = defaults or {} first, last = fields.pop('first_name', ''), fields.pop('last_name', '') fields.setdefault('name', f'{first} {last}'.strip()) if not (fields['name'] or fields.get('email') or fields.get('phone')): return None, 'skipped' fields['name'] = fields['name'] or fields.get('email') or fields.get('phone') status = status_key(fields.pop('status', '')) or defaults.get('status', 'nieuw') value = num(fields.pop('value', 0)) relation_name, role = fields.pop('relation', ''), fields.pop('relation_role', None) fields.setdefault('source', defaults.get('source', '')) cid, outcome = _upsert_contact(fields, extra, update_existing, defaults, status, value) if cid and outcome != 'skipped' and (relation_name or role): current = q1('SELECT relation_id, type FROM contacts WHERE id=?', cid) if relation_name and not current['relation_id']: kind = 'partner' if current['type'] == 'partner' else 'klant' link_relation(cid, find_or_create_relation(relation_name, kind), role) elif role: db().execute('UPDATE contacts SET relation_role=? WHERE id=?', (role, cid)) return cid, outcome def _upsert_contact(fields, extra, update_existing, defaults, status, value): existing = q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE', fields['email']) if fields.get('email') else None if not existing: cid = insert_contact(fields, extra, status=status, type_=defaults.get('type', 'lead'), assigned=defaults.get('assigned'), value=value) return cid, 'created' if not update_existing: return existing['id'], 'skipped' updates = {k: v for k, v in fields.items() if k in CONTACT_FIELDS and v and not existing[k]} if value and not existing['value']: updates['value'] = value merged = {**json.loads(existing['extra'] or '{}'), **extra} sets = ''.join(f'{k}=?, ' for k in updates) db().execute(f'UPDATE contacts SET {sets}extra=?, updated_at=? WHERE id=?', [*updates.values(), json.dumps(merged, ensure_ascii=False), now(), existing['id']]) return existing['id'], 'updated' def set_contact_status(contact_id, status): labels = dict(STATUSES) row = q1('SELECT status FROM contacts WHERE id=?', contact_id) if row and status in labels and status != row['status']: db().execute('UPDATE contacts SET status=?, updated_at=? WHERE id=?', (status, now(), contact_id)) log(contact_id, 'status', f"Status: {labels.get(row['status'], row['status'])} → {labels[status]}") def convert_contact(contact_id, relation_id=None, new_relation=''): """Lead → klant (zoals 'Convert' in EspoCRM): de contactpersoon komt in een klantrelatie. Zonder keuze wordt er een relatie aangemaakt met de bedrijfs- of persoonsnaam.""" row = q1('SELECT * FROM contacts WHERE id=?', contact_id) if not row: return if row['type'] != 'klant': db().execute("UPDATE contacts SET type='klant', updated_at=? WHERE id=?", (now(), contact_id)) log(contact_id, 'status', 'Lead omgezet naar klant') if relation_id or new_relation or not row['relation_id']: rid = relation_id or find_or_create_relation(new_relation or row['company'] or row['name'], 'klant') if rid != row['relation_id']: link_relation(contact_id, rid) 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 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 def read_design(path): """Het woningontwerp (house-design-JSON uit de configurator) in een bestand, of None.""" try: with open(path, encoding='utf-8-sig') as f: design = json.load(f) except (ValueError, OSError): return None return design if isinstance(design, dict) and design.get('type') == 'house-design' else None def design_title(design): return str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' def note_design(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 {} 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 '' if version == 1 else ''] log(doc['contact_id'], 'file', '\n'.join(p for p in parts if p)) 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 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 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 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 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): if stored: try: os.remove(os.path.join(UPLOADS, os.path.basename(stored))) except FileNotFoundError: pass def serve_upload(name, original=None): mime = mimetypes.guess_type(name)[0] or 'application/octet-stream' return send_from_directory(UPLOADS, name, mimetype=mime, as_attachment=mime not in INLINE_MIME, download_name=original or name) def quotes_with_totals(where='', *args): return q(f'''SELECT qu.*, c.name AS contact_name, COALESCE((SELECT SUM(qty*price) FROM quote_lines WHERE quote_id=qu.id), 0) AS subtotal, COALESCE((SELECT SUM(qty*price*(1+vat/100.0)) FROM quote_lines WHERE quote_id=qu.id), 0) AS total FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id {where} ORDER BY qu.date DESC, qu.id DESC''', *args) def quote_totals(lines): subtotal, vat = 0.0, {} for line in lines: amount = line['qty'] * line['price'] subtotal += amount vat[line['vat']] = vat.get(line['vat'], 0) + amount * line['vat'] / 100 return {'subtotal': subtotal, 'vat': sorted(vat.items()), 'total': subtotal + sum(vat.values())} def change_quote_status(qt, status): """Statuswissel van een offerte, met de gevolgen voor het contact.""" if status == qt['status']: return db().execute('UPDATE quotes SET status=?, updated_at=? WHERE id=?', (status, now(), qt['id'])) cid = qt['contact_id'] if not cid: return log(cid, 'quote', f"Offerte {qt['number']}: {dict(QUOTE_STATUSES)[status]}") contact = q1('SELECT status FROM contacts WHERE id=?', cid) if status == 'verzonden' and contact and contact['status'] in ('nieuw', 'contact'): set_contact_status(cid, 'offerte') elif status == 'geaccepteerd': set_contact_status(cid, 'gewonnen') convert_contact(cid) # ---------------------------------------------------------------- templates @app.template_filter('euro') def euro(value): s = f'{float(value or 0):,.2f}' return '€ ' + s.replace(',', 'X').replace('.', ',').replace('X', '.') @app.template_filter('dt') def fmt_dt(value, fmt='%d-%m-%Y %H:%M'): if not value: return '' try: return datetime.fromisoformat(value).strftime(fmt) except ValueError: return value @app.template_filter('nr') def fmt_number(value): return f'{value:g}'.replace('.', ',') @app.context_processor def inject(): ctx = dict(RELATION_KINDS=RELATION_KINDS, RKIND=dict(RELATION_KINDS), STATUSES=STATUSES, STATUS=dict(STATUSES), QUOTE_STATUSES=QUOTE_STATUSES, TYPES=TYPES, TYPE=dict(TYPES), QSTATUS=dict(QUOTE_STATUSES), ROLES=ROLES, ROLE=dict(ROLES), ACT=ACTIVITY_TYPES, LABELS=PRODUCT_LABELS, unread=0, USERS=[], today=date.today().isoformat(), bell=[], bell_unread=0) if g.get('user'): ctx['unread'] = scalar(f"SELECT COUNT(*) FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 " f"AND {visible_mail()}") ctx['USERS'] = q('SELECT id, name FROM users WHERE active=1 ORDER BY name') ctx['bell'] = q('SELECT * FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 8', g.user['id']) ctx['bell_unread'] = scalar('SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL', g.user['id']) return ctx # ---------------------------------------------------------------- toegang @app.before_request def guard(): ep = request.endpoint or '' if ep in PUBLIC: return None if ep.startswith('portal'): cid = session.get('portal_cid') g.portal = q1('SELECT * FROM contacts WHERE id=? AND portal_password_hash IS NOT NULL', cid) if cid else None if ep not in PORTAL_PUBLIC and not g.portal: return redirect(url_for('portal_login')) return None if not scalar('SELECT COUNT(*) FROM users'): return redirect(url_for('setup')) uid = session.get('uid') g.user = q1('SELECT * FROM users WHERE id=? AND active=1', uid) if uid else None if not g.user: return redirect(url_for('login', next=request.full_path)) if ep in ADMIN_ONLY and g.user['role'] != 'admin': flash('Alleen beheerders hebben toegang tot die pagina.', 'error') return redirect(url_for('dashboard')) return None @app.route('/setup', methods=['GET', 'POST']) def setup(): if scalar('SELECT COUNT(*) FROM users'): return redirect(url_for('login')) if request.method == 'POST': f = request.form pw = f.get('password', '') if not f.get('name', '').strip() or not f.get('email', '').strip(): flash('Vul je naam en e-mailadres in.', 'error') elif len(pw) < 8 or pw != f.get('password2'): flash('Het wachtwoord moet minimaal 8 tekens hebben en twee keer hetzelfde zijn.', 'error') else: cur = db().execute("INSERT INTO users (name, email, password_hash, role, created_at) " "VALUES (?, ?, ?, 'admin', ?)", (f['name'].strip(), f['email'].strip().lower(), generate_password_hash(pw), now())) set_setting('api_key', secrets.token_urlsafe(24)) for key, value in [('quote_prefix', 'OFF'), ('quote_valid_days', '30'), ('company_name', 'Van Emous Mantelzorgwoningen')]: set_setting(key, value) db().commit() session.clear() session.update(uid=cur.lastrowid) session.permanent = True flash('Account aangemaakt. Vul hieronder je bedrijfsgegevens en e-mail in.') return redirect(url_for('settings')) return render_template('auth.html', mode='setup') @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': user = q1('SELECT * FROM users WHERE email=? AND active=1', request.form.get('email', '').strip().lower()) if user and check_password_hash(user['password_hash'], request.form.get('password', '')): session.pop('portal_cid', None) session['uid'] = user['id'] session.permanent = True nxt = request.args.get('next', '') return redirect(nxt if nxt.startswith('/') and not nxt.startswith('//') else url_for('dashboard')) flash('Onjuist e-mailadres of wachtwoord.', 'error') return render_template('auth.html', mode='login') @app.route('/logout') def logout(): session.pop('uid', None) return redirect(url_for('login')) @app.route('/account', methods=['GET', 'POST']) def account(): dav = {'url': url_for('dav', path='', _external=True), 'host': request.host, 'active': bool(g.user['dav_password_hash'])} if request.method == 'POST' and request.form.get('action') in ('dav', 'dav-off'): if request.form['action'] == 'dav-off': db().execute('UPDATE users SET dav_password_hash=NULL WHERE id=?', (g.user['id'],)) db().commit() flash('Agendakoppeling uitgezet: apps met het oude app-wachtwoord kunnen niet meer synchroniseren.') return redirect(url_for('account', _anchor='agenda')) # leesbaar (geen 0/o, 1/l/i) en alleen nu te zien: niet via flash, want die staat in de sessiecookie alphabet = 'abcdefghjkmnpqrstuvwxyz23456789' password = '-'.join(''.join(secrets.choice(alphabet) for _ in range(4)) for _ in range(4)) db().execute('UPDATE users SET dav_password_hash=? WHERE id=?', (generate_password_hash(password), g.user['id'])) db().commit() return render_template('account.html', dav=dict(dav, active=True, password=password)) if request.method == 'POST': f = request.form if not check_password_hash(g.user['password_hash'], f.get('old_password', '')): flash('Huidig wachtwoord klopt niet.', 'error') elif len(f.get('new_password', '')) < 8: flash('Het nieuwe wachtwoord moet minimaal 8 tekens hebben.', 'error') else: db().execute('UPDATE users SET password_hash=? WHERE id=?', (generate_password_hash(f['new_password']), g.user['id'])) db().commit() flash('Wachtwoord gewijzigd.') return redirect(url_for('account')) return render_template('account.html', dav=dav) @app.route('/users') def users(): return render_template('users.html', rows=q( 'SELECT u.*, (SELECT COUNT(*) FROM contacts WHERE assigned_user_id=u.id) AS n ' 'FROM users u ORDER BY active DESC, name')) @app.route('/users/new', methods=['GET', 'POST'], defaults={'uid': None}) @app.route('/users/', methods=['GET', 'POST']) def user_edit(uid): u = get_or_404('users', uid) if uid else None if request.method == 'POST': f = request.form data = {'name': f.get('name', '').strip(), 'email': f.get('email', '').strip().lower(), 'role': f.get('role') if f.get('role') in dict(ROLES) else 'user', 'active': 1 if f.get('active') else 0} pw = f.get('password', '') if u and u['id'] == g.user['id']: data.update(role='admin', active=1) # jezelf niet buitensluiten error = None if not data['name'] or not data['email']: error = 'Vul naam en e-mailadres in.' elif (pw or not u) and len(pw) < 8: error = 'Het wachtwoord moet minimaal 8 tekens hebben.' elif q1('SELECT id FROM users WHERE email=? AND id<>?', data['email'], uid or 0): error = 'Er bestaat al een gebruiker met dit e-mailadres.' if error: flash(error, 'error') return render_template('user_form.html', u={**(dict(u) if u else {}), **data}) if pw: data['password_hash'] = generate_password_hash(pw) if u: db().execute(f'UPDATE users SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), uid]) else: data['created_at'] = now() db().execute(f'INSERT INTO users ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())) db().commit() flash('Gebruiker opgeslagen.') return redirect(url_for('users')) return render_template('user_form.html', u=u or {'role': 'user', 'active': 1}) # ---------------------------------------------------------------- dashboard @app.route('/') def dashboard(): since = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') line_sum = 'SELECT COALESCE(SUM(l.qty*l.price), 0) FROM quote_lines l JOIN quotes qu ON qu.id=l.quote_id' open_leads = f"type='lead' AND status NOT IN {CLOSED_STATUSES}" stats = { 'new30': scalar("SELECT COUNT(*) FROM contacts WHERE type='lead' AND created_at >= ?", since), 'open_leads': scalar(f'SELECT COUNT(*) FROM contacts WHERE {open_leads}'), 'pipeline_value': scalar(f'SELECT COALESCE(SUM(value), 0) FROM contacts WHERE {open_leads}'), 'unassigned': scalar(f'SELECT COUNT(*) FROM contacts WHERE {open_leads} AND assigned_user_id IS NULL'), 'open_quotes': scalar("SELECT COUNT(*) FROM quotes WHERE status IN ('concept', 'verzonden')"), 'open_value': scalar(line_sum + " WHERE qu.status IN ('concept', 'verzonden')"), 'won_value': scalar(line_sum + " WHERE qu.status='geaccepteerd' AND qu.updated_at >= ?", since), 'customers': scalar("SELECT COUNT(*) FROM relations WHERE kind='klant'"), 'partners': scalar("SELECT COUNT(*) FROM relations WHERE kind='partner'"), } counts = {r[0]: r[1] for r in q("SELECT status, COUNT(*) FROM contacts WHERE type='lead' GROUP BY status")} pipeline = [(key, label, counts.get(key, 0)) for key, label in STATUSES] monday = date.today() - timedelta(days=date.today().weekday()) per_week = {monday - timedelta(weeks=i): 0 for i in range(11, -1, -1)} for r in q('SELECT created_at FROM contacts WHERE created_at >= ?', min(per_week).isoformat()): d = datetime.fromisoformat(r[0]).date() week = d - timedelta(days=d.weekday()) if week in per_week: per_week[week] += 1 weekly = [(w.isocalendar()[1], n) for w, n in per_week.items()] sources = q("SELECT COALESCE(NULLIF(source, ''), 'Onbekend') AS s, COUNT(*) AS n " "FROM contacts GROUP BY s ORDER BY n DESC LIMIT 6") tasks = q('SELECT t.*, c.name AS contact_name FROM tasks t LEFT JOIN contacts c ON c.id=t.contact_id ' 'WHERE t.done=0 AND t.user_id=? ORDER BY t.due_date IS NULL, t.due_date LIMIT 10', g.user['id']) return render_template( 'dashboard.html', stats=stats, pipeline=pipeline, weekly=weekly, sources=sources, tasks=tasks, agenda=calendar_items(datetime.combine(date.today(), datetime.min.time()), datetime.combine(date.today() + timedelta(days=7), datetime.min.time()), [g.user['id']], tasks=False)[:8], pipe_max=max([n for _, _, n in pipeline] + [1]), week_max=max([n for _, n in weekly] + [1]), source_max=max([r['n'] for r in sources] + [1]), recent=q("SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id " "WHERE c.type='lead' ORDER BY c.created_at DESC LIMIT 8"), mails=q(f"SELECT * FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 AND {visible_mail()} " "ORDER BY e.date DESC LIMIT 5")) # ---------------------------------------------------------------- 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 documents f ON f.id=c.design_id') CONTACT_HIDDEN = ('portal_token', 'portal_password_hash') def contacts_filter(source): """De filters van de contactenlijst (zoekterm, soort, status, eigenaar) als SQL; ook voor de export.""" term = source.get('q', '').strip() args = {k: source.get(k, '') for k in ('type', 'status', 'owner')} sql, params = '', [] if term: sql += (' AND (c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ? OR c.phone LIKE ? OR c.city LIKE ?' ' OR r.name LIKE ?)') params += [f'%{term}%'] * 6 if args['type'] in dict(TYPES): sql += ' AND c.type=?' params.append(args['type']) if args['status']: sql += ' AND c.status=?' params.append(args['status']) if args['owner'] == 'me': sql += ' AND c.assigned_user_id=?' params.append(g.user['id']) elif args['owner'] == 'none': sql += ' AND c.assigned_user_id IS NULL' return term, args, sql, params @app.route('/contacts') def contacts(): term, args, where, params = contacts_filter(request.args) rows = db().execute('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, ' f'f.title AS design_title {CONTACT_FROM} WHERE 1=1{where} ORDER BY c.created_at DESC LIMIT 500', params).fetchall() return render_template('contacts.html', rows=rows, term=term, a=args) def contacts_csv(where, params): """Alle velden van de contacten, met de namen bij de koppelingen en de extra formuliervelden als eigen kolom.""" rows = db().execute(f'SELECT c.*, u.name AS user_name, r.name AS relation_name, f.title AS design_title ' f'{CONTACT_FROM} WHERE 1=1{where} ORDER BY c.id', params).fetchall() cols = [c for c in table_columns('contacts', CONTACT_HIDDEN) if c != 'extra'] extras = [] for r in rows: try: e = json.loads(r['extra'] or '{}') except ValueError: e = {} extras.append(e if isinstance(e, dict) else {}) keys = list(dict.fromkeys(k for e in extras for k in e)) taken = set(cols) | {'soort', 'status_label', 'toegewezen', 'relatie', 'model', 'portaal'} header = [*cols, 'soort', 'status_label', 'toegewezen', 'relatie', 'model', 'portaal', *[f'extra_{k}' if k in taken else k for k in keys]] data = [] for r, e in zip(rows, extras): data.append([*(r[c] for c in cols), dict(TYPES).get(r['type'], r['type']), dict(STATUSES).get(r['status'], r['status']), r['user_name'], r['relation_name'], r['design_title'], 'ja' if r['portal_password_hash'] else ('uitgenodigd' if r['portal_token'] else ''), *(e.get(k) if isinstance(e.get(k), (str, int, float)) or e.get(k) is None else json.dumps(e[k], ensure_ascii=False) for k in keys)]) return csv_response('contacten', header, data) @app.route('/contacts/export.csv') def contacts_export(): _, _, where, params = contacts_filter(request.args) return contacts_csv(where, params) 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,)) @app.post('/contacts/bulk') def contacts_bulk(): ids, action = bulk_ids(), request.form.get('action') if not ids: flash('Vink eerst een of meer contacten aan.', 'error') return bulk_back(url_for('contacts')) if action == 'export': return contacts_csv(f' AND c.id IN {id_list(ids)}', ids) existing = [r['id'] for r in q(f'SELECT id FROM contacts WHERE id IN {id_list(ids)}', *ids)] if not existing: flash('Die contacten bestaan niet meer.', 'error') return bulk_back(url_for('contacts')) if action == 'status' and request.form.get('status') in dict(STATUSES): status = request.form['status'] for cid in existing: set_contact_status(cid, status) msg = f"{len(existing)} contact(en) op {dict(STATUSES)[status]} gezet." elif action == 'assign': uid = request.form.get('user_id', type=int) user = q1('SELECT id, name FROM users WHERE id=?', uid) if uid else None changed = [r['id'] for r in q(f'SELECT id FROM contacts WHERE id IN {id_list(existing)} ' 'AND assigned_user_id IS NOT ?', *existing, user['id'] if user else None)] for cid in changed: db().execute('UPDATE contacts SET assigned_user_id=?, updated_at=? WHERE id=?', (user['id'] if user else None, now(), cid)) log(cid, 'status', f"Toegewezen aan {user['name'] if user else 'niemand'}") msg = f"{len(changed)} contact(en) toegewezen aan {user['name'] if user else 'niemand'}." elif action == 'convert': for cid in existing: convert_contact(cid) msg = f'{len(existing)} contact(en) omgezet naar klant.' elif action == 'delete': for cid in existing: delete_contact(cid) msg = f'{len(existing)} contact(en) verwijderd.' else: flash('Kies een actie.', 'error') return bulk_back(url_for('contacts')) db().commit() flash(msg) return bulk_back(url_for('contacts')) def contact_form_values(): f = request.form data = {k: f.get(k, '').strip() for k in CONTACT_FIELDS} meta = {'type': f.get('type') if f.get('type') in dict(TYPES) else 'lead', 'status': f.get('status') if f.get('status') in dict(STATUSES) else 'nieuw', 'assigned': f.get('assigned_user_id', type=int), 'value': num(f.get('value')), 'relation': f.get('relation_id', type=int), 'role': f.get('relation_role', '').strip()} return data, meta @app.route('/contacts/new', methods=['GET', 'POST']) def contact_new(): if request.method == 'POST': data, meta = contact_form_values() if not data['name']: flash('Vul een naam in.', 'error') return render_template('contact_form.html', c=request.form, new=True, relations=all_relations()) cid = insert_contact(data, status=meta['status'], type_=meta['type'], assigned=meta['assigned'], value=meta['value']) log(cid, 'note', 'Contact handmatig aangemaakt') if meta['relation']: link_relation(cid, meta['relation'], meta['role']) db().commit() return redirect(url_for('contact_view', cid=cid)) c = {'status': 'nieuw', 'type': 'lead', 'assigned_user_id': g.user['id'], **request.args.to_dict()} if c.get('relation_id'): rel = q1('SELECT kind FROM relations WHERE id=?', c['relation_id']) c['type'] = 'partner' if rel and rel['kind'] == 'partner' else 'klant' return render_template('contact_form.html', c=c, new=True, relations=all_relations()) 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) 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), 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), 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()), contact_id=cid)[:10], portal_link=url_for('portal_invite', token=c['portal_token'], _external=True) if c['portal_token'] else '', klant_relations=q("SELECT id, name, type FROM relations WHERE kind='klant' ORDER BY name"), housemates=q('SELECT id, name, relation_role FROM contacts WHERE relation_id=? AND id<>? ORDER BY name', c['relation_id'], cid) if c['relation_id'] else []) @app.route('/contacts//edit', methods=['GET', 'POST']) def contact_edit(cid): c = get_or_404('contacts', cid) if request.method == 'POST': data, meta = contact_form_values() if not data['name']: flash('Vul een naam in.', 'error') return render_template('contact_form.html', c={**request.form, 'id': cid}, new=False, relations=all_relations()) sets = ', '.join(f'{k}=?' for k in CONTACT_FIELDS) db().execute(f'UPDATE contacts SET {sets}, assigned_user_id=?, value=?, updated_at=? WHERE id=?', [*data.values(), meta['assigned'], meta['value'], now(), cid]) if meta['assigned'] != c['assigned_user_id']: user = q1('SELECT name FROM users WHERE id=?', meta['assigned']) if meta['assigned'] else None log(cid, 'status', f"Toegewezen aan {user['name'] if user else 'niemand'}") set_contact_status(cid, meta['status']) if meta['type'] != c['type']: db().execute('UPDATE contacts SET type=? WHERE id=?', (meta['type'], cid)) log(cid, 'status', f"Soort: {dict(TYPES).get(c['type'])} → {dict(TYPES)[meta['type']]}") if meta['relation'] != c['relation_id'] or meta['role'] != (c['relation_role'] or ''): link_relation(cid, meta['relation'], meta['role']) link_emails(cid, data['email']) db().commit() flash('Contact opgeslagen.') return redirect(url_for('contact_view', cid=cid)) return render_template('contact_form.html', c=c, new=False, relations=all_relations()) @app.post('/contacts//status') def contact_status(cid): get_or_404('contacts', cid) set_contact_status(cid, request.form.get('status')) db().commit() return redirect(url_for('contact_view', cid=cid)) @app.post('/contacts//convert') def contact_convert(cid): get_or_404('contacts', cid) convert_contact(cid, request.form.get('relation_id', type=int), request.form.get('new_relation', '').strip()) db().commit() flash('Lead is omgezet naar klant.') return redirect(url_for('contact_view', cid=cid)) @app.post('/contacts//note') def contact_note(cid): get_or_404('contacts', cid) body = request.form.get('body', '').strip() if body: log(cid, 'note', body) db().commit() return redirect(url_for('contact_view', cid=cid)) @app.post('/contacts//upload') def contact_upload(cid): get_or_404('contacts', 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} document(en) toegevoegd' + (f' ({note})' if note else '')) db().commit() return redirect(url_for('contact_view', cid=cid)) @app.post('/contacts//task') def contact_task(cid): get_or_404('contacts', cid) title = request.form.get('title', '').strip() if title: user_id = request.form.get('user_id', type=int) or g.user['id'] due = request.form.get('due_date') or None db().execute('INSERT INTO tasks (contact_id, user_id, title, due_date, created_at) VALUES (?, ?, ?, ?, ?)', (cid, user_id, title, due, now())) log(cid, 'task', f"Taak: {title}" + (f" (voor {fmt_dt(due, '%d-%m-%Y')})" if due else '')) db().commit() return redirect(url_for('contact_view', cid=cid)) @app.post('/tasks//done') def task_done(tid): t = get_or_404('tasks', tid) db().execute('UPDATE tasks SET done=? WHERE id=?', (0 if t['done'] else 1, tid)) if not t['done'] and t['contact_id']: log(t['contact_id'], 'task', f"Taak afgerond: {t['title']}") db().commit() return redirect(back(url_for('dashboard'))) @app.post('/contacts//portal') def contact_portal(cid): c = get_or_404('contacts', cid) action = request.form.get('action') if action == 'invite': db().execute('UPDATE contacts SET portal_token=? WHERE id=?', (secrets.token_urlsafe(24), cid)) log(cid, 'portal', 'Uitnodiging voor het klantportaal aangemaakt') flash('Uitnodigingslink aangemaakt. Kopieer de link of verstuur hem per e-mail.') elif action == 'mail' and c['portal_token'] and c['email']: link = url_for('portal_invite', token=c['portal_token'], _external=True) company = get_setting('company_name', 'ons') body = (f"Beste {c['name']},\n\nVia ons klantportaal kunt u uw offertes bekijken en goedkeuren, en " f"bestanden met ons delen. Stel via deze link uw wachtwoord in:\n\n{link}\n\n" f"Met vriendelijke groet,\n{company}") try: mb = default_mailbox() if not mb: raise RuntimeError('er is geen mailbox waarmee je mag versturen') mail.send(mailbox_cfg(mb), c['email'], f'Uw toegang tot het klantportaal van {company}', body) log(cid, 'portal', 'Uitnodiging voor het klantportaal gemaild') flash('Uitnodiging verstuurd.') except Exception as e: flash(f'Versturen mislukt: {e}', 'error') elif action == 'revoke': db().execute('UPDATE contacts SET portal_token=NULL, portal_password_hash=NULL WHERE id=?', (cid,)) log(cid, 'portal', 'Toegang tot het klantportaal ingetrokken') flash('Portaaltoegang ingetrokken.') db().commit() return redirect(url_for('contact_view', cid=cid)) @app.post('/contacts//delete') def contact_delete(cid): delete_contact(cid) db().commit() flash('Contact verwijderd.') return redirect(url_for('contacts')) @app.route('/uploads/') def upload_file(name): f = q1('SELECT original FROM files WHERE stored=?', 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) 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 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//design') def contact_design(cid): """Gekozen woning: een van de woningen bij het contact (of geen).""" c = get_or_404('contacts', cid) 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 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) 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=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 @app.post('/files//pdf') def design_pdf(fid): """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 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 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"{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=?', new_id) return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id']) # ---------------------------------------------------------------- relaties def relation_form_data(): data = {k: request.form.get(k, '').strip() for k in RELATION_FIELDS} kind = request.form.get('kind') if request.form.get('kind') in dict(RELATION_KINDS) else 'klant' return data, kind def upsert_relation(raw, kind, update_existing=True): """Voor de import: zelfde soort + naam (of KvK-nummer) = dezelfde relatie; alleen lege velden aanvullen.""" data = {k: str(raw.get(k) or '').strip() for k in RELATION_FIELDS if str(raw.get(k) or '').strip()} kind = {'klant': 'klant', 'klanten': 'klant', 'partner': 'partner', 'partners': 'partner', 'leverancier': 'partner'}.get(str(raw.get('kind', '')).strip().lower(), kind) if not data.get('name'): return None, 'skipped' existing = (q1("SELECT * FROM relations WHERE kind=? AND kvk=? AND kvk<>''", kind, data['kvk']) if data.get('kvk') else None) or q1('SELECT * FROM relations WHERE kind=? AND name=? COLLATE NOCASE', kind, data['name']) if existing: if not update_existing: return existing['id'], 'skipped' updates = {k: v for k, v in data.items() if not existing[k]} if not updates: return existing['id'], 'unchanged' updates['updated_at'] = now() db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in updates)} WHERE id=?', [*updates.values(), existing['id']]) return existing['id'], 'updated' if not data.get('type'): rid = find_or_create_relation(data['name'], kind) data.pop('name') if data: db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), rid]) return rid, 'created' ts = now() data.update(kind=kind, created_at=ts, updated_at=ts) cur = db().execute(f'INSERT INTO relations ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())) return cur.lastrowid, 'created' def relations_filter(source): kind = source.get('kind') if source.get('kind') in dict(RELATION_KINDS) else 'klant' type_, term = source.get('type', ''), source.get('q', '').strip() sql, params = ' AND r.kind=?', [kind] if type_: sql += ' AND r.type=?' params.append(type_) if term: sql += (' AND (r.name LIKE ? OR r.city LIKE ? OR r.email LIKE ? OR EXISTS ' '(SELECT 1 FROM contacts c WHERE c.relation_id=r.id AND c.name LIKE ?))') params += [f'%{term}%'] * 4 return kind, type_, term, sql, params @app.route('/relations') def relations(): kind, type_, term, where, params = relations_filter(request.args) sql = ('SELECT r.*, (SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, ' '(SELECT GROUP_CONCAT(name, ", ") FROM (SELECT name FROM contacts WHERE relation_id=r.id LIMIT 4)) AS member_names, ' '(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products ' 'FROM relations r WHERE 1=1') rows = db().execute(sql + where + ' ORDER BY r.name LIMIT 500', params).fetchall() counts = {r[0]: r[1] for r in q('SELECT type, COUNT(*) FROM relations WHERE kind=? GROUP BY type', kind)} return render_template('relations.html', rows=rows, kind=kind, type=type_, term=term, types=relation_types(kind), counts=counts) @app.route('/relations/new', methods=['GET', 'POST'], defaults={'rid': None}) @app.route('/relations//edit', methods=['GET', 'POST']) def relation_edit(rid): r = get_or_404('relations', rid) if rid else None if request.method == 'POST': data, kind = relation_form_data() if not data['name']: flash('Vul een naam in.', 'error') return render_template('relation_form.html', r={**data, 'kind': kind, 'id': rid}, types=relation_types(kind)) data['updated_at'] = now() if r: data['kind'] = kind db().execute(f'UPDATE relations SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), rid]) if kind != r['kind']: for c in q('SELECT id FROM contacts WHERE relation_id=?', rid): link_relation(c['id'], rid) else: data.update(kind=kind, created_at=data['updated_at']) rid = db().execute(f'INSERT INTO relations ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())).lastrowid db().commit() flash('Relatie opgeslagen.') return redirect(url_for('relation_view', rid=rid)) kind = r['kind'] if r else (request.args.get('kind') if request.args.get('kind') in dict(RELATION_KINDS) else 'klant') return render_template('relation_form.html', r=r or {'kind': kind, 'type': request.args.get('type', '')}, types=relation_types(kind)) @app.route('/relations/') def relation_view(rid): r = get_or_404('relations', rid) members = q('SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id ' 'WHERE c.relation_id=? ORDER BY c.name', rid) ids = [m['id'] for m in members] or [0] marks = ', '.join('?' * len(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'), quotes=quotes_with_totals(f'WHERE qu.contact_id IN ({marks})', *ids), activities=q(f'SELECT a.*, c.name AS contact_name, u.name AS user_name FROM activities a ' f'JOIN contacts c ON c.id=a.contact_id LEFT JOIN users u ON u.id=a.user_id ' f'WHERE a.contact_id IN ({marks}) ORDER BY a.created_at DESC LIMIT 30', *ids), tasks=q(f'SELECT t.*, c.name AS contact_name FROM tasks t JOIN contacts c ON c.id=t.contact_id ' f'WHERE t.done=0 AND t.contact_id IN ({marks}) ORDER BY t.due_date IS NULL, t.due_date', *ids), products=q('SELECT * FROM products WHERE supplier_id=? ORDER BY active DESC, name', rid), 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') def relation_member(rid): get_or_404('relations', rid) cid = request.form.get('contact_id', type=int) if cid and q1('SELECT id FROM contacts WHERE id=?', cid): link_relation(cid, rid, request.form.get('relation_role', '').strip()) db().commit() return redirect(url_for('relation_view', rid=rid)) @app.post('/relations//member//remove') def relation_member_remove(rid, cid): if q1('SELECT id FROM contacts WHERE id=? AND relation_id=?', cid, rid): link_relation(cid, None, '') db().commit() return redirect(url_for('relation_view', rid=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): link_relation(c['id'], None, '') db().execute("UPDATE products SET supplier_id=NULL WHERE supplier_id=?", (rid,)) db().execute('DELETE FROM relations WHERE id=?', (rid,)) @app.post('/relations//delete') def relation_delete(rid): r = get_or_404('relations', rid) delete_relation(rid) db().commit() flash('Relatie verwijderd; de contactpersonen en producten zijn bewaard.') return redirect(url_for('relations', kind=r['kind'])) def relations_csv(where, params): """Alle velden van de relaties, met hun contactpersonen (naam, rol, e-mail) en het aantal producten.""" cols = table_columns('relations') rows = db().execute( 'SELECT r.*, (SELECT GROUP_CONCAT(name || CASE WHEN COALESCE(relation_role, \'\') <> \'\' ' 'THEN \' (\' || relation_role || \')\' ELSE \'\' END || CASE WHEN COALESCE(email, \'\') <> \'\' ' 'THEN \' <\' || email || \'>\' ELSE \'\' END, \', \') FROM contacts WHERE relation_id=r.id) AS member_list, ' '(SELECT COUNT(*) FROM contacts WHERE relation_id=r.id) AS members, ' '(SELECT COUNT(*) FROM products WHERE supplier_id=r.id) AS products ' f'FROM relations r WHERE 1=1{where} ORDER BY r.kind, r.name', params).fetchall() header = [*cols, 'aantal_contactpersonen', 'contactpersonen', 'aantal_producten'] return csv_response('relaties', header, [[*(r[c] for c in cols), r['members'], r['member_list'], r['products']] for r in rows]) @app.route('/relations/export.csv') def relations_export(): *_, where, params = relations_filter(request.args) return relations_csv(where, params) @app.post('/relations/bulk') def relations_bulk(): ids, action = bulk_ids(), request.form.get('action') default = url_for('relations', kind=request.form.get('kind') or None) if not ids: flash('Vink eerst een of meer relaties aan.', 'error') return bulk_back(default) if action == 'export': return relations_csv(f' AND r.id IN {id_list(ids)}', ids) rows = q(f'SELECT id, kind FROM relations WHERE id IN {id_list(ids)}', *ids) if action == 'type': type_ = request.form.get('type', '').strip() n = 0 for r in rows: if type_ in relation_types(r['kind']) or not type_: db().execute('UPDATE relations SET type=?, updated_at=? WHERE id=?', (type_, now(), r['id'])) n += 1 msg = f"{n} relatie(s) op type {type_ or '(geen)'} gezet." elif action == 'delete': for r in rows: delete_relation(r['id']) msg = f'{len(rows)} relatie(s) verwijderd; de contactpersonen en producten zijn bewaard.' else: flash('Kies een actie.', 'error') return bulk_back(default) db().commit() flash(msg) return bulk_back(default) # ---------------------------------------------------------------- import IMPORT_NAME = re.compile(r'[0-9a-f]{32}\.(csv|json)') @app.route('/import', methods=['GET', 'POST']) def import_start(): if request.method == 'POST': fs = request.files.get('file') kind = request.form.get('kind') if request.form.get('kind') in importer.TARGETS else 'contacts' ext = os.path.splitext(fs.filename if fs else '')[1].lower() if ext not in ('.csv', '.json', '.txt'): flash('Kies een .csv- of .json-bestand.', 'error') return redirect(url_for('import_start')) token = uuid.uuid4().hex + ('.json' if ext == '.json' else '.csv') fs.save(os.path.join(IMPORTS, token)) session[f'import_{token}'] = fs.filename return redirect(url_for('import_preview', token=token, kind=kind)) return render_template('import.html') @app.route('/import/', methods=['GET', 'POST']) def import_preview(token): if not IMPORT_NAME.fullmatch(token) or not os.path.exists(os.path.join(IMPORTS, token)): flash('Importbestand niet gevonden, upload het opnieuw.', 'error') return redirect(url_for('import_start')) path = os.path.join(IMPORTS, token) kind = request.values.get('kind') if request.values.get('kind') in importer.TARGETS else 'contacts' filename = session.get(f'import_{token}', token) try: cols, rows = importer.read(path) except importer.ImportFout as e: os.remove(path) flash(str(e), 'error') return redirect(url_for('import_start')) if request.method == 'GET': return render_template('import_preview.html', token=token, kind=kind, cols=cols, rows=rows[:8], total=len(rows), mapping=importer.guess(cols, kind), targets=importer.TARGETS[kind], filename=filename, relation_types={k: relation_types(k) for k, _ in RELATION_KINDS}) valid = dict(importer.TARGETS[kind]) mapping = [(col, request.form.get(f'map_{i}', '')) for i, col in enumerate(cols)] mapping = [(col, t) for col, t in mapping if t in valid and t] update = bool(request.form.get('update')) result = {'created': 0, 'updated': 0, 'unchanged': 0, 'skipped': 0} if kind == 'relations': rel_kind = request.form.get('relation_kind') if request.form.get('relation_kind') in dict(RELATION_KINDS) else 'klant' for row in rows: data = {} for col, target in mapping: value = row.get(col) if isinstance(value, (str, int, float)) and str(value).strip(): data.setdefault(target, value) if not data.get('type') and request.form.get('relation_type'): data['type'] = request.form['relation_type'] _, outcome = upsert_relation(data, rel_kind, update) result[outcome] = result.get(outcome, 0) + 1 elif kind == 'contacts': defaults = {'type': request.form.get('type') if request.form.get('type') in dict(TYPES) else 'lead', 'status': request.form.get('status') if request.form.get('status') in dict(STATUSES) else 'nieuw', 'assigned': request.form.get('assigned_user_id', type=int), 'source': request.form.get('source', '').strip() or 'Import'} for row in rows: fields, extra = {}, {} for col, target in mapping: value = row.get(col) if target == 'extra': if value not in (None, ''): extra[col] = value elif isinstance(value, (str, int, float)) and str(value).strip(): fields.setdefault(target, value) message = str(fields.pop('message', '')).strip() cid, outcome = upsert_contact(fields, extra, update, defaults) result[outcome] += 1 if outcome != 'skipped': log(cid, 'import', f'Geïmporteerd uit {filename}' + (f'\n{message}' if message else '')) else: records = [] for row in rows: data = {} for col, target in mapping: value = row.get(col) if isinstance(value, (str, int, float)) and str(value).strip(): data.setdefault(target, value) records.append(data) has_sku = any(t == 'sku' for _, t in mapping) result = import_products(records, f'Import {filename}', update, deactivate_missing=has_sku and bool(request.form.get('deactivate_missing'))) db().commit() os.remove(path) session.pop(f'import_{token}', None) flash(import_summary(result)) if kind == 'relations': return redirect(url_for('relations', kind=request.form.get('relation_kind', 'klant'))) return redirect(url_for('contacts' if kind == 'contacts' else 'products')) # ---------------------------------------------------------------- mailboxen en meldingen # # Elke mailbox is een eigen account (info@, facturen@, nieuwsbrief@, persoonlijk). Wie een mailbox mag zien en # gebruiken staat in mailbox_users; zonder regels is hij gedeeld met iedereen. E-mail is alleen zichtbaar via de # mailbox waarin hij staat (visible_mail). def mailbox_cfg(mb): return {k: '' if mb[k] is None else mb[k] for k in mb.keys()} def user_mailboxes(user=None, sending=False, inactive=False): """Mailboxen waar de gebruiker bij mag (standaard de ingelogde), op naam; sending = alleen met SMTP, de mailboxen met de rol inbox eerst (die staat dan voorgekozen bij opstellen).""" user = user or g.user cache = g.setdefault('_mailboxes', {}) key = (user['id'], inactive) if key not in cache: cache[key] = q('SELECT m.* FROM mailboxes m WHERE (m.active=1 OR ?) AND (NOT EXISTS ' '(SELECT 1 FROM mailbox_users mu WHERE mu.mailbox_id=m.id) OR EXISTS ' '(SELECT 1 FROM mailbox_users mu WHERE mu.mailbox_id=m.id AND mu.user_id=?)) ' 'ORDER BY m.name COLLATE NOCASE', 1 if inactive else 0, user['id']) rows = cache[key] return sorted((m for m in rows if m['smtp_host']), key=lambda m: m['purpose'] != 'inbox') if sending else rows def default_mailbox(): """Mailbox voor mail vanuit de CRM zelf (bijv. portaaluitnodiging): de eerste inbox waarmee de gebruiker mag versturen.""" boxes = user_mailboxes(sending=True) return boxes[0] if boxes else None def visible_mail(alias='e'): """SQL-voorwaarde: e-mail uit de mailboxen van de ingelogde gebruiker (ook inactieve).""" ids = [str(m['id']) for m in user_mailboxes(inactive=True)] return f"{alias}.mailbox_id IN ({', '.join(ids) or 'NULL'})" def mailbox_user_ids(mb): """Gebruikers met toegang tot een mailbox (voor meldingen).""" rows = q('SELECT user_id FROM mailbox_users WHERE mailbox_id=?', mb['id']) if not rows: rows = q('SELECT id AS user_id FROM users WHERE active=1') return [r['user_id'] for r in rows] def notify(user_ids, title, body='', url='', kind=''): """Melding voor gebruikers (bel in de bovenbalk; met toestemming ook als bureaubladmelding).""" for uid in set(user_ids): db().execute('INSERT INTO notifications (user_id, kind, title, body, url, created_at) VALUES (?, ?, ?, ?, ?, ?)', (uid, kind, title, body, url, now())) def mark_bounced(addresses): """Onbestelbaar (afleverbericht): het contact valt buiten de nieuwsbrieven, de laatst verstuurde nieuwsbrief naar dat adres telt als mislukt.""" for addr in set(addresses): for c in q('SELECT id FROM contacts WHERE email=? COLLATE NOCASE AND bounced_at IS NULL', addr): db().execute('UPDATE contacts SET bounced_at=? WHERE id=?', (now(), c['id'])) log(c['id'], 'email', f'E-mailadres {addr} is onbestelbaar (bounce); geen nieuwsbrieven meer') db().execute("UPDATE campaign_recipients SET status='mislukt', error='Onbestelbaar (bounce)' WHERE id=" "(SELECT id FROM campaign_recipients WHERE email=? COLLATE NOCASE AND status='verzonden' " "ORDER BY sent_at DESC LIMIT 1)", (addr,)) def fetch_mailbox(mb): """Nieuwe e-mail van één mailbox ophalen: (aantal, fout of None). Meldt nieuwe e-mail aan wie toegang heeft.""" known = {r[0] for r in q('SELECT uid FROM emails WHERE mailbox_id=? AND uid IS NOT NULL', mb['id'])} try: messages = mail.fetch(mailbox_cfg(mb), known, prefix=f"{mb['id']}/") except Exception as e: # netwerk-, login- of mapfouten: bij de mailbox bewaren en melden error = str(e) or type(e).__name__ db().execute('UPDATE mailboxes SET last_error=? WHERE id=?', (error, mb['id'])) db().commit() return 0, f"{mb['name']}: ophalen mislukt: {error}" new = [] for m in messages: c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', m['from_addr']) if m['from_addr'] else None cur = db().execute('INSERT OR IGNORE INTO emails (uid, message_id, direction, mailbox_id, contact_id, from_name, ' 'from_addr, to_addr, subject, body, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (m['uid'], m['message_id'], 'in', mb['id'], c['id'] if c else None, m['from_name'], m['from_addr'], m['to_addr'], m['subject'], m['body'], m['date'])) if not cur.rowcount: continue new.append(m) if c: log(c['id'], 'email', f"E-mail ontvangen in {mb['name']}: {m['subject']}") mark_bounced(m['bounced']) db().execute("UPDATE mailboxes SET last_sync=?, last_error='' WHERE id=?", (now(), mb['id'])) if new and mb['notify']: n = len(new) notify(mailbox_user_ids(mb), f"{n} nieuwe e-mail{'s' if n != 1 else ''} in {mb['name']}", ' · '.join(m['subject'] for m in new[:3]), url_for('inbox', mb=mb['id']), 'mail') db().commit() return len(new), None def do_sync(mailboxes=None): """E-mail ophalen uit de gegeven mailboxen (standaard alle actieve met IMAP): (aantal, [fouten]).""" if mailboxes is None: mailboxes = q("SELECT * FROM mailboxes WHERE active=1 AND imap_host<>'' ORDER BY id") total, errors = 0, [] for mb in mailboxes: if not mb['imap_host']: continue n, error = fetch_mailbox(mb) total += n if error: errors.append(error) return total, errors @app.route('/settings/mailboxes/new', methods=['GET', 'POST'], defaults={'mbid': None}) @app.route('/settings/mailboxes/', methods=['GET', 'POST']) def mailbox_edit(mbid): mb = get_or_404('mailboxes', mbid) if mbid else None if request.method == 'POST': f = request.form data = {k: f.get(k, '').strip() for k in MAILBOX_FIELDS} data.update(name=f.get('name', '').strip(), active=1 if f.get('active') else 0, notify=1 if f.get('notify') else 0, purpose=f.get('purpose') if f.get('purpose') in dict(MAILBOX_PURPOSES) else 'inbox') for key in MAILBOX_SECRETS: if f.get(key): data[key] = f[key] users = [int(u) for u in f.getlist('users') if u.isdigit()] if not data['name']: flash('Geef de mailbox een naam.', 'error') return render_template('mailbox_form.html', mb={**(dict(mb) if mb else {}), **data}, access=users, PROVIDERS=mail.PROVIDERS, SECURITY=mail.SECURITY, PURPOSES=MAILBOX_PURPOSES) data['updated_at'] = now() if mb: db().execute(f'UPDATE mailboxes SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), mbid]) else: data['created_at'] = data['updated_at'] mbid = db().execute(f'INSERT INTO mailboxes ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())).lastrowid db().execute('DELETE FROM mailbox_users WHERE mailbox_id=?', (mbid,)) for uid in users: db().execute('INSERT OR IGNORE INTO mailbox_users (mailbox_id, user_id) VALUES (?, ?)', (mbid, uid)) db().commit() if f.get('action') == 'test': for ok, message in mail.test(mailbox_cfg(get_or_404('mailboxes', mbid))): flash(message, 'message' if ok else 'error') return redirect(url_for('mailbox_edit', mbid=mbid)) flash('Mailbox opgeslagen.') return redirect(url_for('settings', _anchor='mail')) access = [r['user_id'] for r in q('SELECT user_id FROM mailbox_users WHERE mailbox_id=?', mbid)] if mb else [] return render_template('mailbox_form.html', mb=mb or {'active': 1, 'notify': 1, 'purpose': 'inbox'}, access=access, PROVIDERS=mail.PROVIDERS, SECURITY=mail.SECURITY, PURPOSES=MAILBOX_PURPOSES) @app.route('/notifications') def notifications(): rows = q('SELECT * FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 100', g.user['id']) return render_template('notifications.html', rows=rows) @app.route('/notifications/') def notification_open(nid): n = q1('SELECT * FROM notifications WHERE id=? AND user_id=?', nid, g.user['id']) or abort(404) db().execute('UPDATE notifications SET read_at=? WHERE id=? AND read_at IS NULL', (now(), nid)) db().commit() return redirect(safe_next(n['url']) or url_for('notifications')) @app.post('/notifications/read-all') def notifications_read_all(): db().execute('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL', (now(), g.user['id'])) db().commit() return redirect(back(url_for('notifications'))) @app.route('/notifications/poll') def notifications_poll(): """Voor de bel (static/notify.js): aantal ongelezen en de nieuwe meldingen na `after` (id).""" after = request.args.get('after', type=int) or 0 rows = q('SELECT id, title, body FROM notifications WHERE user_id=? AND read_at IS NULL AND id>? ' 'ORDER BY id DESC LIMIT 5', g.user['id'], after) return jsonify(count=scalar('SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL', g.user['id']), items=[{'id': r['id'], 'title': r['title'], 'body': r['body'], 'url': url_for('notification_open', nid=r['id'])} for r in rows]) # ---------------------------------------------------------------- inbox @app.route('/inbox') def inbox(): flt = request.args.get('f', 'all') term = request.args.get('q', '').strip() mbid = request.args.get('mb', type=int) where = {'unread': "e.direction='in' AND e.archived=0 AND e.is_read=0", 'unlinked': "e.direction='in' AND e.archived=0 AND e.contact_id IS NULL", 'sent': "e.direction='out'", 'archived': "e.direction='in' AND e.archived=1"}.get(flt) if not where: flt, where = 'all', "e.direction='in' AND e.archived=0" mailboxes = user_mailboxes(inactive=True) if mbid not in [m['id'] for m in mailboxes]: mbid = None where += f' AND e.mailbox_id={mbid}' if mbid else f' AND {visible_mail()}' args = [] if term: cols = ('subject', 'from_name', 'from_addr', 'to_addr', 'body') where += ' AND (' + ' OR '.join(f'e.{col} LIKE ?' for col in cols) + ')' args = [f'%{term}%'] * len(cols) rows = q(f'SELECT e.*, c.name AS contact_name, m.name AS mailbox_name FROM emails e ' f'LEFT JOIN contacts c ON c.id=e.contact_id LEFT JOIN mailboxes m ON m.id=e.mailbox_id ' f'WHERE {where} ORDER BY e.date DESC LIMIT 300', *args) unread_by = {r['mailbox_id']: r['n'] for r in q( f"SELECT e.mailbox_id, COUNT(*) AS n FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 " f"AND {visible_mail()} GROUP BY e.mailbox_id")} current = next((m for m in mailboxes if m['id'] == mbid), None) return render_template('inbox.html', rows=rows, f=flt, term=term, mb=mbid, current=current, mailboxes=[m for m in mailboxes if m['active'] or m['id'] == mbid], unread_by=unread_by) @app.post('/inbox/sync') def inbox_sync(): mbid = request.form.get('mb', type=int) boxes = [m for m in user_mailboxes() if m['imap_host'] and (not mbid or m['id'] == mbid)] if not boxes: flash('Er is geen mailbox met IMAP om op te halen.', 'error') return redirect(url_for('inbox', mb=mbid)) count, errors = do_sync(boxes) for error in errors: flash(error, 'error') flash(f'{count} nieuwe e-mail(s) opgehaald.') return redirect(url_for('inbox', mb=mbid)) @app.post('/inbox/read-all') def inbox_read_all(): mbid = request.form.get('mb', type=int) cond = f'mailbox_id={mbid}' if mbid in [m['id'] for m in user_mailboxes(inactive=True)] else visible_mail('emails') db().execute(f"UPDATE emails SET is_read=1 WHERE direction='in' AND is_read=0 AND {cond}") db().commit() flash('Alle e-mail is als gelezen gemarkeerd.') return redirect(back(url_for('inbox'))) def get_email(eid): """E-mail uit een mailbox van de gebruiker, anders 404.""" return q1(f'SELECT e.*, m.name AS mailbox_name FROM emails e LEFT JOIN mailboxes m ON m.id=e.mailbox_id ' f'WHERE e.id=? AND {visible_mail()}', eid) or abort(404) @app.route('/inbox/') def inbox_view(eid): e = get_email(eid) if not e['is_read']: db().execute('UPDATE emails SET is_read=1 WHERE id=?', (eid,)) db().commit() contact = q1('SELECT * FROM contacts WHERE id=?', e['contact_id']) if e['contact_id'] else None return render_template('email.html', e=e, contact=contact, templates=all_mail_templates(), vars=mail_vars(contact), mailboxes=user_mailboxes(sending=True)) @app.post('/inbox//unread') def inbox_unread(eid): e = get_email(eid) db().execute('UPDATE emails SET is_read=0 WHERE id=?', (eid,)) db().commit() return redirect(url_for('inbox', mb=e['mailbox_id'])) # Archiveren haalt een bericht alleen uit de inbox van de CRM; de mailbox op de server blijft zoals hij is. @app.post('/inbox//archive') def inbox_archive(eid): e = get_email(eid) db().execute('UPDATE emails SET archived=?, is_read=1 WHERE id=?', (0 if e['archived'] else 1, eid)) db().commit() flash('E-mail teruggezet in de inbox.' if e['archived'] else 'E-mail gearchiveerd.') return redirect(url_for('inbox', mb=e['mailbox_id'])) @app.post('/inbox//contact') def inbox_contact(eid): e = get_email(eid) c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', e['from_addr']) if c: cid = c['id'] link_emails(cid, e['from_addr']) else: cid = insert_contact({'name': e['from_name'] or e['from_addr'], 'email': e['from_addr'], 'source': 'E-mail'}, assigned=g.user['id']) log(cid, 'note', f"Aangemaakt vanuit e-mail: {e['subject']}") db().commit() return redirect(url_for('contact_view', cid=cid)) # ---------------------------------------------------------------- e-mail opstellen en templates # # Velden tussen accolades ({voornaam}) in onderwerp en tekst vult de opsteller (static/compose.js) direct in als het # contact bekend is; wat dan nog over is, vult mail_send bij het versturen in met het contact van het eerste adres in # Aan (zonder contact worden die velden leeg). Andere tekst tussen accolades blijft staan. MAIL_FIELDS = [('voornaam', 'Voornaam van het contact'), ('naam', 'Volledige naam van het contact'), ('email', 'E-mailadres van het contact'), ('telefoon', 'Telefoon van het contact'), ('bedrijf', 'Bedrijf van het contact'), ('adres', 'Straat en huisnummer'), ('postcode', 'Postcode'), ('plaats', 'Plaats'), ('model', 'Gekozen woningontwerp'), ('afzender', 'Jouw naam'), ('bedrijfsnaam', 'Onze bedrijfsnaam'), ('bedrijfstelefoon', 'Ons telefoonnummer'), ('website', 'Onze website'), ('afmeldlink', 'Afmeldlink (alleen in nieuwsbrieven)')] def mail_vars(contact=None, user=None): """Waarden voor de velden: afzender (de ingelogde gebruiker, of `user` in een routine) en bedrijf altijd, die van het contact alleen als het contact bekend is.""" user = user or g.get('user') values = {'afzender': user['name'] if user else '', 'bedrijfsnaam': get_setting('company_name'), 'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')} if contact: name = (contact['name'] or '').strip() 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 '', model=design['title'] if design else '') return values def send_values(contact=None, user=None, **extra): """Alle velden voor het versturen: wat niet bekend is, wordt leeg (geen {voornaam} in een verstuurde mail).""" values = {**dict.fromkeys(k for k, _ in MAIL_FIELDS), **mail_vars(contact, user), **extra} return {k: v or '' for k, v in values.items()} def fill_mail(text, values): return re.sub(r'\{(\w+)\}', lambda m: values.get(m.group(1), m.group(0)), text or '') def all_mail_templates(): return q('SELECT * FROM email_templates ORDER BY name COLLATE NOCASE') def compose_page(m, contact=None): return render_template('mail_new.html', m=m, contact=contact, templates=all_mail_templates(), vars=mail_vars(contact), mailboxes=user_mailboxes(sending=True), contacts=q("SELECT name, email FROM contacts WHERE email<>'' ORDER BY updated_at DESC LIMIT 1000")) def safe_next(target): return target if target.startswith('/') and not target.startswith(('//', '/\\')) else '' @app.route('/mail/new') def mail_new(): a = request.args contact = q1('SELECT * FROM contacts WHERE id=?', a.get('contact', type=int)) if a.get('contact') else None m = {'to': contact['email'] if contact else a.get('to', ''), 'contact_id': contact['id'] if contact else None, 'template': a.get('template', type=int), 'mailbox_id': a.get('mb', type=int), 'next': url_for('contact_view', cid=contact['id']) if contact else url_for('inbox', f='sent')} fwd = get_email(a.get('forward', type=int)) if a.get('forward') else None if fwd: subject = fwd['subject'] or '' m['subject'] = subject if subject.lower().startswith(('fwd:', 'fw:')) else f'Fwd: {subject}' m['mailbox_id'] = fwd['mailbox_id'] sender = f"{fwd['from_name']} <{fwd['from_addr']}>" if fwd['from_name'] else fwd['from_addr'] m['body'] = '\n\n'.join(['', '---------- Doorgestuurd bericht ----------\n' + f"Van: {sender}\nDatum: {fmt_dt(fwd['date'])}\nOnderwerp: {subject}\nAan: {fwd['to_addr']}", fwd['body'] or '']) return compose_page(m, contact) @app.post('/mail/send') def mail_send(): f = request.form to, cc = f.get('to', '').strip(), f.get('cc', '').strip() cid = f.get('contact_id', type=int) reply = get_email(f.get('reply_to', type=int)) if f.get('reply_to') else None boxes = user_mailboxes(sending=True) mb = next((b for b in boxes if b['id'] == f.get('mailbox_id', type=int)), boxes[0] if boxes else None) first = next((addr for _, addr in getaddresses([to]) if addr), '') contact = (q1('SELECT * FROM contacts WHERE id=?', cid) if cid else None) or \ (q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE', first) if first else None) values = send_values(contact) subject, body = fill_mail(f.get('subject', '').strip(), values), fill_mail(f.get('body', ''), values) files = [(os.path.basename(u.filename.replace('\\', '/')), u.read(), u.mimetype) for u in request.files.getlist('attachments') if u and u.filename] nxt = safe_next(f.get('next', '')) try: if not mb: raise RuntimeError('er is geen mailbox met SMTP waarmee je mag versturen (Instellingen › Mailboxen)') cfg = mailbox_cfg(mb) message_id, warning = mail.send(cfg, to, subject, body, reply['message_id'] if reply else None, cc, files) except Exception as e: flash(f'Versturen mislukt: {e}' + (' Voeg de bijlagen opnieuw toe.' if files else ''), 'error') return compose_page({'to': to, 'cc': cc, 'subject': subject, 'body': body, 'next': nxt, 'contact_id': contact['id'] if contact else None, 'mailbox_id': mb['id'] if mb else None, 'reply_to': reply['id'] if reply else None}, contact) if warning: flash(warning, 'error') cid = contact['id'] if contact else None db().execute("INSERT INTO emails (message_id, direction, mailbox_id, contact_id, from_addr, to_addr, cc, subject, " "body, attachments, date, is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", (message_id, mb['id'], cid, mail.sender(cfg), to, cc, subject, body, ', '.join(name for name, _, _ in files), now())) if cid: log(cid, 'email', f"E-mail verstuurd vanuit {mb['name']}: {subject}") db().commit() flash('E-mail verstuurd.') return redirect(nxt or back(url_for('inbox'))) @app.route('/mail/templates') def mail_templates(): return render_template('mail_templates.html', rows=all_mail_templates(), FIELDS=MAIL_FIELDS) @app.route('/mail/templates/new', methods=['GET', 'POST'], defaults={'tid': None}) @app.route('/mail/templates/', methods=['GET', 'POST']) def mail_template_edit(tid): t = get_or_404('email_templates', tid) if tid else None if request.method == 'POST': f = request.form data = {'name': f.get('name', '').strip(), 'subject': f.get('subject', '').strip(), 'body': f.get('body', '')} if not data['name']: flash('Geef de template een naam.', 'error') return render_template('mail_template_form.html', t={**(dict(t) if t else {}), **data}, FIELDS=MAIL_FIELDS) data['updated_at'] = now() if t: db().execute(f'UPDATE email_templates SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), tid]) else: data['created_at'] = data['updated_at'] db().execute(f'INSERT INTO email_templates ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())) db().commit() flash('Template opgeslagen.') return redirect(url_for('mail_templates')) return render_template('mail_template_form.html', t=t or {}, FIELDS=MAIL_FIELDS) @app.post('/mail/templates//delete') def mail_template_delete(tid): get_or_404('email_templates', tid) db().execute('DELETE FROM email_templates WHERE id=?', (tid,)) db().commit() flash('Template verwijderd.') return redirect(url_for('mail_templates')) # ---------------------------------------------------------------- routines # # Het raamwerk staat in jobs.py (types registreren, instellingen); hier het uitvoeren en de types van de CRM. # Een routine draait in een request-context (url_for werkt, g.user ontbreekt: geef een gebruiker mee waar nodig). def run_job(job, manual=False): """Eén routine uitvoeren: (status, melding), of None als hij al draait.""" started = now() stale = (datetime.now() - timedelta(minutes=jobs.STALE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S') claimed = db().execute('UPDATE jobs SET running_since=? WHERE id=? AND (running_since IS NULL OR running_since'' ORDER BY id") if config.get('mailboxes'): boxes = [b for b in boxes if b['id'] in config['mailboxes']] if not boxes: return 'Geen actieve mailbox met IMAP' total, errors = do_sync(boxes) if errors: raise RuntimeError(f'{total} nieuw; ' + '; '.join(errors)) return f"{total} nieuwe e-mail(s) uit {len(boxes)} mailbox(en)" @jobs.register('campaigns', 'Nieuwsbrieven versturen', 'Verstuurt ingeplande nieuwsbrieven per batch over één verbinding, zodat de mailserver de limiet niet ' 'raakt. Een nieuwsbrief zonder wachtende ontvangers is klaar; de maker krijgt een melding.', interval=10, default=True, fields=[jobs.Field('batch', 'Maximaal aantal e-mails per keer', 'number', 30, 'OVHcloud staat ongeveer 200 e-mails per uur per mailbox toe: 30 per 10 minuten ' 'blijft daar ruim onder.')]) def job_campaigns(job, config): budget = max(1, int(config.get('batch') or 30)) parts = [] for c in q("SELECT * FROM campaigns WHERE status IN ('gepland', 'bezig') AND scheduled_at<=? ORDER BY scheduled_at", now()): if budget <= 0: break sent, failed = send_campaign_batch(c, budget) budget -= sent + failed parts.append(f"{c['name']}: {sent} verstuurd" + (f', {failed} mislukt' if failed else '')) return '; '.join(parts) or 'Geen nieuwsbrief aan de beurt' @jobs.register('cleanup', 'Opschonen', 'Verwijdert gelezen meldingen die ouder zijn dan het ingestelde aantal dagen.', interval=24 * 60, default=True, fields=[jobs.Field('days', 'Gelezen meldingen bewaren (dagen)', 'number', 30)]) def job_cleanup(job, config): limit = (datetime.now() - timedelta(days=max(1, int(config.get('days') or 30)))).strftime('%Y-%m-%d %H:%M:%S') n = db().execute('DELETE FROM notifications WHERE read_at IS NOT NULL AND created_at', methods=['GET', 'POST'], defaults={'jid': None}) @app.route('/routines/', methods=['GET', 'POST'], defaults={'kind': None}) def routine_edit(jid, kind): job = get_or_404('jobs', jid) if jid else None kind = job['kind'] if job else kind jt = jobs.TYPES.get(kind) or abort(404) if request.method == 'POST': f = request.form unit = f.get('unit', type=int) if f.get('unit', type=int) in dict(jobs.UNITS) else 1 data = {'name': f.get('name', '').strip() or jt.label, 'active': 1 if f.get('active') else 0, 'interval_minutes': max(1, (f.get('every', type=int) or 1) * unit), 'config': json.dumps(jobs.clean_config(kind, f), ensure_ascii=False)} if job: # nieuw interval: de volgende keer rekent vanaf de laatste keer data['next_run'] = ((datetime.fromisoformat(job['last_run']) + timedelta(minutes=data['interval_minutes'])) .strftime('%Y-%m-%d %H:%M:%S') if job['last_run'] else None) db().execute(f'UPDATE jobs SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), jid]) else: data.update(kind=kind, created_at=now()) jid = db().execute(f'INSERT INTO jobs ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())).lastrowid db().commit() flash('Routine opgeslagen.') return redirect(url_for('routine_edit', jid=jid)) config = {**jobs.defaults(kind), **json.loads(job['config'] if job else '{}')} minutes = job['interval_minutes'] if job else jt.interval unit = next(size for size, _ in reversed(jobs.UNITS) if minutes % size == 0) return render_template('routine_form.html', job=job, jt=jt, config=config, every=minutes // unit, unit=unit, UNITS=jobs.UNITS, mailboxes=q('SELECT id, name FROM mailboxes ORDER BY name COLLATE NOCASE'), runs=q('SELECT * FROM job_runs WHERE job_id=? ORDER BY id DESC LIMIT 30', jid) if job else []) @app.post('/routines//run') def routine_run(jid): result = run_job(get_or_404('jobs', jid), manual=True) if result is None: flash('Deze routine draait al.', 'error') else: flash(f'Uitgevoerd: {result[1]}' if result[0] == 'ok' else f'Mislukt: {result[1]}', 'message' if result[0] == 'ok' else 'error') return redirect(back(url_for('routines'))) @app.post('/routines//delete') def routine_delete(jid): get_or_404('jobs', jid) db().execute('DELETE FROM jobs WHERE id=?', (jid,)) db().commit() flash('Routine verwijderd.') return redirect(url_for('routines')) # ---------------------------------------------------------------- segmenten en nieuwsbrieven # # Een segment is een opgeslagen filter op contacten. Een nieuwsbrief legt bij het inplannen de ontvangers vast # (campaign_recipients); de routine "Nieuwsbrieven versturen" verstuurt ze per batch. Afgemelde en onbestelbare # contacten vallen altijd buiten een segment; per e-mailadres gaat er maar één mail uit. CAMPAIGN_STATUSES = [('concept', 'Concept'), ('gepland', 'Gepland'), ('bezig', 'Bezig'), ('gepauzeerd', 'Gepauzeerd'), ('verzonden', 'Verzonden'), ('gestopt', 'Gestopt')] RECIPIENT_STATUSES = [('wacht', 'Wacht'), ('verzonden', 'Verzonden'), ('mislukt', 'Mislukt'), ('overgeslagen', 'Overgeslagen')] def segment_where(f): """SQL-voorwaarde op contacten (alias c) uit de filters van een segment.""" where, args = ["c.email<>''", 'c.unsubscribed_at IS NULL', 'c.bounced_at IS NULL'], [] def among(col, values): if values: where.append(f"{col} IN ({', '.join('?' * len(values))})") args.extend(values) among('c.type', f.get('types')) among('c.status', f.get('statuses')) among('c.source', f.get('sources')) among('c.assigned_user_id', f.get('users')) if f.get('city'): where.append('c.city LIKE ?') args.append(f"%{f['city']}%") if f.get('created_from'): where.append('c.created_at>=?') args.append(f['created_from']) if f.get('created_to'): where.append('c.created_at', methods=['GET', 'POST']) def segment_edit(sid): seg = get_or_404('segments', sid) if sid else None filters = json.loads(seg['filters'] or '{}') if seg else {} name = seg['name'] if seg else '' if request.method == 'POST': filters, name = segment_filters(request.form), request.form.get('name', '').strip() if request.form.get('action') == 'save': if not name: flash('Geef het segment een naam.', 'error') else: data = (name, json.dumps(filters, ensure_ascii=False), now()) if seg: db().execute('UPDATE segments SET name=?, filters=?, updated_at=? WHERE id=?', (*data, sid)) else: sid = db().execute('INSERT INTO segments (name, filters, updated_at, created_at) VALUES (?, ?, ?, ?)', (*data, now())).lastrowid db().commit() flash('Segment opgeslagen.') return redirect(url_for('segment_edit', sid=sid)) ids = segment_ids(filters) sample = q(f"SELECT * FROM contacts WHERE id IN ({', '.join(map(str, ids[:25])) or 'NULL'}) ORDER BY name") if ids else [] return render_template('segment_form.html', seg=seg, name=name, filters=filters, count=len(ids), sample=sample, sources=[(r[0], r[0]) for r in q("SELECT DISTINCT source FROM contacts WHERE source<>'' ORDER BY source")], users=[(u['id'], u['name']) for u in q('SELECT id, name FROM users ORDER BY name')]) @app.post('/segments//delete') def segment_delete(sid): get_or_404('segments', sid) db().execute('DELETE FROM segments WHERE id=?', (sid,)) db().commit() flash('Segment verwijderd.') return redirect(url_for('segments')) def unsubscribe_token(cid): return hmac.new(app.secret_key.encode(), f'afmelden:{cid}'.encode(), hashlib.sha256).hexdigest()[:32] def campaign_message(c, cfg, contact, email, creator, base_url): """Het bericht voor één ontvanger: velden ingevuld, afmeldlink (ook als List-Unsubscribe, één klik).""" link = base_url.rstrip('/') + url_for('unsubscribe', cid=contact['id'], token=unsubscribe_token(contact['id'])) values = send_values(contact, creator, afmeldlink=link) body = fill_mail(c['body'], values) if '{afmeldlink}' not in (c['body'] or ''): body += f'\n\n--\nGeen nieuwsbrieven meer ontvangen? Meld u af: {link}' return mail.build(cfg, email, fill_mail(c['subject'], values), body, headers={'List-Unsubscribe': f'<{link}>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 'Precedence': 'bulk'}) def send_campaign_batch(c, limit): """Volgende ontvangers van een nieuwsbrief versturen: (verstuurd, mislukt). Een weigering van de server voor een adres telt als mislukt; andere SMTP-fouten (limiet, verbinding) stoppen de batch, de rest wacht.""" mb = q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']) if c['mailbox_id'] else None creator = q1('SELECT * FROM users WHERE id=?', c['created_by']) if c['created_by'] else None if not mb or not mb['smtp_host'] or not mb['active']: db().execute("UPDATE campaigns SET status='gepauzeerd', updated_at=? WHERE id=?", (now(), c['id'])) if creator: notify([creator['id']], f"Nieuwsbrief {c['name']} gepauzeerd", 'De mailbox kan niet versturen.', url_for('campaign_edit', cid=c['id']), 'campaign') db().commit() return 0, 0 if c['status'] == 'gepland': db().execute("UPDATE campaigns SET status='bezig', started_at=?, updated_at=? WHERE id=?", (now(), now(), c['id'])) db().commit() rows = q("SELECT * FROM campaign_recipients WHERE campaign_id=? AND status='wacht' ORDER BY id LIMIT ?", c['id'], limit) cfg, sent, failed, stop = mailbox_cfg(mb), 0, 0, None if rows: with mail.Outbox(cfg) as out: for r in rows: contact = q1('SELECT * FROM contacts WHERE id=?', r['contact_id']) if r['contact_id'] else None if not contact or contact['unsubscribed_at'] or contact['bounced_at']: db().execute("UPDATE campaign_recipients SET status='overgeslagen' WHERE id=?", (r['id'],)) db().commit() continue try: out.send(campaign_message(c, cfg, contact, r['email'], creator, c['base_url'])) except mail.smtplib.SMTPRecipientsRefused as e: failed += 1 db().execute("UPDATE campaign_recipients SET status='mislukt', error=? WHERE id=?", (str(e)[:500], r['id'])) except mail.smtplib.SMTPException as e: stop = str(e) or type(e).__name__ break else: sent += 1 db().execute("UPDATE campaign_recipients SET status='verzonden', sent_at=? WHERE id=?", (now(), r['id'])) log(contact['id'], 'email', f"Nieuwsbrief verstuurd: {c['name']}") db().commit() if stop: raise RuntimeError(f"{c['name']}: {sent} verstuurd, daarna gestopt: {stop}") if not scalar("SELECT COUNT(*) FROM campaign_recipients WHERE campaign_id=? AND status='wacht'", c['id']): db().execute("UPDATE campaigns SET status='verzonden', finished_at=?, updated_at=? WHERE id=?", (now(), now(), c['id'])) if creator: stats = campaign_stats(c['id']) notify([creator['id']], f"Nieuwsbrief {c['name']} is verzonden", f"{stats['verzonden']} verzonden, {stats['mislukt']} mislukt, {stats['overgeslagen']} overgeslagen", url_for('campaign_edit', cid=c['id']), 'campaign') db().commit() return sent, failed def campaign_stats(cid): stats = dict.fromkeys(dict(RECIPIENT_STATUSES), 0) stats.update({r['status']: r['n'] for r in q('SELECT status, COUNT(*) AS n FROM campaign_recipients ' 'WHERE campaign_id=? GROUP BY status', cid)}) stats['totaal'] = sum(stats.values()) return stats def newsletter_mailboxes(): """Mailboxen waarmee de gebruiker kan versturen, nieuwsbriefmailboxen eerst.""" return sorted(user_mailboxes(sending=True), key=lambda m: m['purpose'] != 'nieuwsbrief') @app.route('/campaigns') def campaigns(): rows = [dict(r, stats=campaign_stats(r['id'])) for r in q( 'SELECT c.*, s.name AS segment_name, m.name AS mailbox_name FROM campaigns c ' 'LEFT JOIN segments s ON s.id=c.segment_id LEFT JOIN mailboxes m ON m.id=c.mailbox_id ORDER BY c.id DESC')] return render_template('campaigns.html', rows=rows, CSTATUS=dict(CAMPAIGN_STATUSES)) @app.post('/campaigns/new') def campaign_new(): boxes = newsletter_mailboxes() cid = db().execute('INSERT INTO campaigns (name, mailbox_id, created_by, created_at, updated_at) ' 'VALUES (?, ?, ?, ?, ?)', (f"Nieuwsbrief {date.today().strftime('%d-%m-%Y')}", boxes[0]['id'] if boxes else None, g.user['id'], now(), now())).lastrowid db().commit() return redirect(url_for('campaign_edit', cid=cid)) @app.route('/campaigns/', methods=['GET', 'POST']) def campaign_edit(cid): c = get_or_404('campaigns', cid) if request.method == 'POST': f, action = request.form, request.form.get('action') if c['status'] == 'concept' and action in ('save', 'test', 'schedule'): boxes = {m['id'] for m in newsletter_mailboxes()} mbid = f.get('mailbox_id', type=int) db().execute('UPDATE campaigns SET name=?, mailbox_id=?, segment_id=?, subject=?, body=?, updated_at=? ' 'WHERE id=?', (f.get('name', '').strip() or c['name'], mbid if mbid in boxes else None, f.get('segment_id', type=int), f.get('subject', '').strip(), f.get('body', ''), now(), cid)) db().commit() c = get_or_404('campaigns', cid) if action == 'test': campaign_test(c) elif action == 'schedule' and c['status'] == 'concept': campaign_schedule(c, f.get('scheduled_at', '')) elif action == 'pause' and c['status'] in ('gepland', 'bezig'): db().execute("UPDATE campaigns SET status='gepauzeerd', updated_at=? WHERE id=?", (now(), cid)) flash('Nieuwsbrief gepauzeerd.') elif action == 'resume' and c['status'] == 'gepauzeerd': db().execute("UPDATE campaigns SET status=?, updated_at=? WHERE id=?", ('bezig' if c['started_at'] else 'gepland', now(), cid)) flash('Nieuwsbrief gaat verder bij de volgende ronde van de routine.') elif action == 'stop' and c['status'] in ('gepland', 'bezig', 'gepauzeerd'): db().execute("UPDATE campaign_recipients SET status='overgeslagen' WHERE campaign_id=? AND status='wacht'", (cid,)) db().execute("UPDATE campaigns SET status='gestopt', finished_at=?, updated_at=? WHERE id=?", (now(), now(), cid)) flash('Nieuwsbrief gestopt; wie nog niets kreeg, krijgt hem niet meer.') elif action == 'copy': new = db().execute('INSERT INTO campaigns (name, mailbox_id, segment_id, subject, body, created_by, ' 'created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (f"{c['name']} (kopie)", c['mailbox_id'], c['segment_id'], c['subject'], c['body'], g.user['id'], now(), now())).lastrowid db().commit() return redirect(url_for('campaign_edit', cid=new)) elif action == 'delete' and c['status'] == 'concept': db().execute('DELETE FROM campaigns WHERE id=?', (cid,)) db().commit() flash('Nieuwsbrief verwijderd.') return redirect(url_for('campaigns')) elif action == 'save': flash('Nieuwsbrief opgeslagen.') db().commit() return redirect(url_for('campaign_edit', cid=cid)) seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None page = q('SELECT r.*, ct.name FROM campaign_recipients r LEFT JOIN contacts ct ON ct.id=r.contact_id ' 'WHERE r.campaign_id=? ORDER BY r.status=\'wacht\', r.sent_at DESC, r.id LIMIT 300', cid) return render_template('campaign_form.html', c=c, CSTATUS=dict(CAMPAIGN_STATUSES), RSTATUS=dict(RECIPIENT_STATUSES), mailboxes=newsletter_mailboxes(), segments=q('SELECT * FROM segments ORDER BY name'), seg_count=len(segment_ids(json.loads(seg['filters'] or '{}'))) if seg else 0, templates=all_mail_templates(), FIELDS=MAIL_FIELDS, stats=campaign_stats(cid), recipients=page, mailbox=q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id'])) def campaign_ready(c): """Fout als de nieuwsbrief niet verstuurd kan worden, anders None.""" mb = q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']) if c['mailbox_id'] else None if not mb or not mb['smtp_host']: return 'Kies een mailbox die kan versturen.' if not c['subject'] or not (c['body'] or '').strip(): return 'Vul een onderwerp en tekst in.' return None def campaign_test(c): """Testmail naar de ingelogde gebruiker, gevuld met het eerste contact van het segment.""" error = campaign_ready(c) or (None if g.user['email'] else 'Je account heeft geen e-mailadres.') if error: flash(error, 'error') return seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg else [] contact = q1('SELECT * FROM contacts WHERE id=?', ids[0]) if ids else None contact = contact or {'id': 0, 'name': g.user['name'], 'email': g.user['email'], 'phone': '', 'company': '', 'address': '', 'postcode': '', 'city': '', 'design_id': None} cfg = mailbox_cfg(q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id'])) msg = campaign_message(c, cfg, contact, g.user['email'], g.user, request.url_root) msg.replace_header('Subject', f"[Test] {msg['Subject']}") try: with mail.Outbox(cfg) as out: out.send(msg) except Exception as e: flash(f'Testmail mislukt: {e}', 'error') return flash(f"Testmail verstuurd naar {g.user['email']}" + (f" (gevuld met {contact['name']})" if ids else '') + '.') def campaign_schedule(c, when): """Ontvangers vastleggen en inplannen; de routine verstuurt vanaf `when` (leeg = zo snel mogelijk).""" error = campaign_ready(c) seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None if not error and not seg: error = 'Kies een segment.' ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg and not error else [] if not error and not ids: error = 'Het segment heeft geen ontvangers.' try: at = datetime.fromisoformat(when).strftime('%Y-%m-%d %H:%M:%S') if when else now() except ValueError: error = error or 'Ongeldig tijdstip.' if error: flash(error, 'error') return for cid in ids: db().execute('INSERT OR IGNORE INTO campaign_recipients (campaign_id, contact_id, email) ' 'SELECT ?, id, lower(email) FROM contacts WHERE id=?', (c['id'], cid)) db().execute("UPDATE campaigns SET status='gepland', scheduled_at=?, base_url=?, updated_at=? WHERE id=?", (at, request.url_root, now(), c['id'])) if not q1("SELECT id FROM jobs WHERE kind='campaigns' AND active=1"): flash('Let op: de routine "Nieuwsbrieven versturen" staat uit; zet hem aan bij Routines.', 'error') flash(f'{len(ids)} ontvanger(s) vastgelegd; versturen begint {fmt_dt(at)} via de routine.') @app.route('/afmelden//', methods=['GET', 'POST']) def unsubscribe(cid, token): c = q1('SELECT * FROM contacts WHERE id=?', cid) valid = c is not None and hmac.compare_digest(token, unsubscribe_token(cid)) done = False if valid and request.method == 'POST': if not c['unsubscribed_at']: db().execute('UPDATE contacts SET unsubscribed_at=? WHERE id=?', (now(), cid)) log(cid, 'email', 'Afgemeld voor nieuwsbrieven (afmeldlink)') db().commit() done = True return render_template('unsubscribe.html', valid=valid, done=done or (valid and bool(c['unsubscribed_at'])), company=get_setting('company_name', 'Van Emous')) @app.post('/contacts//mailstatus') def contact_mailstatus(cid): get_or_404('contacts', cid) db().execute('UPDATE contacts SET unsubscribed_at=NULL, bounced_at=NULL WHERE id=?', (cid,)) log(cid, 'email', 'Weer aangemeld voor nieuwsbrieven') db().commit() flash('Het contact krijgt weer nieuwsbrieven.') return redirect(url_for('contact_view', cid=cid)) # ---------------------------------------------------------------- producten # # Een product wordt herkend aan zijn SKU (hoofdletterongevoelig); zonder SKU aan zijn naam. # Imports en de API werken bestaande producten bij in plaats van ze opnieuw aan te maken, en elke # wijziging komt in product_history. Offerteregels bewaren hun eigen omschrijving en prijs, dus een # prijsmutatie verandert nooit een offerte die al bestaat. def parse_bool(value): return 0 if str(value).strip().lower() in ('0', 'nee', 'no', 'false', 'n', 'inactief', 'uit') else 1 def clean_product(data): """Zet ruwe invoer om; alleen meegegeven velden blijven over (lege waarden tellen niet).""" out = {} for key in (*PRODUCT_FIELDS, 'active'): value = data.get(key) if value is None or (isinstance(value, str) and not value.strip()): continue if key == 'price': out[key] = round(num(value), 4) elif key == 'vat': out[key] = num(value, 21) elif key == 'active': out[key] = value if isinstance(value, int) else parse_bool(value) else: out[key] = str(value).strip() return out def product_history(pid, field, old, new, source): db().execute('INSERT INTO product_history (product_id, field, old_value, new_value, source, user_id, created_at) ' 'VALUES (?, ?, ?, ?, ?, ?, ?)', (pid, field, '' if old is None else str(old), '' if new is None else str(new), source, current_user_id(), now())) def find_product(sku='', name=''): if sku: return q1('SELECT * FROM products WHERE sku=? COLLATE NOCASE', sku) return q1("SELECT * FROM products WHERE sku='' AND name=? COLLATE NOCASE", name) if name else None def upsert_product(raw, source, update_existing=True, existing=None): """Maak een product aan of werk het bij. Geeft (uitkomst, prijs_gewijzigd, product_id) terug; uitkomst is 'created', 'updated', 'unchanged' of 'skipped'.""" data = clean_product(raw) if 'supplier' in data: data['supplier_id'] = find_or_create_relation(data['supplier'], 'partner') elif raw.get('supplier_id'): rel = q1("SELECT id, name FROM relations WHERE id=? AND kind='partner'", raw['supplier_id']) if rel: data.update(supplier_id=rel['id'], supplier=rel['name']) existing = existing or find_product(data.get('sku', ''), data.get('name', '')) if existing is None: if not data.get('name'): return 'skipped', False, None data.setdefault('unit', 'stuk') data['created_at'] = data['updated_at'] = now() cur = db().execute(f'INSERT INTO products ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', list(data.values())) product_history(cur.lastrowid, 'created', '', data.get('price', 0), source) return 'created', False, cur.lastrowid if not update_existing: return 'skipped', False, existing['id'] changes = {k: v for k, v in data.items() if v != existing[k]} if str(changes.get('sku', '')).lower() == (existing['sku'] or '').lower(): changes.pop('sku', None) # zelfde SKU in andere schrijfwijze: de bestaande houden if not changes: return 'unchanged', False, existing['id'] changes['updated_at'] = now() db().execute(f'UPDATE products SET {", ".join(f"{k}=?" for k in changes)} WHERE id=?', [*changes.values(), existing['id']]) for key, value in changes.items(): if key not in ('updated_at', 'supplier_id'): product_history(existing['id'], key, existing[key], value, source) return 'updated', 'price' in changes, existing['id'] def import_products(rows, source, update_existing=True, deactivate_missing=False): """Gedeelde logica voor de CSV/JSON-import en de API. `rows` zijn dicts met productvelden.""" result = {'created': 0, 'updated': 0, 'unchanged': 0, 'skipped': 0, 'prices': 0, 'deactivated': 0} seen = set() for row in rows: outcome, price_changed, pid = upsert_product(row, source, update_existing) result[outcome] += 1 result['prices'] += price_changed if pid: seen.add(pid) if deactivate_missing: for p in q("SELECT id FROM products WHERE active=1 AND sku<>''"): if p['id'] not in seen: db().execute('UPDATE products SET active=0, updated_at=? WHERE id=?', (now(), p['id'])) product_history(p['id'], 'active', 1, 0, f'{source} (niet meer in bestand)') result['deactivated'] += 1 return result def import_summary(r): parts = [f"{r['created']} nieuw", f"{r['updated']} bijgewerkt"] if r.get('prices'): parts.append(f"{r['prices']} prijswijziging(en)") parts += [f"{r.get('unchanged', 0)} ongewijzigd", f"{r['skipped']} overgeslagen"] if r.get('deactivated'): parts.append(f"{r['deactivated']} op inactief gezet") return 'Import klaar: ' + ', '.join(parts) + '.' def products_filter(source): term = source.get('q', '').strip() show = source.get('show', 'active') sql, params = '', [] if term: sql += ' AND (name LIKE ? OR sku LIKE ? OR supplier LIKE ?)' params += [f'%{term}%'] * 3 if show in ('active', 'inactive'): sql += ' AND active=?' params.append(1 if show == 'active' else 0) return term, show, sql, params @app.route('/products') def products(): term, show, where, params = products_filter(request.args) rows = db().execute(f'SELECT * FROM products WHERE 1=1{where} ORDER BY name', params).fetchall() return render_template('products.html', rows=rows, term=term, show=show) def products_csv(where, params): """In het formaat van de import (op SKU terug te lezen), met daarachter de overige velden.""" header = ['sku', 'naam', 'omschrijving', 'eenheid', 'prijs', 'btw', 'leverancier', 'actief', 'bijgewerkt', 'id', 'leverancier_id', 'afbeelding', 'aangemaakt', 'in_offertes'] rows = db().execute('SELECT p.*, (SELECT COUNT(DISTINCT quote_id) FROM quote_lines WHERE product_id=p.id) AS used ' f'FROM products p WHERE 1=1{where} ORDER BY sku, name', params).fetchall() return csv_response('producten', header, [ [p['sku'], p['name'], p['description'], p['unit'], f"{p['price']:.2f}".replace('.', ','), fmt_number(p['vat']), p['supplier'], 'ja' if p['active'] else 'nee', (p['updated_at'] or p['created_at'])[:10], p['id'], p['supplier_id'], p['image'], p['created_at'], p['used']] for p in rows]) @app.route('/products/export.csv') def products_export(): # zonder filters (zoals vroeger) alle producten, ook de inactieve _, _, where, params = products_filter({'show': 'all', **request.args.to_dict()}) return products_csv(where, params) @app.route('/products/new', methods=['GET', 'POST'], defaults={'pid': None}) @app.route('/products//edit', methods=['GET', 'POST']) def product_edit(pid): p = get_or_404('products', pid) if pid else None if request.method == 'POST': f = request.form data = {k: f.get(k, '') for k in PRODUCT_FIELDS} data['active'] = 1 if f.get('active') else 0 sku = data['sku'].strip() clash = find_product(sku) if sku else None error = None if not data['name'].strip(): error = 'Vul een productnaam in.' elif clash and (not p or clash['id'] != p['id']): error = f"SKU {sku} is al in gebruik bij {clash['name']}." if error: flash(error, 'error') return render_template('product_form.html', p={**(dict(p) if p else {}), **data}, history=history_for(pid) if pid else []) if p: # leeggemaakte tekstvelden moeten ook leeg kunnen worden for key in ('sku', 'description', 'supplier'): if not data[key].strip() and p[key]: db().execute(f'UPDATE products SET {key}=? WHERE id=?', ('', pid)) if key == 'supplier': db().execute('UPDATE products SET supplier_id=NULL WHERE id=?', (pid,)) product_history(pid, key, p[key], '', 'Handmatig') p = get_or_404('products', pid) outcome, _, pid = upsert_product(data, 'Handmatig', existing=p) image = save_upload(request.files.get('image')) if image: old = q1('SELECT image FROM products WHERE id=?', pid)['image'] remove_upload(old) db().execute('UPDATE products SET image=?, updated_at=? WHERE id=?', (image, now(), pid)) product_history(pid, 'image', '', 'nieuwe afbeelding', 'Handmatig') db().commit() flash('Product opgeslagen.' if outcome != 'unchanged' or image else 'Geen wijzigingen.') return redirect(url_for('products')) return render_template('product_form.html', p=p or {'unit': 'stuk', 'vat': 21, 'active': 1}, suppliers=q("SELECT id, name FROM relations WHERE kind='partner' ORDER BY name"), history=history_for(pid) if pid else [], used=scalar('SELECT COUNT(DISTINCT quote_id) FROM quote_lines WHERE product_id=?', pid) if pid else 0) def history_for(pid): return q('SELECT h.*, u.name AS user_name FROM product_history h LEFT JOIN users u ON u.id=h.user_id ' 'WHERE h.product_id=? ORDER BY h.created_at DESC, h.id DESC LIMIT 100', pid) def set_product_active(p, active, source): if bool(p['active']) != active: db().execute('UPDATE products SET active=?, updated_at=? WHERE id=?', (int(active), now(), p['id'])) product_history(p['id'], 'active', p['active'], int(active), source) return True return False def delete_product(p): """Verwijder een product; staat het in offertes, dan wordt het gearchiveerd (koppeling en historie blijven).""" if scalar('SELECT COUNT(*) FROM quote_lines WHERE product_id=?', p['id']): set_product_active(p, False, 'Handmatig (gearchiveerd)') return False remove_upload(p['image']) db().execute('DELETE FROM products WHERE id=?', (p['id'],)) return True @app.post('/products//delete') def product_delete(pid): p = get_or_404('products', pid) deleted = delete_product(p) db().commit() flash('Product verwijderd.' if deleted else 'Dit product staat in offertes en is daarom op inactief gezet in plaats van verwijderd.') return redirect(url_for('products')) @app.post('/products/bulk') def products_bulk(): ids, action = bulk_ids(), request.form.get('action') if not ids: flash('Vink eerst een of meer producten aan.', 'error') return bulk_back(url_for('products')) if action == 'export': return products_csv(f' AND p.id IN {id_list(ids)}', ids) rows = q(f'SELECT * FROM products WHERE id IN {id_list(ids)}', *ids) if action in ('activate', 'deactivate'): n = sum(set_product_active(p, action == 'activate', 'Handmatig (massa-actie)') for p in rows) msg = f"{n} product(en) op {'actief' if action == 'activate' else 'inactief'} gezet." elif action == 'delete': deleted = sum(delete_product(p) for p in rows) msg = f'{deleted} product(en) verwijderd.' if deleted < len(rows): msg += f' {len(rows) - deleted} staan in offertes en zijn op inactief gezet.' else: flash('Kies een actie.', 'error') return bulk_back(url_for('products')) db().commit() flash(msg) return bulk_back(url_for('products')) # ---------------------------------------------------------------- offertes def next_quote_number(): base = f"{get_setting('quote_prefix', 'OFF') or 'OFF'}-{date.today().year}-" n = 1 for r in q('SELECT number FROM quotes WHERE number LIKE ?', base + '%'): tail = r['number'][len(base):] if tail.isdigit(): n = max(n, int(tail) + 1) return f'{base}{n:04d}' @app.route('/quotes') def quotes(): status = request.args.get('status', '') rows = quotes_with_totals('WHERE qu.status=?', status) if status else quotes_with_totals() return render_template('quotes.html', rows=rows, status=status) def quotes_csv(rows): """Alle velden van de offertes (zonder het bestek zelf), met klant, aantal regels en de bedragen.""" cols = table_columns('quotes', ('bestek',)) header = [*cols, 'status_label', 'klant', 'klant_email', 'regels', 'bestek', 'subtotaal', 'btw', 'totaal'] extra = {r['id']: r for r in q('SELECT qu.id, c.email, (SELECT COUNT(*) FROM quote_lines WHERE quote_id=qu.id) AS n, ' "COALESCE(qu.bestek, '') <> '' AS has_bestek " 'FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id')} return csv_response('offertes', header, [ [*(r[c] for c in cols), dict(QUOTE_STATUSES).get(r['status'], r['status']), r['contact_name'], extra[r['id']]['email'], extra[r['id']]['n'], 'ja' if extra[r['id']]['has_bestek'] else '', round(r['subtotal'], 2), round(r['total'] - r['subtotal'], 2), round(r['total'], 2)] for r in rows]) @app.route('/quotes/export.csv') def quotes_export(): status = request.args.get('status', '') return quotes_csv(quotes_with_totals('WHERE qu.status=?', status) if status else quotes_with_totals()) @app.post('/quotes/bulk') def quotes_bulk(): ids, action = bulk_ids(), request.form.get('action') if not ids: flash('Vink eerst een of meer offertes aan.', 'error') return bulk_back(url_for('quotes')) if action == 'export': return quotes_csv(quotes_with_totals(f'WHERE qu.id IN {id_list(ids)}', *ids)) rows = q(f'SELECT * FROM quotes WHERE id IN {id_list(ids)}', *ids) if action == 'status' and request.form.get('status') in dict(QUOTE_STATUSES): status = request.form['status'] for qt in rows: change_quote_status(qt, status) msg = f"{len(rows)} offerte(s) op {dict(QUOTE_STATUSES)[status]} gezet." elif action == 'delete': db().execute(f'DELETE FROM quotes WHERE id IN {id_list(ids)}', ids) msg = f'{len(rows)} offerte(s) verwijderd.' else: flash('Kies een actie.', 'error') return bulk_back(url_for('quotes')) db().commit() flash(msg) return bulk_back(url_for('quotes')) @app.post('/quotes/new') def quote_new(): cid = request.form.get('contact_id', type=int) today = date.today() valid = today + timedelta(days=int(num(get_setting('quote_valid_days', '30'), 30))) number, ts = next_quote_number(), now() cur = db().execute('INSERT INTO quotes (number, contact_id, status, date, valid_until, intro, notes, ' 'created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', (number, cid, 'concept', today.isoformat(), valid.isoformat(), get_setting('quote_intro'), '', ts, ts)) if cid: log(cid, 'quote', f'Offerte {number} aangemaakt') db().commit() if request.form.get('bestek'): return redirect(url_for('quote_bestek', qid=cur.lastrowid)) return redirect(url_for('quote_edit', qid=cur.lastrowid)) @app.route('/quotes/', methods=['GET', 'POST']) def quote_edit(qid): qt = get_or_404('quotes', qid) if request.method == 'POST': f = request.form cid = f.get('contact_id', type=int) db().execute('UPDATE quotes SET contact_id=?, title=?, date=?, valid_until=?, intro=?, notes=?, updated_at=? ' 'WHERE id=?', (cid, f.get('title', '').strip(), f.get('date') or qt['date'], f.get('valid_until') or None, f.get('intro', ''), f.get('notes', ''), now(), qid)) db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,)) lines = zip(f.getlist('l_product'), f.getlist('l_desc'), f.getlist('l_qty'), f.getlist('l_unit'), f.getlist('l_price'), f.getlist('l_vat')) for pos, (product, desc, qty, unit, price, vat) in enumerate(lines): if not desc.strip() and not num(price): continue db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) ' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, int(product) if product.isdigit() else None, desc.strip(), num(qty, 1), unit.strip(), num(price), num(vat, 21))) if f.get('status') in dict(QUOTE_STATUSES): change_quote_status(get_or_404('quotes', qid), f['status']) db().commit() if f.get('then') == 'print': return redirect(url_for('quote_print', qid=qid)) flash('Offerte opgeslagen.') return redirect(url_for('quote_edit', qid=qid)) return render_template( 'quote_edit.html', qt=qt, bestek=quote_bestek_data(qt), designs=contact_designs(qt['contact_id']) if qt['contact_id'] else [], lines=q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qid), contacts=q('SELECT id, name, company FROM contacts ORDER BY name'), products=q('SELECT * FROM products WHERE active=1 ORDER BY name'), prices={r['id']: r['price'] for r in q('SELECT id, price FROM products')}) def render_quote(qt, portal=False): lines = q('SELECT * FROM quote_lines WHERE quote_id=? ORDER BY position', qt['id']) contact = q1('SELECT c.*, r.name AS relation_name, r.kind AS relation_kind FROM contacts c ' 'LEFT JOIN relations r ON r.id=c.relation_id WHERE c.id=?', qt['contact_id']) if qt['contact_id'] else None return render_template('quote_portal.html' if portal else 'quote_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines), s=settings_dict(), portal=portal, bestek=quote_bestek_data(qt)) @app.route('/quotes//print') def quote_print(qid): return render_quote(get_or_404('quotes', qid)) # Bestek uit het woningontwerp: de berekening draait in de browser op de engine van de configurator # (static/bestek.js); hier worden het bestek bewaard en de offerteregels gemaakt. def quote_bestek_data(qt): try: data = json.loads(qt['bestek'] or 'null') except ValueError: return None return data if isinstance(data, dict) and isinstance(data.get('chapters'), list) else None def fmt_qty(value, unit=''): value = round(float(value or 0), 2 if unit == 'm³' else 1) return f'{value:g}'.replace('.', ',') def clean_bestek(raw): """Bestek uit het formulier controleren en tot de bekende velden terugbrengen.""" try: data = json.loads(raw or '') except ValueError: return None if not isinstance(data, dict) or not isinstance(data.get('chapters'), list): return None def product(p): if not isinstance(p, dict) or not str(p.get('sku') or '').strip(): return None return {k: str(p.get(k) or '').strip()[:limit] for k, limit in (('sku', 80), ('name', 200), ('description', 1000), ('supplier', 200), ('article', 80))} def line(l): out = {'label': str(l.get('label') or '')[:300], 'qty': num(l.get('qty')), 'unit': str(l.get('unit') or '')[:20], 'price': round(num(l.get('price')), 2), 'unitPrice': round(num(l.get('unitPrice')), 4)} if product(l.get('product')): out['product'] = product(l['product']) return out chapters = [{'key': str(ch.get('key') or '')[:40], 'name': str(ch.get('name') or '')[:120], 'price': round(num(ch.get('price')), 2), 'lines': [line(l) for l in ch.get('lines') or [] if isinstance(l, dict)], 'info': [line(l) for l in ch.get('info') or [] if isinstance(l, dict)]} for ch in data['chapters'] if isinstance(ch, dict)] if not chapters: return None totals = data.get('totals') if isinstance(data.get('totals'), dict) else {} design = data.get('design') if isinstance(data.get('design'), dict) else {} return {'version': 1, 'computedAt': str(data.get('computedAt') or now())[:30], 'vat': num(data.get('vat'), 21), 'units': int(num(data.get('units'))), 'configurator': str(data.get('configurator') or '')[:20], 'design': {'file_id': design.get('file_id'), 'title': str(design.get('title') or '')[:120]}, 'totals': {k: round(num(totals.get(k)), 2) for k in ('exBtw', 'btw', 'inclBtw')}, 'chapters': chapters} def bestek_rows(b, mode='post'): """Offerteregels uit een bestek: dicts met desc, qty, unit, price (per eenheid) en product (of None). post: elke post met zijn hoeveelheid (42 spots = 42 st. × prijs per spot); hoofdstuk: één regel per hoofdstuk. De prijs per eenheid is afgerond op centen; het verschil met het hoofdstuk gaat naar de laatste regel van 1 stuk/post zonder product (bevestiging en bewerking, plaatsen met kraan), wat dan nog over is naar de laatste zo'n regel van de offerte, zodat het totaal precies dat van de configurator is.""" def absorb(rows, target): diff = round(target - sum(r['qty'] * r['price'] for r in rows), 2) spot = next((r for r in reversed(rows) if r['qty'] == 1 and not r['product']), None) or \ next((r for r in reversed(rows) if r['qty'] == 1), None) if spot and diff: spot['price'] = round(spot['price'] + diff, 2) if mode == 'hoofdstuk': rows = [{'desc': ch['name'] + '\n' + ', '.join(f"{l['label']} {fmt_qty(l['qty'], l['unit'])} {l['unit']}".strip() for l in ch['lines']), 'qty': 1, 'unit': 'post', 'price': ch['price'], 'product': None} for ch in b['chapters']] else: rows = [] for ch in b['chapters']: part = [{'desc': f"{l['label']}\n{ch['name']}", 'qty': l['qty'] or 1, 'unit': l['unit'], 'price': round(l.get('unitPrice') or (l['price'] / l['qty'] if l['qty'] else l['price']), 2), 'product': l.get('product')} for l in ch['lines']] absorb(part, ch['price']) rows += part absorb(rows, b['totals'].get('exBtw') or sum(ch['price'] for ch in b['chapters'])) return rows def bestek_product(row, vat, update_price, source): """Product bij een bestekpost (SKU = code uit de kosten van de configurator); bestaat het nog niet, dan wordt het aangemaakt met de prijs per eenheid van deze berekening.""" p = row['product'] existing = find_product(p['sku']) if existing is None: description = '\n'.join(filter(None, [p['description'], f"Artikelnummer leverancier: {p['article']}" if p['article'] else ''])) return upsert_product({'sku': p['sku'], 'name': p['name'] or row['desc'].split('\n')[0], 'unit': row['unit'], 'price': row['price'], 'vat': vat, 'description': description, 'supplier': p['supplier']}, source)[2] if update_price: upsert_product({'sku': p['sku'], 'price': row['price']}, source, existing=existing) return existing['id'] @app.route('/quotes//bestek') def quote_bestek(qid): qt = get_or_404('quotes', qid) contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None designs = contact_designs(contact['id']) if contact else [] chosen = chosen_design_file(contact) want = request.args.get('file', type=int) or qt['design_file_id'] or (chosen['id'] if chosen else None) f = next((d for d in designs if d['id'] == want), designs[0] if designs else None) if not f: flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error') return redirect(url_for('quote_edit', qid=qid)) design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404) return render_template('bestek.html', qt=qt, c=contact, design=design, design_file=f, designs=designs, scripts=VIEWER_SCRIPTS, has_lines=scalar('SELECT COUNT(*) FROM quote_lines WHERE quote_id=?', qid), engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))) @app.post('/quotes//bestek') def quote_bestek_save(qid): qt = get_or_404('quotes', qid) b = clean_bestek(request.form.get('bestek')) if not b: flash('Het bestek kon niet worden overgenomen.', 'error') return redirect(url_for('quote_bestek', qid=qid)) fid = request.form.get('file_id', type=int) if qt['contact_id'] and fid not in {d['id'] for d in contact_designs(qt['contact_id'])}: fid = None vat = b['vat'] rows = bestek_rows(b, request.form.get('mode', 'post')) if request.form.get('replace'): db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,)) start = scalar('SELECT COALESCE(MAX(position), -1) + 1 FROM quote_lines WHERE quote_id=?', qid) source, linked = f"Bestek offerte {qt['number']}", 0 for pos, r in enumerate(rows, start): pid = bestek_product(r, vat, bool(request.form.get('update_prices')), source) if r['product'] else None linked += bool(pid) db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) ' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, pid, r['desc'], r['qty'], r['unit'], r['price'], vat)) title = qt['title'] or (f"Woning {b['design']['title']}" if b['design']['title'] else '') db().execute('UPDATE quotes SET bestek=?, design_file_id=?, title=?, updated_at=? WHERE id=?', (json.dumps(b, ensure_ascii=False), fid, title, now(), qid)) if qt['contact_id']: log(qt['contact_id'], 'quote', f"Offerte {qt['number']}: bestek uit ontwerp {b['design']['title']} " f"({euro(b['totals']['exBtw'])} excl. btw)") db().commit() flash(f'Bestek overgenomen: {len(rows)} regel(s), waarvan {linked} gekoppeld aan een product.') return redirect(url_for('quote_edit', qid=qid)) @app.post('/quotes//bestek/delete') def quote_bestek_delete(qid): get_or_404('quotes', qid) db().execute("UPDATE quotes SET bestek='', design_file_id=NULL, updated_at=? WHERE id=?", (now(), qid)) db().commit() flash('Bestek losgekoppeld; de offerteregels zijn gebleven.') return redirect(url_for('quote_edit', qid=qid)) @app.post('/quotes//delete') def quote_delete(qid): get_or_404('quotes', qid) db().execute('DELETE FROM quotes WHERE id=?', (qid,)) db().commit() flash('Offerte verwijderd.') return redirect(url_for('quotes')) # ---------------------------------------------------------------- instellingen @app.route('/settings', methods=['GET', 'POST']) def settings(): if request.method == 'POST': action = request.form.get('action') if action == 'apikey': set_setting('api_key', secrets.token_urlsafe(24)) flash('Nieuwe API-sleutel aangemaakt. Pas je formulieren aan.') else: for key in SETTING_KEYS: set_setting(key, request.form.get(key, '').strip()) flash('Instellingen opgeslagen.') db().commit() return redirect(url_for('settings')) mailboxes = q('SELECT m.*, (SELECT GROUP_CONCAT(u.name, \', \') FROM mailbox_users mu JOIN users u ON u.id=mu.user_id ' 'WHERE mu.mailbox_id=m.id) AS access FROM mailboxes m ORDER BY m.active DESC, m.name COLLATE NOCASE') return render_template('settings.html', s=settings_dict(), api_url=url_for('api_leads', _external=True), portal_url=url_for('portal_login', _external=True), mailboxes=mailboxes, PURPOSE=dict(MAILBOX_PURPOSES)) # ---------------------------------------------------------------- klantportaal @app.route('/portal/login', methods=['GET', 'POST']) def portal_login(): if request.method == 'POST': c = q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE AND portal_password_hash IS NOT NULL', request.form.get('email', '').strip()) if c and check_password_hash(c['portal_password_hash'], request.form.get('password', '')): session['portal_cid'] = c['id'] session.permanent = True db().execute('UPDATE contacts SET portal_last_login=? WHERE id=?', (now(), c['id'])) db().commit() return redirect(url_for('portal_home')) flash('Onjuist e-mailadres of wachtwoord.', 'error') return render_template('auth.html', mode='portal_login') @app.route('/portal/logout') def portal_logout(): session.pop('portal_cid', None) return redirect(url_for('portal_login')) @app.route('/portal/invite/', methods=['GET', 'POST']) def portal_invite(token): c = q1('SELECT * FROM contacts WHERE portal_token=?', token) if not c: flash('Deze uitnodiging is niet (meer) geldig. Vraag een nieuwe aan.', 'error') return redirect(url_for('portal_login')) if request.method == 'POST': pw = request.form.get('password', '') if len(pw) < 8 or pw != request.form.get('password2'): flash('Het wachtwoord moet minimaal 8 tekens hebben en twee keer hetzelfde zijn.', 'error') else: db().execute('UPDATE contacts SET portal_password_hash=?, portal_token=NULL, portal_last_login=? ' 'WHERE id=?', (generate_password_hash(pw), now(), c['id'])) log(c['id'], 'portal', 'Klant heeft het portaal geactiveerd') db().commit() session['portal_cid'] = c['id'] session.permanent = True return redirect(url_for('portal_home')) return render_template('auth.html', mode='invite', c=c) @app.route('/portal') def portal_home(): cid = g.portal['id'] return render_template( 'portal.html', c=g.portal, quotes=quotes_with_totals("WHERE qu.contact_id=? AND qu.status<>'concept'", 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()) def portal_quote_or_404(qid): qt = q1("SELECT * FROM quotes WHERE id=? AND contact_id=? AND status<>'concept'", qid, g.portal['id']) if qt is None: abort(404) return qt @app.route('/portal/quotes/') def portal_quote(qid): return render_quote(portal_quote_or_404(qid), portal=True) @app.post('/portal/quotes//') def portal_quote_answer(qid, choice): qt = portal_quote_or_404(qid) status = {'accept': 'geaccepteerd', 'decline': 'afgewezen'}.get(choice) if status and qt['status'] == 'verzonden': change_quote_status(qt, status) log(qt['contact_id'], 'portal', f"Klant heeft offerte {qt['number']} {'geaccepteerd' if status == 'geaccepteerd' else 'afgewezen'} via het portaal") db().commit() flash('Bedankt, we hebben uw reactie ontvangen.') return redirect(url_for('portal_home')) @app.post('/portal/upload') def portal_upload(): cid = g.portal['id'] count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid)) if count: log(cid, 'portal', f'Klant heeft {count} bestand(en) geüpload') db().commit() flash('Bestanden ontvangen.') return redirect(url_for('portal_home')) @app.post('/portal/message') def portal_message(): body = request.form.get('body', '').strip() if body: log(g.portal['id'], 'portal', f'Bericht: {body}') db().commit() flash('Bericht verstuurd. We nemen zo snel mogelijk contact op.') return redirect(url_for('portal_home')) @app.route('/portal/files/') def portal_file(name): 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']) # ---------------------------------------------------------------- API def allowed_origins(): return [o.strip().rstrip('/') for o in get_setting('allowed_origins').split(',') if o.strip()] def cors(resp): origin = request.headers.get('Origin') allowed = allowed_origins() if origin and (not allowed or origin in allowed): resp.headers['Access-Control-Allow-Origin'] = origin resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-API-Key' resp.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' resp.headers['Vary'] = 'Origin' return resp def api_error(message, code): return cors(jsonify(ok=False, error=message)), code @app.route('/api/leads', methods=['POST', 'OPTIONS']) def api_leads(): """Nieuwe lead vanuit een formulier. Accepteert JSON, gewone formulieren en multipart (met bestanden).""" if request.method == 'OPTIONS': return cors(make_response('', 204)) data = (request.get_json(silent=True) or {}) if request.is_json else request.form.to_dict() if not isinstance(data, dict): return api_error('Verwacht een JSON-object', 400) key = request.headers.get('X-API-Key') or request.args.get('key') or str(data.get('api_key') or '') if not key or not secrets.compare_digest(key, get_setting('api_key')): return api_error('Ongeldige API-sleutel', 401) goto = str(data.get('redirect') or '') allowed = allowed_origins() goto_ok = goto.startswith(('https://', 'http://')) and ( not allowed or any(goto == o or goto.startswith(o + '/') for o in allowed)) def done(contact_id=None): if goto_ok and not request.is_json: return redirect(goto, 303) return cors(jsonify(ok=True, id=contact_id)) if data.get('_gotcha') or data.get('website_hp'): # honeypot: spam stil negeren return done() fields, extra, designs = {}, {}, [] for k, v in data.items(): if k in API_SKIP: continue if isinstance(v, dict) and v.get('type') == 'house-design': designs.append(v) continue field = FIELD_LOOKUP.get(k.strip().lower()) if field and isinstance(v, (str, int, float)): fields.setdefault(field, v) else: extra[k] = v message = str(fields.pop('message', '')).strip() referer = request.headers.get('Origin') or request.headers.get('Referer') or '' assign = get_setting('api_assign_user') defaults = {'source': urlparse(referer).netloc or 'API', 'assigned': int(assign) if assign.isdigit() else None} cid, outcome = upsert_contact(fields, extra, True, defaults) if not cid: return api_error('Geef minimaal een naam, e-mailadres of telefoonnummer mee', 400) source = q1('SELECT source FROM contacts WHERE id=?', cid)['source'] body = [f'Formulier ontvangen via {source}' + (' (bestaand contact)' if outcome == 'updated' else '')] if message: body.append(message) body += [f'{k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}' for k, v in extra.items()] log(cid, 'form', '\n'.join(body)) if outcome == 'updated': db().execute("UPDATE contacts SET status='nieuw' WHERE id=? AND status IN ('verloren', 'vervallen')", (cid,)) for design in designs: save_design_json(design, cid) count = sum(1 for name in request.files for fs in request.files.getlist(name) if save_upload(fs, cid)) if count: log(cid, 'file', f'{count} bestand(en) meegestuurd met formulier') db().commit() return done(cid) @app.route('/api/products', methods=['POST']) def api_products(): """Producten aanmaken of bijwerken op SKU, bijv. vanuit prijsscripts van leveranciers. Body: één object, een lijst, of {"products": [...]}; veldnamen zoals bij de import (sku/code, naam/omschrijving, prijs, eenheid, btw, leverancier, actief). """ key = request.headers.get('X-API-Key') or request.args.get('key') or '' if not key or not secrets.compare_digest(key, get_setting('api_key')): return jsonify(ok=False, error='Ongeldige API-sleutel'), 401 data = request.get_json(silent=True) if isinstance(data, dict): lists = [v for v in data.values() if isinstance(v, list)] data = lists[0] if lists else [data] if not isinstance(data, list): return jsonify(ok=False, error='Verwacht JSON: een object of een lijst met producten'), 400 table = importer.lookup(importer.PRODUCT_ALIASES) records = [] for item in data: if isinstance(item, dict): record = {} for k, v in item.items(): field = table.get(str(k).strip().lower().replace(' ', '_')) if field and isinstance(v, (str, int, float)): record.setdefault(field, v) records.append(record) result = import_products(records, request.args.get('source') or 'API', deactivate_missing=request.args.get('deactivate_missing') == '1') db().commit() return jsonify(ok=True, **result) # ---------------------------------------------------------------- zoeken # # Eén zoekveld voor alles (bovenbalk): live resultaten via /search/live, de volledige lijst op /search. def search_groups(term, limit): like = '%' + term.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%' digits = re.sub(r'\D', '', term) phone = f'%{digits}%' if len(digits) >= 4 else None phone_sql = "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE({0}, ' ', ''), '-', ''), '+31', '0'), '(', ''), ')', '') LIKE ?" E = " ESCAPE '\\'" def match(*cols, phone_col=None): sql = ' OR '.join(f'{c} LIKE ?{E}' for c in cols) args = [like] * len(cols) if phone and phone_col: sql += ' OR ' + phone_sql.format(phone_col) args.append(phone) return f'({sql})', args groups = [] def add(label, rows, make): if rows: groups.append({'label': label, 'items': [make(r) for r in rows[:limit]], 'more': len(rows) > limit}) cond, args = match('c.name', 'c.email', 'c.company', 'c.city', 'c.postcode', 'c.address', 'r.name', phone_col='c.phone') for label, kind in (('Leads', "c.type='lead'"), ('Contactpersonen', "c.type<>'lead'")): rows = q(f'SELECT c.id, c.name, c.email, c.city, c.type, c.status, r.name AS relation_name FROM contacts c ' f'LEFT JOIN relations r ON r.id=c.relation_id WHERE {kind} AND {cond} ORDER BY c.updated_at DESC LIMIT ?', *args, limit + 1) add(label, rows, lambda r: {'title': r['name'] or r['email'], 'url': url_for('contact_view', cid=r['id']), 'sub': ' · '.join(filter(None, [dict(STATUSES).get(r['status']) if r['type'] == 'lead' else dict(TYPES).get(r['type']), r['relation_name'], r['email'], r['city']]))}) cond, args = match('name', 'email', 'city', 'kvk', 'address', phone_col='phone') add('Klanten en partners', q(f'SELECT * FROM relations WHERE {cond} ORDER BY name LIMIT ?', *args, limit + 1), lambda r: {'title': r['name'], 'url': url_for('relation_view', rid=r['id']), 'sub': ' · '.join(filter(None, [dict(RELATION_KINDS).get(r['kind']), r['type'], r['city']]))}) cond, args = match('qu.number', 'qu.title', 'c.name') add('Offertes', q(f'SELECT qu.*, c.name AS contact_name FROM quotes qu LEFT JOIN contacts c ON c.id=qu.contact_id ' f'WHERE {cond} ORDER BY qu.date DESC, qu.id DESC LIMIT ?', *args, limit + 1), lambda r: {'title': ' · '.join(filter(None, [r['number'], r['title']])), 'url': url_for('quote_edit', qid=r['id']), 'sub': ' · '.join(filter(None, [dict(QUOTE_STATUSES).get(r['status']), r['contact_name'], fmt_dt(r['date'], '%d-%m-%Y')]))}) cond, args = match('e.title', 'e.location', 'e.description', 'c.name') add('Afspraken', q(f'SELECT e.*, u.name AS user_name, c.name AS contact_name FROM events e JOIN users u ON u.id=e.user_id ' f'LEFT JOIN contacts c ON c.id=e.contact_id WHERE {cond} ORDER BY e.starts_at DESC LIMIT ?', *args, limit + 1), lambda r: {'title': r['title'] or '(geen titel)', 'url': url_for('calendar_edit', eid=r['id']), 'sub': ' · '.join(filter(None, [event_when(r), r['contact_name'], r['user_name']]))}) cond, args = match('name', 'sku', 'supplier', 'description') add('Producten', q(f'SELECT * FROM products WHERE {cond} ORDER BY active DESC, name LIMIT ?', *args, limit + 1), lambda r: {'title': r['name'], 'url': url_for('product_edit', pid=r['id']), 'sub': ' · '.join(filter(None, [r['sku'], euro(r['price']), '' if r['active'] else 'inactief']))}) cond, args = match('subject', 'from_addr', 'from_name', 'to_addr') add('E-mail', q(f'SELECT id, subject, from_name, from_addr, to_addr, direction, date FROM emails e ' f'WHERE ({cond}) AND {visible_mail()} ORDER BY date DESC LIMIT ?', *args, limit + 1), lambda r: {'title': r['subject'], 'url': url_for('inbox_view', eid=r['id']), 'sub': ' · '.join(filter(None, [r['from_name'] or r['from_addr'] if r['direction'] == 'in' else 'aan ' + (r['to_addr'] or ''), fmt_dt(r['date'], '%d-%m-%Y')]))}) 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] groups = search_groups(term, 50) if len(term) >= 2 else [] items = [i for grp in groups for i in grp['items']] if len(items) == 1: return redirect(items[0]['url']) return render_template('search.html', term=term, groups=groups) @app.route('/search/live') def search_live(): term = request.args.get('q', '').strip()[:100] return jsonify(q=term, url=url_for('search', q=term), groups=search_groups(term, 5) if len(term) >= 2 else []) # ---------------------------------------------------------------- agenda # # Eén agenda per gebruiker. In de CRM staan ze samen (een kleur per gebruiker) met de open taken die een datum # hebben; via CalDAV (/dav/, hieronder) koppelt elke gebruiker zijn eigen agenda aan telefoon of computer. # Afspraken uit een agenda-app worden bewaard zoals de app ze stuurde (kolom ics); in de CRM bewerken schrijft # een nieuw kalenderobject (herinneringen en uitnodigingen uit de app vervallen dan). MONTHS_NL = ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'] DAYS_NL = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo'] REPEATS = [('', 'Niet'), ('DAILY', 'Elke dag'), ('WEEKLY', 'Elke week'), ('MONTHLY', 'Elke maand'), ('YEARLY', 'Elk jaar')] def user_color(user_id): return f'u{(user_id or 0) % 6}' def day_label(d, year=False): return f"{DAYS_NL[d.weekday()]} {d.day} {MONTHS_NL[d.month - 1]}" + (f' {d.year}' if year else '') def event_when(e, start=None, end=None): """'di 20 oktober 10:00–11:00' of 'di 20 oktober (hele dag)'.""" start = start or ical.to_dt(e['starts_at']) end = end or ical.to_dt(e['ends_at'] or e['starts_at']) if e['all_day']: last = (end - timedelta(days=1)).date() if end > start else start.date() return day_label(start.date()) + (f' t/m {day_label(last)}' if last > start.date() else ' (hele dag)') if end.date() != start.date(): return f"{day_label(start.date())} {start:%H:%M} – {day_label(end.date())} {end:%H:%M}" return f"{day_label(start.date())} {start:%H:%M}–{end:%H:%M}" def bump_calendar(user_id): """Nieuwe ctag: agenda-apps zien dan dat er iets veranderd is.""" db().execute('UPDATE users SET cal_sync=cal_sync+1 WHERE id=?', (user_id,)) def save_event(user_id, ev, ics_text, href, contact_id=None, event_id=None): cols = ['user_id', 'contact_id', 'href', 'uid', 'title', 'location', 'description', 'starts_at', 'ends_at', 'all_day', 'rrule', 'exdates', 'ics', 'etag'] vals = [user_id, contact_id, href, ev['uid'], ev['title'], ev['location'], ev['description'], ev['starts_at'], ev['ends_at'], ev['all_day'], ev['rrule'], ev['exdates'], ics_text, hashlib.md5(ics_text.encode()).hexdigest()] if event_id: old = q1('SELECT user_id FROM events WHERE id=?', event_id) db().execute(f"UPDATE events SET {', '.join(c + '=?' for c in cols)}, updated_at=? WHERE id=?", (*vals, now(), event_id)) if old and old['user_id'] != user_id: bump_calendar(old['user_id']) else: event_id = db().execute(f"INSERT INTO events ({', '.join(cols)}, created_at, updated_at) " f"VALUES ({', '.join('?' * (len(cols) + 2))})", (*vals, now(), now())).lastrowid bump_calendar(user_id) return event_id def calendar_items(start, end, user_ids=None, contact_id=None, tasks=True): """Afspraken (herhalingen uitgevouwen) en open taken met een datum in [start, end), op volgorde.""" where = ["(e.rrule<>'' OR (e.starts_at < ? AND e.ends_at >= ?))"] args = [end.strftime('%Y-%m-%d %H:%M:%S'), start.strftime('%Y-%m-%d')] if user_ids: where.append(f"e.user_id IN ({', '.join('?' * len(user_ids))})") args += user_ids if contact_id: where.append('e.contact_id=?') args.append(contact_id) out = [] for e in q(f"SELECT e.*, u.name AS user_name, c.name AS contact_name FROM events e JOIN users u ON u.id=e.user_id " f"LEFT JOIN contacts c ON c.id=e.contact_id WHERE {' AND '.join(where)}", *args): try: hits = ical.occurrences(dict(e), start, end) except (ValueError, KeyError): continue for s, en in hits: out.append({'kind': 'event', 'id': e['id'], 'start': s, 'end': en, 'all_day': bool(e['all_day']), 'title': e['title'] or '(geen titel)', 'color': user_color(e['user_id']), 'user': e['user_name'], 'contact': e['contact_name'], 'contact_id': e['contact_id'], 'location': e['location'], 'repeats': bool(e['rrule']), 'when': event_when(e, s, en), 'url': url_for('calendar_edit', eid=e['id'])}) if tasks and not contact_id: where = ['t.done=0', 't.due_date >= ?', 't.due_date < ?'] args = [start.strftime('%Y-%m-%d'), end.strftime('%Y-%m-%d')] if user_ids: where.append(f"t.user_id IN ({', '.join('?' * len(user_ids))})") args += user_ids for t in q(f"SELECT t.*, u.name AS user_name, c.name AS contact_name FROM tasks t " f"LEFT JOIN users u ON u.id=t.user_id LEFT JOIN contacts c ON c.id=t.contact_id WHERE {' AND '.join(where)}", *args): d = datetime.strptime(t['due_date'][:10], '%Y-%m-%d') out.append({'kind': 'task', 'start': d, 'end': d + timedelta(days=1), 'all_day': True, 'title': t['title'], 'color': user_color(t['user_id']), 'user': t['user_name'], 'contact': t['contact_name'], 'contact_id': t['contact_id'], 'when': day_label(d.date()), 'url': url_for('contact_view', cid=t['contact_id']) if t['contact_id'] else url_for('dashboard')}) out.sort(key=lambda i: (i['start'].date(), not i['all_day'], i['start'], i['title'].lower())) return out @app.route('/agenda') def calendar_view(): try: month = datetime.strptime(request.args.get('m', ''), '%Y-%m').date() except ValueError: month = date.today().replace(day=1) who = request.args.get('u') or session.get('cal_u') or 'all' session['cal_u'] = who user_ids = [g.user['id']] if who == 'me' else ([int(who)] if who.isdigit() else None) grid_start = month - timedelta(days=month.weekday()) next_month = (month + timedelta(days=32)).replace(day=1) grid_end = next_month + timedelta(days=(7 - next_month.weekday()) % 7) by_day = {} for i in calendar_items(datetime.combine(grid_start, datetime.min.time()), datetime.combine(grid_end, datetime.min.time()), user_ids): first = i['start'].date() last = max(first, (i['end'] - timedelta(seconds=1)).date()) d = max(first, grid_start) while d <= last and d < grid_end: by_day.setdefault(d, []).append(i) d += timedelta(days=1) weeks = [[grid_start + timedelta(days=w * 7 + k) for k in range(7)] for w in range((grid_end - grid_start).days // 7)] return render_template('calendar.html', weeks=weeks, by_day=by_day, month=month, who=who, DAYS=DAYS_NL, title=f'{MONTHS_NL[month.month - 1].capitalize()} {month.year}', day_label=day_label, prev=(month - timedelta(days=1)).strftime('%Y-%m'), next=next_month.strftime('%Y-%m'), this_month=date.today().strftime('%Y-%m'), user_color=user_color) def event_form_values(e): start, end = ical.to_dt(e['starts_at']), ical.to_dt(e['ends_at'] or e['starts_at']) if e['all_day']: end = max(start, end - timedelta(days=1)) return {'title': e['title'], 'user_id': e['user_id'], 'contact_id': e['contact_id'], 'location': e['location'], 'description': e['description'], 'all_day': 'on' if e['all_day'] else '', 'start_date': start.date().isoformat(), 'start_time': start.strftime('%H:%M'), 'end_date': end.date().isoformat(), 'end_time': end.strftime('%H:%M'), 'repeat': ical.simple_freq(e['rrule']) or ('keep' if e['rrule'] else '')} def event_from_form(f, existing=None): title = f.get('title', '').strip() if not title: return None, 'Vul een titel in.' try: sd = date.fromisoformat(f.get('start_date', '')) ed = date.fromisoformat(f.get('end_date') or f.get('start_date', '')) all_day = bool(f.get('all_day')) if all_day: starts, ends = sd.isoformat(), (max(sd, ed) + timedelta(days=1)).isoformat() else: st = datetime.combine(sd, datetime.strptime((f.get('start_time') or '09:00')[:5], '%H:%M').time()) en = (datetime.combine(ed, datetime.strptime(f['end_time'][:5], '%H:%M').time()) if f.get('end_time') else st + timedelta(hours=1)) if en <= st: en = st + timedelta(hours=1) starts, ends = st.strftime('%Y-%m-%d %H:%M:%S'), en.strftime('%Y-%m-%d %H:%M:%S') except ValueError: return None, 'Controleer de datum en de tijd.' repeat = f.get('repeat', '') if repeat == 'keep' and existing: rrule = existing['rrule'] else: rrule = f'FREQ={repeat}' if repeat in dict(REPEATS) and repeat else '' keep_ex = existing and rrule and rrule == existing['rrule'] and bool(existing['all_day']) == all_day \ and starts[11:] == existing['starts_at'][11:] return {'title': title, 'location': f.get('location', '').strip(), 'description': f.get('description', '').strip(), 'starts_at': starts, 'ends_at': ends, 'all_day': 1 if all_day else 0, 'rrule': rrule, 'exdates': existing['exdates'] if keep_ex else ''}, None @app.route('/agenda/new', methods=['GET', 'POST'], defaults={'eid': None}) @app.route('/agenda/', methods=['GET', 'POST']) def calendar_edit(eid): e = q1('SELECT e.*, c.name AS contact_name FROM events e LEFT JOIN contacts c ON c.id=e.contact_id WHERE e.id=?', eid) or abort(404) if eid else None contacts = q("SELECT id, name, type FROM contacts ORDER BY type='lead' DESC, name") nxt = request.values.get('next', '') nxt = nxt if nxt.startswith('/') and not nxt.startswith('//') else '' if request.method == 'POST': f = request.form ev, error = event_from_form(f, e) if error: flash(error, 'error') return render_template('event_form.html', e=e, v=f, contacts=contacts, REPEATS=REPEATS, nxt=nxt) user_id = f.get('user_id', type=int) if not user_id or not q1('SELECT id FROM users WHERE id=?', user_id): user_id = g.user['id'] contact_id = f.get('contact_id', type=int) or None if contact_id and not q1('SELECT id FROM contacts WHERE id=?', contact_id): contact_id = None ev['uid'] = e['uid'] if e else f'{uuid.uuid4()}@vanemous-crm' href = e['href'] if e else f"{ev['uid']}.ics" ics_text = ical.to_ics(ev, url_for('contact_view', cid=contact_id, _external=True) if contact_id else '') eid = save_event(user_id, ev, ics_text, href, contact_id, eid) if contact_id and (not e or e['contact_id'] != contact_id): log(contact_id, 'event', f"Afspraak: {ev['title']}, {event_when(ev)}") db().commit() flash('Afspraak opgeslagen.') return redirect(nxt or url_for('calendar_view', m=ev['starts_at'][:7])) if e: v = event_form_values(e) else: d = request.args.get('date') or date.today().isoformat() hour = min(datetime.now().hour + 1, 22) if d == date.today().isoformat() else 9 v = {'title': '', 'user_id': g.user['id'], 'contact_id': request.args.get('contact_id', type=int), 'location': '', 'description': '', 'all_day': '', 'start_date': d, 'start_time': f'{hour:02d}:00', 'end_date': d, 'end_time': f'{hour + 1:02d}:00', 'repeat': ''} c = q1('SELECT name, address, postcode, city FROM contacts WHERE id=?', v['contact_id']) if v['contact_id'] else None if c: v['title'] = f"Afspraak {c['name']}" v['location'] = ', '.join(filter(None, [c['address'], ' '.join(filter(None, [c['postcode'], c['city']]))])) return render_template('event_form.html', e=e, v=v, contacts=contacts, REPEATS=REPEATS, nxt=nxt) @app.post('/agenda//delete') def calendar_delete(eid): e = get_or_404('events', eid) db().execute('DELETE FROM events WHERE id=?', (eid,)) bump_calendar(e['user_id']) db().commit() flash('Afspraak verwijderd.') return redirect(url_for('calendar_view', m=e['starts_at'][:7])) # ---------------------------------------------------------------- CalDAV # # Elke gebruiker koppelt zijn eigen agenda met een app-wachtwoord (Mijn account): iPhone/Mac als CalDAV-account # (server = het CRM-domein; vindt /dav/ via /.well-known/caldav), Thunderbird en DAVx5 (Android) met # https://domein/dav/. Ondersteund: PROPFIND, PROPPATCH (genegeerd), REPORT calendar-query (met time-range) en # calendar-multiget, GET/PUT/DELETE van afspraken met ETags; apps synchroniseren via getctag en getetag. # Paden: /dav/ → /dav/principals// → /dav/calendars// → /dav/calendars//agenda/.ics D, C, CS = '{DAV:}', '{urn:ietf:params:xml:ns:caldav}', '{http://calendarserver.org/ns/}' A = '{http://apple.com/ns/ical/}' DAV_PREFIX = {'DAV:': 'D', 'urn:ietf:params:xml:ns:caldav': 'C', 'http://calendarserver.org/ns/': 'CS', 'http://apple.com/ns/ical/': 'A'} DAV_METHODS = ['OPTIONS', 'GET', 'HEAD', 'PUT', 'DELETE', 'PROPFIND', 'PROPPATCH', 'REPORT', 'MKCALENDAR', 'MKCOL', 'MOVE', 'COPY'] DAV_CALENDAR = 'agenda' DAV_MAX_SIZE = 1024 * 1024 def dav_el(clark, value=None): ns, _, local = clark[1:].partition('}') prefix = DAV_PREFIX.get(ns) tag, decl = (f'{prefix}:{local}', '') if prefix else (f'X:{local}', f' xmlns:X="{xml_escape(ns)}"') return f'<{tag}{decl}/>' if value in (None, '') else f'<{tag}{decl}>{value}' def dav_href(path): return f'{xml_escape(path)}' def dav_response(href, found=None, missing=(), status=None): out = '' + dav_href(href) if status: out += f'HTTP/1.1 {status}' if found: out += ('' + ''.join(dav_el(k, v) for k, v in found.items()) + 'HTTP/1.1 200 OK') if missing: out += ('' + ''.join(dav_el(k) for k in missing) + 'HTTP/1.1 404 Not Found') return out + '' def dav_multistatus(responses): body = ('\n' + ''.join(responses) + '') return make_response(body, 207, {'Content-Type': 'application/xml; charset=utf-8'}) def dav_props(props, wanted): """wanted None (allprop of leeg verzoek): alles; anders (gevonden, ontbrekend).""" if wanted is None: return props, [] return {k: props[k] for k in wanted if k in props}, [k for k in wanted if k not in props] def dav_wanted(root): if root is None or root.find(f'{D}allprop') is not None or root.find(f'{D}prop') is None: return None return [child.tag for child in root.find(f'{D}prop')] def dav_user(): auth = request.authorization if not auth or (auth.type or '').lower() != 'basic' or not auth.username: return None u = q1('SELECT * FROM users WHERE email=? AND active=1', auth.username.strip().lower()) if u and u['dav_password_hash'] and check_password_hash(u['dav_password_hash'], auth.password or ''): return u return None def dav_paths(u): principal = f"/dav/principals/{u['id']}/" home = f"/dav/calendars/{u['id']}/" return principal, home, f'{home}{DAV_CALENDAR}/' def dav_base_props(u): principal, home, _ = dav_paths(u) return {f'{D}current-user-principal': dav_href(principal), f'{D}principal-collection-set': dav_href('/dav/principals/')} def dav_principal_props(u): principal, home, _ = dav_paths(u) return {**dav_base_props(u), f'{D}resourcetype': '', f'{D}displayname': xml_escape(u['name']), f'{D}principal-URL': dav_href(principal), f'{C}calendar-home-set': dav_href(home), f'{C}calendar-user-address-set': dav_href('mailto:' + u['email'])} def dav_calendar_props(u): principal = dav_paths(u)[0] privs = ''.join(f'' for p in ('read', 'write', 'write-content', 'write-properties', 'bind', 'unbind', 'read-current-user-privilege-set')) reports = ''.join(f'' for r in ('calendar-multiget', 'calendar-query')) return {**dav_base_props(u), f'{D}resourcetype': '', f'{D}displayname': xml_escape(f"Van Emous CRM · {u['name']}"), f'{D}owner': dav_href(principal), f'{CS}getctag': str(u['cal_sync']), f'{C}supported-calendar-component-set': '', f'{C}supported-calendar-data': '', f'{C}calendar-description': 'Afspraken uit de Van Emous CRM', f'{A}calendar-color': '#B95913FF', f'{C}max-resource-size': str(DAV_MAX_SIZE), f'{D}current-user-privilege-set': privs, f'{D}supported-report-set': reports} def dav_event_props(e, with_data=False): props = {f'{D}getetag': xml_escape(f'"{e["etag"]}"'), f'{D}resourcetype': '', f'{D}getcontenttype': 'text/calendar; charset=utf-8; component=vevent', f'{D}getcontentlength': str(len(e['ics'].encode('utf-8')))} if with_data: props[f'{C}calendar-data'] = xml_escape(e['ics']) return props def dav_event_response(u, e, wanted): want_data = bool(wanted) and f'{C}calendar-data' in wanted found, missing = dav_props(dav_event_props(e, want_data), wanted) return dav_response(dav_paths(u)[2] + url_quote(e['href']), found, missing) def dav_body(): data = request.get_data() if not data.strip(): return None try: return ET.fromstring(data) except ET.ParseError: abort(400) def dav_time_range(root): """time-range uit een calendar-query (UTC) → lokale (start, eind), of None.""" tr = root.find(f'.//{C}time-range') if root is not None else None if tr is None: return None try: start = ical.parse_dt(tr.get('start'), {})[0] if tr.get('start') else datetime(1970, 1, 1) end = ical.parse_dt(tr.get('end'), {})[0] if tr.get('end') else datetime(2200, 1, 1) except ValueError: return None return (start if isinstance(start, datetime) else datetime.combine(start, datetime.min.time()), end if isinstance(end, datetime) else datetime.combine(end, datetime.min.time())) @app.route('/.well-known/caldav', methods=['GET', 'HEAD', 'PROPFIND', 'OPTIONS'], provide_automatic_options=False) def dav_wellknown(): return redirect('/dav/', 301) @app.route('/dav/', defaults={'path': ''}, methods=DAV_METHODS, provide_automatic_options=False) @app.route('/dav/', methods=DAV_METHODS, provide_automatic_options=False) def dav(path): allow = ', '.join(DAV_METHODS) if request.method == 'OPTIONS': return make_response('', 200, {'DAV': '1, 3, calendar-access', 'Allow': allow, 'MS-Author-Via': 'DAV'}) u = dav_user() if not u: return make_response('Inloggen met e-mailadres en app-wachtwoord (Mijn account in de CRM).', 401, {'WWW-Authenticate': 'Basic realm="Van Emous CRM agenda", charset="UTF-8"'}) parts = [p for p in path.split('/') if p] if len(parts) >= 2 and parts[1] != str(u['id']): return make_response('Geen toegang tot deze agenda', 403) principal, home, cal = dav_paths(u) method = request.method depth = request.headers.get('Depth', '1') # agenda-afspraak if len(parts) == 4 and parts[0] == 'calendars' and parts[2] == DAV_CALENDAR: return dav_event(u, unquote(parts[3]), method) if len(parts) > 3 or (len(parts) == 3 and (parts[0] != 'calendars' or parts[2] != DAV_CALENDAR)): return make_response('Niet gevonden', 404) if method in ('GET', 'HEAD'): return make_response('Van Emous CRM: CalDAV. Koppel de agenda in je agenda-app met dit adres.', 200, {'Content-Type': 'text/plain; charset=utf-8'}) if method == 'PROPPATCH': # kleur of naam vanuit de app: accepteren maar niet bewaren root = dav_body() tags = [c.tag for p in (root.iter(f'{D}prop') if root is not None else []) for c in p] return dav_multistatus([dav_response('/dav/' + path, {t: None for t in tags})]) if method == 'REPORT': if len(parts) != 3: return make_response('REPORT alleen op de agenda', 403) return dav_report(u, dav_body()) if method != 'PROPFIND': return make_response('Niet toegestaan', 405, {'Allow': allow}) wanted = dav_wanted(dav_body()) out = [] if not parts: props = {**dav_base_props(u), f'{D}resourcetype': '', f'{D}displayname': 'Van Emous CRM'} out.append(dav_response('/dav/', *dav_props(props, wanted))) if depth != '0': out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) elif parts[0] == 'principals': if len(parts) == 1: out.append(dav_response('/dav/principals/', *dav_props( {**dav_base_props(u), f'{D}resourcetype': ''}, wanted))) if depth != '0': out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) else: out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) elif parts[0] == 'calendars' and len(parts) == 2: props = {**dav_base_props(u), f'{D}resourcetype': '', f'{D}owner': dav_href(principal), f'{D}displayname': xml_escape(u['name'])} out.append(dav_response(home, *dav_props(props, wanted))) if depth != '0': out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted))) elif parts[0] == 'calendars' and len(parts) == 3: out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted))) if depth != '0': out += [dav_event_response(u, e, wanted or [f'{D}getetag', f'{D}getcontenttype', f'{D}resourcetype']) for e in q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id'])] else: return make_response('Niet gevonden', 404) return dav_multistatus(out) def dav_report(u, root): if root is None: abort(400) wanted = dav_wanted(root) or [f'{D}getetag'] if root.tag == f'{C}calendar-multiget': out = [] for h in root.iter(f'{D}href'): name = unquote((h.text or '').rstrip('/').rsplit('/', 1)[-1]) e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name) out.append(dav_event_response(u, e, wanted) if e else dav_response(h.text or '', status='404 Not Found')) return dav_multistatus(out) if root.tag == f'{C}calendar-query': span = dav_time_range(root) events = q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id']) if span: events = [e for e in events if ical.occurrences(dict(e), *span)] return dav_multistatus([dav_event_response(u, e, wanted) for e in events]) return make_response('', 403, {'Content-Type': 'application/xml; charset=utf-8'}) def dav_event(u, name, method): if '/' in name or not name or len(name) > 255: return make_response('Ongeldige naam', 400) e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name) etag = f'"{e["etag"]}"' if e else None if_match, if_none = request.headers.get('If-Match'), request.headers.get('If-None-Match') if method in ('GET', 'HEAD'): if not e: return make_response('Niet gevonden', 404) if if_none and etag in [t.strip() for t in if_none.split(',')]: return make_response('', 304, {'ETag': etag}) return make_response(e['ics'], 200, {'Content-Type': 'text/calendar; charset=utf-8', 'ETag': etag}) if method == 'PROPFIND': if not e: return make_response('Niet gevonden', 404) return dav_multistatus([dav_event_response(u, e, dav_wanted(dav_body()))]) if method == 'DELETE': if not e: return make_response('Niet gevonden', 404) if if_match and if_match.strip() not in ('*', etag): return make_response('Gewijzigd op de server', 412) db().execute('DELETE FROM events WHERE id=?', (e['id'],)) bump_calendar(u['id']) db().commit() return make_response('', 204) if method == 'PUT': if (if_none or '').strip() == '*' and e: return make_response('Bestaat al', 412) if if_match and (not e or if_match.strip() not in ('*', etag)): return make_response('Gewijzigd op de server', 412) data = request.get_data() if len(data) > DAV_MAX_SIZE: return make_response('Te groot', 413) text = data.decode('utf-8', 'replace') try: ev = ical.from_ics(text) except ValueError: ev = None if not ev or not ev['uid']: return make_response('Alleen afspraken (VEVENT met UID) worden opgeslagen', 415) if q1('SELECT id FROM events WHERE user_id=? AND uid=? AND href<>?', u['id'], ev['uid'], name): return make_response('Er is al een afspraak met deze UID', 409) save_event(u['id'], ev, text, name, e['contact_id'] if e else None, e['id'] if e else None) db().commit() new = q1('SELECT etag FROM events WHERE user_id=? AND href=?', u['id'], name) return make_response('', 204 if e else 201, {'ETag': f'"{new["etag"]}"'}) return make_response('Niet toegestaan', 405, {'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND'}) # ---------------------------------------------------------------- demo DEMO_LEADS = [ ('Lorem Ipsum', 'Utrecht', 'nieuw', 'amstel'), ('Dolor Sitamet', 'Amersfoort', 'contact', 'wetering'), ('Consectetur Adipiscing', 'Zwolle', 'nieuw', 'ijssel'), ('Sed Doeiusmod', 'Nijmegen', 'offerte', 'waal'), ('Tempor Incididunt', 'Maastricht', 'contact', 'maas'), ('Labore Etdolore', 'Arnhem', 'nieuw', 'rijn'), ('Magna Aliqua', 'Meppel', 'offerte', 'reest'), ('Enim Adminim', 'Breukelen', 'nieuw', 'vecht'), ] DEMO_MESSAGE = ('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Wij zoeken een mantelzorgwoning ' 'voor in de tuin; graag meer informatie over het model en de mogelijkheden.') def seed_demo(): """Demoleads met elk een standaardmodel als gekozen model (demo/modellen, uit tools/export-models.js). Bestaat een demolead al (zelfde e-mailadres), dan wordt hij overgeslagen.""" admin = q1("SELECT id FROM users WHERE role='admin' AND active=1 ORDER BY id") made = 0 for i, (name, city, status, model) in enumerate(DEMO_LEADS): path = os.path.join(DEMO_MODELS, f'{model}.json') design = read_design(path) if not design: print(f'{model}.json ontbreekt in {DEMO_MODELS}; draai node tools/export-models.js') continue email = name.lower().replace(' ', '.') + '@example.nl' cid, outcome = upsert_contact( {'name': name, 'email': email, 'phone': f'06-{12345670 + i}', 'city': city, 'status': status}, {'model': design_title(design)}, update_existing=False, defaults={'source': 'Demo', 'assigned': admin['id'] if admin else None}) if outcome != 'created': continue log(cid, 'form', f'Formulier ontvangen via Demo\n{DEMO_MESSAGE}') save_design_json(design, cid) made += 1 db().commit() print(f'{made} demolead(s) aangemaakt') ensure_default_jobs() if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] == 'demo': with app.app_context(): seed_demo() sys.exit(0) if len(sys.argv) > 1 and sys.argv[1] == 'sync': with app.test_request_context(): n, errors = do_sync() print('\n'.join(errors) or f'{n} nieuwe e-mail(s) opgehaald') sys.exit(1 if errors else 0) if len(sys.argv) > 1 and sys.argv[1] in ('tick', 'scheduler'): # tick: één keer de routines die aan de beurt zijn (cron elke minuut); scheduler: blijft draaien import signal import time signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) while True: with app.test_request_context(): done = run_due_jobs() if sys.argv[1] == 'tick' or done: print(f'{now()} {done} routine(s) uitgevoerd', flush=True) if sys.argv[1] == 'tick': sys.exit(0) time.sleep(30) app.run(host=os.environ.get('HOST', '127.0.0.1'), port=int(os.environ.get('PORT', 5000)), debug=bool(os.environ.get('DEBUG')))