3632 lines
179 KiB
Python
3632 lines
179 KiB
Python
"""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.5'
|
||
|
||
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')]
|
||
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
|
||
|
||
|
||
# ---------------------------------------------------------------- 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 = "type='lead' AND status NOT IN ('gewonnen', 'verloren')"
|
||
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
|
||
|
||
@app.route('/contacts')
|
||
def contacts():
|
||
term = request.args.get('q', '').strip()
|
||
args = {k: request.args.get(k, '') for k in ('type', 'status', 'owner')}
|
||
sql = ('SELECT c.*, u.name AS user_name, r.name AS relation_name, r.kind AS relation_kind, f.title AS design_title '
|
||
'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 WHERE 1=1')
|
||
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'
|
||
rows = db().execute(sql + ' ORDER BY c.created_at DESC LIMIT 500', params).fetchall()
|
||
return render_template('contacts.html', rows=rows, term=term, a=args)
|
||
|
||
|
||
@app.route('/contacts/export.csv')
|
||
def contacts_export():
|
||
out = io.StringIO()
|
||
writer = csv.writer(out, delimiter=';')
|
||
cols = ['id', 'type', *CONTACT_FIELDS, 'status', 'value', 'created_at']
|
||
writer.writerow(cols)
|
||
for r in q(f'SELECT {", ".join(cols)} FROM contacts ORDER BY id'):
|
||
writer.writerow(list(r))
|
||
resp = make_response('' + out.getvalue())
|
||
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
|
||
resp.headers['Content-Disposition'] = f'attachment; filename=contacten-{date.today()}.csv'
|
||
return resp
|
||
|
||
|
||
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):
|
||
for f in q('SELECT stored FROM files WHERE contact_id=?', cid):
|
||
remove_upload(f['stored'])
|
||
db().execute('DELETE FROM contacts WHERE id=?', (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'
|
||
|
||
|
||
@app.route('/relations')
|
||
def relations():
|
||
kind = request.args.get('kind') if request.args.get('kind') in dict(RELATION_KINDS) else 'klant'
|
||
type_, term = request.args.get('type', ''), request.args.get('q', '').strip()
|
||
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 r.kind=?')
|
||
params = [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
|
||
rows = db().execute(sql + ' 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))
|
||
|
||
|
||
@app.post('/relations/<int:rid>/delete')
|
||
def relation_delete(rid):
|
||
r = get_or_404('relations', 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,))
|
||
db().commit()
|
||
flash('Relatie verwijderd; de contactpersonen en producten zijn bewaard.')
|
||
return redirect(url_for('relations', kind=r['kind']))
|
||
|
||
|
||
# ---------------------------------------------------------------- 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) + '.'
|
||
|
||
|
||
@app.route('/products')
|
||
def products():
|
||
term = request.args.get('q', '').strip()
|
||
show = request.args.get('show', 'active')
|
||
sql, params = 'SELECT * FROM products WHERE 1=1', []
|
||
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)
|
||
rows = db().execute(sql + ' ORDER BY name', params).fetchall()
|
||
return render_template('products.html', rows=rows, term=term, show=show)
|
||
|
||
|
||
@app.route('/products/export.csv')
|
||
def products_export():
|
||
out = io.StringIO()
|
||
writer = csv.writer(out, delimiter=';')
|
||
writer.writerow(['sku', 'naam', 'omschrijving', 'eenheid', 'prijs', 'btw', 'leverancier', 'actief', 'bijgewerkt'])
|
||
for p in q('SELECT * FROM products ORDER BY sku, name'):
|
||
writer.writerow([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]])
|
||
resp = make_response('' + out.getvalue())
|
||
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
|
||
resp.headers['Content-Disposition'] = f'attachment; filename=producten-{date.today()}.csv'
|
||
return resp
|
||
|
||
|
||
@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)
|
||
|
||
|
||
@app.post('/products/<int:pid>/delete')
|
||
def product_delete(pid):
|
||
p = get_or_404('products', pid)
|
||
if scalar('SELECT COUNT(*) FROM quote_lines WHERE product_id=?', pid):
|
||
# in gebruik in offertes: archiveren zodat de koppeling en de historie blijven bestaan
|
||
if p['active']:
|
||
db().execute('UPDATE products SET active=0, updated_at=? WHERE id=?', (now(), pid))
|
||
product_history(pid, 'active', 1, 0, 'Handmatig (gearchiveerd)')
|
||
db().commit()
|
||
flash('Dit product staat in offertes en is daarom op inactief gezet in plaats van verwijderd.')
|
||
return redirect(url_for('products'))
|
||
remove_upload(p['image'])
|
||
db().execute('DELETE FROM products WHERE id=?', (pid,))
|
||
db().commit()
|
||
flash('Product verwijderd.')
|
||
return redirect(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)
|
||
|
||
|
||
@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='verloren'", (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')))
|