v0.0.4: e-mailtemplates met velden ({voornaam}, {model}, {afzender}…), te kiezen bij nieuwe e-mail, antwoord en contact; inbox met nieuwe e-mail, doorsturen, CC en bijlagen, Verzonden, Archief, zoeken, als ongelezen markeren en alles gelezen; agenda per gebruiker met CalDAV (/dav/, app-wachtwoord in Mijn account); zoeken en Nieuw in de bovenbalk; e-mailinstellingen met voorinstellingen (OVHcloud, Gmail), beveiliging per server, kopie in Verzonden en verbindingstest; offerte in het klantportaal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 17:29:47 +02:00
co-authored by Claude Opus 5.5
parent 81fc27fa86
commit 366a3f9d17
26 changed files with 2095 additions and 271 deletions
+804 -29
View File
@@ -4,9 +4,10 @@ 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.3'
__version__ = '0.0.4'
import csv
import hashlib
import io
import json
import mimetypes
@@ -17,13 +18,17 @@ import sqlite3
import sys
import uuid
from datetime import date, datetime, timedelta
from urllib.parse import urlparse
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 mail
@@ -46,7 +51,8 @@ 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'}
'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',
@@ -68,15 +74,15 @@ 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',
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'allowed_origins', 'api_assign_user',
'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days', 'imap_security',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_security', 'smtp_sent_folder', 'allowed_origins', 'api_assign_user',
'klant_types', 'partner_types']
SECRET_KEYS = ['imap_pass', 'smtp_pass']
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'}
PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown'}
PORTAL_PUBLIC = {'portal_login', 'portal_invite'}
ADMIN_ONLY = {'settings', 'users', 'user_edit'}
@@ -132,6 +138,8 @@ MIGRATIONS = {
'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'},
'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 ''"},
}
@@ -598,6 +606,19 @@ def logout():
@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', '')):
@@ -610,7 +631,7 @@ def account():
db().commit()
flash('Wachtwoord gewijzigd.')
return redirect(url_for('account'))
return render_template('account.html')
return render_template('account.html', dav=dav)
@app.route('/users')
@@ -693,6 +714,9 @@ def dashboard():
'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 "
@@ -795,6 +819,7 @@ def contact_view(cid):
'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),
@@ -804,6 +829,9 @@ def contact_view(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',
@@ -1287,10 +1315,21 @@ def do_sync():
@app.route('/inbox')
def inbox():
flt = request.args.get('f', 'all')
where = {'unread': 'AND e.is_read=0', 'unlinked': 'AND e.contact_id IS NULL'}.get(flt, '')
rows = q(f"SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id "
f"WHERE e.direction='in' {where} ORDER BY e.date DESC LIMIT 300")
return render_template('inbox.html', rows=rows, f=flt, last_sync=get_setting('last_sync'),
term = request.args.get('q', '').strip()
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"
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 FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id '
f'WHERE {where} ORDER BY e.date DESC LIMIT 300', *args)
return render_template('inbox.html', rows=rows, f=flt, term=term, last_sync=get_setting('last_sync'),
configured=bool(get_setting('imap_host')))
@@ -1304,6 +1343,14 @@ def inbox_sync():
return redirect(url_for('inbox'))
@app.post('/inbox/read-all')
def inbox_read_all():
db().execute("UPDATE emails SET is_read=1 WHERE direction='in' AND is_read=0")
db().commit()
flash('Alle e-mail is als gelezen gemarkeerd.')
return redirect(back(url_for('inbox')))
@app.route('/inbox/<int:eid>')
def inbox_view(eid):
e = get_or_404('emails', eid)
@@ -1311,7 +1358,26 @@ def inbox_view(eid):
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)
return render_template('email.html', e=e, contact=contact, templates=all_mail_templates(),
vars=mail_vars(contact))
@app.post('/inbox/<int:eid>/unread')
def inbox_unread(eid):
get_or_404('emails', eid)
db().execute('UPDATE emails SET is_read=0 WHERE id=?', (eid,))
db().commit()
return redirect(url_for('inbox'))
# 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_or_404('emails', 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'))
@app.post('/inbox/<int:eid>/contact')
@@ -1329,29 +1395,143 @@ def inbox_contact(eid):
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')]
def mail_vars(contact=None):
"""Waarden voor de velden: afzender en bedrijf altijd, die van het contact alleen als het contact bekend is."""
values = {'afzender': g.user['name'], '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 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),
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),
'next': url_for('contact_view', cid=contact['id']) if contact else url_for('inbox', f='sent')}
fwd = q1('SELECT * FROM emails WHERE id=?', 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}'
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, subject, body = f.get('to', '').strip(), f.get('subject', '').strip(), f.get('body', '')
to, cc = f.get('to', '').strip(), f.get('cc', '').strip()
cid = f.get('contact_id', type=int)
reply = q1('SELECT * FROM emails WHERE id=?', f.get('reply_to', type=int)) if f.get('reply_to') else None
target = back(url_for('inbox'))
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 = {**dict.fromkeys(k for k, _ in MAIL_FIELDS), **mail_vars(contact)}
values = {k: v or '' for k, v in values.items()}
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:
message_id = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None)
message_id, warning = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None,
cc, files)
except Exception as e:
flash(f'Versturen mislukt: {e}', 'error')
return redirect(target)
if not cid:
c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', to)
cid = c['id'] if c else None
db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, subject, body, date, "
"is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, 1)",
(message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, subject, body, now()))
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,
'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, contact_id, from_addr, to_addr, cc, subject, body, "
"attachments, date, is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1)",
(message_id, cid, get_setting('smtp_from') or get_setting('smtp_user'), to, cc, subject, body,
', '.join(name for name, _, _ in files), now()))
if cid:
log(cid, 'email', f'E-mail verstuurd: {subject}')
db().commit()
flash('E-mail verstuurd.')
return redirect(target)
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'))
# ---------------------------------------------------------------- producten
@@ -1636,7 +1816,7 @@ 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_print.html', qt=qt, lines=lines, c=contact, t=quote_totals(lines),
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))
@@ -1813,7 +1993,8 @@ def quote_delete(qid):
@app.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
if request.form.get('action') == 'apikey':
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:
@@ -1822,11 +2003,17 @@ def settings():
for key in SECRET_KEYS:
if request.form.get(key):
set_setting(key, request.form[key])
flash('Instellingen opgeslagen.')
if action == 'mailtest':
db().commit()
for ok, message in mail.test(settings_dict()):
flash(message, 'message' if ok else 'error')
else:
flash('Instellingen opgeslagen.')
db().commit()
return redirect(url_for('settings'))
return redirect(url_for('settings', _anchor='mail' if action == 'mailtest' else None))
return render_template('settings.html', s=settings_dict(), api_url=url_for('api_leads', _external=True),
portal_url=url_for('portal_login', _external=True))
portal_url=url_for('portal_login', _external=True), PROVIDERS=mail.PROVIDERS,
SECURITY=mail.SECURITY)
# ---------------------------------------------------------------- klantportaal
@@ -2054,6 +2241,594 @@ def api_products():
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 WHERE {cond} '
f'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())
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 = [