From 4f0044a4ab1375e539664f23c91e6d1795a469ea Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 8 Oct 2025 18:28:41 -0700 Subject: [PATCH 1/8] sketch out deletion --- users/admin.py | 11 +++++++--- users/models.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/users/admin.py b/users/admin.py index 4cb02d3db1..c8fb8a28dc 100644 --- a/users/admin.py +++ b/users/admin.py @@ -135,7 +135,7 @@ class UserAdmin(admin.ModelAdmin): actions = [ "mark_selected_as_spam", "soft_delete_selected", - "hard_delete_selected", + "clean_user_data_deletion", "run_profile_spam_detection_on_selected", ] search_fields = ["username", "email", "pk"] @@ -220,8 +220,13 @@ def soft_delete_selected(self, request, queryset: QuerySet[User]): for user in queryset: user.soft_delete() - def hard_delete_selected(self, request, queryset: QuerySet[User]): - queryset.delete() + def clean_user_data_deletion(self, request, queryset: QuerySet[User]): + for user in queryset: + user.clean_user_data_delete() + + clean_user_data_deletion.short_description = ( + "One click Personal Data deletion (GDPR compliant)" + ) def run_profile_spam_detection_on_selected(self, request, queryset: QuerySet[User]): for user in queryset: diff --git a/users/models.py b/users/models.py index c3411d2d8d..60308de67d 100644 --- a/users/models.py +++ b/users/models.py @@ -1,4 +1,5 @@ from datetime import timedelta, datetime +import random from typing import TYPE_CHECKING import dateutil.parser @@ -8,6 +9,7 @@ from django.db import models from django.db.models import QuerySet from django.utils import timezone +from rest_framework.authtoken.models import Token from utils.models import TimeStampedModel @@ -149,6 +151,62 @@ def soft_delete(self: "User") -> None: self.save() + def clean_user_data_delete(self: "User") -> None: + # Update User object + self.is_active = False + self.bio = "" + self.old_usernames = None + self.website = None + self.twitter = None + self.linkedin = None + self.facebook = None + self.github = None + self.good_judgement_open = None + self.kalshi = None + self.manifold = None + self.infer = None + self.hypermind = None + self.occupation = None + self.location = None + self.profile_picture = None + self.unsubscribed_mailing_tags = [] + self.language = None + + self.username = "deleted_user-" + "".join( + random.choices("qwertyuioopasdfghjklzxxcvbnm", k=20) + ) + self.first_name = "" + self.last_name = "" + self.email = "" + self.set_password(None) + self.save() + + # wipe comments content + self.comment_set.update(is_soft_deleted=True, text="") + # TODO: remove text from translations + + # Token + Token.objects.filter(user=self).delete() + + # TODO: Conversion rates, event tracking + # TODO: Session Identifiers + # TODO: Advertiser cookies and pixels + # TODO: Facebook or Google login credentials + + # soft delete posts, wipe content fields + from posts.models import Post + + posts = self.posts.all() + for post in posts: + post.curation_status = Post.CurationStatus.DELETED + post.title = "" + post.short_title = "" + post.save() + # TODO: wipe content from assicated questions and + # group of questions etc + + self.save() + class UserCampaignRegistration(TimeStampedModel): """ From ef76e68034e305d2fe85c8fb8ce2ba239522b226 Mon Sep 17 00:00:00 2001 From: lsabor Date: Thu, 9 Oct 2025 08:12:52 -0700 Subject: [PATCH 2/8] comment --- users/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/users/models.py b/users/models.py index 60308de67d..364fe309c6 100644 --- a/users/models.py +++ b/users/models.py @@ -171,7 +171,6 @@ def clean_user_data_delete(self: "User") -> None: self.profile_picture = None self.unsubscribed_mailing_tags = [] self.language = None - self.username = "deleted_user-" + "".join( random.choices("qwertyuioopasdfghjklzxxcvbnm", k=20) ) @@ -204,6 +203,9 @@ def clean_user_data_delete(self: "User") -> None: post.save() # TODO: wipe content from assicated questions and # group of questions etc + # be sure to address translations... Maybe hard delete + # post & questions if no other user's forecasts + # and dont do anything if yes other user's forecasts? self.save() From 8dec795338921f45e91688b1b65034049fdfd113 Mon Sep 17 00:00:00 2001 From: lsabor Date: Thu, 9 Oct 2025 14:28:31 -0700 Subject: [PATCH 3/8] update clean data deletion --- users/models.py | 58 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/users/models.py b/users/models.py index 364fe309c6..83ddf27aa6 100644 --- a/users/models.py +++ b/users/models.py @@ -10,6 +10,7 @@ from django.db.models import QuerySet from django.utils import timezone from rest_framework.authtoken.models import Token +from social_django.models import UserSocialAuth from utils.models import TimeStampedModel @@ -155,7 +156,7 @@ def clean_user_data_delete(self: "User") -> None: # Update User object self.is_active = False self.bio = "" - self.old_usernames = None + self.old_usernames = [] self.website = None self.twitter = None self.linkedin = None @@ -171,9 +172,7 @@ def clean_user_data_delete(self: "User") -> None: self.profile_picture = None self.unsubscribed_mailing_tags = [] self.language = None - self.username = "deleted_user-" + "".join( - random.choices("qwertyuioopasdfghjklzxxcvbnm", k=20) - ) + self.username = "deleted_user-" + str(self.id) self.first_name = "" self.last_name = "" self.email = "" @@ -181,31 +180,50 @@ def clean_user_data_delete(self: "User") -> None: self.save() # wipe comments content - self.comment_set.update(is_soft_deleted=True, text="") - # TODO: remove text from translations + self.comment_set.filter(is_private=True).delete() + public_comments = self.comment_set.filter(is_private=False) + for comment in public_comments: + comment.is_soft_deleted = True + comment.text = "" + comment.edit_history = [] + comment.update_and_maybe_translate(should_translate_if_dirty=False) + comment.save() # Token Token.objects.filter(user=self).delete() - # TODO: Conversion rates, event tracking - # TODO: Session Identifiers - # TODO: Advertiser cookies and pixels - # TODO: Facebook or Google login credentials + # Social Auth login credentials + UserSocialAuth.objects.filter(user=self).delete() - # soft delete posts, wipe content fields + # Posts (Notebooks/Questions) from posts.models import Post + def hard_delete_post(post: Post): + if question := post.question: + question.delete() + if group_of_questions := post.group_of_questions: + group_of_questions.delete() + if conditional := post.conditional: + conditional.delete() + if notebook := post.notebook: + notebook.delete() + post.delete() + posts = self.posts.all() for post in posts: - post.curation_status = Post.CurationStatus.DELETED - post.title = "" - post.short_title = "" - post.save() - # TODO: wipe content from assicated questions and - # group of questions etc - # be sure to address translations... Maybe hard delete - # post & questions if no other user's forecasts - # and dont do anything if yes other user's forecasts? + if post.curation_status != Post.CurationStatus.APPROVED: + hard_delete_post(post) + return + questions = post.get_questions() + if questions: + # hard delete if the user is the only one who forecasted + if not any( + [q.user_forecasts.exclude(author=self).exists() for q in questions] + ): + hard_delete_post(post) + return + # Post is either a notebook or a quesiton with others' forecasts... + # ?do nothing? self.save() From 0bfb7a0cd21e103553695181eeec0bf15a75515e Mon Sep 17 00:00:00 2001 From: lsabor Date: Thu, 9 Oct 2025 14:33:28 -0700 Subject: [PATCH 4/8] remove unused import --- users/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/users/models.py b/users/models.py index 83ddf27aa6..26a08c987f 100644 --- a/users/models.py +++ b/users/models.py @@ -1,5 +1,4 @@ from datetime import timedelta, datetime -import random from typing import TYPE_CHECKING import dateutil.parser From b26b67c91102c33b4d845769ef3a9781554191df Mon Sep 17 00:00:00 2001 From: lsabor Date: Fri, 10 Oct 2025 15:10:29 -0700 Subject: [PATCH 5/8] don't delete public comments, and solidify policy around public questions --- users/models.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/users/models.py b/users/models.py index 8458392f15..87313a8a61 100644 --- a/users/models.py +++ b/users/models.py @@ -183,15 +183,9 @@ def clean_user_data_delete(self: "User") -> None: self.set_password(None) self.save() - # wipe comments content + # Comments self.comment_set.filter(is_private=True).delete() - public_comments = self.comment_set.filter(is_private=False) - for comment in public_comments: - comment.is_soft_deleted = True - comment.text = "" - comment.edit_history = [] - comment.update_and_maybe_translate(should_translate_if_dirty=False) - comment.save() + # don't touch public comments # Token Token.objects.filter(user=self).delete() @@ -226,8 +220,8 @@ def hard_delete_post(post: Post): ): hard_delete_post(post) return - # Post is either a notebook or a quesiton with others' forecasts... - # ?do nothing? + # Post is either a notebook or a quesiton with others' forecasts + # nothing required self.save() From cae15afa73c15ece9493c8f27fa93dc0ce59d414 Mon Sep 17 00:00:00 2001 From: lsabor Date: Fri, 10 Oct 2025 15:31:36 -0700 Subject: [PATCH 6/8] minor updates --- users/models.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/users/models.py b/users/models.py index 87313a8a61..fe39dcfce8 100644 --- a/users/models.py +++ b/users/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.postgres.fields import ArrayField -from django.db import models +from django.db import models, transaction from django.db.models import QuerySet from django.utils import timezone from rest_framework.authtoken.models import Token @@ -156,6 +156,7 @@ def soft_delete(self: "User") -> None: self.save() + @transaction.atomic def clean_user_data_delete(self: "User") -> None: # Update User object self.is_active = False @@ -212,14 +213,10 @@ def hard_delete_post(post: Post): if post.curation_status != Post.CurationStatus.APPROVED: hard_delete_post(post) return - questions = post.get_questions() - if questions: - # hard delete if the user is the only one who forecasted - if not any( - [q.user_forecasts.exclude(author=self).exists() for q in questions] - ): + if post.get_questions(): + # hard delete if no one other than user forecasted + if not post.forecasts.exclude(author=self).exists(): hard_delete_post(post) - return # Post is either a notebook or a quesiton with others' forecasts # nothing required From ca9ebe5314ecc2f7c35298b4a8f2414ccad520a5 Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 15 Oct 2025 08:48:05 -0700 Subject: [PATCH 7/8] minor comment suggestion fixes --- users/models.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/users/models.py b/users/models.py index fe39dcfce8..05d6e57ee6 100644 --- a/users/models.py +++ b/users/models.py @@ -212,16 +212,13 @@ def hard_delete_post(post: Post): for post in posts: if post.curation_status != Post.CurationStatus.APPROVED: hard_delete_post(post) - return - if post.get_questions(): + elif post.get_questions(): # hard delete if no one other than user forecasted if not post.forecasts.exclude(author=self).exists(): hard_delete_post(post) # Post is either a notebook or a quesiton with others' forecasts # nothing required - self.save() - class UserCampaignRegistration(TimeStampedModel): """ From 717dbd8d23e9a54ad25e207b7cdcec140884e9ac Mon Sep 17 00:00:00 2001 From: lsabor Date: Wed, 15 Oct 2025 10:09:52 -0700 Subject: [PATCH 8/8] update to protect commented on posts. improves logic legibility --- users/models.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/users/models.py b/users/models.py index 05d6e57ee6..03d13d02aa 100644 --- a/users/models.py +++ b/users/models.py @@ -210,14 +210,13 @@ def hard_delete_post(post: Post): posts = self.posts.all() for post in posts: - if post.curation_status != Post.CurationStatus.APPROVED: - hard_delete_post(post) - elif post.get_questions(): - # hard delete if no one other than user forecasted - if not post.forecasts.exclude(author=self).exists(): - hard_delete_post(post) - # Post is either a notebook or a quesiton with others' forecasts - # nothing required + # keep if there is at least one non-author comment + if post.comments.exclude(author=self).exists(): + continue + # keep if there is at least one non-author forecast + if post.forecasts.exclude(author=self).exists(): + continue + hard_delete_post(post) class UserCampaignRegistration(TimeStampedModel):