Skip to content

Commit 828f02c

Browse files
committed
feat(user-sync): strip employment status and warn on missing email
1 parent 1899d7f commit 828f02c

4 files changed

Lines changed: 118 additions & 6 deletions

File tree

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from opensemantic.base.v1 import Organization, User
1414

15+
from .config import PROTECTED_FIELDS
1516
from .mapping import ProposedOrganization, ProposedUser
1617

1718
# The concrete DisplayName type used by the label field.
@@ -22,12 +23,20 @@ def _labels(text: str) -> List[Any]:
2223
return [_DISPLAY_NAME(text=text)]
2324

2425

26+
def _strip_protected(entity: Any) -> None:
27+
"""Remove protected relation defaults (e.g. employment_contract_status)."""
28+
iris = getattr(entity, "__iris__", None)
29+
if isinstance(iris, dict):
30+
for name in PROTECTED_FIELDS:
31+
iris.pop(name, None)
32+
33+
2534
def build_organization(proposed: ProposedOrganization) -> Organization:
2635
return Organization(uuid=proposed.uuid, label=_labels(proposed.name))
2736

2837

2938
def build_user(proposed: ProposedUser) -> User:
30-
"""Build a full User entity for creation."""
39+
"""Build a full User entity for creation (never carries protected fields)."""
3140
args: dict = {
3241
"uuid": proposed.uuid,
3342
"username": proposed.username,
@@ -43,11 +52,17 @@ def build_user(proposed: ProposedUser) -> User:
4352
args["website"] = set(proposed.websites)
4453
if proposed.organizations:
4554
args["organization"] = list(proposed.organizations)
46-
return User(**args)
55+
entity = User(**args)
56+
_strip_protected(entity)
57+
return entity
4758

4859

4960
def apply_update(entity: Any, proposed: ProposedUser, apply_fields: Set[str]) -> Any:
50-
"""Apply the accepted fields onto a loaded existing entity, in place."""
61+
"""Apply the accepted fields onto a loaded existing entity, in place.
62+
63+
Fields whose proposal is empty (REMOVE) are cleared; protected fields in
64+
``apply_fields`` are stripped from the entity's ``__iris__`` map.
65+
"""
5166
if "label" in apply_fields:
5267
entity.label = _labels(proposed.label)
5368
if "first_name" in apply_fields:
@@ -61,5 +76,10 @@ def apply_update(entity: Any, proposed: ProposedUser, apply_fields: Set[str]) ->
6176
if "websites" in apply_fields:
6277
entity.website = set(proposed.websites)
6378
if "organizations" in apply_fields:
64-
entity.__iris__["organization"] = list(proposed.organizations)
79+
if proposed.organizations:
80+
entity.__iris__["organization"] = list(proposed.organizations)
81+
else:
82+
entity.__iris__.pop("organization", None)
83+
if any(name in apply_fields for name in PROTECTED_FIELDS):
84+
_strip_protected(entity)
6585
return entity

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ class SyncReport:
3838
failed: Dict[str, str] = field(default_factory=dict)
3939
redirects_created: List[str] = field(default_factory=list)
4040
organizations: List[str] = field(default_factory=list)
41+
missing_email: List[str] = field(default_factory=list)
4142
accepted: bool = False
4243

4344
def summary(self) -> str:
4445
state = "applied" if self.accepted else "no changes written"
4546
return (
4647
f"{state}: created={len(self.created)} updated={len(self.updated)} "
4748
f"skipped={len(self.skipped)} failed={len(self.failed)} "
48-
f"redirects={len(self.redirects_created)} orgs={len(self.organizations)}"
49+
f"redirects={len(self.redirects_created)} orgs={len(self.organizations)} "
50+
f"missing_email={len(self.missing_email)}"
4951
)
5052

5153

@@ -144,6 +146,14 @@ def _create_redirects(osw: Any, resolution: Resolution, report: SyncReport) -> N
144146
report.failed[f"redirect:{proposed.username}"] = str(exc)
145147

146148

149+
def _warn_missing_email(prompter: Prompter, missing: List[str]) -> None:
150+
if missing:
151+
prompter.write(
152+
f"WARNING: {len(missing)} user(s) have no ORCID email: "
153+
+ ", ".join(missing)
154+
)
155+
156+
147157
def _verify(osw: Any, report: SyncReport) -> None:
148158
titles = report.created + report.updated
149159
if not titles:
@@ -175,6 +185,7 @@ def run_user_sync(
175185
prompter = prompter or Prompter()
176186

177187
proposed_users, org_map = _build_proposals(config, osw, session)
188+
missing_email = [p.username for p in proposed_users if not p.emails]
178189
existing = load_existing_users(osw)
179190
plan = reconcile(
180191
proposed_users,
@@ -197,8 +208,9 @@ def run_user_sync(
197208
summary_lines=summary_lines,
198209
)
199210

200-
report = SyncReport(accepted=resolution.accepted)
211+
report = SyncReport(accepted=resolution.accepted, missing_email=missing_email)
201212
if not resolution.accepted:
213+
_warn_missing_email(prompter, missing_email)
202214
return report
203215

204216
if config.link_organizations:
@@ -207,4 +219,5 @@ def run_user_sync(
207219
if config.create_redirects:
208220
_create_redirects(osw, resolution, report)
209221
_verify(osw, report)
222+
_warn_missing_email(prompter, missing_email)
210223
return report
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Unit tests for entity building/updating helpers (fakes; no live models)."""
2+
3+
import uuid
4+
from types import SimpleNamespace
5+
6+
from osw.tools.user_sync.build import _strip_protected, apply_update
7+
from osw.tools.user_sync.mapping import ProposedUser
8+
9+
10+
def _proposed(**kw):
11+
base = dict(
12+
uuid=uuid.uuid4(),
13+
username="u",
14+
first_name="F",
15+
surname="S",
16+
label="F S",
17+
orcid=None,
18+
emails=[],
19+
websites=[],
20+
organizations=[],
21+
)
22+
base.update(kw)
23+
return ProposedUser(**base)
24+
25+
26+
def _entity():
27+
return SimpleNamespace(
28+
label=[],
29+
first_name="",
30+
surname="",
31+
orcid=None,
32+
email=set(),
33+
website=set(),
34+
__iris__={
35+
"organization": ["Item:OSWa"],
36+
"employment_contract_status": "Item:OSWx",
37+
},
38+
)
39+
40+
41+
def test_strip_protected_removes_only_protected():
42+
entity = _entity()
43+
_strip_protected(entity)
44+
assert "employment_contract_status" not in entity.__iris__
45+
assert "organization" in entity.__iris__
46+
47+
48+
def test_apply_update_strips_protected_when_in_fields():
49+
entity = _entity()
50+
apply_update(entity, _proposed(), {"employment_contract_status"})
51+
assert "employment_contract_status" not in entity.__iris__
52+
53+
54+
def test_apply_update_keeps_protected_when_not_in_fields():
55+
entity = _entity()
56+
apply_update(entity, _proposed(surname="New"), {"surname"})
57+
assert "employment_contract_status" in entity.__iris__
58+
assert entity.surname == "New"
59+
60+
61+
def test_apply_update_clears_organization_when_empty():
62+
entity = _entity()
63+
apply_update(entity, _proposed(organizations=[]), {"organizations"})
64+
assert "organization" not in entity.__iris__
65+
66+
67+
def test_apply_update_sets_organization_when_present():
68+
entity = _entity()
69+
apply_update(entity, _proposed(organizations=["Item:OSWb"]), {"organizations"})
70+
assert entity.__iris__["organization"] == ["Item:OSWb"]
71+
72+
73+
def test_apply_update_clears_website():
74+
entity = _entity()
75+
entity.website = {"https://x"}
76+
apply_update(entity, _proposed(websites=[]), {"websites"})
77+
assert entity.website == set()

‎tests/tools/test_user_sync_sync.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ def test_dry_run_previews_without_writing():
9696
joined = "\n".join(io.outputs)
9797
assert ORCID in joined
9898
assert "new=1" in joined # bot excluded, only the ORCID user
99+
assert report.missing_email == [ORCID] # record has no email
100+
assert "no ORCID email" in joined
99101

100102

101103
def test_requires_connection():

0 commit comments

Comments
 (0)