Skip to content

Commit 31c2f82

Browse files
committed
feat(user-sync): opt-in extras, standard email, --auto-apply
1 parent 830d120 commit 31c2f82

8 files changed

Lines changed: 123 additions & 32 deletions

File tree

‎docs/tools/user-sync.md‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,31 +22,49 @@ it to write after confirming at the prompt.
2222

2323
### Options
2424

25+
By default the tool syncs both ORCID and MediaWiki-native accounts and writes
26+
only core identity plus email.
27+
2528
| Flag | Effect |
2629
| --- | --- |
2730
| `--domain` | Target OSL domain. |
2831
| `--cred-filepath` | Path to `accounts.pwd.yaml`. |
2932
| `--dry-run` | Preview only; never writes. |
30-
| `--yes` | Non-interactive: create and gap-fill, keep existing on conflicts. |
33+
| `--auto-apply` | Non-interactive: apply creates, gap-fills and removals; keep existing on conflicts. |
3134
| `--limit N` | Process at most N accounts (testing). |
32-
| `--orcid-only` | Only accounts whose username is an ORCID iD. |
35+
| `--orcid-only` | Only ORCID-username accounts (excludes `--mw-only`). |
36+
| `--mw-only` | Only non-ORCID (MediaWiki-native) accounts (excludes `--orcid-only`). |
3337
| `--include-system` | Do not skip MediaWiki reserved system accounts. |
3438
| `--no-redirects` | Do not create `User:` redirect pages. |
35-
| `--no-organizations` | Do not link ORCID affiliations to Organization items. |
39+
| `--with-websites` | Also store ORCID researcher URLs (opt-in). |
40+
| `--with-organizations` | Also create and link Organization items from ORCID affiliations (opt-in). |
41+
| `--with-extras` | Enable both websites and organizations. |
3642

3743
## What it does
3844

3945
1. Enumerate MediaWiki accounts, skipping bots and privileged groups
4046
(`bot`, `sysop`, `bureaucrat`, `interface-admin`) and MediaWiki reserved
4147
system usernames.
42-
2. Enrich ORCID users from `https://pub.orcid.org/v3.0/<id>` (names, public
43-
email, websites, employment or affiliation).
48+
2. Enrich ORCID users from `https://pub.orcid.org/v3.0/<id>`: names and email
49+
always; researcher URLs and affiliations only when their flag is enabled.
4450
3. Map each account to a proposed `User` item with a deterministic id
4551
(uuid5 on the ORCID iD, else the username), so re-runs are idempotent.
46-
4. Reconcile against existing items by `username` into NEW, GAP_FILL, CONFLICT
47-
or UNCHANGED, then preview and resolve conflicts interactively.
48-
5. Store items, create `User:<username>` redirects to each item, ensure linked
49-
Organization items, and verify by reloading.
52+
4. Reconcile against existing items by `username` into NEW, GAP_FILL, CONFLICT,
53+
REMOVE or UNCHANGED, then preview; gap-fills and removals apply
54+
automatically, conflicts are resolved interactively.
55+
5. Store items, create `User:<username>` redirects to each item, ensure any
56+
linked Organization items, verify by reloading, and warn about users with no
57+
email.
58+
59+
## Data written
60+
61+
| Field | Policy |
62+
| --- | --- |
63+
| `username`, `first_name`, `surname`, `label`, `orcid` | Always (core identity). |
64+
| `email` | Standard: always attempted; missing email is warned, not fatal; never removed. |
65+
| `website` | Opt-in (`--with-websites`); removed from existing items when disabled. |
66+
| `organization` | Opt-in (`--with-organizations`); removed from existing items when disabled. |
67+
| `employment_contract_status` | Never written; removed from existing items (data protection). |
5068

5169
## Notes
5270

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

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import argparse
66
from dataclasses import dataclass, field
7-
from typing import List, Optional
7+
from typing import List, Optional, Set
88

99
# Category page titles of the target item types (see opensemantic.base.v1).
1010
USER_CATEGORY = "Category:OSWd9aa0bca9b0040d8af6f5c091bf9eec7"
@@ -17,6 +17,10 @@
1717
# All are standard MediaWiki groups, so this is not a per-instance skip-list.
1818
SYSTEM_GROUPS = ["bot", "sysop", "bureaucrat", "interface-admin"]
1919

20+
# User fields the script must never write and must remove from existing items
21+
# (data protection).
22+
PROTECTED_FIELDS = ("employment_contract_status",)
23+
2024

2125
@dataclass
2226
class SyncConfig:
@@ -25,16 +29,30 @@ class SyncConfig:
2529
domain: Optional[str] = None
2630
cred_filepath: Optional[str] = None
2731
dry_run: bool = False
28-
assume_yes: bool = False
32+
auto_apply: bool = False
2933
limit: Optional[int] = None
34+
include_orcid: bool = True
3035
include_non_orcid: bool = True
3136
exclude_bot_group: bool = True
3237
exclude_system_usernames: bool = True
3338
create_redirects: bool = True
34-
link_organizations: bool = True
39+
# Optional enrichment beyond core identity, opt-in (off by default).
40+
# Email is not optional: it is always attempted, and missing emails are
41+
# reported as warnings rather than failing the run.
42+
include_websites: bool = False
43+
link_organizations: bool = False
3544
orcid_api_base: str = ORCID_API_BASE_DEFAULT
3645
excluded_groups: List[str] = field(default_factory=lambda: list(SYSTEM_GROUPS))
3746

