diff --git a/comments/serializers.py b/comments/serializers.py index 1ec13aece1..f0fe4ae3e3 100644 --- a/comments/serializers.py +++ b/comments/serializers.py @@ -36,6 +36,7 @@ class CommentSerializer(serializers.ModelSerializer): changed_my_mind = serializers.SerializerMethodField(read_only=True) text = serializers.SerializerMethodField() on_post_data = serializers.SerializerMethodField() + included_forecast = serializers.SerializerMethodField(read_only=True) class Meta: model = Comment @@ -82,6 +83,11 @@ def get_on_post_data(self, value: Comment): return {"id": value.on_post_id, "title": getattr(value.on_post, "title", "")} + def get_included_forecast(self, value: Comment): + if value.is_soft_deleted or not value.included_forecast: + return None + return ForecastSerializer(value.included_forecast).data + class OldAPICommentWriteSerializer(serializers.Serializer): comment_text = serializers.CharField(required=True) @@ -128,10 +134,6 @@ def serialize_comment( # Permissions # serialized_data["user_permission"] = post.user_permission - forecast = comment.included_forecast - if forecast is not None: - serialized_data["included_forecast"] = ForecastSerializer(forecast).data - serialized_data["mentioned_users"] = BaseUserSerializer(mentions, many=True).data # Annotate user's vote diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 7be69e705d..ba6d2cfd89 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -448,8 +448,8 @@ "myDivergence": "My Divergence", "profile": "Profile", "changeUsernameButton": "change", - "softDeleteUserButton": "Soft Delete User", - "softDeleteUser?": "Are you sure you want to soft delete this user?", + "markUserAsSpamButton": "Mark User as Spam", + "markUserAsSpam?": "Are you sure you want to mark this user as spam?", "memberSince": "Member Since", "bio": "Bio", "website": "Website", diff --git a/front_end/src/app/(main)/accounts/profile/actions.tsx b/front_end/src/app/(main)/accounts/profile/actions.tsx index be4eacddac..8ce391aa42 100644 --- a/front_end/src/app/(main)/accounts/profile/actions.tsx +++ b/front_end/src/app/(main)/accounts/profile/actions.tsx @@ -47,7 +47,7 @@ export default async function changeUsernameAction( export async function softDeleteUserAction(userId: number) { try { - return await ProfileApi.softDeleteUser(userId); + return await ProfileApi.markUserAsSpam(userId); } catch (err) { const error = err as FetchError; diff --git a/front_end/src/app/(main)/accounts/profile/components/soft_delete_button.tsx b/front_end/src/app/(main)/accounts/profile/components/soft_delete_button.tsx index 47c6a17247..7aee0b0ae9 100644 --- a/front_end/src/app/(main)/accounts/profile/components/soft_delete_button.tsx +++ b/front_end/src/app/(main)/accounts/profile/components/soft_delete_button.tsx @@ -23,7 +23,7 @@ const SoftDeleteButton: FC = ({ id }) => { return ( <> = ({ isOpen, onClose, id }) => { }; return ( - +
= ({ { hidden: !user?.is_staff, id: "deleteUser", - name: t("softDeleteUserButton"), + name: t("markUserAsSpamButton"), onClick: async () => { // change this to the "soft_delete_button" component with modal const response = await softDeleteUserAction(comment.author.id); diff --git a/front_end/src/services/profile.ts b/front_end/src/services/profile.ts index 5fc5548221..87b47db040 100644 --- a/front_end/src/services/profile.ts +++ b/front_end/src/services/profile.ts @@ -16,8 +16,8 @@ class ProfileApi { return await get("/users/me/"); } - static async softDeleteUser(id: number): Promise { - return post(`/users/${id}/soft-delete/`, {}); + static async markUserAsSpam(id: number): Promise { + return post(`/users/${id}/mark-as-spam/`, {}); } static async getProfileById(id: number): Promise { diff --git a/questions/models.py b/questions/models.py index 1b3b7966c6..eae41d50ab 100644 --- a/questions/models.py +++ b/questions/models.py @@ -236,11 +236,19 @@ def __str__(self): return f"Group of Questions {self.post}" +class ForecastNoSpamManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(author__is_spam=False) + + class Forecast(models.Model): # typing id: int author_id: int - objects: QuerySet["Forecast"] + + # custom manager for filtering out spam by default + objects = ForecastNoSpamManager() + all_objects = models.Manager() # times start_time = models.DateTimeField( diff --git a/users/admin.py b/users/admin.py index 878f9c85e4..295d5c8ec1 100644 --- a/users/admin.py +++ b/users/admin.py @@ -115,20 +115,22 @@ class UserAdmin(admin.ModelAdmin): "id", "email", "is_active", + "is_spam", "is_bot", - "date_joined", - "last_login", + "duration_joined_to_last_login", "authored_posts", "duration_before_first_post", "forecasted", + "duration_to_last_forecast", "authored_comments", "bio_length", ] can_delete = False - actions = ["soft_delete_selected", "hard_delete_selected"] + actions = ["mark_selected_as_spam", "soft_delete_selected", "hard_delete_selected"] search_fields = ["username", "email", "pk"] list_filter = [ "is_active", + "is_spam", "is_bot", "date_joined", LastLoginFilter, @@ -149,7 +151,7 @@ def get_queryset(self, request): qs = qs.annotate( authored_posts_count=Count("posts"), authored_comments_count=Count("comment"), - forecasted=Exists(Forecast.objects.filter(author=OuterRef("pk"))), + forecasted=Exists(Forecast.all_objects.filter(author=OuterRef("pk"))), ) return qs @@ -163,6 +165,11 @@ def authored_posts(self, obj): authored_posts.admin_order_field = "authored_posts_count" authored_posts.short_description = "Posts" + def duration_joined_to_last_login(self, obj): + if obj.last_login: + return obj.last_login - obj.date_joined + return None + def duration_before_first_post(self, obj): posts = obj.posts.order_by("created_at") if posts.exists(): @@ -176,6 +183,16 @@ def forecasted(self, obj): forecasted.boolean = True # Display as a boolean icon in admin + def duration_to_last_forecast(self, obj): + if not getattr(obj, "forecasted", False): + return None + forecasts = Forecast.all_objects.filter(author=obj).order_by("-start_time") + if forecasts.exists(): + return forecasts.first().start_time - obj.date_joined + return None + + duration_to_last_forecast.short_description = "Time to Last Forecast" + def authored_comments(self, obj): return getattr(obj, "authored_comments_count", 0) @@ -184,6 +201,10 @@ def authored_comments(self, obj): def bio_length(self, obj): return len(obj.bio) if obj.bio else 0 + def mark_selected_as_spam(self, request, queryset: QuerySet[User]): + for user in queryset: + user.mark_as_spam() + def soft_delete_selected(self, request, queryset: QuerySet[User]): for user in queryset: user.soft_delete() diff --git a/users/migrations/0005_user_is_spam.py b/users/migrations/0005_user_is_spam.py new file mode 100644 index 0000000000..28cff7aeff --- /dev/null +++ b/users/migrations/0005_user_is_spam.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-12-02 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0004_alter_usercampaignregistration_key"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_spam", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/users/models.py b/users/models.py index f4ac6651dd..8e21efec44 100644 --- a/users/models.py +++ b/users/models.py @@ -23,6 +23,7 @@ class User(TimeStampedModel, AbstractUser): # Profile data bio = models.TextField(default="", blank=True) is_bot = models.BooleanField(default=False) + is_spam = models.BooleanField(default=False, db_index=True) old_usernames = models.JSONField(default=list, null=False, editable=False) @@ -89,6 +90,10 @@ def update_username(self, val: str): self.old_usernames.append((self.username, timezone.now().isoformat())) self.username = val + def mark_as_spam(self): + self.is_spam = True + self.soft_delete() + def soft_delete(self: "User") -> None: # set to inactive self.is_active = False diff --git a/users/urls.py b/users/urls.py index efb60ef230..5fdbda2556 100644 --- a/users/urls.py +++ b/users/urls.py @@ -7,9 +7,9 @@ path("users/me/", views.current_user_api_view, name="user-me"), path("users//", views.user_profile_api_view, name="user-profile"), path( - "users//soft-delete/", - views.soft_delete_user_api_view, - name="user-soft-delete", + "users//mark-as-spam/", + views.mark_as_spam_user_api_view, + name="user-mark-as-spam", ), path( "users/change-username/", diff --git a/users/views.py b/users/views.py index 84d5a9de06..8f4696f670 100644 --- a/users/views.py +++ b/users/views.py @@ -390,9 +390,9 @@ def serialize_profile( @api_view(["POST"]) @permission_classes([IsAdminUser]) -def soft_delete_user_api_view(request, pk): - user_to_delete: User = get_object_or_404(User, pk=pk) - user_to_delete.soft_delete() +def mark_as_spam_user_api_view(request, pk): + user_to_mark_as_spam: User = get_object_or_404(User, pk=pk) + user_to_mark_as_spam.mark_as_spam() return Response(status=status.HTTP_200_OK) @@ -460,8 +460,7 @@ def update_profile_api_view(request: Request) -> Response: ) if is_spam: - user.soft_delete() - user.save() + user.mark_as_spam() send_deactivation_email(user.email) return Response( data={