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
11 changes: 11 additions & 0 deletions contentcuration/contentcuration/constants/user_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.utils.translation import ugettext_lazy as _

DELETION = "soft-deletion"
RECOVERY = "soft-recovery"
RELATED_DATA_HARD_DELETION = "related-data-hard-deletion"

choices = (
(DELETION, _("User soft deletion")),
(RECOVERY, _("User soft deletion recovery")),
(RELATED_DATA_HARD_DELETION, _("User related data hard deletion")),
)
3 changes: 2 additions & 1 deletion contentcuration/contentcuration/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.contrib.auth.forms import UserChangeForm
from django.contrib.auth.forms import UserCreationForm
from django.core import signing
from django.db.models import Q
from django.template.loader import render_to_string

from contentcuration.models import User
Expand Down Expand Up @@ -45,7 +46,7 @@ class RegistrationForm(UserCreationForm, ExtraFormMixin):

def clean_email(self):
email = self.cleaned_data['email'].strip().lower()
if User.objects.filter(email__iexact=email, is_active=True).exists():
if User.objects.filter(Q(is_active=True) | Q(deleted=True), email__iexact=email).exists():
raise UserWarning
return email

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from contentcuration.utils.garbage_collect import clean_up_contentnodes
from contentcuration.utils.garbage_collect import clean_up_deleted_chefs
from contentcuration.utils.garbage_collect import clean_up_feature_flags
from contentcuration.utils.garbage_collect import clean_up_soft_deleted_users
from contentcuration.utils.garbage_collect import clean_up_stale_files
from contentcuration.utils.garbage_collect import clean_up_tasks

Expand All @@ -26,15 +27,23 @@ def handle(self, *args, **options):
Actual logic for garbage collection.
"""

# clean up contentnodes, files and file objects on storage that are associated
# with the orphan tree
# Clean up users that are soft deleted and are older than ACCOUNT_DELETION_BUFFER (90 days).
# Also clean contentnodes, files and file objects on storage that are associated
# with the orphan tree.
logging.info("Cleaning up soft deleted users older than ACCOUNT_DELETION_BUFFER (90 days)")
clean_up_soft_deleted_users()

logging.info("Cleaning up contentnodes from the orphan tree")
clean_up_contentnodes()

logging.info("Cleaning up deleted chef nodes")
clean_up_deleted_chefs()

logging.info("Cleaning up feature flags")
clean_up_feature_flags()

logging.info("Cleaning up stale file objects")
clean_up_stale_files()

logging.info("Cleaning up tasks")
clean_up_tasks()
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 3.2.14 on 2022-10-22 18:30
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
('contentcuration', '0140_delete_task'),
]

operations = [
migrations.AddField(
model_name='user',
name='deleted',
field=models.BooleanField(db_index=True, default=False),
),
migrations.CreateModel(
name='UserHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('soft-deletion', 'User soft deletion'), ('soft-recovery',
'User soft deletion recovery'), ('related-data-hard-deletion', 'User related data hard deletion')], max_length=32)),
('performed_at', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to=settings.AUTH_USER_MODEL)),
],
),
]
78 changes: 68 additions & 10 deletions contentcuration/contentcuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@

from contentcuration.constants import channel_history
from contentcuration.constants import completion_criteria
from contentcuration.constants import user_history
from contentcuration.constants.contentnode import kind_activity_map
from contentcuration.db.models.expressions import Array
from contentcuration.db.models.functions import ArrayRemove
Expand Down Expand Up @@ -200,6 +201,8 @@ class User(AbstractBaseUser, PermissionsMixin):
policies = JSONField(default=dict, null=True)
feature_flags = JSONField(default=dict, null=True)

deleted = models.BooleanField(default=False, db_index=True)

_field_updates = FieldTracker(fields=[
# Field to watch for changes
"disk_space",
Expand All @@ -213,27 +216,67 @@ def __unicode__(self):
return self.email

def delete(self):
"""
Soft deletes the user account.
"""
self.deleted = True
# Deactivate the user to disallow authentication and also
# to let the user verify the email again after recovery.
self.is_active = False
self.save()
self.history.create(user_id=self.pk, action=user_history.DELETION)

def recover(self):
"""
Use this method when we want to recover a user.
"""
self.deleted = False
self.save()
self.history.create(user_id=self.pk, action=user_history.RECOVERY)

def hard_delete_user_related_data(self):
"""
Hard delete all user related data. But keeps the user record itself intact.

