392 lines
18 KiB
Python
392 lines
18 KiB
Python
"""
|
||
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
|