Files
crm/app.py
T
MachielandClaude Opus 5.5 db3e26d258 v0.0.6: massa-acties en CSV-export in de lijsten van contacten, relaties, producten en offertes
Rijen aanvinken (shift-klik voor een reeks, of alles op de pagina) en in de balk onderin een actie kiezen
(templates/_bulk.html, static/bulk.js). Contacten: status wijzigen, toewijzen, omzetten naar klant, exporteren,
verwijderen; relaties: type wijzigen, exporteren, verwijderen; producten: actief/inactief, exporteren, verwijderen
(in offertes gebruikt → inactief); offertes: status wijzigen (met de gevolgen voor het contact), exporteren,
verwijderen. Exporteer CSV neemt de filters van de lijst mee en levert alle velden; bij contacten ook labels,
toegewezen gebruiker, relatie, model, portaaltoegang en de extra formuliervelden als eigen kolom. Nieuwe
leadstatus Vervallen (telt als afgesloten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:12:39 +02:00

3893 lines
190 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.6'
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, 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')
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'}
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',
# 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'},
'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 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))
# woningontwerpen van vóór het gekozen model: titel uit de JSON, het nieuwste wordt het gekozen model
for fid, cid, stored in con.execute("SELECT id, contact_id, stored FROM files WHERE original LIKE '%.json' "
"AND (title IS NULL OR title='') ORDER BY created_at DESC, id DESC").fetchall():
try:
with open(os.path.join(UPLOADS, os.path.basename(stored)), encoding='utf-8-sig') as f:
design = json.load(f)
except (ValueError, OSError):
continue
if isinstance(design, dict) and design.get('type') == 'house-design':
title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp'
con.execute('UPDATE files SET title=? WHERE id=?', (title, fid))
con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid))
# 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 save_upload(fs, contact_id=None):
if not fs or not fs.filename:
return None
ext = os.path.splitext(secure_filename(fs.filename))[1].lower()
stored = uuid.uuid4().hex + ext
path = os.path.join(UPLOADS, stored)
fs.save(path)
if contact_id:
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, fs.filename[:200], fs.mimetype or '', os.path.getsize(path), now()))
if ext == '.json':
note_design(contact_id, path, cur.lastrowid)
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(contact_id, path, file_id):
"""Een woningontwerp uit de configurator wordt het gekozen model van het contact (het nieuwste telt),
met een regel in de tijdlijn."""
design = read_design(path)
if not design:
return
project = design.get('project') or {}
title = design_title(design)
db().execute('UPDATE files SET title=? WHERE id=?', (title, file_id))
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (file_id, now(), contact_id))
parts = [f'Woningontwerp: {title} (gekozen model)',
' '.join(filter(None, [project.get('straat'), project.get('nummer'), project.get('plaats')])),
project.get('opmerking') or '']
log(contact_id, 'file', '\n'.join(p for p in parts if p))
PDF_KINDS = {'werk': 'werktekening', 'klant': 'klant-PDF'}
def design_pdfs(design_ids):
"""PDF's (werktekening, klant) per woningontwerp: {design_id: {kind: {url, date, id}}}."""
out = {}
if design_ids:
for f in q(f"SELECT * FROM files WHERE kind IN ('werk', 'klant') AND design_id IN ({', '.join('?' * len(design_ids))})",
*design_ids):
out.setdefault(f['design_id'], {})[f['kind']] = {
'url': url_for('upload_file', name=f['stored']), 'date': fmt_dt(f['created_at']), 'id': f['id']}
return out
def contact_designs(contact_id):
"""Alle woningontwerpen bij een contact, nieuwste eerst."""
return [f for f in q('SELECT * FROM files WHERE contact_id=? AND original LIKE ? ORDER BY created_at DESC, id DESC',
contact_id, '%.json') if f['title']]
def save_design_json(design, contact_id):
stored = uuid.uuid4().hex + '.json'
path = os.path.join(UPLOADS, stored)
with open(path, 'w', encoding='utf-8') as f:
json.dump(design, f, ensure_ascii=False)
name = secure_filename(str((design.get('project') or {}).get('naam') or 'ontwerp')) or 'ontwerp'
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at) VALUES (?, ?, ?, ?, ?, ?)',
(contact_id, stored, f'{name}.json', 'application/json', os.path.getsize(path), now()))
note_design(contact_id, path, cur.lastrowid)
def 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/<int:uid>', 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 files f ON f.id=c.design_file_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 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')
@app.route('/contacts/<int:cid>')
def contact_view(cid):
c = q1('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, r.type AS relation_type '
'FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id LEFT JOIN relations r ON r.id=c.relation_id '
'WHERE c.id=?', cid) or abort(404)
designs = contact_designs(cid)
design = next((f for f in designs if f['id'] == c['design_file_id']), None)
design_meta = {}
if design:
d = read_design(os.path.join(UPLOADS, design['stored'])) or {}
project = d.get('project') or {}
design_meta = {'units': len(d.get('units') or []), 'exported': (d.get('exportedAt') or '')[:19],
'address': ' '.join(filter(None, [project.get('straat'), project.get('nummer'),
project.get('plaats')])),
'note': project.get('opmerking') or '', 'version': d.get('configurator') or ''}
return render_template(
'contact.html', c=c, extra=json.loads(c['extra'] or '{}'),
mail_templates=all_mail_templates(), mail_vars=mail_vars(c),
activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id '
'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid),
files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid),
emails=q(f'SELECT * FROM emails e WHERE e.contact_id=? AND {visible_mail()} ORDER BY e.date DESC LIMIT 50', cid),
mailboxes=user_mailboxes(sending=True),
tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id '
'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid),
designs=designs, design=design, design_meta=design_meta,
pdfs=design_pdfs([design['id']]).get(design['id'], {}) if design else {}, PDF_KINDS=PDF_KINDS,
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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:cid>/upload')
def contact_upload(cid):
get_or_404('contacts', cid)
count = sum(1 for fs in request.files.getlist('files') if save_upload(fs, cid))
if count:
log(cid, 'file', f'{count} bestand(en) toegevoegd')
db().commit()
return redirect(url_for('contact_view', cid=cid))
@app.post('/contacts/<int:cid>/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/<int:tid>/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/<int:cid>/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/<int:cid>/delete')
def contact_delete(cid):
delete_contact(cid)
db().commit()
flash('Contact verwijderd.')
return redirect(url_for('contacts'))
@app.route('/uploads/<name>')
def upload_file(name):
f = q1('SELECT original FROM files WHERE stored=?', name)
return serve_upload(name, f['original'] if f else None)
@app.post('/files/<int:fid>/delete')
def file_delete(fid):
f = get_or_404('files', fid)
chosen = f['contact_id'] and scalar('SELECT design_file_id IS ? FROM contacts WHERE id=?', fid, f['contact_id'])
for pdf in q('SELECT stored FROM files WHERE design_id=?', fid):
remove_upload(pdf['stored'])
db().execute('DELETE FROM files WHERE design_id=?', (fid,))
remove_upload(f['stored'])
db().execute('DELETE FROM files WHERE id=?', (fid,))
if chosen:
rest = contact_designs(f['contact_id'])
db().execute('UPDATE contacts SET design_file_id=? WHERE id=?', (rest[0]['id'] if rest else None, f['contact_id']))
db().commit()
return redirect(url_for('contact_view', cid=f['contact_id']))
@app.post('/contacts/<int:cid>/design')
def contact_design(cid):
"""Gekozen model: een van de woningontwerpen bij het contact (of geen)."""
c = get_or_404('contacts', cid)
fid = request.form.get('file_id', type=int)
f = next((d for d in contact_designs(cid) if d['id'] == fid), None)
if (f['id'] if f else None) != c['design_file_id']:
db().execute('UPDATE contacts SET design_file_id=?, updated_at=? WHERE id=?', (f['id'] if f else None, now(), cid))
log(cid, 'status', f"Gekozen model: {f['title']}" if f else 'Geen gekozen model meer')
db().commit()
return redirect(url_for('contact_view', cid=cid))
@app.route('/files/<int:fid>/viewer')
def design_viewer(fid):
"""3D-viewer van een woningontwerp (in een iframe op de contactpagina, of los)."""
f = get_or_404('files', fid)
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
pdfs = design_pdfs([fid]).get(fid, {})
cfg = {'pdfUrl': url_for('design_pdf', fid=fid) if f['contact_id'] else '', 'pdfs': pdfs,
'auto': len(pdfs) < len(PDF_KINDS)}
resp = make_response(render_template(
'viewer.html', design=design, name=f['title'] or design_title(design), scripts=VIEWER_SCRIPTS, cfg=cfg,
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js'))))
resp.headers['X-Frame-Options'] = 'SAMEORIGIN'
return resp
@app.post('/files/<int:fid>/pdf')
def design_pdf(fid):
"""PDF van een woningontwerp, gemaakt door de viewer (HousePdf van de configurator). Per ontwerp één werktekening
(intern) en één klant-PDF (ook in het portaal); een nieuwe vervangt de vorige."""
f = get_or_404('files', fid)
kind, fs = request.form.get('kind'), request.files.get('pdf')
if kind not in PDF_KINDS or not fs or not f['contact_id'] or not f['title']:
return jsonify(ok=False, error='Ongeldige PDF'), 400
head = fs.stream.read(5)
fs.stream.seek(0)
if head != b'%PDF-':
return jsonify(ok=False, error='Geen PDF'), 400
for old in q('SELECT * FROM files WHERE design_id=? AND kind=?', fid, kind):
remove_upload(old['stored'])
db().execute('DELETE FROM files WHERE id=?', (old['id'],))
stored = uuid.uuid4().hex + '.pdf'
fs.save(os.path.join(UPLOADS, stored))
name = secure_filename(f"{f['title']} {PDF_KINDS[kind]}") or 'ontwerp'
cur = db().execute('INSERT INTO files (contact_id, stored, original, mime, size, created_at, title, kind, design_id) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
(f['contact_id'], stored, f'{name}.pdf', 'application/pdf',
os.path.getsize(os.path.join(UPLOADS, stored)), now(), '', kind, fid))
log(f['contact_id'], 'file', f"{PDF_KINDS[kind][:1].upper() + PDF_KINDS[kind][1:]} gemaakt van ontwerp {f['title']}")
db().commit()
row = q1('SELECT * FROM files WHERE id=?', cur.lastrowid)
return jsonify(ok=True, url=url_for('upload_file', name=stored), date=fmt_dt(row['created_at']), id=row['id'])
# ---------------------------------------------------------------- relaties
def relation_form_data():
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/<int:rid>/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/<int:rid>')
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))
designs = q(f'SELECT f.*, c.name AS contact_name, c.design_file_id = f.id AS chosen FROM files f '
f"JOIN contacts c ON c.id=f.contact_id WHERE f.contact_id IN ({marks}) AND f.title<>'' "
f"AND f.original LIKE '%.json' ORDER BY chosen DESC, f.created_at DESC", *ids)
return render_template(
'relation.html', r=r, members=members,
candidates=q('SELECT id, name, email FROM contacts WHERE relation_id IS NULL ORDER BY name LIMIT 500'),
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)
@app.post('/relations/<int:rid>/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/<int:rid>/member/<int:cid>/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 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/<int:rid>/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/<token>', 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/<int:mbid>', 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/<int:nid>')
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/<int:eid>')
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/<int:eid>/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/<int:eid>/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/<int:eid>/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 files WHERE id=?', contact['design_file_id']) if contact['design_file_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/<int:tid>', 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/<int:tid>/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<?)',
(started, job['id'], stale)).rowcount
db().commit()
if not claimed:
return None
jt = jobs.TYPES.get(job['kind'])
try:
if not jt:
raise RuntimeError(f"onbekend routinetype '{job['kind']}'")
config = {**jobs.defaults(job['kind']), **json.loads(job['config'] or '{}')}
status, message = 'ok', jt.run(job, config) or ''
except Exception as e: # de fout komt in het logboek; de volgende keer gewoon opnieuw
db().rollback()
status, message = 'fout', str(e) or type(e).__name__
app.logger.exception('routine %s (%s) mislukt', job['name'], job['kind'])
finished = now()
next_run = (datetime.fromisoformat(started) + timedelta(minutes=max(1, job['interval_minutes']))).strftime(
'%Y-%m-%d %H:%M:%S')
db().execute('UPDATE jobs SET running_since=NULL, last_run=?, last_status=?, last_message=?, next_run=? WHERE id=?',
(started, status, message[:2000], next_run, job['id']))
db().execute('INSERT INTO job_runs (job_id, started_at, finished_at, status, message, manual) VALUES (?, ?, ?, ?, ?, ?)',
(job['id'], started, finished, status, message[:2000], 1 if manual else 0))
db().execute('DELETE FROM job_runs WHERE job_id=? AND id NOT IN (SELECT id FROM job_runs WHERE job_id=? '
'ORDER BY id DESC LIMIT ?)', (job['id'], job['id'], jobs.KEEP_RUNS))
db().commit()
return status, message
def run_due_jobs():
"""Alle actieve routines die aan de beurt zijn (scheduler en tick)."""
done = 0
for job in q('SELECT * FROM jobs WHERE active=1 AND (next_run IS NULL OR next_run<=?) ORDER BY next_run', now()):
if run_job(job):
done += 1
return done
def ensure_default_jobs():
"""Standaardroutines (default=True) één keer aanmaken; één statement, dus veilig met meerdere workers."""
con = sqlite3.connect(DB_PATH, timeout=10)
for jt in jobs.TYPES.values():
if jt.default:
con.execute('INSERT INTO jobs (kind, name, config, interval_minutes, created_at) SELECT ?, ?, ?, ?, ? '
'WHERE NOT EXISTS (SELECT 1 FROM jobs WHERE kind=?)',
(jt.key, jt.label, json.dumps(jobs.defaults(jt.key)), jt.interval, now(), jt.key))
con.commit()
con.close()
@jobs.register('mail_fetch', 'E-mail ophalen',
'Haalt nieuwe e-mail op uit de mailboxen en geeft een melding aan wie toegang heeft (als de mailbox '
'meldingen aan heeft). Afleverberichten van onbestelbare adressen halen het contact uit de nieuwsbrieven.',
interval=10, default=True,
fields=[jobs.Field('mailboxes', 'Mailboxen', 'mailboxes', [], 'Niets aangevinkt = alle actieve mailboxen.')])
def job_mail_fetch(job, config):
boxes = q("SELECT * FROM mailboxes WHERE active=1 AND imap_host<>'' 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<?', (limit,)).rowcount
db().commit()
return f'{n} melding(en) verwijderd'
@app.route('/routines')
def routines():
return render_template('routines.html', rows=q('SELECT * FROM jobs ORDER BY name COLLATE NOCASE'),
TYPES=jobs.TYPES, interval_text=jobs.interval_text)
@app.route('/routines/new/<kind>', methods=['GET', 'POST'], defaults={'jid': None})
@app.route('/routines/<int:jid>', 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/<int:jid>/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/<int:jid>/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<?')
args.append((date.fromisoformat(f['created_to']) + timedelta(days=1)).isoformat())
if f.get('has_design'):
where.append('c.design_file_id IS NOT NULL')
return ' AND '.join(where), args
def segment_ids(filters):
"""Eén contact per e-mailadres (het oudste)."""
where, args = segment_where(filters)
return [r[0] for r in q(f'SELECT MIN(c.id) FROM contacts c WHERE {where} GROUP BY lower(c.email)', *args)]
def segment_filters(form):
ints = lambda key: [int(v) for v in form.getlist(key) if v.isdigit()]
out = {'types': [v for v in form.getlist('types') if v in dict(TYPES)],
'statuses': [v for v in form.getlist('statuses') if v in dict(STATUSES)],
'sources': [v for v in form.getlist('sources') if v], 'users': ints('users'),
'city': form.get('city', '').strip(), 'has_design': bool(form.get('has_design'))}
for key in ('created_from', 'created_to'):
try:
out[key] = date.fromisoformat(form.get(key, '')).isoformat()
except ValueError:
out[key] = ''
return {k: v for k, v in out.items() if v}
@app.route('/segments')
def segments():
rows = [dict(r, n=len(segment_ids(json.loads(r['filters'] or '{}'))))
for r in q('SELECT * FROM segments ORDER BY name COLLATE NOCASE')]
return render_template('segments.html', rows=rows)
@app.route('/segments/new', methods=['GET', 'POST'], defaults={'sid': None})
@app.route('/segments/<int:sid>', 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/<int:sid>/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/<int:cid>', 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_file_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/<int:cid>/<token>', 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/<int:cid>/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/<int:pid>/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/<int:pid>/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/<int:qid>', 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/<int:qid>/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/<int:qid>/bestek')
def quote_bestek(qid):
qt = get_or_404('quotes', qid)
contact = q1('SELECT * FROM contacts WHERE id=?', qt['contact_id']) if qt['contact_id'] else None
designs = contact_designs(contact['id']) if contact else []
want = request.args.get('file', type=int) or qt['design_file_id'] or (contact['design_file_id'] if contact else None)
f = next((d for d in designs if d['id'] == want), designs[0] if designs else None)
if not f:
flash('Er is geen woningontwerp bij het contact van deze offerte. Kies eerst een klant met een gekozen model.', 'error')
return redirect(url_for('quote_edit', qid=qid))
design = read_design(os.path.join(UPLOADS, os.path.basename(f['stored']))) or abort(404)
return render_template('bestek.html', qt=qt, c=contact, design=design, design_file=f, designs=designs,
scripts=VIEWER_SCRIPTS, has_lines=scalar('SELECT COUNT(*) FROM quote_lines WHERE quote_id=?', qid),
engine=os.path.exists(os.path.join(ENGINE, 'js', 'house', 'house-model.js')))
@app.post('/quotes/<int:qid>/bestek')
def quote_bestek_save(qid):
qt = get_or_404('quotes', qid)
b = clean_bestek(request.form.get('bestek'))
if not b:
flash('Het bestek kon niet worden overgenomen.', 'error')
return redirect(url_for('quote_bestek', qid=qid))
fid = request.form.get('file_id', type=int)
if qt['contact_id'] and fid not in {d['id'] for d in contact_designs(qt['contact_id'])}:
fid = None
vat = b['vat']
rows = bestek_rows(b, request.form.get('mode', 'post'))
if request.form.get('replace'):
db().execute('DELETE FROM quote_lines WHERE quote_id=?', (qid,))
start = scalar('SELECT COALESCE(MAX(position), -1) + 1 FROM quote_lines WHERE quote_id=?', qid)
source, linked = f"Bestek offerte {qt['number']}", 0
for pos, r in enumerate(rows, start):
pid = bestek_product(r, vat, bool(request.form.get('update_prices')), source) if r['product'] else None
linked += bool(pid)
db().execute('INSERT INTO quote_lines (quote_id, position, product_id, description, qty, unit, price, vat) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (qid, pos, pid, r['desc'], r['qty'], r['unit'], r['price'], vat))
title = qt['title'] or (f"Woning {b['design']['title']}" if b['design']['title'] else '')
db().execute('UPDATE quotes SET bestek=?, design_file_id=?, title=?, updated_at=? WHERE id=?',
(json.dumps(b, ensure_ascii=False), fid, title, now(), qid))
if qt['contact_id']:
log(qt['contact_id'], 'quote', f"Offerte {qt['number']}: bestek uit ontwerp {b['design']['title']} "
f"({euro(b['totals']['exBtw'])} excl. btw)")
db().commit()
flash(f'Bestek overgenomen: {len(rows)} regel(s), waarvan {linked} gekoppeld aan een product.')
return redirect(url_for('quote_edit', qid=qid))
@app.post('/quotes/<int:qid>/bestek/delete')
def quote_bestek_delete(qid):
get_or_404('quotes', qid)
db().execute("UPDATE quotes SET bestek='', design_file_id=NULL, updated_at=? WHERE id=?", (now(), qid))
db().commit()
flash('Bestek losgekoppeld; de offerteregels zijn gebleven.')
return redirect(url_for('quote_edit', qid=qid))
@app.post('/quotes/<int:qid>/delete')
def quote_delete(qid):
get_or_404('quotes', qid)
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/<token>', 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),
files=q("SELECT * FROM files WHERE contact_id=? AND kind<>'werk' ORDER BY created_at DESC", cid),
messages=q("SELECT * FROM activities WHERE contact_id=? AND type='portal' AND body LIKE 'Bericht:%' "
"ORDER BY created_at DESC LIMIT 20", cid),
s=settings_dict())
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/<int:qid>')
def portal_quote(qid):
return render_quote(portal_quote_or_404(qid), portal=True)
@app.post('/portal/quotes/<int:qid>/<choice>')
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/<name>')
def portal_file(name):
f = q1("SELECT * FROM files WHERE stored=? AND contact_id=? AND kind<>'werk'", name, g.portal['id'])
if f is None:
abort(404)
return serve_upload(name, f['original'])
# ---------------------------------------------------------------- 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
@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/<int:eid>', 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/<int:eid>/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/<id>/ → /dav/calendars/<id>/ → /dav/calendars/<id>/agenda/<naam>.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}</{tag}>'
def dav_href(path):
return f'<D:href>{xml_escape(path)}</D:href>'
def dav_response(href, found=None, missing=(), status=None):
out = '<D:response>' + dav_href(href)
if status:
out += f'<D:status>HTTP/1.1 {status}</D:status>'
if found:
out += ('<D:propstat><D:prop>' + ''.join(dav_el(k, v) for k, v in found.items())
+ '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>')
if missing:
out += ('<D:propstat><D:prop>' + ''.join(dav_el(k) for k in missing)
+ '</D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>')
return out + '</D:response>'
def dav_multistatus(responses):
body = ('<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" '
'xmlns:A="http://apple.com/ns/ical/">' + ''.join(responses) + '</D:multistatus>')
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': '<D:principal/>', 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'<D:privilege><D:{p}/></D:privilege>' for p in
('read', 'write', 'write-content', 'write-properties', 'bind', 'unbind',
'read-current-user-privilege-set'))
reports = ''.join(f'<D:supported-report><D:report><C:{r}/></D:report></D:supported-report>'
for r in ('calendar-multiget', 'calendar-query'))
return {**dav_base_props(u), f'{D}resourcetype': '<D:collection/><C:calendar/>',
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': '<C:comp name="VEVENT"/>',
f'{C}supported-calendar-data': '<C:calendar-data content-type="text/calendar" version="2.0"/>',
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/<path:path>', 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': '<D:collection/>', 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': '<D:collection/>'}, 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': '<D:collection/>', 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('<?xml version="1.0" encoding="utf-8"?><D:error xmlns:D="DAV:"><D:supported-report/></D:error>',
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')))