Example #1
0
class UserProfile(UserProfilePrivacyModel):
    REFERRAL_SOURCE_CHOICES = (
        ('direct', 'Mozillians'),
        ('contribute', 'Get Involved'),
    )

    objects = UserProfileManager()

    user = models.OneToOneField(User)
    full_name = models.CharField(max_length=255,
                                 default='',
                                 blank=False,
                                 verbose_name=_lazy(u'Full Name'))
    is_vouched = models.BooleanField(
        default=False,
        help_text='You can edit vouched status by editing invidual vouches')
    can_vouch = models.BooleanField(
        default=False,
        help_text='You can edit can_vouch status by editing invidual vouches')
    last_updated = models.DateTimeField(auto_now=True, default=datetime.now)
    groups = models.ManyToManyField(Group,
                                    blank=True,
                                    related_name='members',
                                    through=GroupMembership)
    skills = models.ManyToManyField(Skill, blank=True, related_name='members')
    bio = models.TextField(verbose_name=_lazy(u'Bio'), default='', blank=True)
    photo = ImageField(default='',
                       blank=True,
                       upload_to=_calculate_photo_filename)
    ircname = models.CharField(max_length=63,
                               verbose_name=_lazy(u'IRC Nickname'),
                               default='',
                               blank=True)

    # validated geo data (validated that it's valid geo data, not that the
    # mozillian is there :-) )
    geo_country = models.ForeignKey('geo.Country',
                                    blank=True,
                                    null=True,
                                    on_delete=models.SET_NULL)
    geo_region = models.ForeignKey('geo.Region',
                                   blank=True,
                                   null=True,
                                   on_delete=models.SET_NULL)
    geo_city = models.ForeignKey('geo.City',
                                 blank=True,
                                 null=True,
                                 on_delete=models.SET_NULL)
    lat = models.FloatField(_lazy(u'Latitude'), blank=True, null=True)
    lng = models.FloatField(_lazy(u'Longitude'), blank=True, null=True)

    allows_community_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Sites that can determine my vouched status'),
        choices=((True, _lazy(u'All Community Sites')),
                 (False, _lazy(u'Only Mozilla Properties'))))
    allows_mozilla_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Allow Mozilla sites to access my profile data?'),
        choices=((True, _lazy(u'Yes')), (False, _lazy(u'No'))))
    basket_token = models.CharField(max_length=1024, default='', blank=True)
    date_mozillian = models.DateField('When was involved with Mozilla',
                                      null=True,
                                      blank=True,
                                      default=None)
    timezone = models.CharField(max_length=100,
                                blank=True,
                                default='',
                                choices=zip(common_timezones,
                                            common_timezones))
    tshirt = models.IntegerField(
        _lazy(u'T-Shirt'),
        blank=True,
        null=True,
        default=None,
        choices=((1, _lazy(u'Fitted Small')), (2, _lazy(u'Fitted Medium')),
                 (3, _lazy(u'Fitted Large')), (4, _lazy(u'Fitted X-Large')),
                 (5, _lazy(u'Fitted XX-Large')), (6,
                                                  _lazy(u'Fitted XXX-Large')),
                 (7, _lazy(u'Straight-cut Small')),
                 (8, _lazy(u'Straight-cut Medium')),
                 (9, _lazy(u'Straight-cut Large')),
                 (10, _lazy(u'Straight-cut X-Large')),
                 (11, _lazy(u'Straight-cut XX-Large')),
                 (12, _lazy(u'Straight-cut XXX-Large'))))
    title = models.CharField(_lazy(u'What do you do for Mozilla?'),
                             max_length=70,
                             blank=True,
                             default='')

    story_link = models.URLField(
        _lazy(u'Link to your contribution story'),
        help_text=_lazy(u'If you have created something public that '
                        u'tells the story of how you came to be a '
                        u'Mozillian, specify that link here.'),
        max_length=1024,
        blank=True,
        default='')
    referral_source = models.CharField(max_length=32,
                                       choices=REFERRAL_SOURCE_CHOICES,
                                       default='direct')

    def __unicode__(self):
        """Return this user's name when their profile is called."""
        return self.display_name

    def get_absolute_url(self):
        return reverse('phonebook:profile_view', args=[self.user.username])

    class Meta:
        db_table = 'profile'
        ordering = ['full_name']

    def __getattribute__(self, attrname):
        """Special privacy aware __getattribute__ method.

        This method returns the real value of the attribute of object,
        if the privacy_level of the attribute is at least as large as
        the _privacy_level attribute.

        Otherwise it returns a default privacy respecting value for
        the attribute, as defined in the privacy_fields dictionary.

        special_functions provides methods that privacy safe their
        respective properties, where the privacy modifications are
        more complex.
        """
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_fields = UserProfile.privacy_fields()
        privacy_level = _getattr('_privacy_level')
        special_functions = {
            'accounts': '_accounts',
            'alternate_emails': '_alternate_emails',
            'email': '_primary_email',
            'is_public_indexable': '_is_public_indexable',
            'languages': '_languages',
            'vouches_made': '_vouches_made',
            'vouches_received': '_vouches_received',
            'vouched_by': '_vouched_by',
            'websites': '_websites'
        }

        if attrname in special_functions:
            return _getattr(special_functions[attrname])

        if not privacy_level or attrname not in privacy_fields:
            return _getattr(attrname)

        field_privacy = _getattr('privacy_%s' % attrname)
        if field_privacy < privacy_level:
            return privacy_fields.get(attrname)

        return _getattr(attrname)

    def _filter_accounts_privacy(self, accounts):
        if self._privacy_level:
            return accounts.filter(privacy__gte=self._privacy_level)
        return accounts

    @property
    def _accounts(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        excluded_types = [
            ExternalAccount.TYPE_WEBSITE, ExternalAccount.TYPE_EMAIL
        ]
        accounts = _getattr('externalaccount_set').exclude(
            type__in=excluded_types)
        return self._filter_accounts_privacy(accounts)

    @property
    def _alternate_emails(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        accounts = _getattr('externalaccount_set').filter(
            type=ExternalAccount.TYPE_EMAIL)
        return self._filter_accounts_privacy(accounts)

    @property
    def _is_public_indexable(self):
        for field in PUBLIC_INDEXABLE_FIELDS:
            if getattr(self, field, None) and getattr(
                    self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def _languages(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level > _getattr('privacy_languages'):
            return _getattr('language_set').none()
        return _getattr('language_set').all()

    @property
    def _primary_email(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_fields = UserProfile.privacy_fields()
        if self._privacy_level and _getattr(
                'privacy_email') < self._privacy_level:
            email = privacy_fields['email']
            return email
        return _getattr('user').email

    @property
    def _vouched_by(self):
        privacy_level = self._privacy_level
        voucher = (UserProfile.objects.filter(
            vouches_made__vouchee=self).order_by('vouches_made__date'))

        if voucher.exists():
            voucher = voucher[0]
            if privacy_level:
                voucher.set_instance_privacy_level(privacy_level)
                for field in UserProfile.privacy_fields():
                    if getattr(voucher, 'privacy_%s' % field) >= privacy_level:
                        return voucher
                return None
            return voucher

        return None

    def _vouches(self, type):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))

        vouch_ids = []
        for vouch in _getattr(type).all():
            vouch.vouchee.set_instance_privacy_level(self._privacy_level)
            for field in UserProfile.privacy_fields():
                if getattr(vouch.vouchee, 'privacy_%s' % field,
                           0) >= self._privacy_level:
                    vouch_ids.append(vouch.id)
        vouches = _getattr(type).filter(pk__in=vouch_ids)

        return vouches

    @property
    def _vouches_made(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level:
            return self._vouches('vouches_made')
        return _getattr('vouches_made')

    @property
    def _vouches_received(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level:
            return self._vouches('vouches_received')
        return _getattr('vouches_received')

    @property
    def _websites(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        accounts = _getattr('externalaccount_set').filter(
            type=ExternalAccount.TYPE_WEBSITE)
        return self._filter_accounts_privacy(accounts)

    @property
    def display_name(self):
        return self.full_name

    @property
    def privacy_level(self):
        """Return user privacy clearance."""
        if (self.user.groups.filter(name='Managers').exists()
                or self.user.is_superuser):
            return PRIVILEGED
        if self.groups.filter(name='staff').exists():
            return EMPLOYEES
        if self.is_vouched:
            return MOZILLIANS
        return PUBLIC

    @property
    def is_complete(self):
        """Tests if a user has all the information needed to move on
        past the original registration view.

        """
        return self.display_name.strip() != ''

    @property
    def is_public(self):
        """Return True is any of the privacy protected fields is PUBLIC."""
        # TODO needs update

        for field in type(self).privacy_fields():
            if getattr(self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def is_manager(self):
        return self.user.is_superuser or self.user.groups.filter(
            name='Managers').exists()

    @property
    def date_vouched(self):
        """ Return the date of the first vouch, if available."""
        vouches = self.vouches_received.all().order_by('date')[:1]
        if vouches:
            return vouches[0].date
        return None

    def set_instance_privacy_level(self, level):
        """Sets privacy level of instance."""
        self._privacy_level = level

    def set_privacy_level(self, level, save=True):
        """Sets all privacy enabled fields to 'level'."""
        for field in type(self).privacy_fields():
            setattr(self, 'privacy_%s' % field, level)
        if save:
            self.save()

    def set_membership(self, model, membership_list):
        """Alters membership to Groups and Skills."""
        if model is Group:
            m2mfield = self.groups
            alias_model = GroupAlias
        elif model is Skill:
            m2mfield = self.skills
            alias_model = SkillAlias

        # Remove any visible groups that weren't supplied in this list.
        if model is Group:
            GroupMembership.objects.filter(userprofile=self, group__visible=True)\
                .exclude(group__name__in=membership_list).delete()
        else:
            m2mfield.remove(*[
                g for g in m2mfield.all()
                if g.name not in membership_list and g.is_visible
            ])

        # Add/create the rest of the groups
        groups_to_add = []
        for g in membership_list:
            if alias_model.objects.filter(name=g).exists():
                group = alias_model.objects.get(name=g).alias
            else:
                group = model.objects.create(name=g)

            if group.is_visible:
                groups_to_add.append(group)

        if model is Group:
            for group in groups_to_add:
                group.add_member(self)
        else:
            m2mfield.add(*groups_to_add)

    def get_photo_thumbnail(self, geometry='160x160', **kwargs):
        if 'crop' not in kwargs:
            kwargs['crop'] = 'center'
        if self.photo:
            return get_thumbnail(self.photo, geometry, **kwargs)
        return get_thumbnail(settings.DEFAULT_AVATAR_PATH, geometry, **kwargs)

    def get_photo_url(self, geometry='160x160', **kwargs):
        """Return photo url.

        If privacy allows and no photo set, return gravatar link.
        If privacy allows and photo set return local photo link.
        If privacy doesn't allow return default local link.
        """
        privacy_level = getattr(self, '_privacy_level', MOZILLIANS)
        if (not self.photo and self.privacy_photo >= privacy_level):
            return gravatar(self.user.email, size=geometry)
        return absolutify(self.get_photo_thumbnail(geometry, **kwargs).url)

    def is_vouchable(self, voucher):
        """Check whether self can receive a vouch from voucher."""
        # If there's a voucher, they must be able to vouch.
        if voucher and not voucher.can_vouch:
            return False

        # Maximum VOUCH_COUNT_LIMIT vouches per account, no matter what.
        if self.vouches_received.all().count() >= settings.VOUCH_COUNT_LIMIT:
            return False

        # If you've already vouched this account, you cannot do it again
        vouch_query = self.vouches_received.filter(voucher=voucher)
        if voucher and vouch_query.exists():
            return False

        return True

    def vouch(self, vouched_by, description='', autovouch=False):
        if not self.is_vouchable(vouched_by):
            return

        vouch = self.vouches_received.create(voucher=vouched_by,
                                             date=datetime.now(),
                                             description=description,
                                             autovouch=autovouch)

        self._email_now_vouched(vouched_by, description)
        return vouch

    def auto_vouch(self):
        """Auto vouch mozilla.com users."""
        email = self.user.email

        if any(email.endswith('@' + x) for x in settings.AUTO_VOUCH_DOMAINS):
            if not self.vouches_received.filter(
                    description=settings.AUTO_VOUCH_REASON,
                    autovouch=True).exists():
                self.vouch(None, settings.AUTO_VOUCH_REASON, autovouch=True)

    def _email_now_vouched(self, vouched_by, description=''):
        """Email this user, letting them know they are now vouched."""
        name = None
        voucher_profile_link = None
        vouchee_profile_link = utils.absolutify(self.get_absolute_url())
        if vouched_by:
            name = vouched_by.full_name
            voucher_profile_link = utils.absolutify(
                vouched_by.get_absolute_url())

        number_of_vouches = self.vouches_received.all().count()
        template = get_template(
            'phonebook/emails/vouch_confirmation_email.txt')
        message = template.render({
            'voucher_name':
            name,
            'voucher_profile_url':
            voucher_profile_link,
            'vouchee_profile_url':
            vouchee_profile_link,
            'vouch_description':
            description,
            'functional_areas_url':
            utils.absolutify(reverse('groups:index_functional_areas')),
            'groups_url':
            utils.absolutify(reverse('groups:index_groups')),
            'first_vouch':
            number_of_vouches == 1,
            'can_vouch_threshold':
            number_of_vouches == settings.CAN_VOUCH_THRESHOLD,
        })
        subject = _(u'You have been vouched on Mozillians.org')
        filtered_message = message.replace('&#34;', '"').replace('&#39;', "'")
        send_mail(subject, filtered_message, settings.FROM_NOREPLY,
                  [self.user.email])

    def lookup_basket_token(self):
        """
        Query Basket for this user's token.  If Basket doesn't find the user,
        returns None. If Basket does find the token, returns it. Otherwise,
        there must have been some error from the network or basket, and this
        method just lets that exception propagate so the caller can decide how
        best to handle it.

        (Does not update the token field on the UserProfile.)
        """
        try:
            result = basket.lookup_user(email=self.user.email)
        except basket.BasketException as exception:
            if exception.code == basket.errors.BASKET_UNKNOWN_EMAIL:
                return None
            raise
        return result['token']

    def get_annotated_groups(self):
        """
        Return a list of all the visible groups the user is a member of or pending
        membership. The groups pending membership will have a .pending attribute
        set to True, others will have it set False.
        """
        groups = []
        # Query this way so we only get the groups that the privacy controls allow the
        # current user to see. We have to force evaluation of this query first, otherwise
        # Django combines the whole thing into one query and loses the privacy control.
        groups_manager = self.groups
        # checks to avoid AttributeError exception b/c self.groups may returns
        # EmptyQuerySet instead of the default manager due to privacy controls
        if hasattr(groups_manager, 'visible'):
            user_group_ids = list(groups_manager.visible().values_list(
                'id', flat=True))
        else:
            user_group_ids = []
        for membership in self.groupmembership_set.filter(
                group_id__in=user_group_ids):
            group = membership.group
            group.pending = (membership.status == GroupMembership.PENDING)
            groups.append(group)
        return groups

    def timezone_offset(self):
        """
        Return minutes the user's timezone is offset from UTC.  E.g. if user is
        4 hours behind UTC, returns -240.
        If user has not set a timezone, returns None (not 0).
        """
        if self.timezone:
            return offset_of_timezone(self.timezone)

    def save(self, *args, **kwargs):
        self._privacy_level = None
        autovouch = kwargs.pop('autovouch', True)

        super(UserProfile, self).save(*args, **kwargs)
        # Auto_vouch follows the first save, because you can't
        # create foreign keys without a database id.

        if autovouch:
            self.auto_vouch()

    def reverse_geocode(self):
        """
        Use the user's lat and lng to set their city, region, and country.
        Does not save the profile.
        """
        if self.lat is None or self.lng is None:
            return

        from mozillians.geo.models import Country
        from mozillians.geo.lookup import reverse_geocode, GeoLookupException
        try:
            result = reverse_geocode(self.lat, self.lng)
        except GeoLookupException:
            if self.geo_country:
                # If self.geo_country is already set, just give up.
                pass
            else:
                # No country set, we need to at least set the placeholder one.
                self.geo_country = Country.objects.get(mapbox_id='geo_error')
                self.geo_region = None
                self.geo_city = None
        else:
            if result:
                country, region, city = result
                self.geo_country = country
                self.geo_region = region
                self.geo_city = city
            else:
                logger.error('Got back NONE from reverse_geocode on %s, %s' %
                             (self.lng, self.lat))
Example #2
0
class UserProfile(UserProfilePrivacyModel, SearchMixin):
    objects = UserProfileManager()

    user = models.OneToOneField(User)
    full_name = models.CharField(max_length=255,
                                 default='',
                                 blank=False,
                                 verbose_name=_lazy(u'Full Name'))
    is_vouched = models.BooleanField(
        default=False,
        help_text='You can edit vouched status by editing invidual vouches')
    can_vouch = models.BooleanField(
        default=False,
        help_text='You can edit can_vouch status by editing invidual vouches')
    last_updated = models.DateTimeField(auto_now=True, default=datetime.now)
    groups = models.ManyToManyField(Group,
                                    blank=True,
                                    related_name='members',
                                    through=GroupMembership)
    skills = models.ManyToManyField(Skill, blank=True, related_name='members')
    bio = models.TextField(verbose_name=_lazy(u'Bio'), default='', blank=True)
    photo = ImageField(default='',
                       blank=True,
                       upload_to=_calculate_photo_filename)
    ircname = models.CharField(max_length=63,
                               verbose_name=_lazy(u'IRC Nickname'),
                               default='',
                               blank=True)

    # validated geo data (validated that it's valid geo data, not that the
    # mozillian is there :-) )
    geo_country = models.ForeignKey('geo.Country',
                                    blank=True,
                                    null=True,
                                    on_delete=models.SET_NULL)
    geo_region = models.ForeignKey('geo.Region',
                                   blank=True,
                                   null=True,
                                   on_delete=models.SET_NULL)
    geo_city = models.ForeignKey('geo.City',
                                 blank=True,
                                 null=True,
                                 on_delete=models.SET_NULL)
    lat = models.FloatField(_lazy(u'Latitude'), blank=True, null=True)
    lng = models.FloatField(_lazy(u'Longitude'), blank=True, null=True)

    allows_community_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Sites that can determine my vouched status'),
        choices=((True, _lazy(u'All Community Sites')),
                 (False, _lazy(u'Only Mozilla Properties'))))
    allows_mozilla_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Allow Mozilla sites to access my profile data?'),
        choices=((True, _lazy(u'Yes')), (False, _lazy(u'No'))))
    basket_token = models.CharField(max_length=1024, default='', blank=True)
    date_mozillian = models.DateField('When was involved with Mozilla',
                                      null=True,
                                      blank=True,
                                      default=None)
    timezone = models.CharField(max_length=100,
                                blank=True,
                                default='',
                                choices=zip(common_timezones,
                                            common_timezones))
    tshirt = models.IntegerField(
        _lazy(u'T-Shirt'),
        blank=True,
        null=True,
        default=None,
        choices=((1, _lazy(u'Fitted Small')), (2, _lazy(u'Fitted Medium')),
                 (3, _lazy(u'Fitted Large')), (4, _lazy(u'Fitted X-Large')),
                 (5, _lazy(u'Fitted XX-Large')), (6,
                                                  _lazy(u'Fitted XXX-Large')),
                 (7, _lazy(u'Straight-cut Small')),
                 (8, _lazy(u'Straight-cut Medium')),
                 (9, _lazy(u'Straight-cut Large')),
                 (10, _lazy(u'Straight-cut X-Large')),
                 (11, _lazy(u'Straight-cut XX-Large')),
                 (12, _lazy(u'Straight-cut XXX-Large'))))
    title = models.CharField(_lazy(u'What do you do for Mozilla?'),
                             max_length=70,
                             blank=True,
                             default='')

    story_link = models.URLField(
        _lazy(u'Link to your contribution story'),
        help_text=_lazy(u'If you have created something public that '
                        u'tells the story of how you came to be a '
                        u'Mozillian, specify that link here.'),
        max_length=1024,
        blank=True,
        default='')

    class Meta:
        db_table = 'profile'
        ordering = ['full_name']

    def __getattribute__(self, attrname):
        """Special privacy aware __getattribute__ method.

        This method returns the real value of the attribute of object,
        if the privacy_level of the attribute is at least as large as
        the _privacy_level attribute.

        Otherwise it returns a default privacy respecting value for
        the attribute, as defined in the privacy_fields dictionary.

        special_functions provides methods that privacy safe their
        respective properties, where the privacy modifications are
        more complex.
        """
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_fields = UserProfile.privacy_fields()
        privacy_level = _getattr('_privacy_level')
        special_functions = {
            'vouches_made': '_vouches_made',
            'vouches_received': '_vouches_received'
        }

        if attrname in special_functions and privacy_level:
            return _getattr(special_functions[attrname])

        if not privacy_level:
            return _getattr(attrname)

        if attrname not in privacy_fields:
            return _getattr(attrname)

        field_privacy = _getattr('privacy_%s' % attrname)
        if field_privacy < privacy_level:
            return privacy_fields.get(attrname)

        return _getattr(attrname)

    def _vouches(self, type):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_level = _getattr('_privacy_level')

        vouch_ids = []
        for vouch in _getattr(type).all():
            vouch.vouchee.set_instance_privacy_level(privacy_level)
            for field in UserProfile.privacy_fields():
                if getattr(vouch.vouchee, 'privacy_%s' % field,
                           0) >= privacy_level:
                    vouch_ids.append(vouch.id)
        vouches_made = _getattr(type).filter(pk__in=vouch_ids)

        return vouches_made

    @property
    def _vouches_made(self):
        return self._vouches('vouches_made')

    @property
    def _vouches_received(self):
        return self._vouches('vouches_received')

    @classmethod
    def extract_document(cls, obj_id, obj=None):
        """Method used by elasticutils."""
        if obj is None:
            obj = cls.objects.get(pk=obj_id)
        d = {}

        attrs = ('id', 'is_vouched', 'ircname', 'allows_mozilla_sites',
                 'allows_community_sites')
        for a in attrs:
            data = getattr(obj, a)
            if isinstance(data, basestring):
                data = data.lower()
            d.update({a: data})

        d['country'] = [obj.geo_country.name, obj.geo_country.code
                        ] if obj.geo_country else None
        d['region'] = obj.geo_region.name if obj.geo_region else None
        d['city'] = obj.geo_city.name if obj.geo_city else None

        # user data
        attrs = ('username', 'email', 'last_login', 'date_joined')
        for a in attrs:
            data = getattr(obj.user, a)
            if isinstance(data, basestring):
                data = data.lower()
            d.update({a: data})

        d.update(dict(fullname=obj.full_name.lower()))
        d.update(dict(name=obj.full_name.lower()))
        d.update(dict(bio=obj.bio))
        d.update(dict(has_photo=bool(obj.photo)))

        for attribute in ['groups', 'skills']:
            groups = []
            for g in getattr(obj, attribute).all():
                groups.extend(g.aliases.values_list('name', flat=True))
            d[attribute] = groups
        # Add to search index language code, language name in English
        # native lanugage name.
        languages = []
        for code in obj.languages.values_list('code', flat=True):
            languages.append(code)
            languages.append(langcode_to_name(code, 'en_US').lower())
            languages.append(langcode_to_name(code, code).lower())
        d['languages'] = list(set(languages))
        return d

    @classmethod
    def get_mapping(cls):
        """Returns an ElasticSearch mapping."""
        return {
            'properties': {
                'id': {
                    'type': 'integer'
                },
                'name': {
                    'type': 'string',
                    'index': 'not_analyzed'
                },
                'fullname': {
                    'type': 'string',
                    'analyzer': 'standard'
                },
                'email': {
                    'type': 'string',
                    'index': 'not_analyzed'
                },
                'ircname': {
                    'type': 'string',
                    'index': 'not_analyzed'
                },
                'username': {
                    'type': 'string',
                    'index': 'not_analyzed'
                },
                'country': {
                    'type': 'string',
                    'analyzer': 'whitespace'
                },
                'region': {
                    'type': 'string',
                    'analyzer': 'whitespace'
                },
                'city': {
                    'type': 'string',
                    'analyzer': 'whitespace'
                },
                'skills': {
                    'type': 'string',
                    'analyzer': 'whitespace'
                },
                'groups': {
                    'type': 'string',
                    'analyzer': 'whitespace'
                },
                'languages': {
                    'type': 'string',
                    'index': 'not_analyzed'
                },
                'bio': {
                    'type': 'string',
                    'analyzer': 'snowball'
                },
                'is_vouched': {
                    'type': 'boolean'
                },
                'allows_mozilla_sites': {
                    'type': 'boolean'
                },
                'allows_community_sites': {
                    'type': 'boolean'
                },
                'photo': {
                    'type': 'boolean'
                },
                'last_updated': {
                    'type': 'date'
                },
                'date_joined': {
                    'type': 'date'
                }
            }
        }

    @classmethod
    def search(cls, query, include_non_vouched=False, public=False):
        """Sensible default search for UserProfiles."""
        query = query.lower().strip()
        fields = ('username', 'bio__text', 'email', 'ircname', 'country__text',
                  'country__text_phrase', 'region__text',
                  'region__text_phrase', 'city__text', 'city__text_phrase',
                  'fullname__text', 'fullname__text_phrase',
                  'fullname__prefix', 'fullname__fuzzy'
                  'groups__text')
        s = PrivacyAwareS(cls)
        if public:
            s = s.privacy_level(PUBLIC)
        s = s.indexes(cls.get_index(public))

        if query:
            q = dict((field, query) for field in fields)
            s = (s.boost(fullname__text_phrase=5,
                         username=5,
                         email=5,
                         ircname=5,
                         fullname__text=4,
                         country__text_phrase=4,
                         region__text_phrase=4,
                         city__text_phrase=4,
                         fullname__prefix=3,
                         fullname__fuzzy=2,
                         bio__text=2).query(or_=q))

        s = s.order_by('_score', 'name')

        if not include_non_vouched:
            s = s.filter(is_vouched=True)

        return s

    @property
    def accounts(self):
        accounts_query = self.externalaccount_set.exclude(
            type=ExternalAccount.TYPE_WEBSITE)
        if self._privacy_level:
            accounts_query = accounts_query.filter(
                privacy__gte=self._privacy_level)
        return accounts_query

    @property
    def websites(self):
        websites_query = self.externalaccount_set.filter(
            type=ExternalAccount.TYPE_WEBSITE)
        if self._privacy_level:
            websites_query = websites_query.filter(
                privacy__gte=self._privacy_level)
        return websites_query

    @property
    def email(self):
        """Privacy aware email property."""
        if self._privacy_level and self.privacy_email < self._privacy_level:
            return type(self).privacy_fields()['email']
        return self.user.email

    @property
    def display_name(self):
        return self.full_name

    @property
    def privacy_level(self):
        """Return user privacy clearance."""
        if (self.user.groups.filter(name='Managers').exists()
                or self.user.is_superuser):
            return PRIVILEGED
        if self.groups.filter(name='staff').exists():
            return EMPLOYEES
        if self.is_vouched:
            return MOZILLIANS
        return PUBLIC

    @property
    def is_complete(self):
        """Tests if a user has all the information needed to move on
        past the original registration view.

        """
        return self.display_name.strip() != ''

    @property
    def is_public(self):
        """Return True is any of the privacy protected fields is PUBLIC."""
        for field in type(self).privacy_fields():
            if getattr(self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def is_public_indexable(self):
        """For profile to be public indexable should have at least
        full_name OR ircname OR email set to PUBLIC.

        """
        for field in PUBLIC_INDEXABLE_FIELDS:
            if (getattr(self, 'privacy_%s' % field, None) == PUBLIC
                    and getattr(self, field, None)):
                return True
        return False

    @property
    def is_manager(self):
        return self.user.is_superuser or self.user.groups.filter(
            name='Managers').exists()

    @property
    def languages(self):
        """Return user languages based on privacy settings."""
        if self._privacy_level > self.privacy_languages:
            return self.language_set.none()
        return self.language_set.all()

    @property
    def vouched_by(self):
        """Return the first userprofile who vouched for this userprofile."""
        privacy_level = self._privacy_level
        voucher = (UserProfile.objects.filter(
            vouches_made__vouchee=self).order_by('vouches_made__date'))

        if voucher:
            voucher = voucher[0]
            if privacy_level:
                voucher.set_instance_privacy_level(privacy_level)
                for field in UserProfile.privacy_fields():
                    if getattr(voucher, 'privacy_%s' % field) >= privacy_level:
                        return voucher
                return None
            return voucher

        return None

    @property
    def date_vouched(self):
        """ Return the date of the first vouch, if available."""
        vouches = self.vouches_received.all().order_by('date')[:1]
        if vouches:
            return vouches[0].date
        return None

    def __unicode__(self):
        """Return this user's name when their profile is called."""
        return self.display_name

    def get_absolute_url(self):
        return reverse('phonebook:profile_view', args=[self.user.username])

    def set_instance_privacy_level(self, level):
        """Sets privacy level of instance."""
        self._privacy_level = level

    def set_privacy_level(self, level, save=True):
        """Sets all privacy enabled fields to 'level'."""
        for field in type(self).privacy_fields():
            setattr(self, 'privacy_%s' % field, level)
        if save:
            self.save()

    def set_membership(self, model, membership_list):
        """Alters membership to Groups and Skills."""
        if model is Group:
            m2mfield = self.groups
            alias_model = GroupAlias
        elif model is Skill:
            m2mfield = self.skills
            alias_model = SkillAlias

        # Remove any visible groups that weren't supplied in this list.
        if model is Group:
            GroupMembership.objects.filter(userprofile=self, group__visible=True)\
                .exclude(group__name__in=membership_list).delete()
        else:
            m2mfield.remove(*[
                g for g in m2mfield.all()
                if g.name not in membership_list and g.is_visible
            ])

        # Add/create the rest of the groups
        groups_to_add = []
        for g in membership_list:
            if alias_model.objects.filter(name=g).exists():
                group = alias_model.objects.get(name=g).alias
            else:
                group = model.objects.create(name=g)

            if group.is_visible:
                groups_to_add.append(group)

        if model is Group:
            for group in groups_to_add:
                group.add_member(self)
        else:
            m2mfield.add(*groups_to_add)

    def get_photo_thumbnail(self, geometry='160x160', **kwargs):
        if 'crop' not in kwargs:
            kwargs['crop'] = 'center'
        if self.photo:
            return get_thumbnail(self.photo, geometry, **kwargs)
        return get_thumbnail(settings.DEFAULT_AVATAR_PATH, geometry, **kwargs)

    def get_photo_url(self, geometry='160x160', **kwargs):
        """Return photo url.

        If privacy allows and no photo set, return gravatar link.
        If privacy allows and photo set return local photo link.
        If privacy doesn't allow return default local link.
        """
        privacy_level = getattr(self, '_privacy_level', MOZILLIANS)
        if (not self.photo and self.privacy_photo >= privacy_level):
            return gravatar(self.user.email, size=geometry)
        return self.get_photo_thumbnail(geometry, **kwargs).url

    def is_vouchable(self, voucher):
        """Check whether self can receive a vouch from voucher."""
        # If there's a voucher, they must be able to vouch.
        if voucher and not voucher.can_vouch:
            return False

        # Maximum VOUCH_COUNT_LIMIT vouches per account, no matter what.
        if self.vouches_received.all().count() >= settings.VOUCH_COUNT_LIMIT:
            return False

        # If you've already vouched this account, you cannot do it again, unless
        # this account has a legacy vouch from you.
        vouch_query = self.vouches_received.filter(voucher=voucher)
        if voucher and vouch_query.exists():
            if vouch_query.filter(description='').exists():
                return True
            return False

        return True

    def vouch(self, vouched_by, description='', autovouch=False):
        if not self.is_vouchable(vouched_by):
            return

        now = datetime.now()
        # Update a legacy vouch, if exists, by re-vouching
        # https://bugzilla.mozilla.org/show_bug.cgi?id=1033306
        query = self.vouches_received.filter(voucher=vouched_by)
        if query.filter(description='').exists():
            # If there isn't a date, provide one
            vouch = query[0]
            vouch.description = description
            if not vouch.date:
                vouch.date = now
            vouch.save()
        else:
            vouch = self.vouches_received.create(voucher=vouched_by,
                                                 date=now,
                                                 description=description,
                                                 autovouch=autovouch)

        self._email_now_vouched(vouched_by, description)
        return vouch

    def auto_vouch(self):
        """Auto vouch mozilla.com users."""
        email = self.user.email

        if any(email.endswith('@' + x) for x in settings.AUTO_VOUCH_DOMAINS):
            if not self.vouches_received.filter(
                    description=settings.AUTO_VOUCH_REASON,
                    autovouch=True).exists():
                self.vouch(None, settings.AUTO_VOUCH_REASON, autovouch=True)

    def _email_now_vouched(self, vouched_by, description=''):
        """Email this user, letting them know they are now vouched."""
        name = None
        voucher_profile_link = None
        vouchee_profile_link = utils.absolutify(self.get_absolute_url())
        if vouched_by:
            name = vouched_by.full_name
            voucher_profile_link = utils.absolutify(
                vouched_by.get_absolute_url())

        number_of_vouches = self.vouches_received.all().count()
        template = get_template(
            'phonebook/emails/vouch_confirmation_email.txt')
        message = template.render({
            'voucher_name':
            name,
            'voucher_profile_url':
            voucher_profile_link,
            'vouchee_profile_url':
            vouchee_profile_link,
            'vouch_description':
            description,
            'functional_areas_url':
            utils.absolutify(reverse('groups:index_functional_areas')),
            'groups_url':
            utils.absolutify(reverse('groups:index_groups')),
            'first_vouch':
            number_of_vouches == 1,
            'can_vouch_threshold':
            number_of_vouches == settings.CAN_VOUCH_THRESHOLD,
        })
        subject = _(u'You have been vouched on Mozillians.org')
        filtered_message = message.replace('&#34;', '"').replace('&#39;', "'")
        send_mail(subject, filtered_message, settings.FROM_NOREPLY,
                  [self.user.email])

    def lookup_basket_token(self):
        """
        Query Basket for this user's token.  If Basket doesn't find the user,
        returns None. If Basket does find the token, returns it. Otherwise,
        there must have been some error from the network or basket, and this
        method just lets that exception propagate so the caller can decide how
        best to handle it.

        (Does not update the token field on the UserProfile.)
        """
        try:
            result = basket.lookup_user(email=self.user.email)
        except basket.BasketException as exception:
            if exception.code == basket.errors.BASKET_UNKNOWN_EMAIL:
                return None
            raise
        return result['token']

    def get_annotated_groups(self):
        """
        Return a list of all the visible groups the user is a member of or pending
        membership. The groups pending membership will have a .pending attribute
        set to True, others will have it set False.
        """
        groups = []
        # Query this way so we only get the groups that the privacy controls allow the
        # current user to see. We have to force evaluation of this query first, otherwise
        # Django combines the whole thing into one query and loses the privacy control.
        groups_manager = self.groups
        # checks to avoid AttributeError exception b/c self.groups may returns
        # EmptyQuerySet instead of the default manager due to privacy controls
        if hasattr(groups_manager, 'visible'):
            user_group_ids = list(groups_manager.visible().values_list(
                'id', flat=True))
        else:
            user_group_ids = []
        for membership in self.groupmembership_set.filter(
                group_id__in=user_group_ids):
            group = membership.group
            group.pending = (membership.status == GroupMembership.PENDING)
            groups.append(group)
        return groups

    def timezone_offset(self):
        """
        Return minutes the user's timezone is offset from UTC.  E.g. if user is
        4 hours behind UTC, returns -240.
        If user has not set a timezone, returns None (not 0).
        """
        if self.timezone:
            return offset_of_timezone(self.timezone)

    def save(self, *args, **kwargs):
        self._privacy_level = None
        super(UserProfile, self).save(*args, **kwargs)
        # Auto_vouch follows the first save, because you can't
        # create foreign keys without a database id.
        self.auto_vouch()

    @classmethod
    def get_index(cls, public_index=False):
        if public_index:
            return settings.ES_INDEXES['public']
        return settings.ES_INDEXES['default']

    @classmethod
    def refresh_index(cls, timesleep=0, es=None, public_index=False):
        if es is None:
            es = get_es()

        es.refresh(cls.get_index(public_index), timesleep=timesleep)

    @classmethod
    def index(cls,
              document,
              id_=None,
              bulk=False,
              force_insert=False,
              es=None,
              public_index=False):
        """ Overide elasticutils.index() to support more than one index
        for UserProfile model.

        """
        if bulk and es is None:
            raise ValueError('bulk is True, but es is None')

        if es is None:
            es = get_es()

        es.index(document,
                 index=cls.get_index(public_index),
                 doc_type=cls.get_mapping_type(),
                 id=id_,
                 bulk=bulk,
                 force_insert=force_insert)

    @classmethod
    def unindex(cls, id, es=None, public_index=False):
        if es is None:
            es = get_es()

        es.delete(cls.get_index(public_index), cls.get_mapping_type(), id)

    def reverse_geocode(self):
        """
        Use the user's lat and lng to set their city, region, and country.
        Does not save the profile.
        """
        if self.lat is None or self.lng is None:
            return

        from mozillians.geo.models import Country
        from mozillians.geo.lookup import reverse_geocode, GeoLookupException
        try:
            result = reverse_geocode(self.lat, self.lng)
        except GeoLookupException:
            if self.geo_country:
                # If self.geo_country is already set, just give up.
                pass
            else:
                # No country set, we need to at least set the placeholder one.
                self.geo_country = Country.objects.get(mapbox_id='geo_error')
                self.geo_region = None
                self.geo_city = None
        else:
            if result:
                country, region, city = result
                self.geo_country = country
                self.geo_region = region
                self.geo_city = city
            else:
                logger.error('Got back NONE from reverse_geocode on %s, %s' %
                             (self.lng, self.lat))
Example #3
0
class UserProfile(UserProfilePrivacyModel, SearchMixin):
    objects = UserProfileManager()

    user = models.OneToOneField(User)
    full_name = models.CharField(max_length=255, default='', blank=False,
                                 verbose_name=_lazy(u'Full Name'))
    is_vouched = models.BooleanField(default=False)
    last_updated = models.DateTimeField(auto_now=True, default=datetime.now)
    website = models.URLField(max_length=200, verbose_name=_lazy(u'Website'),
                              default='', blank=True)
    vouched_by = models.ForeignKey('UserProfile', null=True, default=None,
                                   on_delete=models.SET_NULL, blank=True,
                                   related_name='vouchees')
    date_vouched = models.DateTimeField(null=True, blank=True, default=None)
    groups = models.ManyToManyField(Group, blank=True, related_name='members')
    skills = models.ManyToManyField(Skill, blank=True, related_name='members')
    languages = models.ManyToManyField(Language, blank=True,
                                       related_name='members')
    bio = models.TextField(verbose_name=_lazy(u'Bio'), default='', blank=True)
    photo = ImageField(default='', blank=True,
                       upload_to=_calculate_photo_filename)
    ircname = models.CharField(max_length=63,
                               verbose_name=_lazy(u'IRC Nickname'),
                               default='', blank=True)
    country = models.CharField(max_length=50, default='',
                               choices=COUNTRIES.items(),
                               verbose_name=_lazy(u'Country'))
    region = models.CharField(max_length=255, default='', blank=True,
                              verbose_name=_lazy(u'Province/State'))
    city = models.CharField(max_length=255, default='', blank=True,
                            verbose_name=_lazy(u'City'))
    allows_community_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Sites that can determine my vouched status'),
        choices=((True, _lazy(u'All Community Sites')),
                 (False, _lazy(u'Only Mozilla Properties'))))
    allows_mozilla_sites = models.BooleanField(
        default=True,
        verbose_name=_lazy(u'Allow Mozilla sites to access my profile data?'),
        choices=((True, _lazy(u'Yes')), (False, _lazy(u'No'))))
    basket_token = models.CharField(max_length=1024, default='', blank=True)
    date_mozillian = models.DateField('When was involved with Mozilla',
                                      null=True, blank=True, default=None)
    timezone = models.CharField(max_length=100, blank=True, default='',
                                choices=zip(common_timezones, common_timezones))
    tshirt = models.IntegerField(
        _lazy(u'T-Shirt'), blank=True, null=True, default=None,
        choices=(
            (1, _lazy(u'Fitted Small')), (2, _lazy(u'Fitted Medium')),
            (3, _lazy(u'Fitted Large')), (4, _lazy(u'Fitted X-Large')),
            (5, _lazy(u'Fitted XX-Large')), (6, _lazy(u'Fitted XXX-Large')),
            (7, _lazy(u'Straight-cut Small')), (8, _lazy(u'Straight-cut Medium')),
            (9, _lazy(u'Straight-cut Large')), (10, _lazy(u'Straight-cut X-Large')),
            (11, _lazy(u'Straight-cut XX-Large')), (12, _lazy(u'Straight-cut XXX-Large'))
        ))
    title = models.CharField(_lazy(u'What do you do for Mozilla?'),
                             max_length=70, blank=True, default='')

    class Meta:
        db_table = 'profile'
        ordering = ['full_name']

    def __getattribute__(self, attrname):
        """Special privacy aware __getattribute__ method.

        This method returns the real value of the attribute of object,
        if the privacy_level of the attribute is at least as large as
        the _privacy_level attribute.

        Otherwise it returns a default privacy respecting value for
        the attribute, as defined in the privacy_fields dictionary.

        Special case is the vouched_by attribute:

        Since vouched_by refers to another UserProfile object with
        different privacy settings per attribute, we need to load that
        object and check if any of its privacy enabled attributes are
        available in the current privacy level.

        If yes, we return the real UserProfile object, making sure
        that we set the privacy_level of the returned instance to the
        same privacy level as this instance.

        If the object is not available in the current privacy level,
        we return None.

        """
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_fields = _getattr('_privacy_fields')
        privacy_level = _getattr('_privacy_level')

        if not privacy_level or attrname not in privacy_fields:
            return _getattr(attrname)

        if attrname == 'vouched_by':
            voucher = _getattr('vouched_by')
            if voucher:
                voucher.set_instance_privacy_level(privacy_level)
                for field in privacy_fields:
                    if ((getattr(voucher, 'privacy_%s' % field) >=
                         privacy_level)):
                        return voucher
            return None

        field_privacy = _getattr('privacy_%s' % attrname)
        if field_privacy < privacy_level:
            return privacy_fields.get(attrname)

        return _getattr(attrname)

    @classmethod
    def extract_document(cls, obj_id, obj=None):
        """Method used by elasticutils."""
        if obj is None:
            obj = cls.objects.get(pk=obj_id)
        d = {}

        attrs = ('id', 'is_vouched', 'website', 'ircname',
                 'region', 'city', 'allows_mozilla_sites',
                 'allows_community_sites')
        for a in attrs:
            data = getattr(obj, a)
            if isinstance(data, basestring):
                data = data.lower()
            d.update({a: data})

        if obj.country:
            d.update({'country':
                      [obj.country, COUNTRIES[obj.country].lower()]})

        # user data
        attrs = ('username', 'email', 'last_login', 'date_joined')
        for a in attrs:
            data = getattr(obj.user, a)
            if isinstance(data, basestring):
                data = data.lower()
            d.update({a: data})

        d.update(dict(fullname=obj.full_name.lower()))
        d.update(dict(name=obj.full_name.lower()))
        d.update(dict(bio=obj.bio))
        d.update(dict(has_photo=bool(obj.photo)))

        for attribute in ['groups', 'skills', 'languages']:
            groups = []
            for g in getattr(obj, attribute).all():
                groups.extend(g.aliases.values_list('name', flat=True))
            d[attribute] = groups
        return d

    @classmethod
    def get_mapping(cls):
        """Returns an ElasticSearch mapping."""
        return {
            'properties': {
                'id': {'type': 'integer'},
                'name': {'type': 'string', 'index': 'not_analyzed'},
                'fullname': {'type': 'string', 'analyzer': 'standard'},
                'email': {'type': 'string', 'index': 'not_analyzed'},
                'ircname': {'type': 'string', 'index': 'not_analyzed'},
                'username': {'type': 'string', 'index': 'not_analyzed'},
                'country': {'type': 'string', 'analyzer': 'whitespace'},
                'region': {'type': 'string', 'analyzer': 'whitespace'},
                'city': {'type': 'string', 'analyzer': 'whitespace'},
                'skills': {'type': 'string', 'analyzer': 'whitespace'},
                'groups': {'type': 'string', 'analyzer': 'whitespace'},
                'languages': {'type': 'string', 'index': 'not_analyzed'},
                'bio': {'type': 'string', 'analyzer': 'snowball'},
                'is_vouched': {'type': 'boolean'},
                'allows_mozilla_sites': {'type': 'boolean'},
                'allows_community_sites': {'type': 'boolean'},
                'photo': {'type': 'boolean'},
                'website': {'type': 'string', 'index': 'not_analyzed'},
                'last_updated': {'type': 'date'},
                'date_joined': {'type': 'date'}}}

    @classmethod
    def search(cls, query, include_non_vouched=False, public=False):
        """Sensible default search for UserProfiles."""
        query = query.lower().strip()
        fields = ('username', 'bio__text', 'email', 'ircname',
                  'country__text', 'country__text_phrase',
                  'region__text', 'region__text_phrase',
                  'city__text', 'city__text_phrase',
                  'fullname__text', 'fullname__text_phrase',
                  'fullname__prefix', 'fullname__fuzzy'
                  'groups__text')
        s = PrivacyAwareS(cls)
        if public:
            s = s.privacy_level(PUBLIC)
        s = s.indexes(cls.get_index(public))

        if query:
            q = dict((field, query) for field in fields)
            s = (s.boost(fullname__text_phrase=5, username=5, email=5,
                         ircname=5, fullname__text=4, country__text_phrase=4,
                         region__text_phrase=4, city__text_phrase=4,
                         fullname__prefix=3, fullname__fuzzy=2,
                         bio__text=2).query(or_=q))

        s = s.order_by('_score', 'name')

        if not include_non_vouched:
            s = s.filter(is_vouched=True)

        return s

    @property
    def accounts(self):
        if self._privacy_level:
            return self.externalaccount_set.filter(privacy__gte=self._privacy_level)
        return self.externalaccount_set.all()

    @property
    def email(self):
        """Privacy aware email property."""
        if self._privacy_level and self.privacy_email < self._privacy_level:
            return self._privacy_fields['email']
        return self.user.email

    @property
    def display_name(self):
        return self.full_name

    @property
    def privacy_level(self):
        """Return user privacy clearance."""
        if (self.groups.filter(name='privileged').exists() or self.user.is_superuser):
            return PRIVILEGED
        if self.groups.filter(name='staff').exists():
            return EMPLOYEES
        if self.is_vouched:
            return MOZILLIANS
        return PUBLIC

    @property
    def is_complete(self):
        """Tests if a user has all the information needed to move on
        past the original registration view.

        """
        return self.display_name.strip() != ''

    @property
    def is_public(self):
        """Return True is any of the privacy protected fields is PUBLIC."""
        for field in self._privacy_fields:
            if getattr(self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def is_public_indexable(self):
        """For profile to be public indexable should have at least
        full_name OR ircname OR email set to PUBLIC.

        """
        for field in PUBLIC_INDEXABLE_FIELDS:
            if (getattr(self, 'privacy_%s' % field, None) == PUBLIC and
                getattr(self, field, None)):
                return True
        return False

    def __unicode__(self):
        """Return this user's name when their profile is called."""
        return self.display_name

    def get_absolute_url(self):
        return reverse('phonebook:profile_view', args=[self.user.username])

    def set_instance_privacy_level(self, level):
        """Sets privacy level of instance."""
        self._privacy_level = level

    def set_privacy_level(self, level, save=True):
        """Sets all privacy enabled fields to 'level'."""
        for field in self._privacy_fields:
            setattr(self, 'privacy_%s' % field, level)
        if save:
            self.save()

    def set_membership(self, model, membership_list):
        """Alters membership to Groups, Skills and Languages."""
        if model is Group:
            m2mfield = self.groups
            alias_model = GroupAlias
        elif model is Skill:
            m2mfield = self.skills
            alias_model = SkillAlias
        elif model is Language:
            m2mfield = self.languages
            alias_model = LanguageAlias

        # Remove any non-system groups that weren't supplied in this list.
        m2mfield.remove(*[g for g in m2mfield.all()
                          if g.name not in membership_list
                          and not getattr(g, 'system', False)])

        # Add/create the rest of the groups
        groups_to_add = []
        for g in membership_list:
            if alias_model.objects.filter(name=g).exists():
                group = alias_model.objects.get(name=g).alias
            else:
                group = model.objects.create(name=g)

            if not getattr(group, 'system', False):
                groups_to_add.append(group)

        m2mfield.add(*groups_to_add)

    def get_photo_thumbnail(self, geometry='160x160', **kwargs):
        if 'crop' not in kwargs:
            kwargs['crop'] = 'center'
        if self.photo:
            return get_thumbnail(self.photo, geometry, **kwargs)
        return get_thumbnail(settings.DEFAULT_AVATAR_PATH, geometry, **kwargs)

    def get_photo_url(self, geometry='160x160', **kwargs):
        """Return photo url.

        If privacy allows and no photo set, return gravatar link.
        If privacy allows and photo set return local photo link.
        If privacy doesn't allow return default local link.
        """
        privacy_level = getattr(self, '_privacy_level', MOZILLIANS)
        if (not self.photo and self.privacy_photo >= privacy_level):
            return gravatar(self.user.email, size=geometry)
        return self.get_photo_thumbnail(geometry, **kwargs).url

    def vouch(self, vouched_by, commit=True):
        if self.is_vouched:
            return

        self.is_vouched = True
        self.vouched_by = vouched_by
        self.date_vouched = datetime.now()

        if commit:
            self.save()

        self._email_now_vouched()

    def auto_vouch(self):
        """Auto vouch mozilla.com users."""
        email = self.user.email
        if any(email.endswith('@' + x) for x in settings.AUTO_VOUCH_DOMAINS):
            self.vouch(None, commit=False)

    def add_to_staff_group(self):
        """Keep users in the staff group if they're autovouchable."""
        email = self.user.email
        staff, created = Group.objects.get_or_create(name='staff', system=True)
        if any(email.endswith('@' + x) for x in
               settings.AUTO_VOUCH_DOMAINS):
            self.groups.add(staff)
        elif self.groups.filter(pk=staff.pk).exists():
            self.groups.remove(staff)

    def _email_now_vouched(self):
        """Email this user, letting them know they are now vouched."""
        subject = _(u'You are now vouched on Mozillians!')
        message = _(u"You've now been vouched on Mozillians.org. "
                     "You'll now be able to search, vouch "
                     "and invite other Mozillians onto the site.")
        send_mail(subject, message, settings.FROM_NOREPLY,
                  [self.user.email])

    def save(self, *args, **kwargs):
        self._privacy_level = None
        self.auto_vouch()
        super(UserProfile, self).save(*args, **kwargs)
        self.add_to_staff_group()

    @classmethod
    def get_index(cls, public_index=False):
        if public_index:
            return settings.ES_INDEXES['public']
        return settings.ES_INDEXES['default']

    @classmethod
    def refresh_index(cls, timesleep=0, es=None, public_index=False):
        if es is None:
            es = get_es()

        es.refresh(cls.get_index(public_index), timesleep=timesleep)

    @classmethod
    def index(cls, document, id_=None, bulk=False, force_insert=False,
              es=None, public_index=False):
        """ Overide elasticutils.index() to support more than one index
        for UserProfile model.

        """
        if bulk and es is None:
            raise ValueError('bulk is True, but es is None')

        if es is None:
            es = get_es()

        es.index(document, index=cls.get_index(public_index),
                 doc_type=cls.get_mapping_type(),
                 id=id_, bulk=bulk, force_insert=force_insert)

    @classmethod
    def unindex(cls, id, es=None, public_index=False):
        if es is None:
            es = get_es()

        es.delete(cls.get_index(public_index), cls.get_mapping_type(), id)
Example #4
0
class UserProfile(UserProfilePrivacyModel):
    REFERRAL_SOURCE_CHOICES = (
        ('direct', 'Mozillians'),
        ('contribute', 'Get Involved'),
    )

    objects = UserProfileManager.from_queryset(UserProfileQuerySet)()

    user = models.OneToOneField(User)
    full_name = models.CharField(max_length=255,
                                 default='',
                                 blank=False,
                                 verbose_name=_lazy(u'Full Name'))
    full_name_local = models.CharField(
        max_length=255,
        blank=True,
        default='',
        verbose_name=_lazy(u'Name in local language'))
    is_vouched = models.BooleanField(
        default=False,
        help_text='You can edit vouched status by editing invidual vouches')
    can_vouch = models.BooleanField(
        default=False,
        help_text='You can edit can_vouch status by editing invidual vouches')
    last_updated = models.DateTimeField(auto_now=True)
    groups = models.ManyToManyField(Group,
                                    blank=True,
                                    related_name='members',
                                    through=GroupMembership)
    skills = models.ManyToManyField(Skill, blank=True, related_name='members')
    bio = models.TextField(verbose_name=_lazy(u'Bio'), default='', blank=True)
    photo = ImageField(default='',
                       blank=True,
                       upload_to=_calculate_photo_filename)
    ircname = models.CharField(max_length=63,
                               verbose_name=_lazy(u'IRC Nickname'),
                               default='',
                               blank=True)

    # validated geo data (validated that it's valid geo data, not that the
    # mozillian is there :-) )
    geo_country = models.ForeignKey('geo.Country',
                                    blank=True,
                                    null=True,
                                    on_delete=models.SET_NULL)
    geo_region = models.ForeignKey('geo.Region',
                                   blank=True,
                                   null=True,
                                   on_delete=models.SET_NULL)
    geo_city = models.ForeignKey('geo.City',
                                 blank=True,
                                 null=True,
                                 on_delete=models.SET_NULL)
    lat = models.FloatField(_lazy(u'Latitude'), blank=True, null=True)
    lng = models.FloatField(_lazy(u'Longitude'), blank=True, null=True)

    # django-cities-light fields
    city = models.ForeignKey('cities_light.City',
                             blank=True,
                             null=True,
                             on_delete=models.SET_NULL)
    region = models.ForeignKey('cities_light.Region',
                               blank=True,
                               null=True,
                               on_delete=models.SET_NULL)
    country = models.ForeignKey('cities_light.Country',
                                blank=True,
                                null=True,
                                on_delete=models.SET_NULL)

    basket_token = models.CharField(max_length=1024, default='', blank=True)
    date_mozillian = models.DateField('When was involved with Mozilla',
                                      null=True,
                                      blank=True,
                                      default=None)
    timezone = models.CharField(max_length=100,
                                blank=True,
                                default='',
                                choices=zip(common_timezones,
                                            common_timezones))
    tshirt = models.IntegerField(
        _lazy(u'T-Shirt'),
        blank=True,
        null=True,
        default=None,
        choices=((1, _lazy(u'Fitted Small')), (2, _lazy(u'Fitted Medium')),
                 (3, _lazy(u'Fitted Large')), (4, _lazy(u'Fitted X-Large')),
                 (5, _lazy(u'Fitted XX-Large')), (6,
                                                  _lazy(u'Fitted XXX-Large')),
                 (7, _lazy(u'Straight-cut Small')),
                 (8, _lazy(u'Straight-cut Medium')),
                 (9, _lazy(u'Straight-cut Large')),
                 (10, _lazy(u'Straight-cut X-Large')),
                 (11, _lazy(u'Straight-cut XX-Large')),
                 (12, _lazy(u'Straight-cut XXX-Large'))))
    title = models.CharField(_lazy(u'What do you do for Mozilla?'),
                             max_length=70,
                             blank=True,
                             default='')

    story_link = models.URLField(
        _lazy(u'Link to your contribution story'),
        help_text=_lazy(u'If you have created something public that '
                        u'tells the story of how you came to be a '
                        u'Mozillian, specify that link here.'),
        max_length=1024,
        blank=True,
        default='')
    referral_source = models.CharField(max_length=32,
                                       choices=REFERRAL_SOURCE_CHOICES,
                                       default='direct')

    def __unicode__(self):
        """Return this user's name when their profile is called."""
        return self.display_name

    def get_absolute_url(self):
        return reverse('phonebook:profile_view', args=[self.user.username])

    class Meta:
        db_table = 'profile'
        ordering = ['full_name']

    def __getattribute__(self, attrname):
        """Special privacy aware __getattribute__ method.

        This method returns the real value of the attribute of object,
        if the privacy_level of the attribute is at least as large as
        the _privacy_level attribute.

        Otherwise it returns a default privacy respecting value for
        the attribute, as defined in the privacy_fields dictionary.

        special_functions provides methods that privacy safe their
        respective properties, where the privacy modifications are
        more complex.
        """
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        privacy_fields = UserProfile.privacy_fields()
        privacy_level = _getattr('_privacy_level')
        special_functions = {
            'accounts': '_accounts',
            'alternate_emails': '_alternate_emails',
            'email': '_primary_email',
            'is_public_indexable': '_is_public_indexable',
            'languages': '_languages',
            'vouches_made': '_vouches_made',
            'vouches_received': '_vouches_received',
            'vouched_by': '_vouched_by',
            'websites': '_websites',
            'identity_profiles': '_identity_profiles'
        }

        if attrname in special_functions:
            return _getattr(special_functions[attrname])

        if not privacy_level or attrname not in privacy_fields:
            return _getattr(attrname)

        field_privacy = _getattr('privacy_%s' % attrname)
        if field_privacy < privacy_level:
            return privacy_fields.get(attrname)

        return _getattr(attrname)

    def _filter_accounts_privacy(self, accounts):
        if self._privacy_level:
            return accounts.filter(privacy__gte=self._privacy_level)
        return accounts

    @property
    def _accounts(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        excluded_types = [
            ExternalAccount.TYPE_WEBSITE, ExternalAccount.TYPE_EMAIL
        ]
        accounts = _getattr('externalaccount_set').exclude(
            type__in=excluded_types)
        return self._filter_accounts_privacy(accounts)

    @property
    def _alternate_emails(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        accounts = _getattr('externalaccount_set').filter(
            type=ExternalAccount.TYPE_EMAIL)
        return self._filter_accounts_privacy(accounts)

    @property
    def _api_alternate_emails(self):
        """
        Helper private property that creates a compatibility layer
        for API results in alternate emails. Combines both IdpProfile
        and ExternalAccount objects. In conflicts/duplicates it returns
        the minimum privacy level defined.
        """
        legacy_emails_qs = self._alternate_emails
        idp_qs = self._identity_profiles

        e_exclude = [
            e.id for e in legacy_emails_qs if idp_qs.filter(
                email=e.identifier, privacy__gte=e.privacy).exists()
        ]
        legacy_emails_qs = legacy_emails_qs.exclude(id__in=e_exclude)

        idp_exclude = [
            i.id for i in idp_qs
            if legacy_emails_qs.filter(identifier=i.email,
                                       privacy__gte=i.privacy).exists()
        ]
        idp_qs = idp_qs.exclude(id__in=idp_exclude)

        return chain(legacy_emails_qs, idp_qs)

    @property
    def _identity_profiles(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        accounts = _getattr('idp_profiles').all()
        return self._filter_accounts_privacy(accounts)

    @property
    def _is_public_indexable(self):
        for field in PUBLIC_INDEXABLE_FIELDS:
            if getattr(self, field, None) and getattr(
                    self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def _languages(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level > _getattr('privacy_languages'):
            return _getattr('language_set').none()
        return _getattr('language_set').all()

    @property
    def _primary_email(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))

        privacy_fields = UserProfile.privacy_fields()

        if self._privacy_level:
            # Try IDP contact first
            if self.idp_profiles.exists():
                contact_ids = self.identity_profiles.filter(
                    primary_contact_identity=True)
                if contact_ids.exists():
                    return contact_ids[0].email
                return ''

            # Fallback to user.email
            if _getattr('privacy_email') < self._privacy_level:
                return privacy_fields['email']

        # In case we don't have a privacy aware attribute access
        if self.idp_profiles.filter(primary_contact_identity=True).exists():
            return self.idp_profiles.filter(
                primary_contact_identity=True)[0].email
        return _getattr('user').email

    @property
    def _vouched_by(self):
        privacy_level = self._privacy_level
        voucher = (UserProfile.objects.filter(
            vouches_made__vouchee=self).order_by('vouches_made__date'))

        if voucher.exists():
            voucher = voucher[0]
            if privacy_level:
                voucher.set_instance_privacy_level(privacy_level)
                for field in UserProfile.privacy_fields():
                    if getattr(voucher, 'privacy_%s' % field) >= privacy_level:
                        return voucher
                return None
            return voucher

        return None

    def _vouches(self, type):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))

        vouch_ids = []
        for vouch in _getattr(type).all():
            vouch.vouchee.set_instance_privacy_level(self._privacy_level)
            for field in UserProfile.privacy_fields():
                if getattr(vouch.vouchee, 'privacy_%s' % field,
                           0) >= self._privacy_level:
                    vouch_ids.append(vouch.id)
        vouches = _getattr(type).filter(pk__in=vouch_ids)

        return vouches

    @property
    def _vouches_made(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level:
            return self._vouches('vouches_made')
        return _getattr('vouches_made')

    @property
    def _vouches_received(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        if self._privacy_level:
            return self._vouches('vouches_received')
        return _getattr('vouches_received')

    @property
    def _websites(self):
        _getattr = (lambda x: super(UserProfile, self).__getattribute__(x))
        accounts = _getattr('externalaccount_set').filter(
            type=ExternalAccount.TYPE_WEBSITE)
        return self._filter_accounts_privacy(accounts)

    @property
    def display_name(self):
        return self.full_name

    @property
    def privacy_level(self):
        """Return user privacy clearance."""
        if (self.user.groups.filter(name='Managers').exists()
                or self.user.is_superuser):
            return PRIVATE
        if self.groups.filter(name='staff').exists():
            return EMPLOYEES
        if self.is_vouched:
            return MOZILLIANS
        return PUBLIC

    @property
    def is_complete(self):
        """Tests if a user has all the information needed to move on
        past the original registration view.

        """
        return self.display_name.strip() != ''

    @property
    def is_public(self):
        """Return True is any of the privacy protected fields is PUBLIC."""
        # TODO needs update

        for field in type(self).privacy_fields():
            if getattr(self, 'privacy_%s' % field, None) == PUBLIC:
                return True
        return False

    @property
    def is_manager(self):
        return self.user.is_superuser or self.user.groups.filter(
            name='Managers').exists()

    @property
    def is_nda(self):
        query = {
            'userprofile__pk': self.pk,
            'group__name': settings.NDA_GROUP,
            'status': GroupMembership.MEMBER
        }
        return GroupMembership.objects.filter(**query).exists()

    @property
    def date_vouched(self):
        """ Return the date of the first vouch, if available."""
        vouches = self.vouches_received.all().order_by('date')[:1]
        if vouches:
            return vouches[0].date
        return None

    @property
    def can_create_access_groups(self):
        """Check if a user can provision access groups.

        An access group is provisioned if a user holds an email in the AUTO_VOUCH_DOMAINS
        and has an LDAP IdpProfile or the user has a superuser account.
        """
        emails = set([
            idp.email
            for idp in IdpProfile.objects.filter(profile=self,
                                                 type=IdpProfile.PROVIDER_LDAP)
            if idp.email.split('@')[1] in settings.AUTO_VOUCH_DOMAINS
        ])
        if self.user.is_superuser or emails:
            return True
        return False

    def set_instance_privacy_level(self, level):
        """Sets privacy level of instance."""
        self._privacy_level = level

    def set_privacy_level(self, level, save=True):
        """Sets all privacy enabled fields to 'level'."""
        for field in type(self).privacy_fields():
            setattr(self, 'privacy_%s' % field, level)
        if save:
            self.save()

    def set_membership(self, model, membership_list):
        """Alters membership to Groups and Skills."""
        if model is Group:
            m2mfield = self.groups
            alias_model = GroupAlias
        elif model is Skill:
            m2mfield = self.skills
            alias_model = SkillAlias

        # Remove any visible groups that weren't supplied in this list.
        if model is Group:
            (GroupMembership.objects.filter(
                userprofile=self, group__visible=True).exclude(
                    group__name__in=membership_list).delete())
        else:
            m2mfield.remove(*[
                g for g in m2mfield.all()
                if g.name not in membership_list and g.is_visible
            ])

        # Add/create the rest of the groups
        groups_to_add = []
        for g in membership_list:
            if alias_model.objects.filter(name=g).exists():
                group = alias_model.objects.get(name=g).alias
            else:
                group = model.objects.create(name=g)

            if group.is_visible:
                groups_to_add.append(group)

        if model is Group:
            for group in groups_to_add:
                group.add_member(self)
        else:
            m2mfield.add(*groups_to_add)

    def get_photo_thumbnail(self, geometry='160x160', **kwargs):
        if 'crop' not in kwargs:
            kwargs['crop'] = 'center'
        if self.photo:
            return get_thumbnail(self.photo, geometry, **kwargs)
        return get_thumbnail(settings.DEFAULT_AVATAR_PATH, geometry, **kwargs)

    def get_photo_url(self, geometry='160x160', **kwargs):
        """Return photo url.

        If privacy allows and no photo set, return gravatar link.
        If privacy allows and photo set return local photo link.
        If privacy doesn't allow return default local link.
        """
        privacy_level = getattr(self, '_privacy_level', MOZILLIANS)
        if (not self.photo and self.privacy_photo >= privacy_level):
            return gravatar(self.email, size=geometry)

        photo_url = self.get_photo_thumbnail(geometry, **kwargs).url
        if photo_url.startswith('https://') or photo_url.startswith('http://'):
            return photo_url
        return absolutify(photo_url)

    def is_vouchable(self, voucher):
        """Check whether self can receive a vouch from voucher."""
        # If there's a voucher, they must be able to vouch.
        if voucher and not voucher.can_vouch:
            return False

        # Maximum VOUCH_COUNT_LIMIT vouches per account, no matter what.
        if self.vouches_received.all().count() >= settings.VOUCH_COUNT_LIMIT:
            return False

        # If you've already vouched this account, you cannot do it again
        vouch_query = self.vouches_received.filter(voucher=voucher)
        if voucher and vouch_query.exists():
            return False

        return True

    def vouch(self, vouched_by, description='', autovouch=False):
        if not self.is_vouchable(vouched_by):
            return

        vouch = self.vouches_received.create(voucher=vouched_by,
                                             date=datetime.now(),
                                             description=description,
                                             autovouch=autovouch)

        self._email_now_vouched(vouched_by, description)
        return vouch

    def auto_vouch(self):
        """Auto vouch mozilla.com users."""
        emails = [
            acc.identifier for acc in ExternalAccount.objects.filter(
                user=self, type=ExternalAccount.TYPE_EMAIL)
        ]
        emails.append(self.email)

        email_exists = any([
            email for email in emails
            if email.split('@')[1] in settings.AUTO_VOUCH_DOMAINS
        ])
        if email_exists and not self.vouches_received.filter(
                description=settings.AUTO_VOUCH_REASON,
                autovouch=True).exists():
            self.vouch(None, settings.AUTO_VOUCH_REASON, autovouch=True)

    def _email_now_vouched(self, vouched_by, description=''):
        """Email this user, letting them know they are now vouched."""
        name = None
        voucher_profile_link = None
        vouchee_profile_link = utils.absolutify(self.get_absolute_url())
        if vouched_by:
            name = vouched_by.full_name
            voucher_profile_link = utils.absolutify(
                vouched_by.get_absolute_url())

        number_of_vouches = self.vouches_received.all().count()
        template = get_template(
            'phonebook/emails/vouch_confirmation_email.txt')
        message = template.render({
            'voucher_name':
            name,
            'voucher_profile_url':
            voucher_profile_link,
            'vouchee_profile_url':
            vouchee_profile_link,
            'vouch_description':
            description,
            'functional_areas_url':
            utils.absolutify(reverse('groups:index_functional_areas')),
            'groups_url':
            utils.absolutify(reverse('groups:index_groups')),
            'first_vouch':
            number_of_vouches == 1,
            'can_vouch_threshold':
            number_of_vouches == settings.CAN_VOUCH_THRESHOLD,
        })
        subject = _(u'You have been vouched on Mozillians.org')
        filtered_message = message.replace('&#34;', '"').replace('&#39;', "'")
        send_mail(subject, filtered_message, settings.FROM_NOREPLY,
                  [self.email])

    def get_annotated_groups(self):
        """
        Return a list of all the visible groups the user is a member of or pending
        membership. The groups pending membership will have a .pending attribute
        set to True, others will have it set False.
        """
        groups = []
        # Query this way so we only get the groups that the privacy controls allow the
        # current user to see. We have to force evaluation of this query first, otherwise
        # Django combines the whole thing into one query and loses the privacy control.
        groups_manager = self.groups
        # checks to avoid AttributeError exception b/c self.groups may returns
        # EmptyQuerySet instead of the default manager due to privacy controls
        if hasattr(groups_manager, 'visible'):
            user_group_ids = list(groups_manager.visible().values_list(
                'id', flat=True))
        else:
            user_group_ids = []
        for membership in self.groupmembership_set.filter(
                group_id__in=user_group_ids):
            group = membership.group
            group.pending = (membership.status == GroupMembership.PENDING)
            group.pending_terms = (
                membership.status == GroupMembership.PENDING_TERMS)
            groups.append(group)
        return groups

    def get_cis_emails(self):
        """Prepares the entry for emails in the CIS format."""
        idp_profiles = self.idp_profiles.all()
        primary_idp = idp_profiles.filter(primary=True)
        emails = []
        primary_email = {
            'value': self.email,
            'verified': True,
            'primary': True,
            'name': 'mozillians-primary-{0}'.format(self.pk)
        }
        # We have an IdpProfile marked as primary (login identity)
        # If there is not an idp profile, the self.email is the one that is used to login
        if primary_idp.exists():
            primary_email['value'] = primary_idp[0].email
            primary_email['name'] = primary_idp[0].get_type_display()

        emails.append(primary_email)

        # Non primary identity profiles
        for idp in self.idp_profiles.filter(primary=False):
            entry = {
                'value': idp.email,
                'verified': True,
                'primary': False,
                'name': '{0}'.format(idp.get_type_display())
            }
            emails.append(entry)

        return emails

    def get_cis_uris(self):
        """Prepares the entry for URIs in the CIS format."""
        accounts = []
        for account in self.externalaccount_set.exclude(
                type=ExternalAccount.TYPE_EMAIL):
            value = account.get_identifier_url()
            account_type = ExternalAccount.ACCOUNT_TYPES[account.type]
            if value:
                entry = {
                    'value':
                    value,
                    'primary':
                    False,
                    'verified':
                    False,
                    'name':
                    'mozillians-{}-{}'.format(account_type['name'], account.pk)
                }
                accounts.append(entry)

        return accounts

    def get_cis_groups(self, idp):
        """Prepares the entry for profile groups in the CIS format."""

        # Update strategy: send groups for higher MFA idp
        # Wipe groups from the rest
        idps = list(self.idp_profiles.all().values_list('type', flat=True))

        # if the current idp does not match
        # the greatest number in the list, wipe the groups
        if not idps or idp.type != max(idps) or not idp.is_mfa():
            return []

        memberships = GroupMembership.objects.filter(
            userprofile=self,
            status=GroupMembership.MEMBER,
            group__is_access_group=True)
        groups = ['mozilliansorg_{}'.format(m.group.url) for m in memberships]
        return groups

    def get_cis_tags(self):
        """Prepares the entry for profile tags in the CIS format."""
        memberships = GroupMembership.objects.filter(
            userprofile=self,
            status=GroupMembership.MEMBER).exclude(group__is_access_group=True)

        tags = [m.group.url for m in memberships]
        return tags

    def timezone_offset(self):
        """
        Return minutes the user's timezone is offset from UTC.  E.g. if user is
        4 hours behind UTC, returns -240.
        If user has not set a timezone, returns None (not 0).
        """
        if self.timezone:
            return offset_of_timezone(self.timezone)

    def save(self, *args, **kwargs):
        self._privacy_level = None
        autovouch = kwargs.pop('autovouch', True)

        super(UserProfile, self).save(*args, **kwargs)
        # Auto_vouch follows the first save, because you can't
        # create foreign keys without a database id.

        if self.is_complete:
            send_userprofile_to_cis.delay(self.pk)

        if autovouch:
            self.auto_vouch()