PIVOT 1.1.0
This commit is contained in:
commit
8a53234eb6
161 files changed
+31109
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user