diff --git a/README.md b/README.md index 02fb416..ef91006 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,20 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0 - **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling. Het formaat van de configurator (`;`, komma-decimalen, BOM, `#`-regels) werkt direct. - **Inbox**: IMAP ophalen en SMTP versturen, mails automatisch gekoppeld aan contacten (op e-mailadres), - contact aanmaken vanuit een mail, beantwoorden. + contact aanmaken vanuit een mail, beantwoorden. Voorinstellingen voor OVHcloud (Zimbra / MX Plan) en Gmail, + beveiliging per server (SSL/TLS of STARTTLS), een kopie van verstuurde mail in een IMAP-map (`Sent` bij Zimbra) + en een knop om de verbinding te testen (Instellingen › E-mail). Nieuwe e-mail opstellen (ook doorsturen), met CC + en bijlagen; mappen Verzonden en Archief (archiveren raakt de mailbox op de server niet), zoeken, als ongelezen + markeren en alles als gelezen markeren. +- **E-mailtemplates**: onderwerp en tekst met velden als `{voornaam}`, `{model}` en `{afzender}`, te kiezen bij een + nieuwe e-mail, een antwoord en op de pagina van een contact. Velden worden direct ingevuld als het contact bekend + is, anders bij het versturen (contact op het eerste adres in Aan). +- **Agenda**: een agenda per gebruiker, samen in een maandoverzicht met de taken die een datum hebben; afspraken + aan een contact koppelen (ook op de contactpagina en het dashboard), eenvoudige herhaling. Via **CalDAV** + (`/dav/`, ontdekking via `/.well-known/caldav`) zet elke gebruiker zijn agenda op iPhone, Mac, Android (DAVx⁵) + of in Thunderbird; inloggen met e-mailadres en een app-wachtwoord uit Mijn account. +- **Zoeken en Nieuw** in de bovenbalk: één zoekveld voor leads, contacten, relaties, offertes, afspraken, + producten en e-mail (live resultaten, `/` springt erheen), en een knop om direct iets nieuws aan te maken. - **Producten** met SKU als vaste sleutel, prijs, btw, eenheid, leverancier (partner) en afbeelding. Een import of `POST /api/products` werkt producten bij op SKU (alleen de meegegeven velden) en legt elke mutatie vast met de oude en de nieuwe waarde. Optioneel worden producten die niet meer in de lijst @@ -159,10 +172,11 @@ mailwachtwoorden in. ``` app.py routes en logica importer.py CSV/JSON inlezen en kolommen herkennen -mail.py IMAP/SMTP +mail.py IMAP/SMTP (voorinstellingen, verbindingstest, kopie in Verzonden) +ical.py iCalendar lezen/schrijven en herhalingen uitvouwen (agenda en CalDAV) schema.sql database templates/ pagina's (Jinja) -static/ style.css (huisstijltokens), fx.js (GSAP-effecten), logo.svg, viewer.js (3D-viewer), bestek.js +static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), logo.svg, viewer.js (3D-viewer), bestek.js static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git) tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON) demo/modellen standaardmodellen als house-design-JSON voor de demoleads diff --git a/app.py b/app.py index dea856b..193f645 100644 --- a/app.py +++ b/app.py @@ -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/') 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//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//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//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/', methods=['GET', 'POST']) +def mail_template_edit(tid): + t = get_or_404('email_templates', tid) if tid else None + if request.method == 'POST': + f = request.form + data = {'name': f.get('name', '').strip(), 'subject': f.get('subject', '').strip(), 'body': f.get('body', '')} + if not data['name']: + flash('Geef de template een naam.', 'error') + return render_template('mail_template_form.html', t={**(dict(t) if t else {}), **data}, FIELDS=MAIL_FIELDS) + data['updated_at'] = now() + if t: + db().execute(f'UPDATE email_templates SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', + [*data.values(), tid]) + else: + data['created_at'] = data['updated_at'] + db().execute(f'INSERT INTO email_templates ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', + list(data.values())) + db().commit() + flash('Template opgeslagen.') + return redirect(url_for('mail_templates')) + return render_template('mail_template_form.html', t=t or {}, FIELDS=MAIL_FIELDS) + + +@app.post('/mail/templates//delete') +def mail_template_delete(tid): + get_or_404('email_templates', tid) + db().execute('DELETE FROM email_templates WHERE id=?', (tid,)) + db().commit() + flash('Template verwijderd.') + return redirect(url_for('mail_templates')) # ---------------------------------------------------------------- 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/', methods=['GET', 'POST']) +def calendar_edit(eid): + e = q1('SELECT e.*, c.name AS contact_name FROM events e LEFT JOIN contacts c ON c.id=e.contact_id WHERE e.id=?', + eid) or abort(404) if eid else None + contacts = q("SELECT id, name, type FROM contacts ORDER BY type='lead' DESC, name") + nxt = request.values.get('next', '') + nxt = nxt if nxt.startswith('/') and not nxt.startswith('//') else '' + if request.method == 'POST': + f = request.form + ev, error = event_from_form(f, e) + if error: + flash(error, 'error') + return render_template('event_form.html', e=e, v=f, contacts=contacts, REPEATS=REPEATS, nxt=nxt) + user_id = f.get('user_id', type=int) + if not user_id or not q1('SELECT id FROM users WHERE id=?', user_id): + user_id = g.user['id'] + contact_id = f.get('contact_id', type=int) or None + if contact_id and not q1('SELECT id FROM contacts WHERE id=?', contact_id): + contact_id = None + ev['uid'] = e['uid'] if e else f'{uuid.uuid4()}@vanemous-crm' + href = e['href'] if e else f"{ev['uid']}.ics" + ics_text = ical.to_ics(ev, url_for('contact_view', cid=contact_id, _external=True) if contact_id else '') + eid = save_event(user_id, ev, ics_text, href, contact_id, eid) + if contact_id and (not e or e['contact_id'] != contact_id): + log(contact_id, 'event', f"Afspraak: {ev['title']}, {event_when(ev)}") + db().commit() + flash('Afspraak opgeslagen.') + return redirect(nxt or url_for('calendar_view', m=ev['starts_at'][:7])) + if e: + v = event_form_values(e) + else: + d = request.args.get('date') or date.today().isoformat() + hour = min(datetime.now().hour + 1, 22) if d == date.today().isoformat() else 9 + v = {'title': '', 'user_id': g.user['id'], 'contact_id': request.args.get('contact_id', type=int), 'location': '', + 'description': '', 'all_day': '', 'start_date': d, 'start_time': f'{hour:02d}:00', 'end_date': d, + 'end_time': f'{hour + 1:02d}:00', 'repeat': ''} + c = q1('SELECT name, address, postcode, city FROM contacts WHERE id=?', v['contact_id']) if v['contact_id'] else None + if c: + v['title'] = f"Afspraak {c['name']}" + v['location'] = ', '.join(filter(None, [c['address'], ' '.join(filter(None, [c['postcode'], c['city']]))])) + return render_template('event_form.html', e=e, v=v, contacts=contacts, REPEATS=REPEATS, nxt=nxt) + + +@app.post('/agenda//delete') +def calendar_delete(eid): + e = get_or_404('events', eid) + db().execute('DELETE FROM events WHERE id=?', (eid,)) + bump_calendar(e['user_id']) + db().commit() + flash('Afspraak verwijderd.') + return redirect(url_for('calendar_view', m=e['starts_at'][:7])) + + +# ---------------------------------------------------------------- CalDAV +# +# Elke gebruiker koppelt zijn eigen agenda met een app-wachtwoord (Mijn account): iPhone/Mac als CalDAV-account +# (server = het CRM-domein; vindt /dav/ via /.well-known/caldav), Thunderbird en DAVx5 (Android) met +# https://domein/dav/. Ondersteund: PROPFIND, PROPPATCH (genegeerd), REPORT calendar-query (met time-range) en +# calendar-multiget, GET/PUT/DELETE van afspraken met ETags; apps synchroniseren via getctag en getetag. +# Paden: /dav/ → /dav/principals// → /dav/calendars// → /dav/calendars//agenda/.ics + +D, C, CS = '{DAV:}', '{urn:ietf:params:xml:ns:caldav}', '{http://calendarserver.org/ns/}' +A = '{http://apple.com/ns/ical/}' +DAV_PREFIX = {'DAV:': 'D', 'urn:ietf:params:xml:ns:caldav': 'C', 'http://calendarserver.org/ns/': 'CS', + 'http://apple.com/ns/ical/': 'A'} +DAV_METHODS = ['OPTIONS', 'GET', 'HEAD', 'PUT', 'DELETE', 'PROPFIND', 'PROPPATCH', 'REPORT', 'MKCALENDAR', 'MKCOL', + 'MOVE', 'COPY'] +DAV_CALENDAR = 'agenda' +DAV_MAX_SIZE = 1024 * 1024 + + +def dav_el(clark, value=None): + ns, _, local = clark[1:].partition('}') + prefix = DAV_PREFIX.get(ns) + tag, decl = (f'{prefix}:{local}', '') if prefix else (f'X:{local}', f' xmlns:X="{xml_escape(ns)}"') + return f'<{tag}{decl}/>' if value in (None, '') else f'<{tag}{decl}>{value}' + + +def dav_href(path): + return f'{xml_escape(path)}' + + +def dav_response(href, found=None, missing=(), status=None): + out = '' + dav_href(href) + if status: + out += f'HTTP/1.1 {status}' + if found: + out += ('' + ''.join(dav_el(k, v) for k, v in found.items()) + + 'HTTP/1.1 200 OK') + if missing: + out += ('' + ''.join(dav_el(k) for k in missing) + + 'HTTP/1.1 404 Not Found') + return out + '' + + +def dav_multistatus(responses): + body = ('\n' + ''.join(responses) + '') + return make_response(body, 207, {'Content-Type': 'application/xml; charset=utf-8'}) + + +def dav_props(props, wanted): + """wanted None (allprop of leeg verzoek): alles; anders (gevonden, ontbrekend).""" + if wanted is None: + return props, [] + return {k: props[k] for k in wanted if k in props}, [k for k in wanted if k not in props] + + +def dav_wanted(root): + if root is None or root.find(f'{D}allprop') is not None or root.find(f'{D}prop') is None: + return None + return [child.tag for child in root.find(f'{D}prop')] + + +def dav_user(): + auth = request.authorization + if not auth or (auth.type or '').lower() != 'basic' or not auth.username: + return None + u = q1('SELECT * FROM users WHERE email=? AND active=1', auth.username.strip()) + if u and u['dav_password_hash'] and check_password_hash(u['dav_password_hash'], auth.password or ''): + return u + return None + + +def dav_paths(u): + principal = f"/dav/principals/{u['id']}/" + home = f"/dav/calendars/{u['id']}/" + return principal, home, f'{home}{DAV_CALENDAR}/' + + +def dav_base_props(u): + principal, home, _ = dav_paths(u) + return {f'{D}current-user-principal': dav_href(principal), f'{D}principal-collection-set': dav_href('/dav/principals/')} + + +def dav_principal_props(u): + principal, home, _ = dav_paths(u) + return {**dav_base_props(u), f'{D}resourcetype': '', f'{D}displayname': xml_escape(u['name']), + f'{D}principal-URL': dav_href(principal), f'{C}calendar-home-set': dav_href(home), + f'{C}calendar-user-address-set': dav_href('mailto:' + u['email'])} + + +def dav_calendar_props(u): + principal = dav_paths(u)[0] + privs = ''.join(f'' for p in + ('read', 'write', 'write-content', 'write-properties', 'bind', 'unbind', + 'read-current-user-privilege-set')) + reports = ''.join(f'' + for r in ('calendar-multiget', 'calendar-query')) + return {**dav_base_props(u), f'{D}resourcetype': '', + f'{D}displayname': xml_escape(f"Van Emous CRM · {u['name']}"), f'{D}owner': dav_href(principal), + f'{CS}getctag': str(u['cal_sync']), f'{C}supported-calendar-component-set': '', + f'{C}supported-calendar-data': '', + f'{C}calendar-description': 'Afspraken uit de Van Emous CRM', f'{A}calendar-color': '#B95913FF', + f'{C}max-resource-size': str(DAV_MAX_SIZE), f'{D}current-user-privilege-set': privs, + f'{D}supported-report-set': reports} + + +def dav_event_props(e, with_data=False): + props = {f'{D}getetag': xml_escape(f'"{e["etag"]}"'), f'{D}resourcetype': '', + f'{D}getcontenttype': 'text/calendar; charset=utf-8; component=vevent', + f'{D}getcontentlength': str(len(e['ics'].encode('utf-8')))} + if with_data: + props[f'{C}calendar-data'] = xml_escape(e['ics']) + return props + + +def dav_event_response(u, e, wanted): + want_data = bool(wanted) and f'{C}calendar-data' in wanted + found, missing = dav_props(dav_event_props(e, want_data), wanted) + return dav_response(dav_paths(u)[2] + url_quote(e['href']), found, missing) + + +def dav_body(): + data = request.get_data() + if not data.strip(): + return None + try: + return ET.fromstring(data) + except ET.ParseError: + abort(400) + + +def dav_time_range(root): + """time-range uit een calendar-query (UTC) → lokale (start, eind), of None.""" + tr = root.find(f'.//{C}time-range') if root is not None else None + if tr is None: + return None + try: + start = ical.parse_dt(tr.get('start'), {})[0] if tr.get('start') else datetime(1970, 1, 1) + end = ical.parse_dt(tr.get('end'), {})[0] if tr.get('end') else datetime(2200, 1, 1) + except ValueError: + return None + return (start if isinstance(start, datetime) else datetime.combine(start, datetime.min.time()), + end if isinstance(end, datetime) else datetime.combine(end, datetime.min.time())) + + +@app.route('/.well-known/caldav', methods=['GET', 'HEAD', 'PROPFIND', 'OPTIONS'], provide_automatic_options=False) +def dav_wellknown(): + return redirect('/dav/', 301) + + +@app.route('/dav/', defaults={'path': ''}, methods=DAV_METHODS, provide_automatic_options=False) +@app.route('/dav/', methods=DAV_METHODS, provide_automatic_options=False) +def dav(path): + allow = ', '.join(DAV_METHODS) + if request.method == 'OPTIONS': + return make_response('', 200, {'DAV': '1, 3, calendar-access', 'Allow': allow, 'MS-Author-Via': 'DAV'}) + u = dav_user() + if not u: + return make_response('Inloggen met e-mailadres en app-wachtwoord (Mijn account in de CRM).', 401, + {'WWW-Authenticate': 'Basic realm="Van Emous CRM agenda", charset="UTF-8"'}) + parts = [p for p in path.split('/') if p] + if len(parts) >= 2 and parts[1] != str(u['id']): + return make_response('Geen toegang tot deze agenda', 403) + principal, home, cal = dav_paths(u) + method = request.method + depth = request.headers.get('Depth', '1') + + # agenda-afspraak + if len(parts) == 4 and parts[0] == 'calendars' and parts[2] == DAV_CALENDAR: + return dav_event(u, unquote(parts[3]), method) + if len(parts) > 3 or (len(parts) == 3 and (parts[0] != 'calendars' or parts[2] != DAV_CALENDAR)): + return make_response('Niet gevonden', 404) + + if method in ('GET', 'HEAD'): + return make_response('Van Emous CRM: CalDAV. Koppel de agenda in je agenda-app met dit adres.', 200, + {'Content-Type': 'text/plain; charset=utf-8'}) + if method == 'PROPPATCH': # kleur of naam vanuit de app: accepteren maar niet bewaren + root = dav_body() + tags = [c.tag for p in (root.iter(f'{D}prop') if root is not None else []) for c in p] + return dav_multistatus([dav_response('/dav/' + path, {t: None for t in tags})]) + if method == 'REPORT': + if len(parts) != 3: + return make_response('REPORT alleen op de agenda', 403) + return dav_report(u, dav_body()) + if method != 'PROPFIND': + return make_response('Niet toegestaan', 405, {'Allow': allow}) + + wanted = dav_wanted(dav_body()) + out = [] + if not parts: + props = {**dav_base_props(u), f'{D}resourcetype': '', f'{D}displayname': 'Van Emous CRM'} + out.append(dav_response('/dav/', *dav_props(props, wanted))) + if depth != '0': + out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) + elif parts[0] == 'principals': + if len(parts) == 1: + out.append(dav_response('/dav/principals/', *dav_props( + {**dav_base_props(u), f'{D}resourcetype': ''}, wanted))) + if depth != '0': + out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) + else: + out.append(dav_response(principal, *dav_props(dav_principal_props(u), wanted))) + elif parts[0] == 'calendars' and len(parts) == 2: + props = {**dav_base_props(u), f'{D}resourcetype': '', f'{D}owner': dav_href(principal), + f'{D}displayname': xml_escape(u['name'])} + out.append(dav_response(home, *dav_props(props, wanted))) + if depth != '0': + out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted))) + elif parts[0] == 'calendars' and len(parts) == 3: + out.append(dav_response(cal, *dav_props(dav_calendar_props(u), wanted))) + if depth != '0': + out += [dav_event_response(u, e, wanted or [f'{D}getetag', f'{D}getcontenttype', f'{D}resourcetype']) + for e in q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id'])] + else: + return make_response('Niet gevonden', 404) + return dav_multistatus(out) + + +def dav_report(u, root): + if root is None: + abort(400) + wanted = dav_wanted(root) or [f'{D}getetag'] + if root.tag == f'{C}calendar-multiget': + out = [] + for h in root.iter(f'{D}href'): + name = unquote((h.text or '').rstrip('/').rsplit('/', 1)[-1]) + e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name) + out.append(dav_event_response(u, e, wanted) if e else dav_response(h.text or '', status='404 Not Found')) + return dav_multistatus(out) + if root.tag == f'{C}calendar-query': + span = dav_time_range(root) + events = q('SELECT * FROM events WHERE user_id=? ORDER BY id', u['id']) + if span: + events = [e for e in events if ical.occurrences(dict(e), *span)] + return dav_multistatus([dav_event_response(u, e, wanted) for e in events]) + return make_response('', + 403, {'Content-Type': 'application/xml; charset=utf-8'}) + + +def dav_event(u, name, method): + if '/' in name or not name or len(name) > 255: + return make_response('Ongeldige naam', 400) + e = q1('SELECT * FROM events WHERE user_id=? AND href=?', u['id'], name) + etag = f'"{e["etag"]}"' if e else None + if_match, if_none = request.headers.get('If-Match'), request.headers.get('If-None-Match') + if method in ('GET', 'HEAD'): + if not e: + return make_response('Niet gevonden', 404) + if if_none and etag in [t.strip() for t in if_none.split(',')]: + return make_response('', 304, {'ETag': etag}) + return make_response(e['ics'], 200, {'Content-Type': 'text/calendar; charset=utf-8', 'ETag': etag}) + if method == 'PROPFIND': + if not e: + return make_response('Niet gevonden', 404) + return dav_multistatus([dav_event_response(u, e, dav_wanted(dav_body()))]) + if method == 'DELETE': + if not e: + return make_response('Niet gevonden', 404) + if if_match and if_match.strip() not in ('*', etag): + return make_response('Gewijzigd op de server', 412) + db().execute('DELETE FROM events WHERE id=?', (e['id'],)) + bump_calendar(u['id']) + db().commit() + return make_response('', 204) + if method == 'PUT': + if (if_none or '').strip() == '*' and e: + return make_response('Bestaat al', 412) + if if_match and (not e or if_match.strip() not in ('*', etag)): + return make_response('Gewijzigd op de server', 412) + data = request.get_data() + if len(data) > DAV_MAX_SIZE: + return make_response('Te groot', 413) + text = data.decode('utf-8', 'replace') + try: + ev = ical.from_ics(text) + except ValueError: + ev = None + if not ev or not ev['uid']: + return make_response('Alleen afspraken (VEVENT met UID) worden opgeslagen', 415) + if q1('SELECT id FROM events WHERE user_id=? AND uid=? AND href<>?', u['id'], ev['uid'], name): + return make_response('Er is al een afspraak met deze UID', 409) + save_event(u['id'], ev, text, name, e['contact_id'] if e else None, e['id'] if e else None) + db().commit() + new = q1('SELECT etag FROM events WHERE user_id=? AND href=?', u['id'], name) + return make_response('', 204 if e else 201, {'ETag': f'"{new["etag"]}"'}) + return make_response('Niet toegestaan', 405, {'Allow': 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND'}) + + # ---------------------------------------------------------------- demo DEMO_LEADS = [ diff --git a/ical.py b/ical.py new file mode 100644 index 0000000..3cd4361 --- /dev/null +++ b/ical.py @@ -0,0 +1,310 @@ +"""iCalendar (RFC 5545) lezen en schrijven voor de agenda en CalDAV, alleen met de standaardbibliotheek. + +Een afspraak is een dict met uid, title, location, description, starts_at, ends_at, all_day, rrule en exdates. +Tijden zijn lokaal (Europe/Amsterdam) als 'YYYY-MM-DD HH:MM:SS'; een hele dag is 'YYYY-MM-DD' met een +exclusief einde (de dag ná de laatste dag), zoals in iCalendar. exdates is een kommalijst in hetzelfde formaat. +""" +import calendar +import re +from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +TZ_NAME = 'Europe/Amsterdam' +TZ = ZoneInfo(TZ_NAME) +# Outlook/Exchange sturen Windows-namen mee +WINDOWS_TZ = {'W. Europe Standard Time': TZ_NAME, 'Romance Standard Time': 'Europe/Brussels', + 'Central Europe Standard Time': 'Europe/Budapest', 'GMT Standard Time': 'Europe/London', + 'UTC': 'UTC'} +WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + +VTIMEZONE = """BEGIN:VTIMEZONE +TZID:Europe/Amsterdam +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE""".split('\n') + + +# ---------------------------------------------------------------- lezen + +def _parse_line(line): + """'NAAM;PARAM=waarde:inhoud' → (NAAM, {PARAM: waarde}, inhoud); dubbelepunten tussen aanhalingstekens tellen niet.""" + quoted = False + for i, ch in enumerate(line): + if ch == '"': + quoted = not quoted + elif ch == ':' and not quoted: + head, value = line[:i], line[i + 1:] + break + else: + return None + parts = re.findall(r'(?:[^;"]|"[^"]*")+', head) + if not parts: + return None + params = {} + for p in parts[1:]: + k, _, v = p.partition('=') + params[k.strip().upper()] = v.strip().strip('"') + return parts[0].strip().upper(), params, value + + +def parse(text): + """Componentenboom: [{'name': 'VCALENDAR', 'props': [(naam, params, waarde)], 'subs': [...]}].""" + root = {'name': None, 'props': [], 'subs': []} + stack = [root] + for raw in re.sub(r'\r?\n[ \t]', '', text).splitlines(): + parsed = _parse_line(raw) if raw.strip() else None + if not parsed: + continue + name, params, value = parsed + if name == 'BEGIN': + comp = {'name': value.strip().upper(), 'props': [], 'subs': []} + stack[-1]['subs'].append(comp) + stack.append(comp) + elif name == 'END': + if len(stack) > 1: + stack.pop() + else: + stack[-1]['props'].append((name, params, value)) + return root['subs'] + + +def _prop(comp, name): + return next((p for p in comp['props'] if p[0] == name), None) + + +def unescape(value): + return re.sub(r'\\([\\;,nN])', lambda m: '\n' if m.group(1) in 'nN' else m.group(1), value) + + +def _zone(tzid): + tzid = WINDOWS_TZ.get(tzid, tzid) + for candidate in (tzid, '/'.join(tzid.split('/')[-2:])): # ook '/mozilla.org/20050126_1/Europe/Amsterdam' + try: + return ZoneInfo(candidate) + except (KeyError, ValueError, OSError): + continue + return None + + +def parse_dt(value, params): + """iCalendar-datum of -tijd → (date, True) of (lokale naïeve datetime, False).""" + v = value.strip() + if params.get('VALUE', '').upper() == 'DATE' or re.fullmatch(r'\d{8}', v): + return date(int(v[:4]), int(v[4:6]), int(v[6:8])), True + m = re.fullmatch(r'(\d{8})T(\d{4,6})(Z?)', v) + if not m: + raise ValueError(f'onbekende datum {value!r}') + dt = datetime.strptime(m.group(1) + m.group(2).ljust(6, '0'), '%Y%m%d%H%M%S') + zone = timezone.utc if m.group(3) else (_zone(params['TZID']) if params.get('TZID') else None) + if zone: + dt = dt.replace(tzinfo=zone).astimezone(TZ).replace(tzinfo=None) + return dt, False + + +def parse_duration(value): + m = re.fullmatch(r'([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?', value.strip()) + if not m: + return timedelta(0) + sign, w, d, h, mi, s = m.groups() + td = timedelta(weeks=int(w or 0), days=int(d or 0), hours=int(h or 0), minutes=int(mi or 0), seconds=int(s or 0)) + return -td if sign == '-' else td + + +def fmt_local(value): + return value.isoformat() if not isinstance(value, datetime) else value.strftime('%Y-%m-%d %H:%M:%S') + + +def from_ics(text): + """Eerste (hoofd)afspraak uit een kalenderobject, of None als er geen VEVENT in zit.""" + events = [s for c in parse(text) if c['name'] == 'VCALENDAR' for s in c['subs'] if s['name'] == 'VEVENT'] + if not events: + return None + ev = next((e for e in events if not _prop(e, 'RECURRENCE-ID')), events[0]) + start_p = _prop(ev, 'DTSTART') + if not start_p: + return None + start, all_day = parse_dt(start_p[2], start_p[1]) + end_p, dur_p = _prop(ev, 'DTEND'), _prop(ev, 'DURATION') + if end_p: + end, _ = parse_dt(end_p[2], end_p[1]) + if all_day and isinstance(end, datetime): + end = end.date() + elif not all_day and not isinstance(end, datetime): + end = datetime.combine(end, datetime.min.time()) + elif dur_p: + end = start + parse_duration(dur_p[2]) + else: + end = start + timedelta(days=1) if all_day else start + if all_day and end <= start: + end = start + timedelta(days=1) + exdates = [] + for name, params, value in ev['props']: + if name == 'EXDATE': + for v in value.split(','): + try: + exdates.append(fmt_local(parse_dt(v, params)[0])) + except ValueError: + pass + text_of = lambda n: unescape(_prop(ev, n)[2]) if _prop(ev, n) else '' + rrule = _prop(ev, 'RRULE') + return {'uid': text_of('UID').strip(), 'title': text_of('SUMMARY').strip(), 'location': text_of('LOCATION').strip(), + 'description': text_of('DESCRIPTION').strip(), 'starts_at': fmt_local(start), 'ends_at': fmt_local(end), + 'all_day': 1 if all_day else 0, 'rrule': rrule[2].strip() if rrule else '', 'exdates': ','.join(exdates)} + + +# ---------------------------------------------------------------- schrijven + +def escape(value): + return (str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,') + .replace('\r\n', '\\n').replace('\n', '\\n')) + + +def _fold(line): + """Regels langer dan 75 octetten vouwen (zonder een UTF-8-teken te breken).""" + out, cur, size = [], '', 0 + for ch in line: + n = len(ch.encode('utf-8')) + if size + n > 75: + out.append(cur) + cur, size = ' ', 1 + cur += ch + size += n + out.append(cur) + return '\r\n'.join(out) + + +def _ical_dt(value, all_day): + if all_day: + return f";VALUE=DATE:{value.replace('-', '')[:8]}" + return f";TZID={TZ_NAME}:{datetime.strptime(value, '%Y-%m-%d %H:%M:%S').strftime('%Y%m%dT%H%M%S')}" + + +def to_ics(ev, url=''): + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Van Emous//CRM//NL', 'CALSCALE:GREGORIAN'] + if not ev['all_day']: + lines += VTIMEZONE + lines += ['BEGIN:VEVENT', f"UID:{ev['uid']}", f'DTSTAMP:{stamp}', f'LAST-MODIFIED:{stamp}', + 'DTSTART' + _ical_dt(ev['starts_at'], ev['all_day']), 'DTEND' + _ical_dt(ev['ends_at'], ev['all_day']), + f"SUMMARY:{escape(ev['title'])}"] + if ev.get('location'): + lines.append(f"LOCATION:{escape(ev['location'])}") + if ev.get('description'): + lines.append(f"DESCRIPTION:{escape(ev['description'])}") + if url: + lines.append(f'URL:{url}') + if ev.get('rrule'): + lines.append(f"RRULE:{ev['rrule']}") + for x in filter(None, (ev.get('exdates') or '').split(',')): + lines.append('EXDATE' + _ical_dt(x, ev['all_day'])) + lines += ['END:VEVENT', 'END:VCALENDAR'] + return '\r\n'.join(_fold(l) for l in lines) + '\r\n' + + +# ---------------------------------------------------------------- herhalingen + +def to_dt(value): + return datetime.strptime(value, '%Y-%m-%d %H:%M:%S') if len(value) > 10 else datetime.strptime(value, '%Y-%m-%d') + + +def parse_rrule(rrule): + return {k.upper(): v for k, _, v in (p.partition('=') for p in rrule.split(';') if p)} + + +def simple_freq(rrule): + """'WEEKLY' voor een eenvoudige herhaling zoals het CRM-formulier hem maakt, anders '' (dan blijft hij staan).""" + r = parse_rrule(rrule or '') + return r['FREQ'] if set(r) == {'FREQ'} and r['FREQ'] in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') else '' + + +def _nth_weekday(year, month, n, wd): + days = [d for d in range(1, calendar.monthrange(year, month)[1] + 1) if date(year, month, d).weekday() == wd] + try: + return days[n - 1] if n > 0 else days[n] + except IndexError: + return None + + +def _candidates(start, rule): + freq, interval = rule.get('FREQ', ''), max(1, int(rule.get('INTERVAL') or 1)) + byday = [d for d in (rule.get('BYDAY') or '').split(',') if d] + bymonthday = [int(d) for d in (rule.get('BYMONTHDAY') or '').split(',') if re.fullmatch(r'-?\d+', d)] + i = 0 + while True: + if freq == 'DAILY': + yield start + timedelta(days=i * interval) + elif freq == 'WEEKLY': + week = start - timedelta(days=start.weekday()) + timedelta(weeks=i * interval) + days = sorted({WEEKDAYS.index(d[-2:]) for d in byday if d[-2:] in WEEKDAYS}) or [start.weekday()] + for d in days: + c = week + timedelta(days=d) + if c >= start: + yield c + elif freq == 'MONTHLY': + y, m = divmod(start.month - 1 + i * interval, 12) + y, m = start.year + y, m + 1 + last = calendar.monthrange(y, m)[1] + if byday: + found = [] + for spec in byday: + mm = re.fullmatch(r'([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)', spec) + if mm: + day = _nth_weekday(y, m, int(mm.group(1) or 1), WEEKDAYS.index(mm.group(2))) + if day: + found.append(day) + else: + found = [d if d > 0 else last + d + 1 for d in (bymonthday or [start.day])] + for d in sorted(set(found)): + if 1 <= d <= last: + c = start.replace(year=y, month=m, day=d) + if c >= start: + yield c + elif freq == 'YEARLY': + try: + yield start.replace(year=start.year + i * interval) + except ValueError: # 29 februari + pass + else: + return + i += 1 + if i > 5000: + return + + +def occurrences(ev, range_start, range_end): + """Begin- en eindtijden (datetimes) van een afspraak, herhalingen uitgevouwen, die [range_start, range_end) raken.""" + start, end = to_dt(ev['starts_at']), to_dt(ev['ends_at'] or ev['starts_at']) + dur = max(end - start, timedelta(0)) + hits = lambda s: s < range_end and (s + dur > range_start if dur else s >= range_start) + if not ev.get('rrule'): + return [(start, start + dur)] if hits(start) else [] + rule = parse_rrule(ev['rrule']) + count = int(rule['COUNT']) if (rule.get('COUNT') or '').isdigit() else None + until = None + if rule.get('UNTIL'): + try: + u, is_date = parse_dt(rule['UNTIL'], {}) + until = datetime.combine(u, datetime.max.time()) if is_date else u + except ValueError: + pass + skip = {to_dt(x) for x in (ev.get('exdates') or '').split(',') if x} + out, n = [], 0 + for c in _candidates(start, rule): + if (until and c > until) or c >= range_end or (count is not None and n >= count): + break + n += 1 + if c not in skip and hits(c): + out.append((c, c + dur)) + return out diff --git a/mail.py b/mail.py index 83ccea4..a509d2e 100644 --- a/mail.py +++ b/mail.py @@ -1,9 +1,16 @@ -"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek.""" +"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek. + +Beveiliging per server: 'ssl' (direct TLS, IMAP 993 / SMTP 465), 'starttls' (IMAP 143 / SMTP 587) of 'none'; +leeg = afleiden uit de poort. Met smtp_sent_folder komt een kopie van verstuurde mail in die IMAP-map, voor +servers die dat zelf niet doen (Zimbra, MX Plan; Gmail doet het wel zelf). +""" import email import html import imaplib import re import smtplib +import ssl +import time from datetime import datetime, timedelta from email.message import EmailMessage from email.policy import default as policy @@ -28,17 +35,98 @@ def _text(msg): return re.sub(r'\n{3,}', '\n\n', content).strip() +# Voorinstellingen voor Instellingen › E-mail (de pagina vult de velden in; daarna blijft alles aanpasbaar). +PROVIDERS = { + 'ovh': {'label': 'OVHcloud Zimbra / MX Plan (Europa)', 'imap_host': 'imap.mail.ovh.net', 'imap_port': '993', + 'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.net', 'smtp_port': '465', 'smtp_security': 'ssl', + 'smtp_sent_folder': 'Sent'}, + 'ovh-ca': {'label': 'OVHcloud Zimbra / MX Plan (Amerika, Azië)', 'imap_host': 'imap.mail.ovh.ca', + 'imap_port': '993', 'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.ca', 'smtp_port': '465', + 'smtp_security': 'ssl', 'smtp_sent_folder': 'Sent'}, + 'gmail': {'label': 'Gmail / Google Workspace (app-wachtwoord)', 'imap_host': 'imap.gmail.com', 'imap_port': '993', + 'imap_security': 'ssl', 'smtp_host': 'smtp.gmail.com', 'smtp_port': '587', 'smtp_security': 'starttls', + 'smtp_sent_folder': ''}, +} +SECURITY = [('', 'Automatisch (uit de poort)'), ('ssl', 'SSL/TLS'), ('starttls', 'STARTTLS'), ('none', 'Geen')] + + +def _security(cfg, kind, ssl_port): + sec = (cfg.get(f'{kind}_security') or '').lower() + return sec if sec in ('ssl', 'starttls', 'none') else ('ssl' if int(cfg.get(f'{kind}_port') or ssl_port) == ssl_port else 'starttls') + + def _connect_imap(cfg): host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993) - if port == 993: - conn = imaplib.IMAP4_SSL(host, port, timeout=30) + sec = _security(cfg, 'imap', 993) + if sec == 'ssl': + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30) else: conn = imaplib.IMAP4(host, port, timeout=30) - conn.starttls() + if sec == 'starttls': + conn.starttls(ssl.create_default_context()) conn.login(cfg['imap_user'], cfg.get('imap_pass', '')) return conn +def _connect_smtp(cfg): + host, port = cfg.get('smtp_host'), int(cfg.get('smtp_port') or 587) + if not host: + raise RuntimeError('Geen SMTP-server ingesteld') + sec = _security(cfg, 'smtp', 465) + if sec == 'ssl': + server = smtplib.SMTP_SSL(host, port, timeout=30, context=ssl.create_default_context()) + else: + server = smtplib.SMTP(host, port, timeout=30) + if sec == 'starttls': + server.starttls(context=ssl.create_default_context()) + user = cfg.get('smtp_user') or cfg.get('imap_user', '') + if user: + server.login(user, cfg.get('smtp_pass') or cfg.get('imap_pass', '')) + return server + + +def _quote(folder): + return '"' + folder.replace('\\', '\\\\').replace('"', '\\"') + '"' + + +def folders(conn): + names = [] + for line in conn.list()[1] or []: + m = re.match(rb'\((?P[^)]*)\) (?:"[^"]*"|NIL) (?P.+)$', line or b'') + if m and b'\\noselect' not in m.group('flags').lower(): + names.append(m.group('name').decode('utf-8', 'replace').strip().strip('"')) + return names + + +def test(cfg): + """Verbindingen proberen zonder iets te versturen: [(gelukt, melding)].""" + out = [] + if cfg.get('imap_host'): + try: + conn = _connect_imap(cfg) + try: + names = folders(conn) + folder = cfg.get('imap_folder') or 'INBOX' + out.append((True, f"IMAP: ingelogd op {cfg['imap_host']}; mappen: {', '.join(names[:12]) or '—'}")) + if conn.select(_quote(folder), readonly=True)[0] != 'OK': + out.append((False, f'IMAP: map "{folder}" niet gevonden')) + sent = cfg.get('smtp_sent_folder') + if sent and sent not in names: + out.append((False, f'IMAP: map voor verzonden mail "{sent}" niet gevonden')) + finally: + conn.logout() + except Exception as e: + out.append((False, f"IMAP: {e or type(e).__name__}")) + else: + out.append((False, 'IMAP: geen server ingesteld')) + try: + with _connect_smtp(cfg): + out.append((True, f"SMTP: ingelogd op {cfg['smtp_host']}")) + except Exception as e: + out.append((False, f"SMTP: {e or type(e).__name__}")) + return out + + def fetch(cfg, known, limit=200): """Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan.""" folder = cfg.get('imap_folder') or 'INBOX' @@ -48,7 +136,7 @@ def fetch(cfg, known, limit=200): conn = _connect_imap(cfg) try: - typ, _ = conn.select(f'"{folder}"', readonly=True) + typ, _ = conn.select(_quote(folder), readonly=True) if typ != 'OK': raise RuntimeError(f'Map "{folder}" niet gevonden') _, data = conn.uid('search', None, 'SINCE', since_str) @@ -85,32 +173,42 @@ def fetch(cfg, known, limit=200): pass -def send(cfg, to, subject, body, in_reply_to=None): - host = cfg.get('smtp_host') - if not host: - raise RuntimeError('Geen SMTP-server ingesteld') +def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()): + """Versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een waarschuwing. + attachments = [(bestandsnaam, bytes, mimetype)].""" user = cfg.get('smtp_user') or cfg.get('imap_user', '') - password = cfg.get('smtp_pass') or cfg.get('imap_pass', '') - port = int(cfg.get('smtp_port') or 587) - + sender = cfg.get('smtp_from') or user msg = EmailMessage() - msg['From'] = cfg.get('smtp_from') or user + msg['From'] = sender msg['To'] = to + if cc: + msg['Cc'] = cc msg['Subject'] = subject msg['Date'] = formatdate(localtime=True) - msg['Message-ID'] = make_msgid() + domain = parseaddr(sender)[1].rpartition('@')[2] or None + msg['Message-ID'] = make_msgid(domain=domain) if in_reply_to: msg['In-Reply-To'] = in_reply_to msg['References'] = in_reply_to msg.set_content(body) + for name, data, ctype in attachments: + maintype, _, subtype = (ctype or 'application/octet-stream').partition('/') + msg.add_attachment(data, maintype=maintype, subtype=subtype or 'octet-stream', filename=name) - if port == 465: - server = smtplib.SMTP_SSL(host, port, timeout=30) - else: - server = smtplib.SMTP(host, port, timeout=30) - server.starttls() - with server: - if user: - server.login(user, password) + with _connect_smtp(cfg) as server: server.send_message(msg) - return msg['Message-ID'] + + warning = None + folder = cfg.get('smtp_sent_folder') + if folder and cfg.get('imap_host'): + try: + conn = _connect_imap(cfg) + try: + typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes()) + if typ != 'OK': + raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ) + finally: + conn.logout() + except Exception as e: + warning = f'Verstuurd, maar niet bewaard in "{folder}": {e}' + return msg['Message-ID'], warning diff --git a/schema.sql b/schema.sql index a3e967a..a582d04 100644 --- a/schema.sql +++ b/schema.sql @@ -145,10 +145,24 @@ CREATE TABLE IF NOT EXISTS emails ( subject TEXT DEFAULT '', body TEXT DEFAULT '', date TEXT NOT NULL, - is_read INTEGER NOT NULL DEFAULT 0 + is_read INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + cc TEXT DEFAULT '', + attachments TEXT DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_addr); +-- E-mailtemplates: onderwerp en tekst met velden als {voornaam}, ingevuld bij het opstellen of versturen +-- (MAIL_FIELDS in app.py). +CREATE TABLE IF NOT EXISTS email_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + subject TEXT DEFAULT '', + body TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, @@ -158,3 +172,29 @@ CREATE TABLE IF NOT EXISTS tasks ( done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); + +-- Agenda: één agenda per gebruiker, ook via CalDAV (/dav/). `ics` is het kalenderobject zoals een +-- agenda-app het stuurde (of zoals de CRM het maakte), de losse kolommen zijn daaruit gehaald voor weergave. +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + uid TEXT NOT NULL, + href TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + location TEXT DEFAULT '', + description TEXT DEFAULT '', + starts_at TEXT NOT NULL, + ends_at TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + rrule TEXT DEFAULT '', + exdates TEXT DEFAULT '', + ics TEXT NOT NULL, + etag TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_events_href ON events(user_id, href); +CREATE UNIQUE INDEX IF NOT EXISTS idx_events_uid ON events(user_id, uid); +CREATE INDEX IF NOT EXISTS idx_events_start ON events(starts_at); +CREATE INDEX IF NOT EXISTS idx_events_contact ON events(contact_id); diff --git a/static/compose.js b/static/compose.js new file mode 100644 index 0000000..bf3cad7 --- /dev/null +++ b/static/compose.js @@ -0,0 +1,27 @@ +// E-mail opstellen (templates/_compose.html): een template zet zijn tekst boven het bericht (of vervangt de tekst die +// de vorige template daar zette) en vult een leeg onderwerp. Velden als {voornaam} worden ingevuld met data-vars; wat +// overblijft, vult de server bij het versturen in. +document.querySelectorAll('form.compose').forEach((form) => { + const pick = form.querySelector('select[name="template"]'); + if (!pick) return; + const vars = JSON.parse(form.dataset.vars || '{}'); + const fill = (text) => text.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? vars[k] : m)); + const subject = form.elements.subject, body = form.elements.body; + let added = '', addedSubject = ''; + function apply() { + const o = pick.selectedOptions[0]; + const on = o && o.value; + const rest = (added && body.value.startsWith(added) ? body.value.slice(added.length) : body.value).replace(/^\n+/, ''); + const text = on ? fill(o.dataset.body || '') : ''; + added = text && rest ? `${text}\n\n` : text; + body.value = added + rest; + if (!subject.value.trim() || subject.value === addedSubject) { + addedSubject = on ? fill(o.dataset.subject || '') : ''; + subject.value = addedSubject; + } + body.focus(); + body.setSelectionRange(text.length, text.length); + } + pick.addEventListener('change', apply); + if (pick.value) apply(); +}); diff --git a/static/search.js b/static/search.js new file mode 100644 index 0000000..0af521e --- /dev/null +++ b/static/search.js @@ -0,0 +1,58 @@ +/* Zoeken in de bovenbalk: live resultaten (search_live) onder het veld; Enter opent de volledige zoekpagina. + * Pijltjes lopen door de resultaten, Escape sluit, "/" springt naar het zoekveld. */ +(() => { + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + + document.querySelectorAll('form.tsearch').forEach((form) => { + const input = form.querySelector('input[name=q]'); + const box = form.querySelector('.tsearch-results'); + let timer = null, ctrl = null, last = ''; + + const items = () => [...box.querySelectorAll('a')]; + const close = () => { box.classList.remove('open'); input.setAttribute('aria-expanded', 'false'); }; + const open = () => { if (box.innerHTML) { box.classList.add('open'); input.setAttribute('aria-expanded', 'true'); } }; + + const render = (data) => { + box.innerHTML = data.groups.length + ? data.groups.map((g) => `
${esc(g.label)}
` + g.items.map((i) => + `${esc(i.title)}${i.sub ? `${esc(i.sub)}` : ''}`).join('')).join('') + + `Alle resultaten voor “${esc(data.q)}”` + : `
Niets gevonden voor “${esc(data.q)}”.
`; + open(); + }; + + input.addEventListener('input', () => { + clearTimeout(timer); + const v = input.value.trim(); + if (v.length < 2) { box.innerHTML = ''; close(); return; } + timer = setTimeout(() => { + if (v === last && box.innerHTML) { open(); return; } + if (ctrl) ctrl.abort(); + ctrl = new AbortController(); + fetch(`${form.dataset.live}?q=${encodeURIComponent(v)}`, { signal: ctrl.signal, headers: { Accept: 'application/json' } }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (d && input.value.trim() === v) { last = v; render(d); } }) + .catch(() => {}); + }, 180); + }); + + form.addEventListener('keydown', (e) => { + const list = items(), at = list.indexOf(document.activeElement); + if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(); list[Math.min(at + 1, list.length - 1)].focus(); } + else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); (at <= 0 ? input : list[at - 1]).focus(); } + else if (e.key === 'Escape') { close(); input.focus(); } + }); + input.addEventListener('focus', () => { if (input.value.trim().length >= 2) open(); }); + document.addEventListener('click', (e) => { if (!form.contains(e.target)) close(); }); + + // mobiel: het veld krijgt de focus zodra het uitklapmenu opengaat + const toggle = form.closest('.dropdown')?.querySelector('[data-bs-toggle="dropdown"]'); + if (toggle) toggle.addEventListener('shown.bs.dropdown', () => input.focus()); + }); + + document.addEventListener('keydown', (e) => { + if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select, [contenteditable]')) return; + const input = [...document.querySelectorAll('form.tsearch input[name=q]')].find((i) => i.offsetParent); + if (input) { e.preventDefault(); input.focus(); input.select(); } + }); +})(); diff --git a/static/style.css b/static/style.css index 1cb47f6..95b8f48 100644 --- a/static/style.css +++ b/static/style.css @@ -72,15 +72,41 @@ a:focus-visible, button:focus-visible, .btn:focus-visible { outline: 3px solid v .active .count { background: var(--white); } .topbar { position: sticky; top: 0; z-index: 1020; background: var(--white); border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.75rem; } -.topbar img { width: 120px; display: block; } +.topbar-logo img { width: 120px; display: block; } .topbar .menu-btn { border: 1px solid var(--line); background: var(--white); border-radius: var(--radius-pill); padding: 0.45rem 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 600; color: var(--ink); } .topbar .menu-btn svg { width: 20px; height: 20px; } -.topbar .count { margin-left: auto; } +.topbar-tools { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } +.icon-btn { width: 40px; height: 40px; border: 1px solid var(--line); background: var(--white); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--ink); padding: 0; } +.icon-btn svg { width: 20px; height: 20px; } +.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: var(--orange-tint-soft); border-color: var(--orange-line); } +.new-btn { --bs-btn-padding-x: 0.9rem; height: 40px; } +.new-btn svg { width: 18px; height: 18px; } +@media (max-width: 575.98px) { .new-btn { width: 40px; padding: 0; } } +.new-menu { --bs-dropdown-link-active-bg: var(--orange); --bs-dropdown-border-color: var(--line); --bs-dropdown-border-radius: var(--radius-md); min-width: 12rem; padding: 0.4rem; } +.new-menu .dropdown-item { border-radius: var(--radius-sm); padding: 0.45rem 0.8rem; font-weight: 600; } +.new-menu .dropdown-item:hover { background: var(--orange-tint-soft); } + +/* zoeken: live resultaten onder het veld (desktop) of in het uitklapmenu (mobiel) */ +.tsearch { position: relative; } +.tsearch-icon { position: absolute; left: 0.8rem; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--ink-muted); pointer-events: none; } +.tsearch input { padding-left: 2.4rem; border-radius: var(--radius-pill); width: 340px; max-width: 100%; padding-top: 0.45rem; padding-bottom: 0.45rem; } +.tsearch-results { display: none; position: absolute; right: 0; top: calc(100% + 0.4rem); width: min(440px, 90vw); max-height: 70vh; overflow-y: auto; background: var(--white); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); padding: 0.4rem; z-index: 1030; } +.tsearch-results.open { display: block; } +.ts-group { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0.5rem 0.6rem 0.2rem; } +.ts-item { display: block; padding: 0.4rem 0.6rem; border-radius: var(--radius-sm); color: var(--ink); line-height: 1.3; } +.ts-item small { display: block; color: var(--ink-muted); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ts-item:hover, .ts-item:focus { background: var(--orange-tint-soft); text-decoration: none; outline: none; } +.ts-all { display: block; text-align: center; padding: 0.5rem; border-top: 1px solid var(--line); margin-top: 0.3rem; } +.ts-empty { color: var(--ink-muted); padding: 0.6rem; } +.tsearch-pop { width: calc(100vw - 2rem); padding: 0.75rem; border-radius: var(--radius-md); } +.tsearch-pop .tsearch input { width: 100%; } +.tsearch-pop .tsearch-results { position: static; width: auto; box-shadow: none; border: 0; padding: 0.4rem 0 0; max-height: 60vh; } .app-main { flex: 1; min-width: 0; } .content { padding: 1.25rem 1rem 2rem; max-width: 1400px; } @media (min-width: 992px) { + .topbar { padding: 0.75rem 2.5rem; } .app { display: flex; min-height: 100vh; } .side { width: 240px; flex: none; position: sticky; top: 0; height: 100vh; background: var(--white); border-right: 1px solid var(--line); padding: 1.5rem 1rem; display: flex; flex-direction: column; } .side-panel { display: flex; flex-direction: column; } @@ -301,3 +327,47 @@ tr.link td { transition: background-color 0.2s; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } + +/* ---------- agenda ---------- */ +.u0 { --u: #b95913; } .u1 { --u: #3f6e9e; } .u2 { --u: #5b8a3a; } .u3 { --u: #8a4f9e; } .u4 { --u: #a33f4a; } .u5 { --u: #2f7f7b; } +.cal { width: 100%; table-layout: fixed; border-collapse: collapse; } +.cal th { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-muted); padding: 0 0.4rem 0.4rem; } +.cal td { border: 1px solid var(--line); vertical-align: top; height: 7.5rem; padding: 0.3rem; } +.cal td.out { background: var(--ultra-light); } +.cal td.out .cal-num { color: var(--control); } +.cal-num { display: inline-flex; align-items: center; justify-content: center; min-width: 1.8rem; height: 1.8rem; border-radius: 50%; color: var(--ink); font-weight: 600; font-size: 0.875rem; } +.cal-num:hover { background: var(--orange-tint-soft); text-decoration: none; } +.cal td.today .cal-num { background: var(--orange); color: var(--white); } +.cal-ev { display: block; font-size: 0.8rem; line-height: 1.25; padding: 0.15rem 0.4rem; margin-top: 0.15rem; border-radius: 0.35rem; color: var(--ink); background: color-mix(in srgb, var(--u, #b95913) 12%, white); border-left: 3px solid var(--u, #b95913); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cal-ev:hover { text-decoration: none; background: color-mix(in srgb, var(--u, #b95913) 22%, white); } +.cal-ev .t { font-weight: 400; color: var(--ink-muted); margin-right: 0.25rem; } +.cal-ev.task { background: var(--white); border: 1px dashed var(--control); border-left-width: 1px; font-weight: 400; } +.cal-ev.allday { background: var(--u, #b95913); color: var(--white); } +.cal-ld { display: flex; gap: 0.9rem; padding: 0.7rem 0; border-bottom: 1px solid var(--line); } +.cal-ld:last-child { border-bottom: 0; } +.cal-ldate { width: 3.2rem; flex: none; text-align: center; line-height: 1.1; } +.cal-ldate b { display: block; font-family: var(--font-heading); font-size: 1.4rem; font-weight: 600; } +.cal-ldate span { font-size: 0.75rem; color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.06em; } +.cal-ld.today .cal-ldate b { color: var(--orange); } +.cal-ld .cal-items { flex: 1; min-width: 0; } +.cal-ld .cal-ev { white-space: normal; font-size: 0.9rem; padding: 0.35rem 0.6rem; margin: 0 0 0.3rem; } +.cal-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; font-size: 0.85rem; color: var(--ink-muted); margin-top: 0.75rem; } +.cal-legend span::before { content: ""; display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 3px; background: var(--u); margin-right: 0.35rem; vertical-align: -1px; } +.agenda-row { display: flex; gap: 0.75rem; padding: 0.55rem 0; border-bottom: 1px solid var(--line); align-items: baseline; } +.agenda-row:last-child { border-bottom: 0; } +.agenda-row .when { width: 7.5rem; flex: none; color: var(--ink-muted); font-size: 0.875rem; } +.dav-box code { word-break: break-all; } +.dav-pass { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.15rem; letter-spacing: 0.04em; background: var(--orange-tint-soft); border: 1px solid var(--orange-line); border-radius: var(--radius-sm); padding: 0.5rem 0.8rem; display: inline-block; } + +/* ---------- afdrukken: alleen de inhoud (offerte, bestek) ---------- */ +@media print { + body { background: #fff; } + .side, .topbar, .flash, .no-print { display: none !important; } + .app { display: block; } + .content { padding: 0; max-width: none; } +} + +/* E-mailtemplates: velden om in te voegen */ +.mail-fields { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; } +.mail-fields li { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; } +.mail-fields .btn { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; flex: none; } diff --git a/templates/_compose.html b/templates/_compose.html new file mode 100644 index 0000000..601cbec --- /dev/null +++ b/templates/_compose.html @@ -0,0 +1,29 @@ +{# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next, + template}. Een template zet zijn tekst boven het bericht; velden als {voornaam} vult static/compose.js meteen in met + vars, wat overblijft vult mail_send bij het versturen in. #} +{% macro compose(m, templates, vars, id='m', datalist='', button='Versturen') %} +
+ {% if m.reply_to %}{% endif %} + {% if m.contact_id %}{% endif %} + {% if m.next %}{% endif %} +
+ {% if templates %} +
+ +

{% if vars.naam is defined %}Velden als {voornaam} worden ingevuld met de gegevens van {{ vars.naam or 'het contact' }}.{% else %}Velden als {voornaam} worden bij het versturen ingevuld met het contact van het adres in Aan.{% endif %}

+
+ {% endif %} +
+
+
+
+
+
+
+
+ +{% endmacro %} diff --git a/templates/_quote_doc.html b/templates/_quote_doc.html new file mode 100644 index 0000000..3230d89 --- /dev/null +++ b/templates/_quote_doc.html @@ -0,0 +1,121 @@ +{# Offerte als document (A4): in de CRM binnen het menu (quote_print.html), in het portaal los (quote_portal.html). #} + +
+
+
+ {{ s.company_name }} +
+ {{ s.company_name }}
+ {{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join('
'|safe) }} +
+
+ +
+
+
Offerte voor
+ {% if c %} + {{ c.name }}
+ {{ [c.relation_name if c.relation_kind == 'klant' and c.relation_name != c.name, c.company, c.address, (c.postcode ~ ' ' ~ c.city)|trim]|select|join('
'|safe) }} + {% else %}—{% endif %} +
+ + + + {% if qt.valid_until %}{% endif %} +
Offertenummer{{ qt.number }}
Datum{{ qt.date|dt('%d-%m-%Y') }}
Geldig tot{{ qt.valid_until|dt('%d-%m-%Y') }}
+
+ +

Offerte{% if qt.title %}: {{ qt.title }}{% endif %}

+ {% if qt.intro %}
{{ qt.intro }}
{% endif %} + + + + + {% for l in lines %} + + + + + + + + {% endfor %} + +
OmschrijvingAantalPrijsBtwTotaal
{{ l.description }}{{ l.qty|nr }} {{ l.unit }}{{ l.price|euro }}{{ l.vat|nr }}%{{ (l.qty * l.price)|euro }}
+ + + + {% for rate, amount in t.vat %}{% endfor %} + +
Subtotaal excl. btw{{ t.subtotal|euro }}
Btw {{ rate|nr }}%{{ amount|euro }}
Totaal{{ t.total|euro }}
+ + {% if qt.notes %}
Opmerkingen en voorwaarden
{{ qt.notes }}
{% endif %} + +
Datum en handtekening opdrachtgever
Namens {{ s.company_name }}
+ +
+ {% if s.quote_footer %}
{{ s.quote_footer }}
{% endif %} + {{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }} +
+ + {% if bestek %} +
+

Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}

+

Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %} + ({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto + volgens tekening; de prijzen staan in de offerte.

+ {% for ch in bestek.chapters %} +

{{ loop.index }}. {{ ch.name }}

+ + {% for l in ch.lines %}{% endfor %} + {% for l in ch.info %}{% endfor %} +
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
+ {% endfor %} +
+ {% endif %} +
+
diff --git a/templates/account.html b/templates/account.html index d746c16..d9ed24b 100644 --- a/templates/account.html +++ b/templates/account.html @@ -2,7 +2,9 @@ {% block title %}Mijn account{% endblock %} {% block content %}
{{ ROLE.get(g.user.role) }}

{{ g.user.name }}

{{ g.user.email }}
-
+
+
+

Wachtwoord wijzigen

@@ -10,4 +12,34 @@
+
+ +
+
+

Agenda koppelen

+

Zet je CRM-agenda in de agenda van je telefoon of computer (CalDAV). Wijzigingen gaan beide kanten op. Je logt + daar in met een apart app-wachtwoord, niet met je CRM-wachtwoord.

+ {% if dav.password %} +

Je nieuwe app-wachtwoord (alleen nu te zien):

+

{{ dav.password }}

+ {% endif %} + + + + + +
Server{{ dav.host }}
Volledig adres{{ dav.url }}
Gebruikersnaam{{ g.user.email }}
Wachtwoord{{ 'het app-wachtwoord' if dav.active else 'maak hieronder een app-wachtwoord aan' }}
+
    +
  • iPhone / iPad: Instellingen › Agenda › Accounts › Voeg account toe › Anders › Voeg CalDAV-account toe; server {{ dav.host }}.
  • +
  • Mac: Agenda › Instellingen › Accounts › + › Ander CalDAV-account; type Handmatig, server {{ dav.host }}.
  • +
  • Android: installeer DAVx⁵, kies "Inloggen met URL en gebruikersnaam" met het volledige adres.
  • +
  • Thunderbird: Nieuwe agenda › Op het netwerk; het volledige adres.
  • +
+
+ + {% if dav.active %}{% endif %} +
+
+
+
{% endblock %} diff --git a/templates/base.html b/templates/base.html index b70920c..a4e379b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,6 +3,7 @@ + {% block head %}{% endblock %} {% block title %}CRM{% endblock %} · Van Emous CRM @@ -19,6 +20,8 @@ + {% if g.user %}{% endif %} + {% block styles %}{% endblock %} {% block body %} @@ -40,7 +43,9 @@ Klanten Partners Contactpersonen - Inbox {% if unread %}{{ unread }}{% endif %} + Agenda + Inbox {% if unread %}{{ unread }}{% endif %} + E-mailtemplates Producten Offertes Importeren @@ -55,12 +60,45 @@
-
- - Van Emous - {% if unread %}{{ unread }}{% endif %} + +
+ {% set sq = request.args.get('q', '') if ep == 'search' else '' %} + {% macro search_form(id, sq) %} + + {% endmacro %} +
{{ search_form('q-top', sq) }}
+ + {% if unread %}{{ unread }}{% endif %} + +
{% with msgs = get_flashed_messages(with_categories=true) %} @@ -71,5 +109,6 @@
{% endblock %} +{% block scripts %}{% endblock %} diff --git a/templates/bestek.html b/templates/bestek.html index a4bb385..a6a8a79 100644 --- a/templates/bestek.html +++ b/templates/bestek.html @@ -1,18 +1,12 @@ - - - - - +{% extends 'base.html' %} +{% block head %} {# De engine haalt kosten/*.csv en assets/ relatief op: base = de gekopieerde configurator. Alle CRM-links zijn absoluut. #} - Bestek · Offerte {{ qt.number }} · Van Emous CRM - - - - - +{% endblock %} +{% block title %}Bestek · Offerte {{ qt.number }}{% endblock %} +{% block styles %} - - -
+{% endblock %} +{% block content %} + -
+ +{% endblock %} +{% block scripts %} {% if engine %} {% for src in scripts %} {% endfor %} {% endif %} - - +{% endblock %} diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..467a8f9 --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% block title %}Agenda · {{ title }}{% endblock %} +{% block content %} +
+
Agenda

{{ title }}

+
+
+ + +
+ ‹ + {% if month.strftime('%Y-%m') != this_month %}Vandaag{% endif %} + › + Nieuwe afspraak +
+
+ +{% macro item(i) %} + + {%- if i.kind == 'task' %}☐ {% elif not i.all_day %}{{ i.start.strftime('%H:%M') }}{% endif -%} + {{ i.title }}{% if i.repeats %} ↻{% endif %} + +{% endmacro %} + +
+ + {% for d in DAYS %}{% endfor %} + + {% for week in weeks %} + + {% for d in week %} + + {% endfor %} + + {% endfor %} + +
{{ d }}
+ {{ d.day }} + {% for i in by_day.get(d, []) %}{{ item(i) }}{% endfor %} +
+ +
+ {% set ns = namespace(any=false) %} + {% for week in weeks %}{% for d in week if d.month == month.month and by_day.get(d) %} + {% set ns.any = true %} +
+ {{ d.day }}{{ DAYS[d.weekday()] }} +
{% for i in by_day[d] %}{{ item(i) }}{% endfor %}
+
+ {% endfor %}{% endfor %} + {% if not ns.any %}

Geen afspraken of taken in {{ title|lower }}.

{% endif %} +
+ + {% if who == 'all' and USERS|length > 1 %} +
{% for u in USERS %}{{ u.name }}{% endfor %}☐ taak
+ {% endif %} +
+

Je eigen agenda op je telefoon of computer? Zie Mijn account › Agenda koppelen.

+{% endblock %} diff --git a/templates/contact.html b/templates/contact.html index 8abf230..68f40d7 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% from '_compose.html' import compose %} {% block title %}{{ c.name }}{% endblock %} {% block content %}
@@ -101,6 +102,18 @@
+
+

Afspraken

+
+ {% for i in appointments %} +
{{ i.when }}
+
{{ i.title }}{% if i.repeats %} ↻{% endif %} +
{{ i.user }}{% if i.location %} · {{ i.location }}{% endif %}
+ {% else %} +

Geen afspraken gepland.

+ {% endfor %} +
+

Notitie toevoegen

@@ -113,15 +126,7 @@
E-mail sturen - - -
-
-
-
-
-
- + {{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars) }}
{% endif %} @@ -170,7 +175,7 @@ {% if c.email %}
E-mail
{{ c.email }}
{% endif %} {% if c.phone %}
Telefoon
{{ c.phone }}
{% endif %} {% if c.company %}
Bedrijf
{{ c.company }}
{% endif %} - {% if c.address or c.city %}
Adres
{{ c.address }}{% if c.address %} + {% if c.address or c.postcode or c.city %}
Adres
{{ c.address }}{% if c.address %} {% endif %}{{ c.postcode }} {{ c.city }}
{% endif %} {% if c.source %}
Bron
{{ c.source }}
{% endif %}
Aangemaakt
{{ c.created_at|dt }}
diff --git a/templates/dashboard.html b/templates/dashboard.html index 70590ee..08e4b63 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -97,6 +97,15 @@

Geen openstaande taken.

{% endfor %}
+
+

Mijn agenda

+ {% for i in agenda %} +
{{ 'Vandaag' if i.start.date().isoformat() == today else i.when.split(' ')[:3]|join(' ') }}{% if not i.all_day %}
{{ i.start.strftime('%H:%M') }}{% endif %}
+
{{ i.title }}{% if i.contact %}
{{ i.contact }}
{% endif %}
+ {% else %} +

Geen afspraken de komende 7 dagen.

+ {% endfor %} +

Ongelezen e-mail

{% for e in mails %} diff --git a/templates/email.html b/templates/email.html index c88a9bb..325953c 100644 --- a/templates/email.html +++ b/templates/email.html @@ -1,39 +1,40 @@ {% extends 'base.html' %} +{% from '_compose.html' import compose %} {% block title %}{{ e.subject }}{% endblock %} {% block content %} +{% set incoming = e.direction == 'in' %}
- ← Inbox + ← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}

{{ e.subject }}

- {% if e.direction == 'in' %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %} + {% if incoming %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %} + {% if e.cc %}· CC {{ e.cc }}{% endif %} · {{ e.date|dt }}
+ {% if e.attachments %}
Bijlagen: {{ e.attachments }}
{% endif %}
{% if contact %} Naar {{ contact.name }} - {% elif e.direction == 'in' %} + {% elif incoming %}
{% endif %} + Doorsturen + {% if incoming %} +
+
+ {% endif %}
{{ e.body }}
-{% if e.direction == 'in' %} +{% if incoming %}

Beantwoorden

-
- - {% if contact %}{% endif %} -
-
-
-
-
-
-
+ {{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject, + 'reply_to': e.id, 'contact_id': contact.id if contact else None}, templates, vars) }}
{% endif %} {% endblock %} diff --git a/templates/event_form.html b/templates/event_form.html new file mode 100644 index 0000000..0705e95 --- /dev/null +++ b/templates/event_form.html @@ -0,0 +1,74 @@ +{% extends 'base.html' %} +{% block title %}{{ e.title if e else 'Nieuwe afspraak' }}{% endblock %} +{% block content %} +
+
+ ← Agenda +

{{ 'Afspraak bewerken' if e else 'Nieuwe afspraak' }}

+
+ {% if e and e.contact_id %}{% endif %} +
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ {% if e and e.ics and 'VALARM' in e.ics %}

Deze afspraak heeft herinneringen uit een agenda-app; opslaan in de CRM haalt die weg.

{% endif %} +
+ + Annuleren + {% if e %}{% endif %} +
+
+{% if e %}
{% endif %} + +{% endblock %} diff --git a/templates/inbox.html b/templates/inbox.html index a41d581..9d00633 100644 --- a/templates/inbox.html +++ b/templates/inbox.html @@ -1,10 +1,17 @@ {% extends 'base.html' %} {% block title %}Inbox{% endblock %} {% block content %} +{% set sent = f == 'sent' %}
-
E-mail

Inbox

{% if last_sync %}
Laatst opgehaald: {{ last_sync|dt }}
{% endif %}
+
E-mail

{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}

{% if last_sync %}
Laatst opgehaald: {{ last_sync|dt }}
{% endif %}
-
+ + Templates +
+ Nieuwe e-mail
@@ -13,26 +20,30 @@ {% endif %}
- Alle - Ongelezen - Niet gekoppeld aan contact + {% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %} + {{ l }}{% if k == 'unread' and unread %} {{ unread }}{% endif %} + {% endfor %} + {% if unread and not sent %} +
+ {% endif %}
{% if rows %}
- + {% for e in rows %} - - + + {% endfor %}
VanOnderwerpContactDatum
{{ 'Aan' if sent else 'Van' }}OnderwerpContactDatum
- {% else %}

Geen berichten.

{% endif %} + {% else %}

{{ 'Niets gevonden.' if term else 'Geen berichten.' }}

{% endif %}
+{% if f == 'archived' %}

Archiveren haalt een bericht alleen uit de inbox van de CRM; in je mailbox blijft het staan.

{% endif %} {% endblock %} diff --git a/templates/mail_new.html b/templates/mail_new.html new file mode 100644 index 0000000..e611d57 --- /dev/null +++ b/templates/mail_new.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% from '_compose.html' import compose %} +{% block title %}Nieuwe e-mail{% endblock %} +{% block content %} +
+
← Inbox

{{ 'Beantwoorden' if m.reply_to else 'Nieuwe e-mail' }}

+ {% if contact %}
Aan {{ contact.name }}
{% endif %}
+ +
+
+ {{ compose(m, templates, vars, datalist='mail-contacts') }} + {% if not templates %}

Nog geen templates. Maak een template voor berichten die je vaker stuurt.

{% endif %} +
+{% for c in contacts %}{% endfor %} +{% endblock %} diff --git a/templates/mail_template_form.html b/templates/mail_template_form.html new file mode 100644 index 0000000..04c6fff --- /dev/null +++ b/templates/mail_template_form.html @@ -0,0 +1,52 @@ +{% extends 'base.html' %} +{% block title %}{{ t.name or 'Nieuwe template' }}{% endblock %} +{% block content %} +
+
← Templates

{{ 'Template bewerken' if t.id else 'Nieuwe template' }}

+ {% if t.id %}{% endif %} +
+
+
+
+
+
+
+
+
+
+ + Annuleren +
+
+ {% if t.id %} +
+ +
+ {% endif %} +
+
+
+

Velden

+

Klik om een veld in te voegen in het onderwerp of de tekst (waar de cursor stond).

+
    + {% for k, label in FIELDS %}
  • {{ label }}
  • {% endfor %} +
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/mail_templates.html b/templates/mail_templates.html new file mode 100644 index 0000000..31b7541 --- /dev/null +++ b/templates/mail_templates.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% block title %}E-mailtemplates{% endblock %} +{% block content %} +
+
E-mail

Templates

+ +
+
+ {% if rows %} +
+ + + {% for t in rows %} + + + + + + + {% endfor %} + +
NaamOnderwerpBijgewerkt
+ {% else %}

Nog geen templates.

{% endif %} +
+

Kies een template bij een nieuwe e-mail, een antwoord of op de pagina van een contact. Velden tussen accolades, + zoals {voornaam}, worden ingevuld met de gegevens van het contact, van jou en van het bedrijf.

+{% endblock %} diff --git a/templates/quote_portal.html b/templates/quote_portal.html new file mode 100644 index 0000000..0f1ac24 --- /dev/null +++ b/templates/quote_portal.html @@ -0,0 +1,36 @@ + + + + + + Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %} + + + + + + +
+ ← Terug naar het portaal + {% if qt.status == 'verzonden' %} +
+
+ {% endif %} + +
+ +{% include '_quote_doc.html' %} + + diff --git a/templates/quote_print.html b/templates/quote_print.html index 2131057..9b99930 100644 --- a/templates/quote_print.html +++ b/templates/quote_print.html @@ -1,152 +1,22 @@ - - - - - - Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %} - - - - - - -
- {% if portal %} - ← Terug naar het portaal - {% if qt.status == 'verzonden' %} -
-
- {% endif %} - {% else %} - ← Terug naar bewerken - {% endif %} - -
- -
-
- {{ s.company_name }} -
- {{ s.company_name }}
- {{ [s.company_address, s.company_postcode_city, s.company_phone, s.company_email, s.company_website]|select|join('
'|safe) }} -
-
- -
-
-
Offerte voor
- {% if c %} - {{ c.name }}
- {{ [c.relation_name if c.relation_kind == 'klant' and c.relation_name != c.name, c.company, c.address, (c.postcode ~ ' ' ~ c.city)|trim]|select|join('
'|safe) }} - {% else %}—{% endif %} -
- - - - {% if qt.valid_until %}{% endif %} -
Offertenummer{{ qt.number }}
Datum{{ qt.date|dt('%d-%m-%Y') }}
Geldig tot{{ qt.valid_until|dt('%d-%m-%Y') }}
+{% extends 'base.html' %} +{% block title %}Offerte {{ qt.number }}{% if c %} · {{ c.name }}{% endif %}{% endblock %} +{% block styles %} + +{% endblock %} +{% block content %} +
+
+ ← Offerte {{ qt.number }} +

Offerte bekijken

+
+
+ Bewerken +
- -

Offerte{% if qt.title %}: {{ qt.title }}{% endif %}

- {% if qt.intro %}
{{ qt.intro }}
{% endif %} - - - - - {% for l in lines %} - - - - - - - - {% endfor %} - -
OmschrijvingAantalPrijsBtwTotaal
{{ l.description }}{{ l.qty|nr }} {{ l.unit }}{{ l.price|euro }}{{ l.vat|nr }}%{{ (l.qty * l.price)|euro }}
- - - - {% for rate, amount in t.vat %}{% endfor %} - -
Subtotaal excl. btw{{ t.subtotal|euro }}
Btw {{ rate|nr }}%{{ amount|euro }}
Totaal{{ t.total|euro }}
- - {% if qt.notes %}
Opmerkingen en voorwaarden
{{ qt.notes }}
{% endif %} - -
Datum en handtekening opdrachtgever
Namens {{ s.company_name }}
- -
- {% if s.quote_footer %}
{{ s.quote_footer }}
{% endif %} - {{ [('KvK ' ~ s.company_kvk) if s.company_kvk, ('Btw ' ~ s.company_btw) if s.company_btw, ('IBAN ' ~ s.company_iban) if s.company_iban]|select|join(' · ') }} -
- - {% if bestek %} -
-

Bestek{% if bestek.design.title %}: {{ bestek.design.title }}{% endif %}

-

Bijlage bij offerte {{ qt.number }}. Omschrijving en hoeveelheden van de woning volgens het ontwerp{% if bestek.units %} - ({{ bestek.units }} module{{ 's' if bestek.units != 1 }}){% endif %}, berekend op {{ bestek.computedAt[:19]|dt('%d-%m-%Y') }}. Hoeveelheden zijn netto - volgens tekening; de prijzen staan in de offerte.

- {% for ch in bestek.chapters %} -

{{ loop.index }}. {{ ch.name }}

- - {% for l in ch.lines %}{% endfor %} - {% for l in ch.info %}{% endfor %} -
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
{{ l.label }}{{ l.qty|nr }} {{ l.unit }}
- {% endfor %} -
- {% endif %}
- - +{% include '_quote_doc.html' %} +{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..6cdafa1 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block title %}Zoeken{% if term %}: {{ term }}{% endif %}{% endblock %} +{% block content %} +
Zoeken

{{ '“' ~ term ~ '”' if term else 'Zoeken' }}

+{% if term|length < 2 %} +

Typ minstens twee tekens in het zoekveld bovenaan.

+{% elif not groups %} +

Niets gevonden. Zoek op naam, e-mailadres, telefoonnummer, plaats, offertenummer of SKU.

+{% else %} + {% for grp in groups %} +
+

{{ grp.label }} {{ grp['items']|length }}{{ '+' if grp.more }}

+ {% for i in grp['items'] %} +
{{ i.title }}{% if i.sub %}
{{ i.sub }}
{% endif %}
+ {% endfor %} + {% if grp.more %}

Meer dan {{ grp['items']|length }} resultaten; maak de zoekterm specifieker.

{% endif %} +
+ {% endfor %} +{% endif %} +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index dcfb1e5..5022c78 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -3,6 +3,10 @@ {% macro field(key, label, type='text', placeholder='', full=false) %}
{% endmacro %} +{% macro security(key) %} +
+
+{% endmacro %} {% block content %}
Beheer

Instellingen

@@ -35,24 +39,39 @@

E-mail koppelen

-

Gebruik voor Gmail of Microsoft 365 een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

+

Eén gedeelde mailbox (bijv. info@) voor de inbox en het versturen vanuit de CRM. Bij OVHcloud + (Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres; de afzender moet dat adres (of een alias ervan) zijn. + Gmail werkt met een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

+
+
+ +

Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.

+
+

Ontvangen (IMAP)

- {{ field('imap_host', 'Server', placeholder='imap.gmail.com') }} + {{ field('imap_host', 'Server', placeholder='imap.mail.ovh.net') }} {{ field('imap_port', 'Poort', 'number', '993') }} - {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemous.nl') }} + {{ security('imap_security') }} + {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }}
{{ field('imap_folder', 'Map', placeholder='INBOX') }} {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}

Versturen (SMTP)

- {{ field('smtp_host', 'Server', placeholder='smtp.gmail.com') }} - {{ field('smtp_port', 'Poort', 'number', '587') }} + {{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }} + {{ field('smtp_port', 'Poort', 'number', '465') }} + {{ security('smtp_security') }} {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }}
- {{ field('smtp_from', 'Afzender', placeholder='Van Emous ', full=true) }} +

Bij Zimbra/MX Plan Sent, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf.

+ {{ field('smtp_from', 'Afzender', placeholder='Van Emous ', full=true) }}
+
@@ -126,4 +145,14 @@ const { ok, id } = await res.json();{% endfilter %}
+ {% endblock %}