blender-studio/users/models.py
Anna Sirota ab24bfefca Anonymise Customer records instead of deleting
This is necessary to upgrade looper: newer looper assumes every account
has a Customer records, and accounts without Customer records break
one of looper's migration.
2024-06-10 10:43:59 +02:00

274 lines
9.7 KiB
Python

from typing import Optional
import logging
import time
from actstream.models import Action
from django.contrib.admin.utils import NestedObjects
from django.contrib.auth.models import AbstractUser
from django.db import models, DEFAULT_DB_ALIAS, transaction
from django.db.models import Case, When, Value, IntegerField
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from common.upload_paths import get_upload_to_hashed_path
import common.storage
logger = logging.getLogger(__name__)
def shortuid() -> str:
"""Generate a 14-characters long string ID based on time."""
return hex(int(time.monotonic() * 10**10))[2:]
class User(AbstractUser):
class Meta:
db_table = 'auth_user'
permissions = [('can_view_content', 'Can view subscription-only content')]
email = models.EmailField(_('email address'), blank=False, null=False, unique=True)
full_name = models.CharField(max_length=255, blank=True, default='')
image = models.ImageField(upload_to=get_upload_to_hashed_path, blank=True, null=True)
is_subscribed_to_newsletter = models.BooleanField(default=False)
badges = models.JSONField(null=True, blank=True)
date_deletion_requested = models.DateTimeField(null=True, blank=True)
confirmed_email_at = models.DateTimeField(null=True, blank=True)
def __str__(self) -> str:
return f'{self.full_name or self.username} ({self.email})'
@property
def image_url(self) -> Optional[str]:
"""Return a URL of the Profile image."""
if not self.image:
return static('common/images/blank-profile-pic.png')
return self.image.url
@property
def notifications(self):
return (
self.notifications.select_related(
'action',
'user',
'action__target_content_type',
'action__target_object',
'action__action_object_content_type',
'action__action_object',
).annotate(
unread=Case(
When(date_read__isnull=True, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
# Unread notifications come first
.order_by('unread', '-date_created')
)
@property
def notifications_unread(self):
return self.notifications.filter(date_read__isnull=True)
def _get_nested_objects_collector(self) -> NestedObjects:
collector = NestedObjects(using=DEFAULT_DB_ALIAS)
collector.collect([self])
return collector
@property
def can_be_deleted(self) -> bool:
"""Fetch objects referencing this profile and determine if it can be deleted."""
if self.is_staff or self.is_superuser:
return False
collector = self._get_nested_objects_collector()
if collector.protected:
return False
return True
def request_deletion(self, date_deletion_requested):
"""Store date of the deletion request and deactivate the user."""
if not self.can_be_deleted:
logger.error('Deletion requested for a protected account pk=%s, ignoring', self.pk)
return
logger.warning(
'Deletion of pk=%s requested on %s, deactivating this account',
self.pk,
date_deletion_requested,
)
self.is_active = False
self.date_deletion_requested = date_deletion_requested
self.is_subscribed_to_newsletter = False
self.save(
update_fields=['is_active', 'date_deletion_requested', 'is_subscribed_to_newsletter']
)
def delete_oauth(self):
"""Delete OAuth records linked to this account."""
from blender_id_oauth_client.models import OAuthUserInfo, OAuthToken
oauth_tokens_q = OAuthToken.objects.filter(user=self)
oauth_user_ids = ','.join([str(_.oauth_user_id) for _ in oauth_tokens_q])
logger.warning(
'Deleting %s OAuth tokens with OAuth ID=%s pk=%s',
oauth_tokens_q.count(),
oauth_user_ids,
self.pk,
)
oauth_tokens_q.delete()
oauth_info_q = OAuthUserInfo.objects.filter(user=self)
oauth_user_ids = ','.join([str(_.oauth_user_id) for _ in oauth_info_q])
logger.warning(
'Deleting %s OAuth infos with OAuth ID=%s pk=%s',
oauth_info_q.count(),
oauth_user_ids,
self.pk,
)
oauth_info_q.delete()
@transaction.atomic
def anonymize_or_delete(self):
"""Delete user completely if they don't have a subscription with order, otherwise anonymize.
Does nothing if deletion hasn't been explicitly requested earlier.
"""
import looper.admin_log as admin_log
import looper.models
import subscriptions.queries
import subscriptions.models
if not self.can_be_deleted:
logger.error(
'User.anonymize called, but pk=%s cannot be deleted',
self.pk,
)
return
if not self.date_deletion_requested:
logger.error(
"User.anonymize_or_delete called, but deletion of pk=%s hasn't been requested",
self.pk,
)
return
if subscriptions.queries.has_not_yet_cancelled_subscription(self):
logger.error(
"User.anonymize_or_delete called, but pk=%s has not yet cancelled subscriptions",
self.pk,
)
return
if self.image:
try:
self.image.delete(save=False)
except Exception:
logger.exception(
'Unable to delete image %s for pk=%s',
self.image.name,
self.pk,
)
# Delete OAuth info regardless of whether the account will be deleted or anonymised
self.delete_oauth()
# If there are no orders, the user account can be deleted
if self.order_set.count() == 0:
logger.warning(
'User pk=%s requested deletion and has no orders: deleting the account',
self.pk,
)
self.delete()
return
logger.warning(
'User pk=%s requested deletion and has orders: anonymizing the account',
self.pk,
)
username = f'del{shortuid()}'
self.__class__.objects.filter(pk=self.pk).update(
email=f'{username}@example.com',
full_name='',
username=username,
badges=None,
is_subscribed_to_newsletter=False,
is_active=False,
image=None,
)
logger.warning('Anonymized user pk=%s', self.pk)
logger.warning('Soft-deleting payment methods records of user pk=%s', self.pk)
for payment_method in self.paymentmethod_set.all():
payment_method.recognisable_name = '<deleted>'
logger.warning(
'Deleting payment method %s of user pk=%s at the payment gateway',
payment_method.pk,
self.pk,
)
payment_method.delete()
logger.warning('Deleting address records of user pk=%s', self.pk)
looper.models.Address.objects.filter(user_id=self.pk).delete()
logger.warning('Anonymizing Customer record of user pk=%s', self.pk)
looper.models.Customer.objects.exclude(user_id=None).filter(user_id=self.pk).update(
billing_email=f'{username}@example.com',
full_name='',
)
looper.models.GatewayCustomerId.objects.filter(user_id=self.pk).delete()
subscriptions.models.TeamUsers.objects.filter(user_id=self.pk).delete()
logger.warning('Anonymizing comments of user pk=%s', self.pk)
self.comments.update(user_id=None)
logger.warning('Anonymizing likes of user pk=%s', self.pk)
self.like_set.update(user_id=None)
logger.warning('Deleting actions of user pk=%s', self.pk)
self.actor_actions.all().delete()
message = 'Anonymized because account deletion was requested'
admin_log.attach_log_entry(self, message)
def get_cloud_archive_url(self):
try:
blender_id = self.oauth_info.oauth_user_id
key = f'archives/cloud_archive_{blender_id}.zip'
if common.storage.file_exists(key):
return common.storage.get_s3_url(key)
except Exception:
logger.exception(f'Cannot retrieve Cloud archive for user pk={self.pk}')
return ''
class Notification(models.Model):
"""Store additional data about an actstream notification.
In general, it's not easy to determine if an action qualifies as a notification
for a certain user because of the variaty of targets
(assets, comments with relations to different pages and so on),
so it's best to link actions to their relevant users when a new action is created.
This simplifies retrieving notifications and checking if they can be marked as read.
"""
action = models.ForeignKey(Action, on_delete=models.CASCADE, related_name='notifications')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
date_created = models.DateTimeField(auto_now_add=True)
date_read = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-date_created']
db_table = 'users_notification'
indexes = [
models.Index(fields=['user_id']),
]
@property
def mark_read_url(self):
"""Return a URL that that allows marking this Notification as read."""
return reverse('api-notification-mark-read', kwargs={'pk': self.pk})