Skip to content

Commit b146c21

Browse files
frafrataheeraahmed
authored andcommitted
feat: sync Mediebank employee portraits to S3
1 parent 45a4a9a commit b146c21

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ UBW_AWS_ENDPOINT=
4444
UBW_BUCKET=
4545
UBW_PREFIX=
4646

47+
# Mediebank (employee portraits)
48+
MEDIEBANK_CLIENT_ID=
49+
MEDIEBANK_CLIENT_SECRET=
50+
MEDIEBANK_TOKEN_URL=https://login.sdl.no/oauth/token
51+
MEDIEBANK_AUDIENCE=https://api.ntb.no
52+
MEDIEBANK_API_URL=https://api.ntb.no/media/v1/mb
53+
MEDIEBANK_COLLECTION=67480
54+
MEDIEBANK_S3_ACCESS_KEY=
55+
MEDIEBANK_S3_SECRET_KEY=
56+
MEDIEBANK_S3_ENDPOINT_URL=
57+
MEDIEBANK_S3_BUCKET=
58+
MEDIEBANK_S3_PREFIX=nina.no/ansattbilder
59+
NINA_EMPLOYEES_PARQUET=https://s3-ext-1.nina.no/dms/nina/employees.parquet
60+
4761
# Publishing/Geoapi
4862
PUBLISH_USER=
4963
PUBLISH_PASSWORD=

src/datasync/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
dms,
99
gbif_backbone,
1010
grass,
11+
mediebank,
1112
ninagen,
1213
nva,
1314
pit_registering_salmon,
@@ -28,6 +29,7 @@
2829
app.add_typer(services.app, name="services")
2930
app.add_typer(gbif_backbone.app, name="gbif-backbone")
3031
app.add_typer(ipt_app, name="ipt")
32+
app.add_typer(mediebank.app, name="mediebank")
3133

3234
if __name__ == "__main__":
3335
app()

src/datasync/mediebank.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)