Oleg Komarov
bd3a17a19b
Implementation for #72 based on https://github.com/justquick/django-activity-stream/ Summary: - create an Action object, specifying a corresponding Extension object as a `target` and other relevant objects (Rating, ApprovalActivity, AbuseReport) as `action_object` (search code for `action.send` to see where it happens) - there is one exception when we don't have an `action_object` - when we submit a new extension for a review, but this could be restructured as creating a first ApprovalActivity item, TODO revisit this - use `follow` provided by actstream to specify relations between users and extensions, use different flags (author, moderator, reviewer) to explicitly manage those relations; proactively call the `follow` to make sure that users are subscribed before an Action interesting to them is created; - one downside here is that each moderator needs to follow each extensions individually, but this potentially allows to explicitly unsubscribe from activity on extensions that are not interesting to a given user - introduce `VERB2FLAGS` mapping to define when a given Action needs to generate a Notification for a follower of a particular type (=flag) based on the Action's verb - process Notification records by a new `send_notifications` management command that sends emails - add a profile setting to disable notification emails First iteration includes only internal (`@blender.org`) emails. If you have a DB with some preexisting data, you need to run `./manage.py ensure_followers` command to retroactively create expected follow relations. Next steps (out of scope for this PR): - refine notification texts: current `Verb` usage may be not grammatical and is not covered by i18n - UI: templates for showing notifications in user profile, marking notifications as read, unread counter in the header (there is some views code in this PR, but it it not surfaced to the users yet) - remove the internal email check Co-authored-by: Anna Sirota <railla@noreply.localhost> Reviewed-on: #80 Reviewed-by: Anna Sirota <railla@noreply.localhost>
145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
from pathlib import Path
|
|
from typing import Optional
|
|
import logging
|
|
import time
|
|
|
|
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.templatetags.static import static
|
|
|
|
from common.model_mixins import TrackChangesMixin
|
|
from files.utils import get_sha256_from_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def user_image_upload_to(instance, filename):
|
|
assert instance.pk
|
|
prefix = 'avatars/'
|
|
_hash = get_sha256_from_value(instance.pk)
|
|
extension = Path(filename).suffix
|
|
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
|
|
return path
|
|
|
|
|
|
def shortuid() -> str:
|
|
"""Generate a 14-characters long string ID based on time."""
|
|
return hex(int(time.monotonic() * 10**10))[2:]
|
|
|
|
|
|
class User(TrackChangesMixin, AbstractUser):
|
|
track_changes_to_fields = {
|
|
'is_superuser',
|
|
'is_staff',
|
|
'date_deletion_requested',
|
|
'confirmed_email_at',
|
|
'full_name',
|
|
'email',
|
|
'is_subscribed_to_notification_emails',
|
|
}
|
|
|
|
class Meta:
|
|
db_table = 'auth_user'
|
|
|
|
email = models.EmailField(blank=False, null=False, unique=True)
|
|
full_name = models.CharField(max_length=255, blank=True, default='')
|
|
image = models.ImageField(upload_to=user_image_upload_to, blank=True, null=True)
|
|
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)
|
|
|
|
is_subscribed_to_notification_emails = models.BooleanField(null=False, default=True)
|
|
|
|
def __str__(self) -> str:
|
|
return f'{self.full_name or self.username}'
|
|
|
|
@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
|
|
|
|
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.save(update_fields=['is_active', 'date_deletion_requested'])
|
|
|
|
@transaction.atomic
|
|
def anonymize_or_delete(self):
|
|
"""Delete user completely if they don't have public records, otherwise anonymize.
|
|
|
|
Does nothing if deletion hasn't been explicitly requested earlier.
|
|
"""
|
|
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 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,
|
|
)
|
|
|
|
# Simply delete the user if there are no publicly listed records linked to it
|
|
if self.extensions.listed.count() == 0 and self.files.listed.count() == 0:
|
|
logger.warning('Deleted user pk=%s', self.pk)
|
|
self.delete()
|
|
else:
|
|
username = f'del{shortuid()}'
|
|
self.__class__.objects.filter(pk=self.pk).update(
|
|
email=f'{username}@example.com',
|
|
full_name='',
|
|
username=username,
|
|
badges=None,
|
|
is_active=False,
|
|
image=None,
|
|
)
|
|
logger.warning('Anonymized user pk=%s', self.pk)
|
|
|
|
@property
|
|
def is_moderator(self):
|
|
# Used to review and approve extensions
|
|
return self.groups.filter(name='moderators').exists()
|