Skip to content

Commit 59b5610

Browse files
committed
feat(user-sync): map sources to user and organization items
1 parent 5e40525 commit 59b5610

4 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Load existing OSW User items so the sync can update in place.
2+
3+
Matches are keyed on the ``username`` field, so accounts already stored under a
4+
different uuid are updated rather than duplicated.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass, field
10+
from typing import Any, Dict, List, Optional
11+
12+
from .config import USER_CATEGORY
13+
14+
15+
@dataclass
16+
class ExistingUsers:
17+
"""Existing User items indexed by their MediaWiki username."""
18+
19+
by_username: Dict[str, Any] = field(default_factory=dict)
20+
21+
def get(self, username: str) -> Optional[Any]:
22+
return self.by_username.get(username)
23+
24+
25+
def _as_entity_list(result: Any) -> List[Any]:
26+
"""Normalize load_entity output (single, list, or LoadEntityResult)."""
27+
if result is None:
28+
return []
29+
entities = getattr(result, "entities", None)
30+
if entities is not None:
31+
return list(entities)
32+
if isinstance(result, list):
33+
return result
34+
return [result]
35+
36+
37+
def load_existing_users(
38+
osw: Any,
39+
category: str = USER_CATEGORY,
40+
limit: Optional[int] = None,
41+
) -> ExistingUsers:
42+
"""Query and load all existing User items, indexed by username.
43+
44+
Args:
45+
osw: An authenticated OSW connection exposing ``query_instances`` and
46+
``load_entity``.
47+
category: The User category page title.
48+
limit: Load at most this many items (for testing).
49+
"""
50+
titles = osw.query_instances(category=category)
51+
if limit is not None:
52+
titles = titles[:limit]
53+
if not titles:
54+
return ExistingUsers()
55+
entities = _as_entity_list(osw.load_entity(titles))
56+
by_username: Dict[str, Any] = {}
57+
for entity in entities:
58+
username = getattr(entity, "username", None)
59+
if username:
60+
by_username[username] = entity
61+
return ExistingUsers(by_username=by_username)