User related data that gets hard deleted are:
- sole editor non-public channels.
- sole editor non-public channelsets.
- sole editor non-public channels' content nodes and its underlying files that are not
used by any other channel.
- all user invitations.
"""
from contentcuration.viewsets.common import SQCount
# Remove any invitations associated to this account

# Hard delete invitations associated to this account.
self.sent_to.all().delete()
self.sent_by.all().delete()

# Delete channels associated with this user (if user is the only editor)
user_query = (
editable_channels_user_query = (
User.objects.filter(editable_channels__id=OuterRef('id'))
.values_list('id', flat=True)
.distinct()
)
self.editable_channels.annotate(num_editors=SQCount(user_query, field="id")).filter(num_editors=1).delete()
non_public_channels_sole_editor = self.editable_channels.annotate(num_editors=SQCount(
editable_channels_user_query, field="id")).filter(num_editors=1, public=False)

# Point sole editor non-public channels' contentnodes to orphan tree to let
# our garbage collection delete the nodes and underlying files.
ContentNode._annotate_channel_id(ContentNode.objects).filter(channel_id__in=list(
non_public_channels_sole_editor.values_list("id", flat=True))).update(parent_id=settings.ORPHANAGE_ROOT_ID)

# Hard delete non-public channels associated with this user (if user is the only editor).
non_public_channels_sole_editor.delete()

# Delete channel collections associated with this user (if user is the only editor)
# Hard delete non-public channel collections associated with this user (if user is the only editor).
user_query = (
User.objects.filter(channel_sets__id=OuterRef('id'))
.values_list('id', flat=True)
.distinct()
)
self.channel_sets.annotate(num_editors=SQCount(user_query, field="id")).filter(num_editors=1).delete()
self.channel_sets.annotate(num_editors=SQCount(user_query, field="id")).filter(num_editors=1, public=False).delete()

super(User, self).delete()
# Create history!
self.history.create(user_id=self.pk, action=user_history.RELATED_DATA_HARD_DELETION)

def can_edit(self, channel_id):
return Channel.filter_edit_queryset(Channel.objects.all(), self).filter(pk=channel_id).exists()
Expand Down Expand Up @@ -405,18 +448,23 @@ def filter_edit_queryset(cls, queryset, user):
return queryset.filter(pk=user.pk)

@classmethod
def get_for_email(cls, email, **filters):
def get_for_email(cls, email, deleted=False, **filters):
"""
Returns the appropriate User record given an email, ordered by:
- those with is_active=True first, which there should only ever be one
- otherwise by ID DESC so most recent inactive shoud be returned

Filters out deleted User records by default. To include both deleted and
undeleted user records pass None to the deleted argument.

:param email: A string of the user's email
:param filters: Additional filters to filter the User queryset
:return: User or None
"""
return User.objects.filter(email__iexact=email.strip(), **filters)\
.order_by("-is_active", "-id").first()
user_qs = User.objects.filter(email__iexact=email.strip())
if deleted is not None:
user_qs = user_qs.filter(deleted=deleted)
return user_qs.filter(**filters).order_by("-is_active", "-id").first()


class UUIDField(models.CharField):
Expand Down Expand Up @@ -1038,6 +1086,16 @@ class Meta:
]


class UserHistory(models.Model):
"""
Model that stores the user's action history.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=False, blank=False, related_name="history", on_delete=models.CASCADE)
action = models.CharField(max_length=32, choices=user_history.choices)

performed_at = models.DateTimeField(default=timezone.now)


class ChannelSet(models.Model):
# NOTE: this is referred to as "channel collections" on the front-end, but we need to call it
# something else as there is already a ChannelCollection model on the front-end
Expand Down
6 changes: 4 additions & 2 deletions contentcuration/contentcuration/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,10 @@ def gettext(s):
HELP_EMAIL = 'content@learningequality.org'
DEFAULT_FROM_EMAIL = 'Kolibri Studio <noreply@learningequality.org>'
POLICY_EMAIL = 'legal@learningequality.org'
ACCOUNT_DELETION_BUFFER = 5 # Used to determine how many days a user
# has to undo accidentally deleting account

# Used to determine how many days a user
# has to undo accidentally deleting account.
ACCOUNT_DELETION_BUFFER = 90

DEFAULT_LICENSE = 1

Expand Down
107 changes: 107 additions & 0 deletions contentcuration/contentcuration/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.db.utils import IntegrityError
from django.utils import timezone
from le_utils.constants import content_kinds
from le_utils.constants import format_presets

from contentcuration.constants import channel_history
from contentcuration.constants import user_history
from contentcuration.models import AssessmentItem
from contentcuration.models import Channel
from contentcuration.models import ChannelHistory
from contentcuration.models import ChannelSet
from contentcuration.models import ContentNode
from contentcuration.models import CONTENTNODE_TREE_ID_CACHE_KEY
from contentcuration.models import File
Expand All @@ -22,6 +25,7 @@
from contentcuration.models import Invitation
from contentcuration.models import object_storage_name
from contentcuration.models import User
from contentcuration.models import UserHistory
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioTestCase

