class UserProfile(OnChangeMixin, ModelBase, AbstractBaseUser): objects = UserManager() USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] username = models.CharField(max_length=255, default='', unique=True) display_name = models.CharField(max_length=50, default='', null=True, blank=True, validators=[ validators.MinLengthValidator(2), OneOrMorePrintableCharacterValidator() ]) email = models.EmailField(unique=True, null=True, max_length=75) averagerating = models.FloatField(null=True) # biography can (and does) contain html and other unsanitized content. # It must be cleaned before display. biography = models.TextField(blank=True, null=True) deleted = models.BooleanField(default=False) display_collections = models.BooleanField(default=False) homepage = models.URLField(max_length=255, blank=True, default='') location = models.CharField(max_length=255, blank=True, default='') notes = models.TextField(blank=True, null=True) occupation = models.CharField(max_length=255, default='', blank=True) # This is essentially a "has_picture" flag right now picture_type = models.CharField(max_length=75, default=None, null=True, blank=True) read_dev_agreement = models.DateTimeField(null=True, blank=True) last_login_ip = models.CharField(default='', max_length=45, editable=False) email_changed = models.DateTimeField(null=True, editable=False) banned = models.DateTimeField(null=True, editable=False) # Is the profile page for this account publicly viewable? # Note: this is only used for API responses (thus addons-frontend) - all # users's profile pages are publicly viewable on legacy frontend. # TODO: Remove this note once legacy profile pages are removed. is_public = models.BooleanField(default=False, db_column='public') fxa_id = models.CharField(blank=True, null=True, max_length=128) # Identifier that is used to invalidate internal API tokens (i.e. those # that we generate for addons-frontend, NOT the API keys external clients # use) and django sessions. Should be changed if a user is known to have # been compromised. auth_id = models.PositiveIntegerField(null=True, default=generate_auth_id) # Token used to manage the users subscriptions in basket. Basket # is proxying directly to Salesforce, e.g for the about-addons # newsletter basket_token = models.CharField(blank=True, default='', max_length=128) bypass_upload_restrictions = models.BooleanField(default=False) reviewer_name = models.CharField( max_length=50, default='', null=True, blank=True, validators=[validators.MinLengthValidator(2)]) class Meta: db_table = 'users' def __init__(self, *args, **kw): super(UserProfile, self).__init__(*args, **kw) if self.username: self.username = force_text(self.username) def __str__(self): return u'%s: %s' % (self.id, self.display_name or self.username) @property def is_superuser(self): return any(group.rules == '*:*' for group in self.groups_list) @property def is_staff(self): """Property indicating whether the user should be able to log in to the django admin tools. Does not guarantee that the user will then be able to do anything, as each module can have its own permission checks. (see has_module_perms() and has_perm())""" from olympia.access import acl return acl.action_allowed_user(self, amo.permissions.ANY_ADMIN) def has_perm(self, perm, obj=None): """Determine what the user can do in the django admin tools. perm is in the form "<app>.<action>_<model>". """ from olympia.access import acl return acl.action_allowed_user( self, amo.permissions.DJANGO_PERMISSIONS_MAPPING[perm]) def has_module_perms(self, app_label): """Determine whether the user can see a particular app in the django admin tools. """ # If the user is a superuser or has permission for any action available # for any of the models of the app, they can see the app in the django # admin. The is_superuser check is needed to allow superuser to access # modules that are not in the mapping at all (i.e. things only they # can access). return (self.is_superuser or any( self.has_perm(key) for key in amo.permissions.DJANGO_PERMISSIONS_MAPPING if key.startswith('%s.' % app_label))) def has_read_developer_agreement(self): from olympia.zadmin.models import get_config # Fallback date in case the config date value is invalid or set to the # future. The current fallback date is the last update on June 10, 2019 dev_agreement_change_fallback = datetime(2019, 6, 10, 12, 00) if self.read_dev_agreement is None: return False try: last_agreement_change_config = get_config( 'last_dev_agreement_change_date') change_config_date = datetime.strptime( last_agreement_change_config, '%Y-%m-%d %H:%M') # If the config date is in the future, instead check against the # fallback date if change_config_date > datetime.now(): return self.read_dev_agreement > dev_agreement_change_fallback return self.read_dev_agreement > change_config_date except (ValueError, TypeError): log.exception('last_developer_agreement_change misconfigured, ' '"%s" is not a ' 'datetime' % last_agreement_change_config) return self.read_dev_agreement > dev_agreement_change_fallback backend = 'django.contrib.auth.backends.ModelBackend' def get_session_auth_hash(self): """Return a hash used to invalidate sessions of users when necessary. Can return None if auth_id is not set on this user.""" if self.auth_id is None: # Old user that has not re-logged since we introduced that field, # return None, it won't be used by django session invalidation # mechanism. return None # Mimic what the AbstractBaseUser implementation does, but with our # own custom field instead of password, which we don't have. key_salt = 'olympia.models.users.UserProfile.get_session_auth_hash' return salted_hmac(key_salt, str(self.auth_id)).hexdigest() @staticmethod def create_user_url(id_, src=None): from olympia.amo.utils import urlparams url = reverse('users.profile', args=[id_]) return urlparams(url, src=src) def get_themes_url_path(self, src=None, args=None): from olympia.amo.utils import urlparams url = reverse('users.themes', args=[self.id] + (args or [])) return urlparams(url, src=src) def get_url_path(self, src=None): return self.create_user_url(self.id, src=src) @cached_property def groups_list(self): """List of all groups the user is a member of, as a cached property.""" return list(self.groups.all()) def get_addons_listed(self): """Return queryset of public add-ons thi user is listed as author of. """ return self.addons.public().filter(addonuser__user=self, addonuser__listed=True) @property def num_addons_listed(self): """Number of public add-ons this user is listed as author of.""" return self.get_addons_listed().count() def my_addons(self, n=8): """Returns n addons""" qs = order_by_translation(self.addons, 'name') return qs[:n] @property def picture_dir(self): from olympia.amo.templatetags.jinja_helpers import user_media_path split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return os.path.join(user_media_path('userpics'), split_id.group(2) or '0', split_id.group(1) or '0') @property def picture_path(self): return os.path.join(self.picture_dir, str(self.id) + '.png') @property def picture_path_original(self): return os.path.join(self.picture_dir, str(self.id) + '_original.png') @property def picture_url(self): from olympia.amo.templatetags.jinja_helpers import user_media_url if not self.picture_type: return settings.STATIC_URL + '/img/zamboni/anon_user.png' else: split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) modified = int(time.mktime(self.modified.timetuple())) path = "/".join([ split_id.group(2) or '0', split_id.group(1) or '0', "%s.png?modified=%s" % (self.id, modified) ]) return user_media_url('userpics') + path @cached_property def cached_developer_status(self): addon_types = list( self.addonuser_set.exclude( addon__status=amo.STATUS_DELETED).values_list('addon__type', flat=True)) all_themes = [t for t in addon_types if t in amo.GROUP_TYPE_THEME] return { 'is_developer': bool(addon_types), 'is_extension_developer': len(all_themes) != len(addon_types), 'is_theme_developer': bool(all_themes) } @property def is_developer(self): return self.cached_developer_status['is_developer'] @property def is_addon_developer(self): return self.cached_developer_status['is_extension_developer'] @property def is_artist(self): """Is this user a theme artist?""" return self.cached_developer_status['is_theme_developer'] @use_primary_db def update_is_public(self): pre = self.is_public is_public = (self.addonuser_set.filter( role__in=[amo.AUTHOR_ROLE_OWNER, amo.AUTHOR_ROLE_DEV], listed=True, addon__status=amo.STATUS_APPROVED).exists()) if is_public != pre: log.info('Updating %s.is_public from %s to %s' % (self.pk, pre, is_public)) self.update(is_public=is_public) else: log.info('Not changing %s.is_public from %s' % (self.pk, pre)) @property def name(self): if self.display_name: return force_text(self.display_name) else: return ugettext('Firefox user {id}').format(id=self.id) welcome_name = name def get_full_name(self): return self.name def get_short_name(self): return self.name def anonymize_username(self): """Set an anonymous username.""" if self.pk: log.info('Anonymizing username for {}'.format(self.pk)) else: log.info('Generating username for {}'.format(self.email)) self.username = '******'.format( force_text(binascii.b2a_hex(os.urandom(16)))) return self.username @property def has_anonymous_username(self): return re.match('^anonymous-[0-9a-f]{32}$', self.username) is not None @property def has_anonymous_display_name(self): return not self.display_name @cached_property def ratings(self): """All ratings that are not dev replies.""" return self._ratings_all.filter(reply_to=None) def delete_or_disable_related_content(self, delete=False): """Delete or disable content produced by this user if they are the only author.""" self.collections.all().delete() for addon in self.addons.all().iterator(): if not addon.authors.exclude(pk=self.pk).exists(): if delete: addon.delete() else: addon.force_disable() else: addon.addonuser_set.filter(user=self).delete() user_responsible = core.get_user() self._ratings_all.all().delete(user_responsible=user_responsible) self.delete_picture() def delete_picture(self, picture_path=None, original_picture_path=None): """Delete picture of this user.""" # Recursive import from olympia.users.tasks import delete_photo if picture_path is None: picture_path = self.picture_path if original_picture_path is None: original_picture_path = self.picture_path_original if storage.exists(picture_path): delete_photo.delay(picture_path) if storage.exists(original_picture_path): delete_photo.delay(original_picture_path) if self.picture_type: self.update(picture_type=None) def ban_and_disable_related_content(self): """Admin method to ban the user and disable the content they produced. Similar to deletion, except that the content produced by the user is forcibly disabled instead of being deleted where possible, and the user is not fully anonymized: we keep their fxa_id and email so that they are never able to log back in. """ self.delete_or_disable_related_content(delete=False) return self.delete(ban_user=True) @classmethod def ban_and_disable_related_content_bulk(cls, users, move_files=False): """Like ban_and_disable_related_content, but in bulk. """ from olympia.addons.models import Addon, AddonUser from olympia.addons.tasks import index_addons from olympia.bandwagon.models import Collection from olympia.files.models import File from olympia.ratings.models import Rating # collect affected addons addon_ids = set( Addon.unfiltered.exclude(status=amo.STATUS_DELETED).filter( addonuser__user__in=users).values_list('id', flat=True)) # First addons who have other authors we aren't banning addon_joint_ids = set( AddonUser.objects.filter(addon_id__in=addon_ids).exclude( user__in=users).values_list('addon_id', flat=True)) AddonUser.objects.filter(user__in=users, addon_id__in=addon_joint_ids).delete() # Then deal with users who are the sole author addons_sole = Addon.unfiltered.filter(id__in=addon_ids - addon_joint_ids) # set the status to disabled - using the manager update() method addons_sole.update(status=amo.STATUS_DISABLED) # collect Files that need to be disabled now the addons are disabled files_to_disable = File.objects.filter(version__addon__in=addons_sole) files_to_disable.update(status=amo.STATUS_DISABLED) if move_files: # if necessary move the files out of the CDN (expensive operation) for file_ in files_to_disable: file_.hide_disabled_file() # Finally run Addon.force_disable to add the logging; update versions # Status was already DISABLED so shouldn't fire watch_disabled again. for addon in addons_sole: addon.force_disable() # Don't pass a set to a .delay - sets can't be serialized as JSON index_addons.delay(list(addon_ids - addon_joint_ids)) # delete the other content associated with the user Collection.objects.filter(author__in=users).delete() Rating.objects.filter(user__in=users).delete( user_responsible=core.get_user()) # And then delete the users. for user in users: user.delete(ban_user=True) def delete(self, hard=False, ban_user=False): # Cache the values in case we do a hard delete and loose # reference to the user-id. picture_path = self.picture_path original_picture_path = self.picture_path_original if hard: super(UserProfile, self).delete() else: if ban_user: log.info(f'User ({self}: <{self.email}>) is being partially ' 'anonymized and banned.') # We don't clear email or fxa_id when banning self.banned = datetime.now() else: log.info(u'User (%s: <%s>) is being anonymized.' % (self, self.email)) self.email = None self.fxa_id = None self.last_login_ip = '' self.biography = '' self.display_name = None self.homepage = '' self.location = '' self.deleted = True self.picture_type = None self.auth_id = generate_auth_id() self.anonymize_username() self.save() self.delete_picture(picture_path=picture_path, original_picture_path=original_picture_path) def set_unusable_password(self): raise NotImplementedError('cannot set unusable password') def set_password(self, password): raise NotImplementedError('cannot set password') def check_password(self, password): raise NotImplementedError('cannot check password') @staticmethod def user_logged_in(sender, request, user, **kwargs): """Log when a user logs in and records its IP address.""" log.debug(u'User (%s) logged in successfully' % user, extra={'email': user.email}) user.update(last_login_ip=core.get_remote_addr() or '') def mobile_collection(self): return self.special_collection(amo.COLLECTION_MOBILE, defaults={ 'slug': 'mobile', 'listed': False, 'name': ugettext('My Mobile Add-ons') }) def favorites_collection(self): return self.special_collection(amo.COLLECTION_FAVORITES, defaults={ 'slug': 'favorites', 'listed': False, 'name': ugettext('My Favorite Add-ons') }) def special_collection(self, type_, defaults): from olympia.bandwagon.models import Collection c, new = Collection.objects.get_or_create(author=self, type=type_, defaults=defaults) if new: # Do an extra query to make sure this gets transformed. c = Collection.objects.using('default').get(id=c.id) return c def addons_for_collection_type(self, type_): """Return the addons for the given special collection type.""" from olympia.bandwagon.models import CollectionAddon qs = CollectionAddon.objects.filter(collection__author=self, collection__type=type_) return qs.values_list('addon', flat=True) @cached_property def mobile_addons(self): return self.addons_for_collection_type(amo.COLLECTION_MOBILE) @cached_property def favorite_addons(self): return self.addons_for_collection_type(amo.COLLECTION_FAVORITES) @cached_property def watching(self): return self.collectionwatcher_set.values_list('collection', flat=True)
class UserProfile(OnChangeMixin, ModelBase, AbstractBaseUser): objects = UserManager() USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email'] # These are the fields that will be cleared on UserProfile.delete() # last_login_ip is kept, to be deleted later, in line with our data # retention policies: https://github.com/mozilla/addons-server/issues/14494 ANONYMIZED_FIELDS = ( 'auth_id', 'averagerating', 'biography', 'bypass_upload_restrictions', 'display_name', 'homepage', 'is_public', 'location', 'occupation', 'picture_type', 'read_dev_agreement', 'reviewer_name', 'username', ) username = models.CharField(max_length=255, default=get_anonymized_username, unique=True) display_name = models.CharField( max_length=50, default='', null=True, blank=True, validators=[ validators.MinLengthValidator(2), OneOrMorePrintableCharacterValidator(), ], ) email = models.EmailField(unique=True, null=True, max_length=75) averagerating = models.FloatField(null=True) # biography can (and does) contain html and other unsanitized content. # It must be cleaned before display. biography = models.TextField(blank=True, null=True) deleted = models.BooleanField(default=False) display_collections = models.BooleanField(default=False) homepage = models.URLField(max_length=255, blank=True, default='') location = models.CharField(max_length=255, blank=True, default='') notes = models.TextField(blank=True, null=True) occupation = models.CharField(max_length=255, default='', blank=True) # This is essentially a "has_picture" flag right now picture_type = models.CharField(max_length=75, default=None, null=True, blank=True) read_dev_agreement = models.DateTimeField(null=True, blank=True) last_login_ip = models.CharField(default='', max_length=45, editable=False) email_changed = models.DateTimeField(null=True, editable=False) banned = models.DateTimeField(null=True, editable=False) # Is the profile page for this account publicly viewable? is_public = models.BooleanField(default=False, db_column='public') fxa_id = models.CharField(blank=True, null=True, max_length=128) # Identifier that is used to invalidate internal API tokens (i.e. those # that we generate for addons-frontend, NOT the API keys external clients # use) and django sessions. Should be changed if a user is known to have # been compromised. auth_id = models.PositiveIntegerField(null=True, default=generate_auth_id) # Token used to manage the users subscriptions in basket. Basket # is proxying directly to Salesforce, e.g for the about-addons # newsletter basket_token = models.CharField(blank=True, default='', max_length=128) bypass_upload_restrictions = models.BooleanField(default=False) reviewer_name = models.CharField( max_length=50, default='', null=True, blank=True, validators=[validators.MinLengthValidator(2)], ) class Meta: db_table = 'users' indexes = [ models.Index(fields=('created', ), name='created'), models.Index(fields=('fxa_id', ), name='users_fxa_id_index'), ] def __init__(self, *args, **kw): super(UserProfile, self).__init__(*args, **kw) if self.username: self.username = force_str(self.username) def __str__(self): return '%s: %s' % (self.id, self.display_name or self.username) @classmethod def get_lookup_field(cls, identifier): lookup_field = 'pk' if identifier and not identifier.isdigit(): # If the identifier contains anything other than a digit, # it's an email. lookup_field = 'email' return lookup_field @property def is_superuser(self): return any(group.rules == '*:*' for group in self.groups_list) @property def is_staff(self): """Property indicating whether the user is considered to be a Mozilla Employee. Django admin uses this to allow logging in, though it doesn't give access to the individual admin pages: each module has their own permission checks (see has_module_perms() and has_perm() below). In addition we also force users to use the VPN to access the admin. It's also used by waffle Flag `staff` property, which allows a feature behind a flag to be enabled just for users for which this property returns True. This shouldn't be used as a replacement to a permission check, but only for progressive rollouts of features that are intended to eventually be enabled globally. """ return self.email and self.email.endswith('@mozilla.com') def has_perm(self, perm, obj=None): """Determine what the user can do in the django admin tools. perm is in the form "<app>.<action>_<model>". """ from olympia.access import acl return acl.action_allowed_user( self, amo.permissions.DJANGO_PERMISSIONS_MAPPING[perm]) def has_module_perms(self, app_label): """Determine whether the user can see a particular app in the django admin tools.""" # If the user is a superuser or has permission for any action available # for any of the models of the app, they can see the app in the django # admin. The is_superuser check is needed to allow superuser to access # modules that are not in the mapping at all (i.e. things only they # can access). return self.is_superuser or any( self.has_perm(key) for key in amo.permissions.DJANGO_PERMISSIONS_MAPPING if key.startswith('%s.' % app_label)) def has_read_developer_agreement(self): from olympia.zadmin.models import get_config if self.read_dev_agreement is None: return False last_agreement_change_config = None try: last_agreement_change_config = get_config( 'last_dev_agreement_change_date') change_config_date = datetime.strptime( last_agreement_change_config, '%Y-%m-%d %H:%M') # If the config date is in the future, instead check against the # fallback date if change_config_date > datetime.now(): return self.read_dev_agreement > settings.DEV_AGREEMENT_CHANGE_FALLBACK return self.read_dev_agreement > change_config_date except (ValueError, TypeError): log.exception('last_developer_agreement_change misconfigured, ' '"%s" is not a ' 'datetime' % last_agreement_change_config) return self.read_dev_agreement > settings.DEV_AGREEMENT_CHANGE_FALLBACK backend = 'django.contrib.auth.backends.ModelBackend' def get_session_auth_hash(self): """Return a hash used to invalidate sessions of users when necessary. Can return None if auth_id is not set on this user.""" if self.auth_id is None: # Old user that has not re-logged since we introduced that field, # return None, it won't be used by django session invalidation # mechanism. return None # Mimic what the AbstractBaseUser implementation does, but with our # own custom field instead of password, which we don't have. key_salt = 'olympia.models.users.UserProfile.get_session_auth_hash' return salted_hmac(key_salt, str(self.auth_id)).hexdigest() @staticmethod def create_user_url(id_, src=None): from olympia.amo.utils import urlparams url = reverse('users.profile', args=[id_]) return urlparams(url, src=src) def get_themes_url_path(self, src=None, args=None): from olympia.amo.utils import urlparams url = reverse('users.themes', args=[self.id] + (args or [])) return urlparams(url, src=src) def get_url_path(self, src=None): return self.create_user_url(self.id, src=src) @cached_property def groups_list(self): """List of all groups the user is a member of, as a cached property.""" return list(self.groups.all()) def get_addons_listed(self): """Return queryset of public add-ons thi user is listed as author of.""" return self.addons.public().filter(addonuser__user=self, addonuser__listed=True) @property def num_addons_listed(self): """Number of public add-ons this user is listed as author of.""" return self.get_addons_listed().count() def my_addons(self, n=8): """Returns n addons""" qs = order_by_translation(self.addons, 'name') return qs[:n] @property def picture_dir(self): from olympia.amo.templatetags.jinja_helpers import user_media_path split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) return os.path.join( user_media_path('userpics'), split_id.group(2) or '0', split_id.group(1) or '0', ) @property def picture_path(self): return os.path.join(self.picture_dir, str(self.id) + '.png') @property def picture_path_original(self): return os.path.join(self.picture_dir, str(self.id) + '_original.png') @property def picture_url(self): from olympia.amo.templatetags.jinja_helpers import user_media_url if not self.picture_type: return settings.STATIC_URL + '/img/zamboni/anon_user.png' else: split_id = re.match(r'((\d*?)(\d{0,3}?))\d{1,3}$', str(self.id)) modified = int(time.mktime(self.modified.timetuple())) path = '/'.join([ split_id.group(2) or '0', split_id.group(1) or '0', '%s.png?modified=%s' % (self.id, modified), ]) return user_media_url('userpics') + path @cached_property def cached_developer_status(self): addon_types = list( self.addonuser_set.exclude( addon__status=amo.STATUS_DELETED).values_list('addon__type', flat=True)) all_themes = [t for t in addon_types if t in amo.GROUP_TYPE_THEME] return { 'is_developer': bool(addon_types), 'is_extension_developer': len(all_themes) != len(addon_types), 'is_theme_developer': bool(all_themes), } @property def is_developer(self): return self.cached_developer_status['is_developer'] @property def is_addon_developer(self): return self.cached_developer_status['is_extension_developer'] @property def is_artist(self): """Is this user a theme artist?""" return self.cached_developer_status['is_theme_developer'] @use_primary_db def update_is_public(self): pre = self.is_public is_public = self.addonuser_set.filter( role__in=[amo.AUTHOR_ROLE_OWNER, amo.AUTHOR_ROLE_DEV], listed=True, addon__status=amo.STATUS_APPROVED, ).exists() if is_public != pre: log.info('Updating %s.is_public from %s to %s' % (self.pk, pre, is_public)) self.update(is_public=is_public) else: log.info('Not changing %s.is_public from %s' % (self.pk, pre)) @property def name(self): if self.display_name: return force_str(self.display_name) else: return gettext('Firefox user {id}').format(id=self.id) welcome_name = name def get_full_name(self): return self.name def get_short_name(self): return self.name @property def has_anonymous_username(self): return re.match('^anonymous-[0-9a-f]{32}$', self.username) is not None @property def has_anonymous_display_name(self): return not self.display_name @cached_property def ratings(self): """All ratings that are not dev replies.""" return self._ratings_all.filter(reply_to=None) def _delete_related_content(self, *, addon_msg=''): """Delete content produced by this user if they are the only author.""" self.collections.all().delete() for addon in self.addons.all().iterator(): if not addon.authors.exclude(pk=self.pk).exists(): addon.delete(msg=addon_msg) else: addon.addonuser_set.get(user=self).delete() user_responsible = core.get_user() self._ratings_all.all().delete(user_responsible=user_responsible) def delete_picture(self): """Delete picture of this user.""" # Recursive import from olympia.users.tasks import delete_photo if storage.exists(self.picture_path): delete_photo.delay(self.picture_path) if storage.exists(self.picture_path_original): delete_photo.delay(self.picture_path_original) if self.picture_type: self.update(picture_type=None) @classmethod def anonymize_users(cls, users): fields = { field_name: cls._meta.get_field(field_name) for field_name in cls.ANONYMIZED_FIELDS } for user in users: log.info('Anonymizing username for {}'.format(user.pk)) for field_name, field in fields.items(): setattr(user, field_name, field.get_default()) user.delete_picture() @classmethod def ban_and_disable_related_content_bulk(cls, users, move_files=False): """Admin method to ban users and disable the content they produced. Similar to deletion, except that the content produced by the user is forcibly disabled instead of being deleted where possible, and the user is not fully anonymized: we keep their fxa_id and email so that they are never able to log back in. """ from olympia.addons.models import Addon, AddonUser from olympia.addons.tasks import index_addons from olympia.bandwagon.models import Collection from olympia.files.models import File from olympia.ratings.models import Rating # collect affected addons addon_ids = set( Addon.unfiltered.exclude(status=amo.STATUS_DELETED).filter( addonuser__user__in=users).values_list('id', flat=True)) # First addons who have other authors we aren't banning addon_joint_ids = set( AddonUser.objects.filter(addon_id__in=addon_ids).exclude( user__in=users).values_list('addon_id', flat=True)) AddonUser.objects.filter(user__in=users, addon_id__in=addon_joint_ids).delete() # Then deal with users who are the sole author addons_sole = Addon.unfiltered.filter(id__in=addon_ids - addon_joint_ids) # set the status to disabled - using the manager update() method addons_sole.update(status=amo.STATUS_DISABLED) # collect Files that need to be disabled now the addons are disabled files_to_disable = File.objects.filter(version__addon__in=addons_sole) files_to_disable.update(status=amo.STATUS_DISABLED) if move_files: # if necessary move the files out of the CDN (expensive operation) for file_ in files_to_disable: file_.hide_disabled_file() # Finally run Addon.force_disable to add the logging; update versions # Status was already DISABLED so shouldn't fire watch_disabled again. addons_sole_ids = [] for addon in addons_sole: addons_sole_ids.append(addon.pk) addon.force_disable() index_addons.delay(addons_sole_ids) # delete the other content associated with the user Collection.objects.filter(author__in=users).delete() Rating.objects.filter(user__in=users).delete( user_responsible=core.get_user()) # And then delete the users. ids = [] for user in users: log.info( f'User ({user}: <{user.email}>) is being anonymized and banned.' ) user.banned = user.modified = datetime.now() user.deleted = True ids.append(user.pk) cls.anonymize_users(users) cls.objects.bulk_update(users, fields=('banned', 'deleted', 'modified') + cls.ANONYMIZED_FIELDS) from olympia.amo.tasks import trigger_sync_objects_to_basket trigger_sync_objects_to_basket('userprofile', ids, 'user ban') trigger_sync_objects_to_basket('addon', addons_sole_ids, 'user ban content') def _prepare_delete_email(self): site_url = settings.EXTERNAL_SITE_URL template = loader.get_template('users/emails/user_deleted.ltxt') email_msg = template.render(context={ 'site_url': site_url, 'name': self.name }) return { 'subject': f'Your account on {site_url} has been deleted', 'message': email_msg, 'recipient_list': [str(self.email)], 'reply_to': ['*****@*****.**'], } def should_send_delete_email(self): return (self.display_name or self.addons.exists() or self.ratings.exists() or self.collections.exists()) def delete(self, addon_msg=''): from olympia.amo.utils import send_mail send_delete_email = self.should_send_delete_email() self._delete_related_content(addon_msg=addon_msg) log.info(f'User ({self}: <{self.email}>) is being anonymized.') email = self._prepare_delete_email() if send_delete_email else None self.anonymize_users((self, )) self.deleted = True self.save() if send_delete_email: send_mail(**email) def set_unusable_password(self): raise NotImplementedError('cannot set unusable password') def set_password(self, password): raise NotImplementedError('cannot set password') def check_password(self, password): raise NotImplementedError('cannot check password') @staticmethod def user_logged_in(sender, request, user, **kwargs): """Log when a user logs in and records its IP address.""" # The following log statement is used by foxsec-pipeline. log.info('User (%s) logged in successfully' % user, extra={'email': user.email}) user.update(last_login_ip=core.get_remote_addr() or '')