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
23 changes: 22 additions & 1 deletion posts/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from admin_auto_filters.filters import AutocompleteFilterFactory
from admin_auto_filters.filters import AutocompleteFilterFactory, AutocompleteFilter
from django.contrib import admin, messages
from django.db.models import QuerySet
from django.http import HttpResponse
Expand All @@ -19,6 +19,26 @@
from utils.models import CustomTranslationAdmin


class DefaultOrSecondaryProjectFilter(AutocompleteFilter):
"""Autocomplete filter — `?default_or_secondary_project=<id>` matches posts
where the project is the default_project OR appears in the projects M2M."""

title = "Default or Secondary Project"
field_name = "default_project"
parameter_name = "default_or_secondary_project"
use_pk_exact = False

def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset
try:
project = Project.objects.get(pk=int(value))
except (Project.DoesNotExist, ValueError):
return queryset.none()
return queryset.filter_projects(project)


@admin.register(Post)
class PostAdmin(CustomTranslationAdmin):
list_display = [
Expand All @@ -36,6 +56,7 @@ class PostAdmin(CustomTranslationAdmin):
"show_on_homepage",
AutocompleteFilterFactory("Default Project", "default_project"),
AutocompleteFilterFactory("Project", "projects"),
DefaultOrSecondaryProjectFilter,
]
autocomplete_fields = [
"author",
Expand Down
72 changes: 38 additions & 34 deletions projects/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import random
from urllib.parse import urlencode

from admin_auto_filters.filters import AutocompleteFilterFactory
from django import forms
Expand Down Expand Up @@ -790,42 +791,34 @@ def view_posts_link(self, obj):
def posts_summary(self, obj):
if obj.type == Project.ProjectTypes.SITE_MAIN:
return None
all_posts = Post.objects.filter(
Q(default_project=obj) | Q(projects=obj)
).distinct("id")
all_ids = list(all_posts.values_list("id", flat=True))
all_count = len(all_ids)

default_posts = Post.objects.filter(default_project=obj)
default_posts_ids = list(default_posts.values_list("id", flat=True))
default_posts_count = len(default_posts_ids)

posts = Post.objects.filter(projects=obj).distinct("id")
posts_ids = list(posts.values_list("id", flat=True))
posts_count = len(posts_ids)

def make_link(ids, label):
query_string = f"id__in={','.join(map(str, ids)) or '0'}"
url = reverse("admin:posts_post_changelist") + "?" + query_string
return format_html('<a href="{}">{}</a>', url, label)

all_count = Post.objects.filter_projects(obj).count()
default_posts_count = Post.objects.filter(default_project=obj).count()
posts_count = Post.objects.filter(projects=obj).count()

def make_link(label, **query_params):
url = reverse("admin:posts_post_changelist") + "?" + urlencode(query_params)
return format_html('<a href="{}">{}</a>', url, label)

return format_html_join(
format_html("<br>"),
"{}",
[
(make_link(all_ids, f"{all_count} Posts in Project"),),
(
make_link(
default_posts_ids,
f"{all_count} Posts in Project",
default_or_secondary_project=obj.id,
),
),
(
make_link(
f"{default_posts_count} Default Posts (Determines Permissions)",
default_project=obj.id,
),
),
(
make_link(
posts_ids,
f"{posts_count} Secondary Posts",
projects=obj.id,
),
),
],
Expand All @@ -840,46 +833,57 @@ def questions_summary(self, obj):
if not leaderboard:
return None

all_questions = leaderboard.get_questions().filter(question_weight__gt=0)
all_ids = list(all_questions.values_list("id", flat=True))
all_count = len(all_ids)
all_count = Question.objects.filter(
post__in=Post.objects.filter_projects(obj)
).count()

weighted_questions = leaderboard.get_questions().filter(question_weight__gt=0)
weighted_ids = list(weighted_questions.values_list("id", flat=True))

finalize_time = leaderboard.finalize_time or obj.close_date
if finalize_time:
in_leaderboard_qs = all_questions.filter(
in_leaderboard_qs = weighted_questions.filter(
Q(resolution_set_time__isnull=True)
| Q(resolution_set_time__lte=finalize_time),
scheduled_close_time__lte=finalize_time,
)
else:
in_leaderboard_qs = all_questions
in_leaderboard_qs = weighted_questions

in_leaderboard_ids = list(in_leaderboard_qs.values_list("id", flat=True))
in_leaderboard_count = len(in_leaderboard_ids)

not_in_leaderboard_ids = list(set(all_ids) - set(in_leaderboard_ids))
not_in_leaderboard_ids = list(set(weighted_ids) - set(in_leaderboard_ids))
not_in_leaderboard_count = len(not_in_leaderboard_ids)

def make_link(ids, label):
query_string = f"id__in={','.join(map(str, ids)) or '0'}"
url = reverse("admin:questions_question_changelist") + "?" + query_string
def make_link(label, **query_params):
url = (
reverse("admin:questions_question_changelist")
+ "?"
+ urlencode(query_params)
)
return format_html('<a href="{}">{}</a>', url, label)

return format_html_join(
format_html("<br>"),
"{}",
[
(make_link(all_ids, f"{all_count} Questions in Project"),),
(
make_link(
in_leaderboard_ids,
f"{all_count} Questions in Project",
default_or_secondary_project=obj.id,
),
),
(
make_link(
f"{in_leaderboard_count} Questions in Primary Leaderboard",
id__in=",".join(map(str, in_leaderboard_ids)) or "0",
),
),
(
make_link(
not_in_leaderboard_ids,
f"{not_in_leaderboard_count} Questions NOT in Primary Leaderboard",
id__in=",".join(map(str, not_in_leaderboard_ids)) or "0",
),
Comment on lines +877 to 887
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's any custom id__in filter defined for QuestionAdmin
rg -n "id__in" questions/admin.py

# Check if there's a base admin class that might handle this
rg -nP "class.*Filter.*:" questions/admin.py

Repository: Metaculus/metaculus

Length of output: 126


🏁 Script executed:

# Check the actual code in projects/admin.py at the specified lines
sed -n '870,895p' projects/admin.py | cat -n

Repository: Metaculus/metaculus

Length of output: 1141


🏁 Script executed:

# Look for QuestionAdmin class and its list_filter definition
rg -n "class QuestionAdmin" questions/admin.py -A 30

Repository: Metaculus/metaculus

Length of output: 2364


🏁 Script executed:

# Check the make_link function definition
rg -n "def make_link" projects/admin.py -B 2 -A 15

Repository: Metaculus/metaculus

Length of output: 1440


🏁 Script executed:

# Check if id__in filter exists or any custom filter handling in questions/admin.py
rg -n "list_filter" questions/admin.py -A 10

Repository: Metaculus/metaculus

Length of output: 1205


🌐 Web query:

Django admin list_filter URL parameters unregistered filter behavior

💡 Result:

In Django admin, when accessing the changelist view with a URL parameter that does not correspond to any registered list_filter (an "unregistered filter"), the behavior depends on whether the parameter represents a valid model lookup and passes ModelAdmin.lookup_allowed. If the parameter is a valid lookup (e.g., ?myfield=value where myfield exists on the model or through relations) but not whitelisted by list_filter or lookup_allowed, Django raises DisallowedModelAdminLookup (a SuspiciousOperation subclass). This typically results in a 400 Bad Request response in production (DEBUG=False) or a debug page in development. If the parameter does not represent a valid lookup (e.g., ?invalidparam=value), it is silently ignored. The ChangeList processes only recognized parameters from registered filters (via get_filters), and unrecognized keys remain in self.params but do not affect the queryset or trigger errors. The page renders normally with the full (unfiltered by that param) queryset. The ?e=1 parameter appears in URLs after a previous DisallowedModelAdminLookup redirect (handled in older Django versions or custom code), indicating a prior invalid lookup attempt. In modern Django (4.2+), invalid lookups raise exceptions rather than redirecting. To allow custom unregistered parameters without affecting the queryset, add a pass-through SimpleListFilter with matching parameter_name whose lookups accepts the value and queryset returns the unchanged queryset, or override changelist_view to pop the parameter from request.GET after making it mutable.

Citations:


🏁 Script executed:

# Check for lookup_allowed override in QuestionAdmin
rg -n "lookup_allowed" questions/admin.py -B 2 -A 5

Repository: Metaculus/metaculus

Length of output: 45


🏁 Script executed:

# Check if there's any parent class that might handle this
rg -n "class CustomTranslationAdmin" questions/admin.py -B 2 -A 20

Repository: Metaculus/metaculus

Length of output: 45


🏁 Script executed:

# Look for any configuration in base admin classes
fd -name "admin*.py" | xargs rg -l "lookup_allowed" | head -5

Repository: Metaculus/metaculus

Length of output: 294


🏁 Script executed:

# Fix the fd command to search for admin files and check lookup_allowed
rg "lookup_allowed" --type python | head -20

Repository: Metaculus/metaculus

Length of output: 94


🏁 Script executed:

# Check imports in questions/admin.py to see where CustomTranslationAdmin comes from
head -50 questions/admin.py | grep -E "^from|^import"

Repository: Metaculus/metaculus

Length of output: 1249


🏁 Script executed:

# Check if QuestionAdmin or base classes override lookup_allowed
rg -A 20 "class QuestionAdmin" questions/admin.py | grep -E "lookup_allowed|class " | head -10

Repository: Metaculus/metaculus

Length of output: 185


🏁 Script executed:

# Find and check CustomTranslationAdmin
rg -n "class CustomTranslationAdmin" -A 30

Repository: Metaculus/metaculus

Length of output: 1908


🏁 Script executed:

# Check if there's any custom changelist_view or lookup_allowed in the admin configuration
rg -n "changelist_view|lookup_allowed" projects/admin.py

Repository: Metaculus/metaculus

Length of output: 45


🏁 Script executed:

# Check if there's any custom error handling or middleware
rg -n "DisallowedModelAdminLookup" --type py

Repository: Metaculus/metaculus

Length of output: 45


🏁 Script executed:

# Verify the Django version to confirm the behavior
grep -E "Django|django" pyproject.toml requirements.txt setup.py 2>/dev/null | head -5

Repository: Metaculus/metaculus

Length of output: 314


The id__in URL parameter will cause a DisallowedModelAdminLookup error.

In Django 5.2+, using a valid lookup like id__in that isn't whitelisted in QuestionAdmin.list_filter raises a security exception (DisallowedModelAdminLookup), resulting in a 400 Bad Request response. The links for "Questions in Primary Leaderboard" and "Questions NOT in Primary Leaderboard" will error instead of filtering.

Add a custom filter to QuestionAdmin.list_filter to whitelist the id__in lookup (e.g., using SimpleListFilter or a custom filter class), or override lookup_allowed() to explicitly allow this lookup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@projects/admin.py` around lines 877 - 887, The links use the `id__in` lookup
which Django 5.2+ will block unless whitelisted; update the admin to allow this
lookup by either adding a custom list filter class (subclassing
SimpleListFilter) and include it in QuestionAdmin.list_filter to expose/handle
the "id__in" selection, or override QuestionAdmin.lookup_allowed(self, lookup,
value) to return True for lookups starting with "id__in"; ensure the new filter
class or lookup_allowed explicitly permits the "id__in" lookup so the
make_link-generated URLs do not raise DisallowedModelAdminLookup.

),
],
Expand Down
25 changes: 24 additions & 1 deletion questions/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from admin_auto_filters.filters import AutocompleteFilterFactory
from admin_auto_filters.filters import AutocompleteFilterFactory, AutocompleteFilter
from datetime import datetime, timedelta, timezone as dt_timezone

from django import forms
Expand All @@ -15,6 +15,7 @@

from posts.models import Post
from posts.tasks import run_post_generate_history_snapshot
from projects.models import Project
from questions.constants import UnsuccessfulResolutionType
from questions.models import (
AggregateForecast,
Expand Down Expand Up @@ -480,6 +481,27 @@ def clean(self):
raise forms.ValidationError("Invalid action selected.")


class DefaultOrSecondaryProjectFilter(AutocompleteFilter):
"""Autocomplete filter — `?default_or_secondary_project=<id>` matches
questions whose post has the project as default_project OR in the projects M2M."""

title = "Default or Secondary Project"
field_name = "default_project"
rel_model = Post
parameter_name = "default_or_secondary_project"
use_pk_exact = False

def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset
try:
project = Project.objects.get(pk=int(value))
except (Project.DoesNotExist, ValueError):
return queryset.none()
return queryset.filter(post__in=Post.objects.filter_projects(project))
Comment on lines +484 to +502
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.

⚠️ Potential issue | 🔴 Critical

Bug: rel_model = Post should be rel_model = Project.

The autocomplete widget will display Posts instead of Projects because rel_model = Post. When a user selects from the dropdown, the selected value will be a Post ID, but the queryset method tries to look it up as a Project ID (Project.objects.get(pk=int(value))), causing incorrect behavior or no results.

Additionally, field_name = "default_project" doesn't exist directly on Question. Consider using post__default_project for clarity, though this may be optional if rel_model is correctly set to Project.

🐛 Proposed fix
 class DefaultOrSecondaryProjectFilter(AutocompleteFilter):
     """Autocomplete filter — `?default_or_secondary_project=<id>` matches
     questions whose post has the project as default_project OR in the projects M2M."""

     title = "Default or Secondary Project"
-    field_name = "default_project"
-    rel_model = Post
+    field_name = "post__default_project"
+    rel_model = Project
     parameter_name = "default_or_secondary_project"
     use_pk_exact = False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class DefaultOrSecondaryProjectFilter(AutocompleteFilter):
"""Autocomplete filter`?default_or_secondary_project=<id>` matches
questions whose post has the project as default_project OR in the projects M2M."""
title = "Default or Secondary Project"
field_name = "default_project"
rel_model = Post
parameter_name = "default_or_secondary_project"
use_pk_exact = False
def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset
try:
project = Project.objects.get(pk=int(value))
except (Project.DoesNotExist, ValueError):
return queryset.none()
return queryset.filter(post__in=Post.objects.filter_projects(project))
class DefaultOrSecondaryProjectFilter(AutocompleteFilter):
"""Autocomplete filter`?default_or_secondary_project=<id>` matches
questions whose post has the project as default_project OR in the projects M2M."""
title = "Default or Secondary Project"
field_name = "post__default_project"
rel_model = Project
parameter_name = "default_or_secondary_project"
use_pk_exact = False
def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset
try:
project = Project.objects.get(pk=int(value))
except (Project.DoesNotExist, ValueError):
return queryset.none()
return queryset.filter(post__in=Post.objects.filter_projects(project))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@questions/admin.py` around lines 484 - 502, The filter is using the wrong
related model and field: update DefaultOrSecondaryProjectFilter by changing
rel_model from Post to Project and adjust field_name to refer to the project's
relation (e.g., "post__default_project") so the autocomplete shows Project
instances and the value is a Project PK; keep the queryset method that looks up
Project (Project.objects.get(...)) and continue returning
queryset.filter(post__in=Post.objects.filter_projects(project)) so the selected
project correctly filters Questions.



@admin.register(Question)
class QuestionAdmin(CustomTranslationAdmin, DynamicArrayMixin):
form = QuestionAdminForm
Expand Down Expand Up @@ -521,6 +543,7 @@ class QuestionAdmin(CustomTranslationAdmin, DynamicArrayMixin):
AutocompleteFilterFactory("Author", "post__author"),
AutocompleteFilterFactory("Default Project", "post__default_project"),
AutocompleteFilterFactory("Project", "post__projects"),
DefaultOrSecondaryProjectFilter,
]

autocomplete_fields = ["group"]
Expand Down
Loading