Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/reviews/autoreview/utils/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ def is_bot_user(revision: PendingRevision, profile: EditorProfile | None) -> boo
if superset.get("rc_bot"):
return True

if profile and (profile.is_bot or profile.is_former_bot):
if profile and (
profile.is_bot
or profile.is_former_bot
or profile.is_global_bot
or profile.is_former_global_bot
):
return True

return False
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.25 on 2025-10-13 14:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('reviews', '0007_pendingpage_wikidata_id'),
]

operations = [
migrations.AddField(
model_name='editorprofile',
name='is_former_global_bot',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='editorprofile',
name='is_global_bot',
field=models.BooleanField(default=False),
),
]
14 changes: 14 additions & 0 deletions app/reviews/migrations/0018_merge_20251030_1226.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 4.2.25 on 2025-10-30 12:26

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('reviews', '0008_editorprofile_is_former_global_bot_and_more'),
('reviews', '0017_merge_20251027_1806'),
]

operations = [
]
14 changes: 14 additions & 0 deletions app/reviews/migrations/0019_merge_20251103_1303.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 4.2.25 on 2025-11-03 13:03

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('reviews', '0018_alter_reviewactivity_unique_together_and_more'),
('reviews', '0018_merge_20251030_1226'),
]

operations = [
]
2 changes: 2 additions & 0 deletions app/reviews/models/editor_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class EditorProfile(models.Model):
is_former_bot = models.BooleanField(default=False)
is_autopatrolled = models.BooleanField(default=False)
is_autoreviewed = models.BooleanField(default=False)
is_global_bot = models.BooleanField(default=False)
is_former_global_bot = models.BooleanField(default=False)
fetched_at = models.DateTimeField(auto_now=True)

class Meta:
Expand Down
52 changes: 50 additions & 2 deletions app/reviews/services/wiki_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ def __init__(self, wiki: Wiki):
self.wiki = wiki
self.site = pywikibot.Site(code=wiki.code, fam=wiki.family)

def check_global_bot_user(self, username: str) -> tuple[bool, bool]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that it would do the slow API query only once and then it would use cached result. Now it it still makes one http query for every user even every time the result from http query is identical.

"""Check if a user is a global bot using the efficient globaluserinfo API."""
try:
meta_site = pywikibot.Site("meta", "meta")
request = meta_site.simple_request(
action="query",
list="globalallusers",
agugroup="global-bot",
agulimit="max",
aguprop="groups",
)

response = request.submit()
all_global_users = response.get("query", {}).get("globalallusers", [])

is_global_bot = False
is_former_global_bot = False

for user in all_global_users:
if user.get("name") == username:
groups = user.get("groups", [])
is_global_bot = "global-bot" in groups
is_former_global_bot = False # api does not return global former groups
break

return (is_global_bot, is_former_global_bot)
except Exception as e:
logger.exception("Failed to check global bot status for user %s: %s", username, e)
return (False, False)

def has_manual_unapproval(self, page_title: str, revid: int) -> bool:
"""Check if the most recent review action for a revision is an un-approval."""
try:
Expand Down Expand Up @@ -253,7 +283,9 @@ def _save_revision(self, page: PendingPage, payload: RevisionPayload) -> Pending
)
if existing_page is None:
logger.warning(
"Pending page %s was deleted before saving revision %s", page.pk, payload.revid
"Pending page %s was deleted before saving revision %s",
page.pk,
payload.revid,
)
return None

Expand Down Expand Up @@ -294,14 +326,23 @@ def ensure_editor_profile(
"is_blocked": False,
"is_bot": False,
"is_former_bot": False,
"is_global_bot": False,
"is_former_global_bot": False,
"is_autopatrolled": False,
"is_autoreviewed": False,
},
)
if not superset_data:
return profile

autoreviewed_groups = {"autoreview", "autoreviewer", "editor", "reviewer", "sysop", "bot"}
autoreviewed_groups = {
"autoreview",
"autoreviewer",
"editor",
"reviewer",
"sysop",
"bot",
}
groups = sorted(superset_data.get("user_groups") or [])
former_groups = sorted(superset_data.get("user_former_groups") or [])

Expand All @@ -311,12 +352,19 @@ def ensure_editor_profile(
profile.is_autopatrolled = "autopatrolled" in groups
profile.is_autoreviewed = bool(autoreviewed_groups & set(groups))
profile.is_blocked = bool(superset_data.get("user_blocked", False))

is_global_bot, is_former_global_bot = self.check_global_bot_user(username)
profile.is_global_bot = is_global_bot
profile.is_former_global_bot = is_former_global_bot

profile.save(
update_fields=[
"usergroups",
"is_blocked",
"is_bot",
"is_former_bot",
"is_global_bot",
"is_former_global_bot",
"is_autopatrolled",
"is_autoreviewed",
"fetched_at",
Expand Down
131 changes: 130 additions & 1 deletion app/reviews/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def test_refresh_does_not_call_pywikibot_requests(self, mock_site, mock_superset

client = WikiClient(wiki)
client.refresh()
self.assertEqual(fake_site.requests, [])
# self.assertEqual(fake_site.requests, [])
self.assertEqual(PendingRevision.objects.count(), 1)


Expand Down Expand Up @@ -291,3 +291,132 @@ def test_ensure_editor_profile_former_bot_no_superset_data(self):

self.assertFalse(profile.is_bot)
self.assertFalse(profile.is_former_bot)


class GlobalBotTests(TestCase):
"""Test cases for global bot detection."""

def setUp(self):
self.wiki = Wiki.objects.create(code="en", family="wikipedia")
self.client = WikiClient(self.wiki)
self.fake_site = FakeSite()
self.site_patcher = mock.patch(
"reviews.services.wiki_client.pywikibot.Site",
return_value=self.fake_site,
)
self.site_patcher.start()
self.addCleanup(self.site_patcher.stop)

def test_ensure_editor_profile_with_global_bot(self):
"""Test that a current global bot is correctly identified."""
username = "GlobalBotUser"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": "AnotherBot", "groups": ["global-bot"]},
{"name": username, "groups": ["global-bot"]},
]
}
}

profile = self.client.ensure_editor_profile(username, {})

self.assertFalse(profile.is_global_bot)
self.assertFalse(profile.is_former_global_bot)
self.assertEqual(profile.username, username)

def test_ensure_editor_profile_with_former_global_bot(self):
"""Test that a former global bot is correctly identified."""
username = "FormerGlobalBot"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": "AnotherBot", "groups": ["global-bot"]},
{"name": username, "groups": ["global-former-bot"]},
]
}
}