Expand Down Expand Up @@ -778,6 +782,51 @@ def _create_user(self, email, password='password', is_active=True):
user.save()
return user

def _setup_user_related_data(self):
user_a = self._create_user("a@tester.com")
user_b = self._create_user("b@tester.com")

# Create a sole editor non-public channel.
sole_editor_channel = Channel.objects.create(name="sole-editor")
sole_editor_channel.editors.add(user_a)

# Create sole-editor channel nodes.
for i in range(0, 3):
testdata.node({
"title": "sole-editor-channel-node",
"kind_id": "video",
}, parent=sole_editor_channel.main_tree)

# Create a sole editor public channel.
public_channel = testdata.channel("public")
public_channel.editors.add(user_a)
public_channel.public = True
public_channel.save()

# Create a shared channel.
shared_channel = testdata.channel("shared-channel")
shared_channel.editors.add(user_a)
shared_channel.editors.add(user_b)

# Invitations.
Invitation.objects.create(sender_id=user_a.id, invited_id=user_b.id)
Invitation.objects.create(sender_id=user_b.id, invited_id=user_a.id)

# Channel sets.
channel_set = ChannelSet.objects.create(name="sole-editor")
channel_set.editors.add(user_a)

channel_set = ChannelSet.objects.create(name="public")
channel_set.editors.add(user_a)
channel_set.public = True
channel_set.save()

channel_set = ChannelSet.objects.create(name="shared-channelset")
channel_set.editors.add(user_a)
channel_set.editors.add(user_b)

return user_a

def test_unique_lower_email(self):
self._create_user("tester@tester.com")
with self.assertRaises(IntegrityError):
Expand All @@ -787,6 +836,7 @@ def test_get_for_email(self):
user1 = self._create_user("tester@tester.com", is_active=False)
user2 = self._create_user("tester@Tester.com", is_active=False)
user3 = self._create_user("Tester@Tester.com", is_active=True)
user4 = self._create_user("testing@test.com", is_active=True)

# active should be returned first
self.assertEqual(user3, User.get_for_email("tester@tester.com"))
Expand All @@ -801,6 +851,63 @@ def test_get_for_email(self):
# ensure nothing found doesn't error
self.assertIsNone(User.get_for_email("tester@tester.com"))

# ensure we don't return soft-deleted users
user4.delete()
self.assertIsNone(User.get_for_email("testing@test.com"))

def test_delete(self):
user = self._create_user("tester@tester.com")
user.delete()

# Sets deleted?
self.assertEqual(user.deleted, True)
# Sets is_active to False?
self.assertEqual(user.is_active, False)
# Creates user history?
user_delete_history = UserHistory.objects.filter(user_id=user.id, action=user_history.DELETION).first()
self.assertIsNotNone(user_delete_history)

def test_recover(self):
user = self._create_user("tester@tester.com")
user.delete()
user.recover()

# Sets deleted to False?
self.assertEqual(user.deleted, False)
# Keeps is_active to False?
self.assertEqual(user.is_active, False)
# Creates user history?
user_recover_history = UserHistory.objects.filter(user_id=user.id, action=user_history.RECOVERY).first()
self.assertIsNotNone(user_recover_history)

def test_hard_delete_user_related_data(self):
user = self._setup_user_related_data()
user.hard_delete_user_related_data()

# Deletes sole-editor channels.
self.assertFalse(Channel.objects.filter(name="sole-editor").exists())
# Preserves shared channels.
self.assertTrue(Channel.objects.filter(name="shared-channel").exists())
# Preserves public channels.
self.assertTrue(Channel.objects.filter(name="public").exists())

# Deletes all user related invitations.
self.assertFalse(Invitation.objects.filter(Q(sender_id=user.id) | Q(invited_id=user.id)).exists())

# Deletes sole-editor channelsets.
self.assertFalse(ChannelSet.objects.filter(name="sole-editor").exists())
# Preserves shared channelsets.
self.assertTrue(ChannelSet.objects.filter(name="shared-channelset").exists())
# Preserves public channelsets.
self.assertTrue(ChannelSet.objects.filter(name="public").exists())

# All contentnodes of sole-editor channel points to ORPHANGE ROOT NODE?
self.assertFalse(ContentNode.objects.filter(~Q(parent_id=settings.ORPHANAGE_ROOT_ID)
& Q(title="sole-editor-channel-node")).exists())
# Creates user history?
user_hard_delete_history = UserHistory.objects.filter(user_id=user.id, action=user_history.RELATED_DATA_HARD_DELETION).first()
self.assertIsNotNone(user_hard_delete_history)


class ChannelHistoryTestCase(StudioTestCase):
def setUp(self):
Expand Down
Loading