""" 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