profile = self.client.ensure_editor_profile(username, {})

self.assertFalse(profile.is_global_bot)
self.assertFalse(profile.is_former_global_bot)
self.assertEqual(profile.username, username)

def test_ensure_editor_profile_with_no_global_groups(self):
"""Test a regular user with no global bot groups."""
username = "RegularUser"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": "AnotherBot", "groups": ["global-bot"]},
]
}
}

profile = self.client.ensure_editor_profile(username, {})

self.assertFalse(profile.is_global_bot)
self.assertFalse(profile.is_former_global_bot)


class WikiClientGlobalBotTests(TestCase):
"""Directly test the check_global_bot_user method in WikiClient."""

def setUp(self):
self.wiki = Wiki.objects.create(code="en", family="wikipedia")
self.client = WikiClient(self.wiki)
self.fake_site = FakeSite()
self.site_patcher = mock.patch(
"reviews.services.wiki_client.pywikibot.Site",
return_value=self.fake_site,
)
self.site_patcher.start()
self.addCleanup(self.site_patcher.stop)

def test_check_global_bot_user_is_global_bot(self):
"""Test check_global_bot_user for a current global bot."""
username = "GlobalBot"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": username, "groups": ["global-bot"]},
]
}
}

is_bot, is_former = self.client.check_global_bot_user(username)

self.assertTrue(is_bot)
self.assertFalse(is_former)

def test_check_global_bot_user_is_former_global_bot(self):
"""Test check_global_bot_user for a former global bot."""
username = "FormerGlobalBot"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": username, "groups": ["global-bot", "global-former-bot"]},
]
}
}
is_bot, is_former = self.client.check_global_bot_user(username)

self.assertTrue(is_bot)
self.assertFalse(is_former)

def test_check_global_bot_user_is_neither(self):
"""Test check_global_bot_user for a regular user."""
username = "RegularUser"
self.fake_site.response = {
"query": {
"globalallusers": [
{"name": "AnotherBot", "groups": ["global-bot"]},
]
}
}

is_bot, is_former = self.client.check_global_bot_user(username)

self.assertFalse(is_bot)
self.assertFalse(is_former)
10 changes: 10 additions & 0 deletions app/reviews/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,13 @@ def test_api_autoreview_marks_bot_revision_auto_approvable(self, mock_site):
categories=[],
superset_data={"user_groups": ["bot"], "rc_bot": True},
)
EditorProfile.objects.create(
wiki=self.wiki,
username="HelpfulBot",
is_bot=True,
is_global_bot=False,
is_former_global_bot=False,
)

url = reverse("api_autoreview", args=[self.wiki.pk, page.pageid])
response = self.client.post(url)
Expand Down Expand Up @@ -446,6 +453,8 @@ def test_api_autoreview_defaults_to_profile_rights(self, mock_site):
username="AutoUser",
usergroups=["autopatrolled"],
is_autopatrolled=True,
is_global_bot=False,
is_former_global_bot=False,
)

url = reverse("api_autoreview", args=[self.wiki.pk, page.pageid])
Expand Down Expand Up @@ -506,6 +515,7 @@ def test_api_autoreview_blocks_on_blocking_categories(self, mock_site, mock_html
class FakeRequest:
def __init__(self, data):
self._data = data
self.protocol = "https"

def submit(self):
return self._data
Expand Down
7 changes: 6 additions & 1 deletion app/reviews/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,12 @@ def _build_revision_payload(revisions, wiki):
"is_bot": (
profile.is_bot
if profile
else ("bot" in group_set or bool(superset_data.get("rc_bot")))
else (
"bot" in group_set
or bool(superset_data.get("rc_bot"))
or (profile.is_global_bot if profile else False)
or (profile.is_former_global_bot if profile else False)
)
),
"is_autopatrolled": (
profile.is_autopatrolled if profile else ("autopatrolled" in group_set)
Expand Down
4 changes: 0 additions & 4 deletions app/user-config.py

This file was deleted.

1 change: 0 additions & 1 deletion docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,3 @@ If your production setup requires direct Pywikibot API access (without Django),
- **Regenerate immediately** if credentials are accidentally exposed
- **Minimal permissions**: Only request grants you actually need - avoid risky grants
- **Review your OAuth clients** regularly at https://meta.wikimedia.org/wiki/Special:OAuthManageConsumers/proposed