‎src/osw/tools/user_sync/mapping.py‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Map MediaWiki accounts and ORCID profiles to proposed OSW items.
2+
3+
These functions are pure: they compute the field values and deterministic ids of
4+
the items to store, without instantiating the pydantic models (which requires an
5+
open OSW connection) or touching the network. The store step builds the actual
6+
``User`` / ``Organization`` entities from these proposals.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import uuid
12+
from dataclasses import dataclass, field
13+
from typing import List, Optional, Tuple
14+
15+
from osw.data.import_utility import uuid_to_full_page_title
16+
17+
from .config import ORGANIZATION_CATEGORY, USER_CATEGORY
18+
from .sources import MwUser, OrcidAffiliation, OrcidProfile
19+
20+
21+
def _category_namespace(category: str) -> uuid.UUID:
22+
"""Derive the uuid5 namespace of a category from its page title."""
23+
return uuid.UUID(category.split("OSW")[-1])
24+
25+
26+
USER_NAMESPACE = _category_namespace(USER_CATEGORY)
27+
ORGANIZATION_NAMESPACE = _category_namespace(ORGANIZATION_CATEGORY)
28+
29+
30+
@dataclass
31+
class ProposedOrganization:
32+
"""An Organization item derived from an ORCID affiliation."""
33+
34+
uuid: uuid.UUID
35+
name: str
36+
ror_id: Optional[str] = None
37+
38+
@property
39+
def full_page_title(self) -> str:
40+
return uuid_to_full_page_title(self.uuid)
41+
42+
43+
@dataclass
44+
class ProposedUser:
45+
"""A User item derived from a MediaWiki account and optional ORCID data."""
46+
47+
uuid: uuid.UUID
48+
username: str
49+
first_name: str
50+
surname: str
51+
label: str
52+
orcid: Optional[str] = None
53+
emails: List[str] = field(default_factory=list)
54+
websites: List[str] = field(default_factory=list)
55+
organizations: List[str] = field(default_factory=list)
56+
placeholder_name: bool = False
57+
58+
@property
59+
def full_page_title(self) -> str:
60+
return uuid_to_full_page_title(self.uuid)
61+
62+
63+
def normalize_org_name(name: str) -> str:
64+
"""Collapse whitespace and lowercase, for a stable org identity key."""
65+
return " ".join(name.lower().split())
66+
67+
68+
def user_uuid(mw_user: MwUser) -> uuid.UUID:
69+
"""Deterministic uuid5: keyed on ORCID iD for ORCID users, else username."""
70+
key = mw_user.orcid_id or mw_user.name
71+
return uuid.uuid5(USER_NAMESPACE, key)
72+
73+
74+
def organization_uuid(affiliation: OrcidAffiliation) -> uuid.UUID:
75+
"""Deterministic uuid5: keyed on ROR id when present, else the org name."""
76+
key = affiliation.ror_id or normalize_org_name(affiliation.organization)
77+
return uuid.uuid5(ORGANIZATION_NAMESPACE, key)
78+
79+
80+
def map_organization(affiliation: OrcidAffiliation) -> ProposedOrganization:
81+
return ProposedOrganization(
82+
uuid=organization_uuid(affiliation),
83+
name=affiliation.organization,
84+
ror_id=affiliation.ror_id,
85+
)
86+
87+
88+
def _derive_names(
89+
profile: Optional[OrcidProfile], username: str
90+
) -> Tuple[str, str, str, bool]:
91+
"""Return (first_name, surname, label, is_placeholder).
92+
93+
Prefers ORCID given/family names, then a split of the display name, then a
94+
username-based placeholder when no name information is available.
95+
"""
96+
given = profile.given_names if profile else None
97+
family = profile.family_name if profile else None
98+
display = profile.display_name if profile else None
99+
if given and family:
100+
return given, family, display or f"{given} {family}", False
101+
if display:
102+
parts = display.split()
103+
first = given or parts[0]
104+
surname = family or (parts[-1] if len(parts) > 1 else parts[0])
105+
return first, surname, display, False
106+
if given or family:
107+
first = given or family
108+
surname = family or given
109+
return first, surname, f"{first} {surname}", False
110+
return username, username, username, True
111+
112+
113+
def map_user(
114+
mw_user: MwUser,
115+
profile: Optional[OrcidProfile] = None,
116+
link_organizations: bool = True,
117+
) -> Tuple[ProposedUser, List[ProposedOrganization]]:
118+
"""Build a ProposedUser (and any linked organizations) from the sources."""
119+
orcid_uri = f"https://orcid.org/{mw_user.orcid_id}" if mw_user.orcid_id else None
120+
first, surname, label, placeholder = _derive_names(profile, mw_user.name)
121+
122+
organizations: List[ProposedOrganization] = []
123+
org_titles: List[str] = []
124+
if link_organizations and profile:
125+
seen = set()
126+
for affiliation in profile.affiliations:
127+
org = map_organization(affiliation)
128+
if org.uuid in seen:
129+
continue
130+
seen.add(org.uuid)
131+
organizations.append(org)
132+
org_titles.append(org.full_page_title)
133+
134+
proposed = ProposedUser(
135+
uuid=user_uuid(mw_user),
136+
username=mw_user.name,
137+
first_name=first,
138+
surname=surname,
139+
label=label,
140+
orcid=orcid_uri,
141+
emails=list(profile.emails) if profile else [],
142+
websites=list(profile.urls) if profile else [],
143+
organizations=org_titles,
144+
placeholder_name=placeholder,
145+
)
146+
return proposed, organizations
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Unit tests for loading and indexing existing User items."""
2+
3+
from types import SimpleNamespace
4+
5+
from osw.tools.user_sync.existing import load_existing_users
6+
7+
8+
class FakeOsw:
9+
def __init__(self, titles, entities):
10+
self._titles = titles
11+
self._entities = entities
12+
self.loaded = None
13+
14+
def query_instances(self, category):
15+
assert category.startswith("Category:OSW")
16+
return list(self._titles)
17+
18+
def load_entity(self, titles):
19+
self.loaded = list(titles)
20+
return [self._entities[t] for t in titles]
21+
22+
23+
def test_load_indexes_by_username():
24+
entities = {
25+
"Item:OSW1": SimpleNamespace(username="0000-0002-6374-9831"),
26+
"Item:OSW2": SimpleNamespace(username="Alice"),
27+
}
28+
osw = FakeOsw(["Item:OSW1", "Item:OSW2"], entities)
29+
existing = load_existing_users(osw)
30+
assert set(existing.by_username) == {"0000-0002-6374-9831", "Alice"}
31+
assert existing.get("Alice") is entities["Item:OSW2"]
32+
assert existing.get("missing") is None
33+
34+
35+
def test_load_empty():
36+
osw = FakeOsw([], {})
37+
existing = load_existing_users(osw)
38+
assert existing.by_username == {}
39+
40+
41+
def test_load_skips_entities_without_username():
42+
entities = {
43+
"Item:OSW1": SimpleNamespace(username=None),
44+
"Item:OSW2": SimpleNamespace(username="Bob"),
45+
}
46+
osw = FakeOsw(["Item:OSW1", "Item:OSW2"], entities)
47+
existing = load_existing_users(osw)
48+
assert set(existing.by_username) == {"Bob"}
49+
50+
51+
def test_load_respects_limit():
52+
entities = {
53+
"Item:OSW1": SimpleNamespace(username="A"),
54+
"Item:OSW2": SimpleNamespace(username="B"),
55+
}
56+
osw = FakeOsw(["Item:OSW1", "Item:OSW2"], entities)
57+
existing = load_existing_users(osw, limit=1)
58+
assert osw.loaded == ["Item:OSW1"]
59+
assert set(existing.by_username) == {"A"}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Unit tests for mapping sources to proposed User/Organization items."""
2+
3+
import uuid
4+
5+
from osw.tools.user_sync import mapping
6+
from osw.tools.user_sync.mapping import (
7+
ProposedUser,
8+
map_organization,
9+
map_user,
10+
normalize_org_name,
11+
organization_uuid,
12+
user_uuid,
13+
)
14+
from osw.tools.user_sync.sources import MwUser, OrcidAffiliation, OrcidProfile
15+
16+
ORCID = "0000-0002-6374-9831"
17+
18+
19+
def _profile(**kw):
20+
base = dict(orcid_id=ORCID, given_names="Lukas", family_name="Koschmieder")
21+
base.update(kw)
22+
return OrcidProfile(**base)
23+
24+
25+
def test_map_orcid_user_full_profile():
26+
mw = MwUser(name=ORCID)
27+
profile = _profile(
28+
emails=["l@example.org"],
29+
urls=["https://example.org"],
30+
affiliations=[OrcidAffiliation(organization="Example University")],
31+
)
32+
proposed, orgs = map_user(mw, profile)
33+
assert isinstance(proposed, ProposedUser)
34+
assert proposed.username == ORCID
35+
assert proposed.orcid == f"https://orcid.org/{ORCID}"
36+
assert proposed.first_name == "Lukas"
37+
assert proposed.surname == "Koschmieder"
38+
assert proposed.label == "Lukas Koschmieder"
39+
assert proposed.emails == ["l@example.org"]
40+
assert proposed.websites == ["https://example.org"]
41+
assert proposed.placeholder_name is False
42+
assert len(orgs) == 1
43+
assert proposed.organizations == [orgs[0].full_page_title]
44+
assert proposed.full_page_title.startswith("Item:OSW")
45+
46+
47+
def test_map_orcid_user_without_profile_uses_placeholder_names():
48+
mw = MwUser(name=ORCID)
49+
proposed, orgs = map_user(mw, None)
50+
assert proposed.orcid == f"https://orcid.org/{ORCID}"
51+
assert proposed.first_name == ORCID
52+
assert proposed.surname == ORCID
53+
assert proposed.placeholder_name is True
54+
assert orgs == []
55+
56+
57+
def test_map_non_orcid_user():
58+
mw = MwUser(name="Alice")
59+
proposed, _orgs = map_user(mw, None)
60+
assert proposed.orcid is None
61+
assert proposed.placeholder_name is True
62+
assert proposed.uuid == uuid.uuid5(mapping.USER_NAMESPACE, "Alice")
63+
64+
65+
def test_user_uuid_is_deterministic_and_orcid_keyed():
66+
mw = MwUser(name=ORCID)
67+
expected = uuid.uuid5(mapping.USER_NAMESPACE, ORCID)
68+
assert user_uuid(mw) == expected
69+
assert user_uuid(mw) == user_uuid(MwUser(name=ORCID))
70+
71+
72+
def test_credit_name_split_when_no_given_family():
73+
mw = MwUser(name=ORCID)
74+
profile = OrcidProfile(
75+
orcid_id=ORCID, given_names=None, family_name=None, credit_name="Jane A. Doe"
76+
)
77+
proposed, _ = map_user(mw, profile)
78+
assert proposed.first_name == "Jane"
79+
assert proposed.surname == "Doe"
80+
assert proposed.label == "Jane A. Doe"
81+
assert proposed.placeholder_name is False
82+
83+
84+
def test_organization_uuid_prefers_ror_over_name():
85+
ror = OrcidAffiliation(organization="Example U", ror_id="https://ror.org/01")
86+
named = OrcidAffiliation(organization="Example U")
87+
assert organization_uuid(ror) != organization_uuid(named)
88+
assert organization_uuid(named) == uuid.uuid5(
89+
mapping.ORGANIZATION_NAMESPACE, normalize_org_name("Example U")
90+
)
91+
assert map_organization(ror).ror_id == "https://ror.org/01"
92+
93+
94+
def test_duplicate_affiliations_deduped():
95+
mw = MwUser(name=ORCID)
96+
profile = _profile(
97+
affiliations=[
98+
OrcidAffiliation(organization="Example University"),
99+
OrcidAffiliation(organization="example university"),
100+
]
101+
)
102+
proposed, orgs = map_user(mw, profile)
103+
assert len(orgs) == 1
104+
assert len(proposed.organizations) == 1
105+
106+
107+
def test_link_organizations_disabled():
108+
mw = MwUser(name=ORCID)
109+
profile = _profile(affiliations=[OrcidAffiliation(organization="Example U")])
110+
proposed, orgs = map_user(mw, profile, link_organizations=False)
111+
assert orgs == []
112+
assert proposed.organizations == []

0 commit comments

Comments
 (0)