diff --git a/comments/migrations/0023_comment_comment_user_private_post_idx.py b/comments/migrations/0023_comment_comment_user_private_post_idx.py new file mode 100644 index 0000000000..fc2e46b3b4 --- /dev/null +++ b/comments/migrations/0023_comment_comment_user_private_post_idx.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.15 on 2025-12-16 19:09 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("comments", "0022_keyfactor_news"), + ("posts", "0027_alter_postusersnapshot_private_note_updated_at"), + ("projects", "0021_projectindex_project_index_projectindexpost"), + ("questions", "0031_forecast_questions_f_author__d4ea27_idx"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="comment", + index=models.Index( + fields=["author", "is_private", "on_post"], + name="comment_user_private_post_idx", + ), + ), + ] diff --git a/comments/models.py b/comments/models.py index 8699a4de1f..7b1f505b59 100644 --- a/comments/models.py +++ b/comments/models.py @@ -135,6 +135,12 @@ class Meta: name="comment_check_pinned_comment_is_root", ) ] + indexes = [ + models.Index( + fields=["author", "is_private", "on_post"], + name="comment_user_private_post_idx", + ) + ] def __str__(self): return f"Comment by {self.author.username} on {self.on_post or self.on_project}" diff --git a/posts/migrations/0026_postusersnapshot_private_note_and_more.py b/posts/migrations/0026_postusersnapshot_private_note_and_more.py index f606b5a987..a2595edbe3 100644 --- a/posts/migrations/0026_postusersnapshot_private_note_and_more.py +++ b/posts/migrations/0026_postusersnapshot_private_note_and_more.py @@ -108,6 +108,7 @@ def migrate_private_comments(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ ("posts", "0025_post_actual_resolve_time"), + ("comments", "0022_keyfactor_news"), ] operations = [ diff --git a/scoring/views.py b/scoring/views.py index fcbfaf870e..00a7e8dd8a 100644 --- a/scoring/views.py +++ b/scoring/views.py @@ -13,7 +13,6 @@ from projects.permissions import ObjectPermission from projects.services.common import get_site_main_project from projects.views import get_projects_qs, get_project_permission_for_user -from questions.models import AggregationMethod from scoring.constants import LeaderboardScoreTypes from scoring.models import Leaderboard, LeaderboardEntry, LeaderboardsRanksEntry from scoring.serializers import ( @@ -24,7 +23,7 @@ ) from scoring.utils import get_contributions, update_project_leaderboard from users.models import User -from users.services.profile_stats import serialize_profile +from users.services.profile_stats import serialize_metaculus_stats @api_view(["GET"]) @@ -352,13 +351,9 @@ def medal_contributions( return Response(return_data) -@cache_page(60 * 60 * 24) @api_view(["GET"]) @permission_classes([AllowAny]) def metaculus_track_record( request: Request, ): - # TODO: make it "default" - return Response( - serialize_profile(aggregation_method=AggregationMethod.RECENCY_WEIGHTED) - ) + return Response(serialize_metaculus_stats()) diff --git a/users/services/profile_stats.py b/users/services/profile_stats.py index c43cd5317e..2ecf9bf20e 100644 --- a/users/services/profile_stats.py +++ b/users/services/profile_stats.py @@ -12,8 +12,8 @@ from questions.types import AggregationMethod from scoring.constants import ScoreTypes from scoring.models import Score -from users.models import User, UserSpamActivity -from users.serializers import UserPublicSerializer +from users.models import User +from utils.cache import cache_get_or_set @dataclass(frozen=True) @@ -376,57 +376,66 @@ def get_authoring_stats_data( } -def get_user_profile_data( - user: User, -) -> dict: - return UserPublicSerializer(user).data +def _serialize_user_stats(user: User): + score_qs = Score.objects.filter( + question__related_posts__post__default_project__default_permission__isnull=False, + score_type=ScoreTypes.PEER, + ) + score_qs = score_qs.filter(user=user) + scores = generate_question_scores(score_qs) + data = {} + + data.update(get_score_scatter_plot_data(scores=scores, user=user)) + data.update(get_score_histogram_data(scores=scores, user=user)) + data.update(get_calibration_curve_data(user=user)) + data.update(get_forecasting_stats_data(scores=scores, user=user)) + data.update(get_authoring_stats_data(user)) + + return data + + +def serialize_user_stats(user: User): + return cache_get_or_set( + f"serialize_user_stats:{user.id}", + lambda: _serialize_user_stats(user), + # 1h + timeout=3600, + ) + + +def _serialize_metaculus_stats() -> dict: + aggregation_method = AggregationMethod.RECENCY_WEIGHTED -def serialize_profile( - user: User | None = None, - aggregation_method: AggregationMethod | None = None, - score_type: ScoreTypes | None = None, - current_user: User | None = None, -) -> dict: - if (user is None and aggregation_method is None) or ( - user is not None and aggregation_method is not None - ): - raise ValueError("Either user or aggregation_method must be provided only") - if user is not None and score_type is None: - score_type = ScoreTypes.PEER - if aggregation_method is not None and score_type is None: - score_type = ScoreTypes.BASELINE # TODO: support archived scores score_qs = Score.objects.filter( question__related_posts__post__default_project__default_permission__isnull=False, - score_type=score_type, + score_type=ScoreTypes.BASELINE, ) - if user is not None: - score_qs = score_qs.filter(user=user) - else: - score_qs = score_qs.filter(aggregation_method=aggregation_method) + score_qs = score_qs.filter(aggregation_method=aggregation_method) scores = generate_question_scores(score_qs) data = {} data.update( get_score_scatter_plot_data( - scores=scores, user=user, aggregation_method=aggregation_method + scores=scores, aggregation_method=aggregation_method ) ) data.update( - get_score_histogram_data( - scores=scores, user=user, aggregation_method=aggregation_method - ) + get_score_histogram_data(scores=scores, aggregation_method=aggregation_method) ) - data.update(get_calibration_curve_data(user, aggregation_method)) + data.update(get_calibration_curve_data(aggregation_method=aggregation_method)) data.update( - get_forecasting_stats_data( - scores=scores, user=user, aggregation_method=aggregation_method - ) + get_forecasting_stats_data(scores=scores, aggregation_method=aggregation_method) ) - if user is not None: - data.update(get_user_profile_data(user)) - data.update(get_authoring_stats_data(user)) - if current_user is not None and current_user.is_staff: - data.update({"spam_count": UserSpamActivity.objects.filter(user=user).count()}) + return data + + +def serialize_metaculus_stats(): + return cache_get_or_set( + "serialize_metaculus_stats", + lambda: _serialize_metaculus_stats(), + # 24h + timeout=60 * 60 * 24, + ) diff --git a/users/views.py b/users/views.py index 319198d533..4b0a0d348e 100644 --- a/users/views.py +++ b/users/views.py @@ -3,7 +3,6 @@ from django.contrib.auth.password_validation import validate_password from django.utils import timezone -from django.views.decorators.cache import cache_page from rest_framework import serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import ValidationError @@ -12,7 +11,7 @@ from rest_framework.request import Request from rest_framework.response import Response -from users.models import User +from users.models import User, UserSpamActivity from users.serializers import ( UserPrivateSerializer, UserPublicSerializer, @@ -32,7 +31,7 @@ ) from utils.paginator import LimitOffsetPagination from utils.tasks import email_user_their_data_task -from .services.profile_stats import serialize_profile +from .services.profile_stats import serialize_user_stats from .services.spam_detection import ( check_profile_update_for_spam, send_deactivation_email, @@ -59,16 +58,29 @@ def current_user_api_view(request): return Response(UserPrivateSerializer(request.user).data) -@cache_page(60 * 60) @api_view(["GET"]) @permission_classes([AllowAny]) def user_profile_api_view(request, pk: int): + current_user = request.user + qs = User.objects.all() - if not request.user.is_staff: + if not current_user.is_staff: qs = qs.filter(is_active=True, is_spam=False) + user = get_object_or_404(qs, pk=pk) - return Response(serialize_profile(user, current_user=request.user)) + # Basic profile data + profile = UserPublicSerializer(user).data + + if current_user and current_user.is_staff: + profile.update( + {"spam_count": UserSpamActivity.objects.filter(user=user).count()} + ) + + # Performing slow but cached profile request + profile.update(serialize_user_stats(user)) + + return Response(profile) @api_view(["GET"])