47+
def enabled_optional_fields(self) -> Set[str]:
48+
"""Reconcile field names for the enabled optional data."""
49+
enabled: Set[str] = set()
50+
if self.include_websites:
51+
enabled.add("websites")
52+
if self.link_organizations:
53+
enabled.add("organizations")
54+
return enabled
55+
3856

3957
def build_arg_parser() -> argparse.ArgumentParser:
4058
"""Build the argument parser used by the example wrapper."""
@@ -53,10 +71,10 @@ def build_arg_parser() -> argparse.ArgumentParser:
5371
help="Show the preview only; do not write anything.",
5472
)
5573
parser.add_argument(
56-
"--yes",
57-
dest="assume_yes",
74+
"--auto-apply",
75+
dest="auto_apply",
5876
action="store_true",
59-
help="Non-interactive: apply new items and gap-fills, keep existing on conflicts.",
77+
help="Non-interactive: apply creates, gap-fills and removals; keep existing on conflicts.",
6078
)
6179
parser.add_argument(
6280
"--limit",
@@ -70,24 +88,36 @@ def build_arg_parser() -> argparse.ArgumentParser:
7088
help="Do not create User: redirect pages.",
7189
)
7290
parser.add_argument(
73-
"--no-organizations",
74-
dest="link_organizations",
75-
action="store_false",
76-
help="Do not resolve or link ORCID affiliations to Organization items.",
91+
"--with-websites",
92+
dest="include_websites",
93+
action="store_true",
94+
help="Also store ORCID researcher URLs on the user item (opt-in).",
7795
)
7896
parser.add_argument(
79-
"--include-non-orcid",
80-
dest="include_non_orcid",
97+
"--with-organizations",
98+
dest="link_organizations",
8199
action="store_true",
82-
default=True,
83-
help="Also sync non-bot accounts without an ORCID username (default).",
100+
help="Also resolve ORCID affiliations to Organization items and link them.",
84101
)
85102
parser.add_argument(
103+
"--with-extras",
104+
dest="with_extras",
105+
action="store_true",
106+
help="Enable all optional data: websites and organizations.",
107+
)
108+
account_scope = parser.add_mutually_exclusive_group()
109+
account_scope.add_argument(
86110
"--orcid-only",
87111
dest="include_non_orcid",
88112
action="store_false",
89113
help="Sync only accounts whose username is an ORCID iD.",
90114
)
115+
account_scope.add_argument(
116+
"--mw-only",
117+
dest="include_orcid",
118+
action="store_false",
119+
help="Sync only non-ORCID (MediaWiki-native) accounts.",
120+
)
91121
parser.add_argument(
92122
"--include-system",
93123
dest="exclude_system_usernames",
@@ -104,10 +134,12 @@ def config_from_args(argv: Optional[List[str]] = None) -> SyncConfig:
104134
domain=args.domain,
105135
cred_filepath=args.cred_filepath,
106136
dry_run=args.dry_run,
107-
assume_yes=args.assume_yes,
137+
auto_apply=args.auto_apply,
108138
limit=args.limit,
139+
include_orcid=args.include_orcid,
109140
include_non_orcid=args.include_non_orcid,
110141
exclude_system_usernames=args.exclude_system_usernames,
111142
create_redirects=args.create_redirects,
112-
link_organizations=args.link_organizations,
143+
include_websites=args.include_websites or args.with_extras,
144+
link_organizations=args.link_organizations or args.with_extras,
113145
)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def resolve_plan(
143143
plan: ReconcilePlan,
144144
prompter: Prompter,
145145
dry_run: bool = False,
146-
assume_yes: bool = False,
146+
auto_apply: bool = False,
147147
summary_lines: Optional[Sequence[str]] = None,
148148
) -> Resolution:
149149
"""Preview the plan and resolve conflicts into a write decision."""
@@ -154,7 +154,7 @@ def resolve_plan(
154154

155155
conflict_mode: Optional[str] = None
156156
if plan.conflicts and not dry_run:
157-
if assume_yes:
157+
if auto_apply:
158158
conflict_mode = "keep"
159159
else:
160160
choice = prompter.ask(
@@ -188,7 +188,7 @@ def resolve_plan(
188188

189189
if dry_run:
190190
accepted_run = False
191-
elif assume_yes:
191+
elif auto_apply:
192192
accepted_run = True
193193
else:
194194
n_create = sum(1 for r in resolved if r.action == "create")

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def enumerate_mw_users(
9696
site: Any,
9797
excluded_groups: Sequence[str] = ("bot",),
9898
excluded_usernames: Sequence[str] = (),
99+
include_orcid: bool = True,
99100
include_non_orcid: bool = True,
100101
limit: Optional[int] = None,
101102
batch: Any = "max",
@@ -106,6 +107,7 @@ def enumerate_mw_users(
106107
site: An object exposing ``api("query", ...)`` (an mwclient Site).
107108
excluded_groups: Accounts in any of these groups are skipped (bots, admins).
108109
excluded_usernames: Exact usernames to skip (reserved system accounts).
110+
include_orcid: If False, skip ORCID-username accounts.
109111
include_non_orcid: If False, keep only ORCID-username accounts.
110112
limit: Keep at most this many accounts after filtering.
111113
batch: ``aulimit`` value passed to the API.
@@ -121,6 +123,8 @@ def enumerate_mw_users(
121123
continue
122124
if not include_non_orcid and not user.is_orcid:
123125
continue
126+
if not include_orcid and user.is_orcid:
127+
continue
124128
users.append(user)
125129
if limit is not None and len(users) >= limit:
126130
break

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def _build_proposals(config: SyncConfig, osw: Any, session: Any):
5757
osw.mw_site,
5858
excluded_groups=excluded,
5959
excluded_usernames=reserved,
60+
include_orcid=config.include_orcid,
6061
include_non_orcid=config.include_non_orcid,
6162
limit=config.limit,
6263
)
@@ -184,7 +185,7 @@ def run_user_sync(
184185
plan,
185186
prompter,
186187
dry_run=config.dry_run,
187-
assume_yes=config.assume_yes,
188+
auto_apply=config.auto_apply,
188189
summary_lines=summary_lines,
189190
)
190191

‎tests/tools/test_user_sync_config.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ def test_defaults():
1414
cfg = SyncConfig()
1515
assert cfg.include_non_orcid is True
1616
assert cfg.create_redirects is True
17-
assert cfg.link_organizations is True
17+
# Websites and organizations are opt-in (off by default); email is standard.
18+
assert cfg.include_websites is False
19+
assert cfg.link_organizations is False
20+
assert cfg.enabled_optional_fields() == set()
1821
assert cfg.exclude_system_usernames is True
1922
assert "bot" in cfg.excluded_groups
2023
assert "sysop" in cfg.excluded_groups
@@ -27,6 +30,18 @@ def test_include_system_flag():
2730
assert cfg.exclude_system_usernames is False
2831

2932

33+
def test_optional_field_flags():
34+
cfg = config_from_args(["--with-organizations", "--with-websites"])
35+
assert cfg.link_organizations is True
36+
assert cfg.include_websites is True
37+
assert cfg.enabled_optional_fields() == {"organizations", "websites"}
38+
39+
40+
def test_with_extras_enables_all():
41+
cfg = config_from_args(["--with-extras"])
42+
assert cfg.enabled_optional_fields() == {"websites", "organizations"}
43+
44+
3045
def test_config_from_args_parses_flags():
3146
cfg = config_from_args([
3247
"--domain",
@@ -40,6 +55,16 @@ def test_config_from_args_parses_flags():
4055
assert cfg.dry_run is True
4156
assert cfg.limit == 5
4257
assert cfg.include_non_orcid is False
58+
assert cfg.include_orcid is True
59+
60+
61+
def test_account_scope_defaults_and_mw_only():
62+
cfg = SyncConfig()
63+
assert cfg.include_orcid is True
64+
assert cfg.include_non_orcid is True # both are standard by default
65+
mw_only = config_from_args(["--mw-only"])
66+
assert mw_only.include_orcid is False
67+
assert mw_only.include_non_orcid is True
4368

4469

4570
def test_report_summary():

‎tests/tools/test_user_sync_interactive.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ def test_resolve_dry_run_makes_no_writes_and_no_prompts():
136136
assert io.answers == [] # nothing was asked
137137

138138

139-
def test_resolve_assume_yes_keeps_conflicts():
139+
def test_resolve_auto_apply_keeps_conflicts():
140140
prompter, io = _prompter([])
141-
res = resolve_plan(_mixed_plan(), prompter, assume_yes=True)
141+
res = resolve_plan(_mixed_plan(), prompter, auto_apply=True)
142142
assert res.accepted is True
143143
updates = {r.change.proposed.username: r.apply_fields for r in res.updates()}
144144
assert updates == {"gap1": {"emails"}, "mix1": {"emails"}}

‎tests/tools/test_user_sync_sources.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ def test_enumerate_excludes_privileged_groups_and_reserved_names():
110110
assert [u.name for u in users] == ["0000-0002-6374-9831"]
111111

112112

113+
def test_enumerate_mw_only_skips_orcid():
114+
site = FakeSite([
115+
_page([
116+
{"name": "0000-0002-6374-9831", "groups": ["user"]},
117+
{"name": "Alice", "groups": ["user"]},
118+
])
119+
])
120+
users = enumerate_mw_users(site, include_orcid=False)
121+
assert [u.name for u in users] == ["Alice"]
122+
123+
113124
def test_partition_by_orcid():
114125
users = [
115126
MwUser(name="0000-0002-6374-9831"),

0 commit comments

Comments
 (0)