PIVOT 1.1.0

This commit is contained in:
marijn committed 2026-10-14 13:34:37 +02:00
commit 8a53234eb6
161 files changed
+31109

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
"""
Gmail › Contacten: twee soorten contactbeheer in Google Workspace.
1. Adresboek van de organisatie (Directory API, admin.directory.user)
Per gebruiker 'includeInGlobalAddressList': zichtbaar of verborgen in het adresboek en bij het aanvullen van adressen.
2. Contactlijsten in de mailboxen (People API, https://www.googleapis.com/auth/contacts)
Een lijst (label) met contacten die PIVOT in de persoonlijke contacten van elke gebruiker in de gekozen OU's zet en
bijhoudt. PIVOT herkent zijn eigen contacten aan een eigen veld (userDefined 'PIVOT'), en past enkel die aan.
"""
import hashlib
import json
import sys
import threading
import time
from datetime import datetime
from config import Config
from database import execute_query
from tenancy import school_singleton, met_school, google_credentials, huidige_school
SCOPE_PEOPLE = 'https://www.googleapis.com/auth/contacts'
PIVOT_SLEUTEL = 'PIVOT'
class ContactFout(Exception):
pass
def vriendelijk(e, scope=None):
t = str(e)
laag = t.lower()
if 'unauthorized_client' in laag or 'access_denied' in laag or 'invalid_scope' in laag:
return ("Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie "
f"het bereik {scope or '(zie handleiding)'} toe bij het serviceaccount van PIVOT.")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag or 'service_disabled' in laag:
return ("De nodige API staat niet aan in het Google Cloud-project van het serviceaccount "
"(voor contactlijsten: People API).")
return t[:300]
def _directory():
from googleapiclient.discovery import build
creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL)
return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False)
def ou_lijst():
try:
resp = _directory().orgunits().list(customerId='my_customer', type='all').execute()
except Exception as e:
raise ContactFout(f"OU's ophalen mislukt: {vriendelijk(e)}")
return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower)
def gebruikers_in(ous, met_sub=True, velden='primaryEmail,name,orgUnitPath,includeInGlobalAddressList,suspended'):
api, uit, gezien = _directory(), [], set()
for ou in ous:
token = None
while True:
params = dict(customer='my_customer', maxResults=500, pageToken=token,
fields=f'nextPageToken,users({velden})')
if ou != '/':
params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'"
try:
resp = api.users().list(**params).execute()
except Exception as e:
raise ContactFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}")
for u in resp.get('users', []):
pad = u.get('orgUnitPath') or '/'
if (not met_sub and pad != ou) or u['primaryEmail'].lower() in gezien:
continue
gezien.add(u['primaryEmail'].lower())
n = u.get('name') or {}
uit.append({'email': u['primaryEmail'].lower(), 'naam': n.get('fullName') or u['primaryEmail'],
'achternaam': n.get('familyName', ''), 'ou': pad, 'geschorst': bool(u.get('suspended')),
'zichtbaar': u.get('includeInGlobalAddressList', True) is not False})
token = resp.get('nextPageToken')
if not token:
break
return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['naam'].lower()))
# =====================================================================
# 1. Adresboek: zichtbaar of verborgen
# =====================================================================
def zet_zichtbaar(emails, zichtbaar):
api, fouten = _directory(), []
for e in emails:
try:
api.users().patch(userKey=e, body={'includeInGlobalAddressList': bool(zichtbaar)}).execute()
except Exception as ex:
fouten.append(f"{e}: {vriendelijk(ex)}")
return fouten
# =====================================================================
# 2. Contactlijsten in de mailboxen (People API)
# =====================================================================
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS contact_lijsten (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Naam VARCHAR(100) NOT NULL,
OUs TEXT NULL,
Met_sub_ous TINYINT NOT NULL DEFAULT 1,
Bijgewerkt DATETIME NULL,
Door VARCHAR(255) NULL,
Laatste_sync DATETIME NULL,
Resultaat VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS contact_lijst_items (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Lijst_ID INT NOT NULL,
Voornaam VARCHAR(100) NULL,
Achternaam VARCHAR(100) NULL,
Organisatie VARCHAR(150) NULL,
Functie VARCHAR(150) NULL,
Email VARCHAR(255) NULL,
Telefoon VARCHAR(60) NULL,
Notitie VARCHAR(500) NULL,
KEY idx_cli_lijst (Lijst_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
def _schoon(c):
uit = {k: (str(c.get(k) or '').strip())[:500] for k in
('voornaam', 'achternaam', 'organisatie', 'functie', 'email', 'telefoon', 'notitie')}
if not (uit['voornaam'] or uit['achternaam'] or uit['organisatie']):
raise ContactFout("Geef bij elk contact minstens een naam of een organisatie.")
if uit['email'] and '@' not in uit['email']:
raise ContactFout(f"Ongeldig e-mailadres: {uit['email']}")
return uit
def _ous(r):
try:
return [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)]
except ValueError:
return []
def lijsten():
ensure_tables()
rijen = execute_query("SELECT l.*, (SELECT COUNT(*) FROM contact_lijst_items i WHERE i.Lijst_ID = l.ID) AS aantal "
"FROM contact_lijsten l ORDER BY l.Naam") or []
for r in rijen:
r['ous'] = _ous(r)
return rijen
def lijst(lijst_id):
ensure_tables()
r = execute_query("SELECT * FROM contact_lijsten WHERE ID = %s", (lijst_id,))
if not r:
return None
r = r[0]
r['ous'] = _ous(r)
r['items'] = execute_query("SELECT * FROM contact_lijst_items WHERE Lijst_ID = %s ORDER BY Achternaam, Voornaam, "
"Organisatie", (lijst_id,)) or []
return r
def lijst_bewaren(lijst_id, naam, ous, met_sub, items, door):
ensure_tables()
naam = (naam or '').strip()[:100]
if not naam:
raise ContactFout("Geef de lijst een naam (die wordt het label in Google Contacten).")
schoon = []
for i in items:
if any(str(i.get(k) or '').strip() for k in i if k != 'id'):
c = _schoon(i)
c['id'] = int(i['id']) if str(i.get('id') or '').isdigit() else None
schoon.append(c)
if lijst_id:
execute_query("UPDATE contact_lijsten SET Naam = %s, OUs = %s, Met_sub_ous = %s, Bijgewerkt = NOW(), Door = %s "
"WHERE ID = %s", (naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door, lijst_id),
commit=True)
else:
from database import execute_insert
lijst_id = execute_insert("INSERT INTO contact_lijsten (Naam, OUs, Met_sub_ous, Bijgewerkt, Door) "
"VALUES (%s, %s, %s, NOW(), %s)",
(naam, json.dumps(sorted(set(ous))), 1 if met_sub else 0, door))
# Bestaande contacten behouden hun ID (zo herkent PIVOT ze in de mailboxen en past het ze aan i.p.v. opnieuw te maken)
bestaand = {r['ID'] for r in execute_query("SELECT ID FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,)) or []}
behouden = set()
velden = (lambda i: (i['voornaam'], i['achternaam'], i['organisatie'], i['functie'], i['email'], i['telefoon'],
i['notitie']))
for i in schoon:
if i['id'] in bestaand:
execute_query("UPDATE contact_lijst_items SET Voornaam = %s, Achternaam = %s, Organisatie = %s, Functie = %s, "
"Email = %s, Telefoon = %s, Notitie = %s WHERE ID = %s AND Lijst_ID = %s",
velden(i) + (i['id'], lijst_id), commit=True)
behouden.add(i['id'])
else:
execute_query("INSERT INTO contact_lijst_items (Lijst_ID, Voornaam, Achternaam, Organisatie, Functie, Email, "
"Telefoon, Notitie) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", (lijst_id,) + velden(i), commit=True)
for weg in bestaand - behouden:
execute_query("DELETE FROM contact_lijst_items WHERE ID = %s", (weg,), commit=True)
return lijst_id
def lijst_verwijderen_db(lijst_id):
execute_query("DELETE FROM contact_lijst_items WHERE Lijst_ID = %s", (lijst_id,), commit=True)
execute_query("DELETE FROM contact_lijsten WHERE ID = %s", (lijst_id,), commit=True)
def _people(mailbox):
from googleapiclient.discovery import build
creds = google_credentials([SCOPE_PEOPLE], subject=mailbox)
try:
return build('people', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('people', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def _persoon(item, merk, groep):
p = {'names': [{'givenName': item['Voornaam'] or '', 'familyName': item['Achternaam'] or ''}],
'userDefined': [{'key': PIVOT_SLEUTEL, 'value': merk}],
'memberships': [{'contactGroupMembership': {'contactGroupResourceName': groep}}]}
if not (item['Voornaam'] or item['Achternaam']):
p['names'] = [{'givenName': item['Organisatie'] or item['Email'] or ''}]
if item['Email']:
p['emailAddresses'] = [{'value': item['Email'], 'type': 'work'}]
if item['Telefoon']:
p['phoneNumbers'] = [{'value': item['Telefoon'], 'type': 'work'}]
if item['Organisatie'] or item['Functie']:
p['organizations'] = [{'name': item['Organisatie'] or '', 'title': item['Functie'] or ''}]
if item['Notitie']:
p['biographies'] = [{'value': item['Notitie'], 'contentType': 'TEXT_PLAIN'}]
return p
def _hash(item):
return hashlib.sha1(json.dumps([item[k] for k in ('Voornaam', 'Achternaam', 'Organisatie', 'Functie', 'Email',
'Telefoon', 'Notitie')]).encode()).hexdigest()[:12]
PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,biographies,userDefined,memberships'
def _herhaal(fn, pogingen=5):
for poging in range(pogingen):
try:
return fn()
except Exception as e:
status = getattr(getattr(e, 'resp', None), 'status', None)
if status in (429, 500, 503) and poging < pogingen - 1:
time.sleep(2 ** poging + 1)
continue
raise
def _pivot_contacten(svc, lijst_id):
"""{item-sleutel: person} van de contacten die PIVOT voor deze lijst in deze mailbox zette."""
uit, token = {}, None
prefix = f"lijst{lijst_id}:"
while True:
resp = _herhaal(lambda: svc.people().connections().list(
resourceName='people/me', pageSize=1000, pageToken=token, personFields=PERSON_FIELDS).execute())
for p in resp.get('connections', []):
merk = next((u.get('value', '') for u in p.get('userDefined', []) if u.get('key') == PIVOT_SLEUTEL), '')
if merk.startswith(prefix):
uit[merk.split('#')[0]] = (merk, p)
token = resp.get('nextPageToken')
if not token:
return uit
def _groep(svc, naam, maak=True):
resp = _herhaal(lambda: svc.contactGroups().list(pageSize=1000).execute())
for g in resp.get('contactGroups', []):
if g.get('groupType') == 'USER_CONTACT_GROUP' and g.get('name') == naam:
return g['resourceName']
if not maak:
return None
return _herhaal(lambda: svc.contactGroups().create(body={'contactGroup': {'name': naam}}).execute())['resourceName']
def sync_mailbox(email, l, verwijderen=False, oude_naam=None):
"""Zet de contacten van lijst `l` in de mailbox (of haalt ze weg). Geeft (toegevoegd, gewijzigd, verwijderd)."""
svc = _people(email)
bestaand = _pivot_contacten(svc, l['ID'])
toegevoegd = gewijzigd = verwijderd = 0
gewenst = {} if verwijderen else {f"lijst{l['ID']}:{i['ID']}": i for i in l['items']}
groep = None
if gewenst:
groep = _groep(svc, l['Naam'])
if oude_naam and oude_naam != l['Naam']:
oud = _groep(svc, oude_naam, maak=False)
if oud and oud != groep:
_herhaal(lambda: svc.contactGroups().delete(resourceName=oud, deleteContacts=False).execute())
for sleutel, item in gewenst.items():
merk = f"{sleutel}#{_hash(item)}"
if sleutel in bestaand:
oud_merk, p = bestaand[sleutel]
in_groep = any(m.get('contactGroupMembership', {}).get('contactGroupResourceName') == groep
for m in p.get('memberships', []))
if oud_merk == merk and in_groep:
continue
body = _persoon(item, merk, groep)
body['etag'] = p.get('etag')
body['memberships'] = [m for m in p.get('memberships', [])
if m.get('contactGroupMembership', {}).get('contactGroupResourceName') != groep] \
+ body['memberships']
_herhaal(lambda: svc.people().updateContact(
resourceName=p['resourceName'], updatePersonFields=PERSON_FIELDS, body=body).execute())
gewijzigd += 1
else:
_herhaal(lambda: svc.people().createContact(body=_persoon(item, merk, groep)).execute())
toegevoegd += 1
for sleutel, (merk, p) in bestaand.items():
if sleutel not in gewenst:
_herhaal(lambda: svc.people().deleteContact(resourceName=p['resourceName']).execute())
verwijderd += 1
if verwijderen:
g = _groep(svc, l['Naam'], maak=False)
if g:
try:
_herhaal(lambda: svc.contactGroups().delete(resourceName=g, deleteContacts=False).execute())
except Exception:
pass
return toegevoegd, gewijzigd, verwijderd
class LijstSync:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'lijst': None, 'voortgang': 0, 'totaal': 0, 'fouten': 0, 'fout': None,
'resultaat': None, 'foutvoorbeelden': []}
return school_singleton(cls, init)
def run(self, lijst_id, verwijderen=False, oude_naam=None, oude_ous=None):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een opdracht."
s = self.status
try:
l = lijst(lijst_id)
if not l:
raise ContactFout("Lijst niet gevonden.")
s.update(bezig=True, lijst=l['Naam'], voortgang=0, totaal=0, fouten=0, fout=None, resultaat=None,
foutvoorbeelden=[])
ontvangers = gebruikers_in(l['ous'], bool(l['Met_sub_ous']), 'primaryEmail,name,orgUnitPath,suspended')
ontvangers = [u for u in ontvangers if not u['geschorst']]
# Wie niet meer in de gekozen OU's zit (OU's gewijzigd): de contacten van deze lijst weghalen
weg = []
if oude_ous is not None:
nu = {u['email'] for u in ontvangers}
weg = [u for u in gebruikers_in(oude_ous, True, 'primaryEmail,name,orgUnitPath,suspended')
if u['email'] not in nu and not u['geschorst']]
s['totaal'] = len(ontvangers) + len(weg)
t = {'toegevoegd': 0, 'gewijzigd': 0, 'verwijderd': 0}
for u, enkel_weg in [(u, verwijderen) for u in ontvangers] + [(u, True) for u in weg]:
try:
a, b, c = sync_mailbox(u['email'], l, verwijderen=enkel_weg, oude_naam=oude_naam)
t['toegevoegd'] += a; t['gewijzigd'] += b; t['verwijderd'] += c
except Exception as e:
s['fouten'] += 1
if len(s['foutvoorbeelden']) < 6:
s['foutvoorbeelden'].append(f"{u['email']}: {vriendelijk(e, SCOPE_PEOPLE)[:180]}")
sys.stderr.write(f"Contactlijst {l['Naam']} → {u['email']}: {e}\n")
s['voortgang'] += 1
if verwijderen:
msg = f"Lijst '{l['Naam']}' uit {len(ontvangers)} mailbox(en) gehaald ({t['verwijderd']} contacten)"
else:
msg = (f"Lijst '{l['Naam']}' in {len(ontvangers)} mailbox(en): {t['toegevoegd']} toegevoegd, "
f"{t['gewijzigd']} bijgewerkt, {t['verwijderd']} verwijderd")
if s['fouten']:
msg += f", {s['fouten']} mailbox(en) mislukt"
s['resultaat'] = msg + '.'
if verwijderen and not s['fouten']:
lijst_verwijderen_db(lijst_id)
else:
execute_query("UPDATE contact_lijsten SET Laatste_sync = NOW(), Resultaat = %s WHERE ID = %s",
(s['resultaat'][:500], lijst_id), commit=True)
return True, s['resultaat']
except Exception as e:
s['fout'] = vriendelijk(e, SCOPE_PEOPLE)
return False, s['fout']
finally:
s['bezig'] = False
self._lock.release()
def start(self, *args, **kw):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
threading.Thread(target=met_school(lambda: self.run(*args, **kw)), daemon=True).start()
return True