|
| 1 | +"""Sync Mediebank employee portraits to S3.""" |
| 2 | + |
| 3 | +import unicodedata |
| 4 | + |
| 5 | +import duckdb |
| 6 | +import requests |
| 7 | +import s3fs |
| 8 | +import typer |
| 9 | +from dlt.sources.helpers.rest_client import RESTClient |
| 10 | +from dlt.sources.helpers.rest_client.auth import BearerTokenAuth |
| 11 | +from dlt.sources.helpers.rest_client.paginators import OffsetPaginator |
| 12 | + |
| 13 | +from .settings import log |
| 14 | + |
| 15 | +PAGE_SIZE = 199 |
| 16 | + |
| 17 | +app = typer.Typer(help="Commands to handle Mediebank employee portraits") |
| 18 | + |
| 19 | + |
| 20 | +def get_token( |
| 21 | + client_id: str, |
| 22 | + client_secret: str, |
| 23 | + token_url: str, |
| 24 | + audience: str, |
| 25 | +) -> str: |
| 26 | + """Request a Mediebank access token using OAuth2 client credentials.""" |
| 27 | + response = requests.post( |
| 28 | + token_url, |
| 29 | + data={ |
| 30 | + "grant_type": "client_credentials", |
| 31 | + "client_id": client_id, |
| 32 | + "client_secret": client_secret, |
| 33 | + "audience": audience, |
| 34 | + }, |
| 35 | + headers={"Content-Type": "application/x-www-form-urlencoded"}, |
| 36 | + timeout=30, |
| 37 | + ) |
| 38 | + response.raise_for_status() |
| 39 | + return response.json()["access_token"] |
| 40 | + |
| 41 | + |
| 42 | +def browse_assets(token: str, api_url: str, collection: int) -> list[dict]: |
| 43 | + """List all assets in the employee portraits collection.""" |
| 44 | + client = RESTClient( |
| 45 | + base_url=f"{api_url}/", |
| 46 | + auth=BearerTokenAuth(token), |
| 47 | + paginator=OffsetPaginator( |
| 48 | + limit=PAGE_SIZE, |
| 49 | + offset_param="offset", |
| 50 | + limit_param="limit", |
| 51 | + total_path="_page.total", |
| 52 | + ), |
| 53 | + ) |
| 54 | + assets: list[dict] = [ |
| 55 | + item |
| 56 | + for page in client.paginate("/", params={"collections": collection}) |
| 57 | + for item in page |
| 58 | + ] |
| 59 | + log.info("browsed assets", collection=collection, count=len(assets)) |
| 60 | + return assets |
| 61 | + |
| 62 | + |
| 63 | +def download_asset(token: str, api_url: str, asset_id: str) -> bytes | None: |
| 64 | + """Download an asset's original file.""" |
| 65 | + response = requests.get( |
| 66 | + f"{api_url}/download/{asset_id}", |
| 67 | + headers={"Authorization": f"Bearer {token}"}, |
| 68 | + timeout=120, |
| 69 | + ) |
| 70 | + if response.status_code != 200: |
| 71 | + log.warning( |
| 72 | + "failed to download asset", |
| 73 | + asset_id=asset_id, |
| 74 | + status=response.status_code, |
| 75 | + ) |
| 76 | + return None |
| 77 | + return response.content |
| 78 | + |
| 79 | + |
| 80 | +def normalize(value: str) -> str: |
| 81 | + """Lowercase, strip diacritics and non-alphanumeric characters.""" |
| 82 | + normalized = unicodedata.normalize("NFKD", value.lower()) |
| 83 | + return "".join(c for c in normalized if c.isalnum()) |
| 84 | + |
| 85 | + |
| 86 | +def load_employees(employees_parquet: str) -> dict[str, dict]: |
| 87 | + """Load employees with a portrait url from the employees parquet.""" |
| 88 | + with duckdb.connect() as connection: |
| 89 | + rows = connection.execute( |
| 90 | + "SELECT employee_id, firstname, lastname, picture_url FROM read_parquet(?)", |
| 91 | + [employees_parquet], |
| 92 | + ).fetchall() |
| 93 | + return { |
| 94 | + str(employee_id): { |
| 95 | + "display": f"{first} {last}", |
| 96 | + "norm": normalize(f"{first} {last}"), |
| 97 | + } |
| 98 | + for employee_id, first, last, url in rows |
| 99 | + if url and "ansattbilder/" in url |
| 100 | + } |
| 101 | + |
| 102 | + |
| 103 | +def asset_names(asset: dict) -> set[str]: |
| 104 | + """Normalized names associated with a Mediebank asset.""" |
| 105 | + names = [asset.get("headline")] |
| 106 | + names += [person.get("personName", "") for person in asset.get("personsShown", [])] |
| 107 | + names.append(asset.get("file", {}).get("originalFilename", "")) |
| 108 | + return {normalize(name) for name in names if name} |
| 109 | + |
| 110 | + |
| 111 | +def match_assets_to_employees( |
| 112 | + assets: list[dict], employees: dict[str, dict] |
| 113 | +) -> dict[str, dict]: |
| 114 | + """Map each employee to their best portrait (newest dateArchived wins).""" |
| 115 | + by_norm = { |
| 116 | + employee["norm"]: employee_id for employee_id, employee in employees.items() |
| 117 | + } |
| 118 | + |
| 119 | + candidates: dict[str, list[dict]] = {} |
| 120 | + for asset in assets: |
| 121 | + stem = asset.get("file", {}).get("originalFilename", "").split(".")[0] |
| 122 | + employee_id = stem if stem.isdigit() and stem in employees else None |
| 123 | + if not employee_id: |
| 124 | + employee_id = next( |
| 125 | + (by_norm[name] for name in asset_names(asset) if name in by_norm), |
| 126 | + None, |
| 127 | + ) |
| 128 | + if employee_id: |
| 129 | + candidates.setdefault(employee_id, []).append(asset) |
| 130 | + |
| 131 | + log.info("matched assets to employees", matched=len(candidates)) |
| 132 | + return { |
| 133 | + employee_id: max(assets, key=lambda asset: asset.get("dateArchived") or "") |
| 134 | + for employee_id, assets in candidates.items() |
| 135 | + } |
| 136 | + |
| 137 | + |
| 138 | +@app.command(help="Sync employee portraits from Mediebank to S3") |
| 139 | +def employees_portraits( |
| 140 | + client_id: str = typer.Option( |
| 141 | + ..., envvar="MEDIEBANK_CLIENT_ID", help="Mediebank API client id" |
| 142 | + ), |
| 143 | + client_secret: str = typer.Option( |
| 144 | + ..., envvar="MEDIEBANK_CLIENT_SECRET", help="Mediebank API client secret" |
| 145 | + ), |
| 146 | + token_url: str = typer.Option( |
| 147 | + "https://login.sdl.no/oauth/token", |
| 148 | + envvar="MEDIEBANK_TOKEN_URL", |
| 149 | + help="OAuth2 token URL", |
| 150 | + ), |
| 151 | + audience: str = typer.Option( |
| 152 | + "https://api.ntb.no", envvar="MEDIEBANK_AUDIENCE", help="OAuth2 audience" |
| 153 | + ), |
| 154 | + api_url: str = typer.Option( |
| 155 | + "https://api.ntb.no/media/v1/mb", |
| 156 | + envvar="MEDIEBANK_API_URL", |
| 157 | + help="Mediebank API base URL", |
| 158 | + ), |
| 159 | + collection: int = typer.Option( |
| 160 | + 67480, envvar="MEDIEBANK_COLLECTION", help="Portraits collection id" |
| 161 | + ), |
| 162 | + employees_parquet: str = typer.Option( |
| 163 | + "https://s3-ext-1.nina.no/dms/nina/employees.parquet", |
| 164 | + envvar="NINA_EMPLOYEES_PARQUET", |
| 165 | + help="Path to the employees parquet file", |
| 166 | + ), |
| 167 | + s3_endpoint_url: str = typer.Option( |
| 168 | + ..., envvar="MEDIEBANK_S3_ENDPOINT_URL", help="S3 endpoint URL" |
| 169 | + ), |
| 170 | + s3_access_key: str = typer.Option( |
| 171 | + ..., envvar="MEDIEBANK_S3_ACCESS_KEY", help="S3 access key" |
| 172 | + ), |
| 173 | + s3_secret_key: str = typer.Option( |
| 174 | + ..., envvar="MEDIEBANK_S3_SECRET_KEY", help="S3 secret key" |
| 175 | + ), |
| 176 | + s3_bucket: str = typer.Option(..., envvar="MEDIEBANK_S3_BUCKET", help="S3 bucket"), |
| 177 | + s3_prefix: str = typer.Option( |
| 178 | + "nina.no/ansattbilder", |
| 179 | + envvar="MEDIEBANK_S3_PREFIX", |
| 180 | + help="S3 prefix for the portraits", |
| 181 | + ), |
| 182 | +) -> None: |
| 183 | + """Copy employee portraits from the Mediebank to the S3 bucket.""" |
| 184 | + log.info("Starting Mediebank employee portraits sync") |
| 185 | + token = get_token(client_id, client_secret, token_url, audience) |
| 186 | + employees = load_employees(employees_parquet) |
| 187 | + assets = browse_assets(token, api_url, collection) |
| 188 | + matched = match_assets_to_employees(assets, employees) |
| 189 | + |
| 190 | + log.info( |
| 191 | + "plan", |
| 192 | + employees=len(employees), |
| 193 | + matched=len(matched), |
| 194 | + missing=len(employees) - len(matched), |
| 195 | + ) |
| 196 | + |
| 197 | + fs = s3fs.S3FileSystem( |
| 198 | + endpoint_url=s3_endpoint_url, |
| 199 | + key=s3_access_key, |
| 200 | + secret=s3_secret_key, |
| 201 | + ) |
| 202 | + for employee_id, asset in sorted(matched.items(), key=lambda item: int(item[0])): |
| 203 | + data = download_asset(token, api_url, asset["id"]) |
| 204 | + if data is None: |
| 205 | + log.warning("download failed", employee_id=employee_id) |
| 206 | + continue |
| 207 | + path = f"s3://{s3_bucket}/{s3_prefix.rstrip('/')}/{employee_id}.jpg" |
| 208 | + fs.write_bytes(path, data) |
| 209 | + log.info("copied", employee_id=employee_id, path=path) |
| 210 | + |
| 211 | + for employee_id in sorted(set(employees) - set(matched), key=int): |
| 212 | + log.warning( |
| 213 | + "no portrait found", |
| 214 | + employee_id=employee_id, |
| 215 | + name=employees[employee_id]["display"], |
| 216 | + ) |
| 217 | + |
| 218 | + log.info("Mediebank employee portraits sync completed") |
| 219 | + |
| 220 | + |
| 221 | +if __name__ == "__main__": |
| 222 | + app() |
0 commit comments