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:
co-authored by
Claude Opus 5.5
parent
81fc27fa86
commit
366a3f9d17
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user