Beispiel #1
0
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)
Beispiel #2
0
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 '')