Skip to content
Merged
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
10 changes: 6 additions & 4 deletions comments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions front_end/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion front_end/src/app/(main)/accounts/profile/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const SoftDeleteButton: FC<SoftDeleteButtonProps> = ({ id }) => {
return (
<>
<Button onClick={() => setModalIsOpen(true)}>
{t("softDeleteUserButton")}
{t("markUserAsSpamButton")}
</Button>
<SoftDeleteModal
isOpen={modalIsOpen}
Expand All @@ -46,7 +46,7 @@ const SoftDeleteModal: FC<SoftDeleteModalType> = ({ isOpen, onClose, id }) => {
};

return (
<BaseModal label={t("softDeleteUser?")} isOpen={isOpen} onClose={onClose}>
<BaseModal label={t("markUserAsSpam?")} isOpen={isOpen} onClose={onClose}>
<form
method="post"
action={handleSubmit}
Expand Down
2 changes: 1 addition & 1 deletion front_end/src/components/comment_feed/comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ const Comment: FC<CommentProps> = ({
{
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);
Expand Down
4 changes: 2 additions & 2 deletions front_end/src/services/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ class ProfileApi {
return await get<CurrentUser>("/users/me/");
}

static async softDeleteUser(id: number): Promise<Response | null> {
return post(`/users/${id}/soft-delete/`, {});
static async markUserAsSpam(id: number): Promise<Response | null> {
return post(`/users/${id}/mark-as-spam/`, {});
}

static async getProfileById(id: number): Promise<CurrentUser> {
Expand Down
10 changes: 9 additions & 1 deletion questions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 25 additions & 4 deletions users/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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():
Expand All @@ -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)

Expand All @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions users/migrations/0005_user_is_spam.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
5 changes: 5 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
path("users/me/", views.current_user_api_view, name="user-me"),
path("users/<int:pk>/", views.user_profile_api_view, name="user-profile"),
path(
"users/<int:pk>/soft-delete/",
views.soft_delete_user_api_view,
name="user-soft-delete",
"users/<int:pk>/mark-as-spam/",
views.mark_as_spam_user_api_view,
name="user-mark-as-spam",
),
path(
"users/change-username/",
Expand Down
9 changes: 4 additions & 5 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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={
Expand Down
Loading