Exemplo n.º 1
0
class DraftRevision(ModelBase, AbstractRevision):
    based_on = models.ForeignKey(Revision, on_delete=models.CASCADE)
    content = models.TextField(blank=True)
    locale = LocaleField(blank=False, db_index=True)
    slug = models.CharField(max_length=255, blank=True)
    summary = models.TextField(blank=True)
    title = models.CharField(max_length=255, blank=True)
Exemplo n.º 2
0
class WikiMetric(ModelBase):
    """A single numeric measurement for a locale, product and date.

    For example, the percentage of all FxOS articles localized to Spanish."""

    code = models.CharField(db_index=True,
                            max_length=255,
                            choices=METRIC_CODE_CHOICES)
    locale = LocaleField(db_index=True, null=True, blank=True)
    product = models.ForeignKey(Product,
                                on_delete=models.CASCADE,
                                null=True,
                                blank=True)
    date = models.DateField()
    value = models.FloatField()

    class Meta(object):
        unique_together = ("code", "product", "locale", "date")
        ordering = ["-date"]

    def __str__(self):
        return "[{date}][{locale}][{product}] {code}: {value}".format(
            date=self.date,
            code=self.code,
            locale=self.locale,
            value=self.value,
            product=self.product,
        )
Exemplo n.º 3
0
class QuestionLocale(ModelBase):
    locale = LocaleField(choices=settings.LANGUAGE_CHOICES_ENGLISH, unique=True)
    products = models.ManyToManyField(Product, related_name="questions_locales")

    objects = QuestionLocaleManager()

    class Meta:
        verbose_name = "AAQ enabled locale"
Exemplo n.º 4
0
class Profile(ModelBase):
    """Profile model for django users, get it with user.get_profile()."""

    user = models.OneToOneField(User, primary_key=True,
                                verbose_name=_lazy(u'User'))
    name = models.CharField(max_length=255, null=True, blank=True,
                            verbose_name=_lazy(u'Display name'))
    public_email = models.BooleanField(  # show/hide email
        default=False, verbose_name=_lazy(u'Make my email public'))
    avatar = models.ImageField(upload_to=settings.USER_AVATAR_PATH, null=True,
                               blank=True, verbose_name=_lazy(u'Avatar'),
                               max_length=settings.MAX_FILEPATH_LENGTH)
    bio = models.TextField(null=True, blank=True,
                           verbose_name=_lazy(u'Biography'))
    website = models.URLField(max_length=255, null=True, blank=True,
                              verbose_name=_lazy(u'Website'))
    twitter = models.URLField(max_length=255, null=True, blank=True,
                              verbose_name=_lazy(u'Twitter URL'))
    facebook = models.URLField(max_length=255, null=True, blank=True,
                               verbose_name=_lazy(u'Facebook URL'))
    irc_handle = models.CharField(max_length=255, null=True, blank=True,
                                  verbose_name=_lazy(u'IRC nickname'))
    timezone = TimeZoneField(null=True, blank=True,
                             verbose_name=_lazy(u'Timezone'))
    country = models.CharField(max_length=2, choices=COUNTRIES, null=True,
                               blank=True, verbose_name=_lazy(u'Country'))
    # No city validation
    city = models.CharField(max_length=255, null=True, blank=True,
                            verbose_name=_lazy(u'City'))
    locale = LocaleField(default=settings.LANGUAGE_CODE,
                         verbose_name=_lazy(u'Preferred language'))

    class Meta(object):
        permissions = (('view_karma_points', 'Can view karma points'),
                       ('deactivate_users', 'Can deactivate users'),)

    def __unicode__(self):
        return unicode(self.user)

    def get_absolute_url(self):
        return reverse('users.profile', args=[self.user_id])

    def clear(self):
        """Clears out the users profile"""
        self.name = ''
        self.public_email = False
        self.avatar = None
        self.bio = ''
        self.website = ''
        self.twitter = ''
        self.facebook = ''
        self.irc_handle = ''
        self.city = ''
Exemplo n.º 5
0
class Media(ModelBase):
    """Generic model for media"""
    title = models.CharField(max_length=255, db_index=True)
    created = models.DateTimeField(default=datetime.now, db_index=True)
    updated = models.DateTimeField(default=datetime.now, db_index=True)
    updated_by = models.ForeignKey(User, null=True)
    description = models.TextField(max_length=10000)
    locale = LocaleField(default=settings.GALLERY_DEFAULT_LANGUAGE,
                         db_index=True)
    is_draft = models.NullBooleanField(default=None, null=True, editable=False)

    class Meta(object):
        abstract = True
        ordering = ['-created']
        unique_together = (('locale', 'title'), ('is_draft', 'creator'))

    def __unicode__(self):
        return '[%s] %s' % (self.locale, self.title)
Exemplo n.º 6
0
class Media(ModelBase):
    """Generic model for media"""

    title = models.CharField(max_length=255, db_index=True)
    created = models.DateTimeField(default=datetime.now, db_index=True)
    updated = models.DateTimeField(default=datetime.now, db_index=True)
    updated_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
    description = models.TextField(max_length=10000)
    locale = LocaleField(default=settings.GALLERY_DEFAULT_LANGUAGE,
                         db_index=True)
    is_draft = models.NullBooleanField(default=None, null=True, editable=False)

    class Meta(object):
        abstract = True
        ordering = ["-created"]
        unique_together = (("locale", "title"), ("is_draft", "creator"))

    def __str__(self):
        return "[%s] %s" % (self.locale, self.title)
Exemplo n.º 7
0
class Locale(ModelBase):
    """A localization team."""

    locale = LocaleField(unique=True)
    leaders = models.ManyToManyField(User,
                                     blank=True,
                                     related_name="locales_leader")
    reviewers = models.ManyToManyField(User,
                                       blank=True,
                                       related_name="locales_reviewer")
    editors = models.ManyToManyField(User,
                                     blank=True,
                                     related_name="locales_editor")

    class Meta:
        ordering = ["locale"]

    def get_absolute_url(self):
        return reverse("wiki.locale_details", args=[self.locale])

    def __str__(self):
        return self.locale
Exemplo n.º 8
0
class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin,
               SearchMixin, DocumentPermissionMixin):
    """A localized knowledgebase document, not revision-specific."""
    title = models.CharField(max_length=255, db_index=True)
    slug = models.CharField(max_length=255, db_index=True)

    # Is this document a template or not?
    is_template = models.BooleanField(default=False, editable=False,
                                      db_index=True)
    # Is this document localizable or not?
    is_localizable = models.BooleanField(default=True, db_index=True)

    # TODO: validate (against settings.SUMO_LANGUAGES?)
    locale = LocaleField(default=settings.WIKI_DEFAULT_LANGUAGE, db_index=True)

    # Latest approved revision. L10n dashboard depends on this being so (rather
    # than being able to set it to earlier approved revisions). (Remove "+" to
    # enable reverse link.)
    current_revision = models.ForeignKey('Revision', null=True,
                                         related_name='current_for+')

    # Latest revision which both is_approved and is_ready_for_localization,
    # This may remain non-NULL even if is_localizable is changed to false.
    latest_localizable_revision = models.ForeignKey(
        'Revision', null=True, related_name='localizable_for+')

    # The Document I was translated from. NULL iff this doc is in the default
    # locale or it is nonlocalizable. TODO: validate against
    # settings.WIKI_DEFAULT_LANGUAGE.
    parent = models.ForeignKey('self', related_name='translations',
                               null=True, blank=True)

    # Cached HTML rendering of approved revision's wiki markup:
    html = models.TextField(editable=False)

    # A document's category must always be that of its parent. If it has no
    # parent, it can do what it wants. This invariant is enforced in save().
    category = models.IntegerField(choices=CATEGORIES, db_index=True)

    # A document's is_archived flag must match that of its parent. If it has no
    # parent, it can do what it wants. This invariant is enforced in save().
    is_archived = models.BooleanField(
        default=False, db_index=True, verbose_name='is obsolete',
        help_text=_lazy(
            u'If checked, this wiki page will be hidden from basic searches '
             'and dashboards. When viewed, the page will warn that it is no '
             'longer maintained.'))

    # Enable discussion (kbforum) on this document.
    allow_discussion = models.BooleanField(
        default=True, help_text=_lazy(
            u'If checked, this document allows discussion in an associated '
             'forum. Uncheck to hide/disable the forum.'))

    # List of users that have contributed to this document.
    contributors = models.ManyToManyField(User)

    # List of products this document applies to.
    products = models.ManyToManyField(Product)

    # List of product-specific topics this document applies to.
    topics = models.ManyToManyField(Topic)

    # Needs change fields.
    needs_change = models.BooleanField(default=False, help_text=_lazy(
        u'If checked, this document needs updates.'), db_index=True)
    needs_change_comment = models.CharField(max_length=500, blank=True)

    # firefox_versions,
    # operating_systems:
    #    defined in the respective classes below. Use them as in
    #    test_firefox_versions.

    # TODO: Rethink indexes once controller code is near complete. Depending on
    # how MySQL uses indexes, we probably don't need individual indexes on
    # title and locale as well as a combined (title, locale) one.
    class Meta(object):
        unique_together = (('parent', 'locale'), ('title', 'locale'),
                           ('slug', 'locale'))
        permissions = [('archive_document', 'Can archive document'),
                       ('edit_needs_change', 'Can edit needs_change')]

    def _collides(self, attr, value):
        """Return whether there exists a doc in this locale whose `attr` attr
        is equal to mine."""
        return Document.uncached.filter(locale=self.locale,
                                        **{attr: value}).exists()

    def _raise_if_collides(self, attr, exception):
        """Raise an exception if a page of this title/slug already exists."""
        if self.id is None or hasattr(self, 'old_' + attr):
            # If I am new or my title/slug changed...
            if self._collides(attr, getattr(self, attr)):
                raise exception

    def clean(self):
        """Translations can't be localizable."""
        self._clean_is_localizable()
        self._clean_category()
        self._ensure_inherited_attr('is_archived')

    def _clean_is_localizable(self):
        """is_localizable == allowed to have translations. Make sure that isn't
        violated.

        For default language (en-US), is_localizable means it can have
        translations. Enforce:
            * is_localizable=True if it has translations
            * if has translations, unable to make is_localizable=False

        For non-default langauges, is_localizable must be False.

        """
        if self.locale != settings.WIKI_DEFAULT_LANGUAGE:
            self.is_localizable = False

        # Can't save this translation if parent not localizable
        if self.parent and not self.parent.is_localizable:
            raise ValidationError('"%s": parent "%s" is not localizable.' % (
                                  unicode(self), unicode(self.parent)))

        # Can't make not localizable if it has translations
        # This only applies to documents that already exist, hence self.pk
        # TODO: Use uncached manager here, if we notice problems
        if self.pk and not self.is_localizable and self.translations.exists():
            raise ValidationError('"%s": document has %s translations but is '
                                  'not localizable.' % (
                                  unicode(self), self.translations.count()))

    def _ensure_inherited_attr(self, attr):
        """Make sure my `attr` attr is the same as my parent's if I have one.

        Otherwise, if I have children, make sure their `attr` attr is the same
        as mine.

        """
        if self.parent:
            # We always set the child according to the parent rather than vice
            # versa, because we do not expose an Archived checkbox in the
            # translation UI.
            setattr(self, attr, getattr(self.parent, attr))
        else:  # An article cannot have both a parent and children.
            # Make my children the same as me:
            if self.id:
                self.translations.all().update(**{attr: getattr(self, attr)})

    def _clean_category(self):
        """Make sure a doc's category is the same as its parent's."""
        if (not self.parent and
            self.category not in (id for id, name in CATEGORIES)):
            # All we really need to do here is make sure category != '' (which
            # is what it is when it's missing from the DocumentForm). The extra
            # validation is just a nicety.
            raise ValidationError(_('Please choose a category.'))
        self._ensure_inherited_attr('category')

    def _attr_for_redirect(self, attr, template):
        """Return the slug or title for a new redirect.

        `template` is a Python string template with "old" and "number" tokens
        used to create the variant.

        """
        def unique_attr():
            """Return a variant of getattr(self, attr) such that there is no
            Document of my locale with string attribute `attr` equal to it.

            Never returns the original attr value.

            """
            # "My God, it's full of race conditions!"
            i = 1
            while True:
                new_value = template % dict(old=getattr(self, attr), number=i)
                if not self._collides(attr, new_value):
                    return new_value
                i += 1

        old_attr = 'old_' + attr
        if hasattr(self, old_attr):
            # My slug (or title) is changing; we can reuse it for the redirect.
            return getattr(self, old_attr)
        else:
            # Come up with a unique slug (or title):
            return unique_attr()

    def save(self, *args, **kwargs):
        self.is_template = self.title.startswith(TEMPLATE_TITLE_PREFIX)

        self._raise_if_collides('slug', SlugCollision)
        self._raise_if_collides('title', TitleCollision)

        # These are too important to leave to a (possibly omitted) is_valid
        # call:
        self._clean_is_localizable()
        self._ensure_inherited_attr('is_archived')
        # Everything is validated before save() is called, so the only thing
        # that could cause save() to exit prematurely would be an exception,
        # which would cause a rollback, which would negate any category changes
        # we make here, so don't worry:
        self._clean_category()

        super(Document, self).save(*args, **kwargs)

        # Make redirects if there's an approved revision and title or slug
        # changed. Allowing redirects for unapproved docs would (1) be of
        # limited use and (2) require making Revision.creator nullable.
        slug_changed = hasattr(self, 'old_slug')
        title_changed = hasattr(self, 'old_title')
        if self.current_revision and (slug_changed or title_changed):
            doc = Document.objects.create(locale=self.locale,
                                          title=self._attr_for_redirect(
                                              'title', REDIRECT_TITLE),
                                          slug=self._attr_for_redirect(
                                              'slug', REDIRECT_SLUG),
                                          category=self.category,
                                          is_localizable=False)
            Revision.objects.create(document=doc,
                                    content=REDIRECT_CONTENT % self.title,
                                    is_approved=True,
                                    reviewer=self.current_revision.creator,
                                    creator=self.current_revision.creator)

            if slug_changed:
                del self.old_slug
            if title_changed:
                del self.old_title

        self.parse_and_calculate_links()

    def __setattr__(self, name, value):
        """Trap setting slug and title, recording initial value."""
        # Public API: delete the old_title or old_slug attrs after changing
        # title or slug (respectively) to suppress redirect generation.
        if getattr(self, 'id', None):
            # I have been saved and so am worthy of a redirect.
            if name in ('slug', 'title') and hasattr(self, name):
                old_name = 'old_' + name
                if not hasattr(self, old_name):
                    # Case insensitive comparison:
                    if getattr(self, name).lower() != value.lower():
                        # Save original value:
                        setattr(self, old_name, getattr(self, name))
                elif value == getattr(self, old_name):
                    # They changed the attr back to its original value.
                    delattr(self, old_name)
        super(Document, self).__setattr__(name, value)

    @property
    def content_parsed(self):
        if not self.current_revision:
            return ''
        return self.current_revision.content_parsed

    @property
    def language(self):
        return settings.LANGUAGES[self.locale.lower()]

    @property
    def is_hidden_from_search_engines(self):
        return (self.is_template or self.is_archived or
                self.category in (ADMINISTRATION_CATEGORY,
                                  CANNED_RESPONSES_CATEGORY))

    def get_absolute_url(self):
        return reverse('wiki.document', locale=self.locale, args=[self.slug])

    @classmethod
    def from_url(cls, url, required_locale=None, id_only=False,
                 check_host=True):
        """Return the approved Document the URL represents, None if there isn't
        one.

        Return None if the URL is a 404, the URL doesn't point to the right
        view, or the indicated document doesn't exist.

        To limit the universe of discourse to a certain locale, pass in a
        `required_locale`. To fetch only the ID of the returned Document, set
        `id_only` to True.

        If the URL has a host component, we assume it does not point to this
        host and thus does not point to a Document, because that would be a
        needlessly verbose way to specify an internal link. However, if you
        pass check_host=False, we assume the URL's host is the one serving
        Documents, which comes in handy for analytics whose metrics return
        host-having URLs.

        """
        try:
            components = _doc_components_from_url(
                url, required_locale=required_locale, check_host=check_host)
        except _NotDocumentView:
            return None
        if not components:
            return None
        locale, path, slug = components

        doc = cls.uncached
        if id_only:
            doc = doc.only('id')
        try:
            doc = doc.get(locale=locale, slug=slug)
        except cls.DoesNotExist:
            try:
                doc = doc.get(locale=settings.WIKI_DEFAULT_LANGUAGE, slug=slug)
                translation = doc.translated_to(locale)
                if translation:
                    return translation
                return doc
            except cls.DoesNotExist:
                return None
        return doc

    def redirect_url(self, source_locale=settings.LANGUAGE_CODE):
        """If I am a redirect, return the URL to which I redirect.

        Otherwise, return None.

        """
        # If a document starts with REDIRECT_HTML and contains any <a> tags
        # with hrefs, return the href of the first one. This trick saves us
        # from having to parse the HTML every time.
        if self.html.startswith(REDIRECT_HTML):
            anchors = PyQuery(self.html)('a[href]')
            if anchors:
                # Articles with a redirect have a link that has the locale
                # hardcoded into it, and so by simply redirecting to the given
                # link, we end up possibly losing the locale. So, instead,
                # we strip out the locale and replace it with the original
                # source locale only in the case where an article is going
                # from one locale and redirecting it to a different one.
                # This only applies when it's a non-default locale because we
                # don't want to override the redirects that are forcibly
                # changing to (or staying within) a specific locale.
                full_url = anchors[0].get('href')
                (dest_locale, url) = split_path(full_url)
                if (source_locale != dest_locale
                    and dest_locale == settings.LANGUAGE_CODE):
                    return '/' + source_locale + '/' + url
                return full_url

    def redirect_document(self):
        """If I am a redirect to a Document, return that Document.

        Otherwise, return None.

        """
        url = self.redirect_url()
        if url:
            return self.from_url(url)

    def __unicode__(self):
        return '[%s] %s' % (self.locale, self.title)

    def allows_vote(self, request):
        """Return whether `user` can vote on this document."""
        return (not self.is_archived and self.current_revision and
                not self.current_revision.has_voted(request) and
                not self.redirect_document())

    def translated_to(self, locale):
        """Return the translation of me to the given locale.

        If there is no such Document, return None.

        """
        if self.locale != settings.WIKI_DEFAULT_LANGUAGE:
            raise NotImplementedError('translated_to() is implemented only on'
                                      'Documents in the default language so'
                                      'far.')
        try:
            return Document.objects.get(locale=locale, parent=self)
        except Document.DoesNotExist:
            return None

    @property
    def original(self):
        """Return the document I was translated from or, if none, myself."""
        return self.parent or self

    def localizable_or_latest_revision(self, include_rejected=False):
        """Return latest ready-to-localize revision if there is one,
        else the latest approved revision if there is one,
        else the latest unrejected (unreviewed) revision if there is one,
        else None.

        include_rejected -- If true, fall back to the latest rejected
            revision if all else fails.

        """
        def latest(queryset):
            """Return the latest item from a queryset (by ID).

            Return None if the queryset is empty.

            """
            try:
                return queryset.order_by('-id')[0:1].get()
            except ObjectDoesNotExist:  # Catching IndexError seems overbroad.
                return None

        rev = self.latest_localizable_revision
        if not rev or not self.is_localizable:
            rejected = Q(is_approved=False, reviewed__isnull=False)

            # Try latest approved revision:
            rev = (latest(self.revisions.filter(is_approved=True)) or
                   # No approved revs. Try unrejected:
                   latest(self.revisions.exclude(rejected)) or
                   # No unrejected revs. Maybe fall back to rejected:
                   (latest(self.revisions) if include_rejected else None))
        return rev

    def is_outdated(self, level=MEDIUM_SIGNIFICANCE):
        """Return whether an update of a given magnitude has occured
        to the parent document since this translation had an approved
        update and such revision is ready for l10n.

        If this is not a translation or has never been approved, return
        False.

        level: The significance of an edit that is "enough". Defaults to
            MEDIUM_SIGNIFICANCE.

        """
        if not (self.parent and self.current_revision):
            return False

        based_on_id = self.current_revision.based_on_id
        more_filters = {'id__gt': based_on_id} if based_on_id else {}

        return self.parent.revisions.filter(
            is_approved=True, is_ready_for_localization=True,
            significance__gte=level, **more_filters).exists()

    def is_majorly_outdated(self):
        """Return whether a MAJOR_SIGNIFICANCE-level update has occurred to the
        parent document since this translation had an approved update and such
        revision is ready for l10n.

        If this is not a translation or has never been approved, return False.

        """
        return self.is_outdated(level=MAJOR_SIGNIFICANCE)

    def is_watched_by(self, user):
        """Return whether `user` is notified of edits to me."""
        from kitsune.wiki.events import EditDocumentEvent
        return EditDocumentEvent.is_notifying(user, self)

    def get_topics(self, uncached=False):
        """Return the list of new topics that apply to this document.

        If the document has a parent, it inherits the parent's topics.
        """
        if self.parent:
            return self.parent.get_topics()
        if uncached:
            q = Topic.uncached
        else:
            q = Topic.objects
        return q.filter(document=self)

    def get_products(self, uncached=False):
        """Return the list of products that apply to this document.

        If the document has a parent, it inherits the parent's products.
        """
        if self.parent:
            return self.parent.get_products()
        if uncached:
            q = Product.uncached
        else:
            q = Product.objects
        return q.filter(document=self)

    @property
    def recent_helpful_votes(self):
        """Return the number of helpful votes in the last 30 days."""
        start = datetime.now() - timedelta(days=30)
        return HelpfulVote.objects.filter(
            revision__document=self, created__gt=start, helpful=True).count()

    @property
    def related_documents(self):
        """Return documents that are 'morelikethis' one."""
        # Only documents in default IA categories have related.
        if (self.redirect_url() or not self.current_revision or
            self.category not in settings.IA_DEFAULT_CATEGORIES):
            return []

        # First try to get the results from the cache
        key = 'wiki_document:related_docs:%s' % self.id
        documents = cache.get(key)
        if documents is not None:
            statsd.incr('wiki.related_documents.cache.hit')
            log.debug('Getting MLT for {doc} from cache.'
                .format(doc=repr(self)))
            return documents

        try:
            statsd.incr('wiki.related_documents.cache.miss')
            mt = self.get_mapping_type()
            documents = mt.morelikethis(
                self.id,
                s=mt.search().filter(
                    document_locale=self.locale,
                    document_is_archived=False,
                    document_category__in=settings.IA_DEFAULT_CATEGORIES,
                    product__in=[p.slug for p in self.get_products()]),
                fields=[
                    'document_title',
                    'document_summary',
                    'document_content'])[:3]
            cache.add(key, documents)
        except ES_EXCEPTIONS as exc:
            statsd.incr('wiki.related_documents.esexception')
            log.exception('ES MLT related_documents')
            documents = []

        return documents

    @property
    def related_questions(self):
        """Return questions that are 'morelikethis' document."""
        # Only documents in default IA categories have related.
        if (self.redirect_url() or not self.current_revision or
                self.category not in settings.IA_DEFAULT_CATEGORIES or
                self.locale not in settings.AAQ_LANGUAGES):
            return []

        # First try to get the results from the cache
        key = 'wiki_document:related_questions:%s' % self.id
        questions = cache.get(key)
        if questions is not None:
            statsd.incr('wiki.related_questions.cache.hit')
            log.debug('Getting MLT questions for {doc} from cache.'
                .format(doc=repr(self)))
            return questions

        try:
            statsd.incr('wiki.related_questions.cache.miss')
            max_age = settings.SEARCH_DEFAULT_MAX_QUESTION_AGE
            start_date = int(time.time()) - max_age

            s = Question.get_mapping_type().search()
            questions = s.values_dict('id', 'question_title', 'url').filter(
                    question_locale=self.locale,
                    product__in=[p.slug for p in self.get_products()],
                    question_has_helpful=True,
                    created__gte=start_date
                ).query(
                    __mlt={
                        'fields': ['question_title', 'question_content'],
                        'like_text': self.title,
                        'min_term_freq': 1,
                        'min_doc_freq': 1,
                    }
                )[:3]
            questions = list(questions)
            cache.add(key, questions)
        except ES_EXCEPTIONS as exc:
            statsd.incr('wiki.related_questions.esexception')
            log.exception('ES MLT related_questions')
            questions = []

        return questions

    @classmethod
    def get_mapping_type(cls):
        return DocumentMappingType

    def parse_and_calculate_links(self):
        """Calculate What Links Here data for links going out from this.

        Also returns a parsed version of the current html, because that
        is a byproduct of the process, and is useful.
        """
        if not self.current_revision:
            return ''

        # Remove "what links here" reverse links, because they might be
        # stale and re-rendering will re-add them. This cannot be done
        # reliably in the parser's parse() function, because that is
        # often called multiple times per document.
        self.links_from().delete()

        from kitsune.wiki.parser import wiki_to_html, WhatLinksHereParser
        return wiki_to_html(self.current_revision.content,
                            locale=self.locale,
                            doc_id=self.id,
                            parser_cls=WhatLinksHereParser)

    def links_from(self):
        """Get a query set of links that are from this document to another."""
        return DocumentLink.objects.filter(linked_from=self)

    def links_to(self):
        """Get a query set of links that are from another document to this."""
        return DocumentLink.objects.filter(linked_to=self)

    def add_link_to(self, linked_to, kind):
        """Create a DocumentLink to another Document."""
        try:
            DocumentLink(linked_from=self,
                         linked_to=linked_to,
                         kind=kind).save()
        except IntegrityError:
            # This link already exists, ok.
            pass
Exemplo n.º 9
0
class Question(ModelBase, BigVocabTaggableMixin, SearchMixin):
    """A support question."""
    title = models.CharField(max_length=255)
    creator = models.ForeignKey(User, related_name='questions')
    content = models.TextField()

    created = models.DateTimeField(default=datetime.now, db_index=True)
    updated = models.DateTimeField(default=datetime.now, db_index=True)
    updated_by = models.ForeignKey(User, null=True, blank=True,
                                   related_name='questions_updated')
    last_answer = models.ForeignKey('Answer', related_name='last_reply_in',
                                    null=True, blank=True)
    num_answers = models.IntegerField(default=0, db_index=True)
    solution = models.ForeignKey('Answer', related_name='solution_for',
                                 null=True)
    is_locked = models.BooleanField(default=False)
    is_archived = models.NullBooleanField(default=False, null=True)
    num_votes_past_week = models.PositiveIntegerField(default=0, db_index=True)

    is_spam = models.BooleanField(default=False)
    marked_as_spam = models.DateTimeField(default=None, null=True)
    marked_as_spam_by = models.ForeignKey(
        User, null=True, related_name='questions_marked_as_spam')

    images = generic.GenericRelation(ImageAttachment)
    flags = generic.GenericRelation(FlaggedObject)

    product = models.ForeignKey(
        Product, null=True, default=None, related_name='questions')
    topic = models.ForeignKey(
        Topic, null=True, related_name='questions')

    locale = LocaleField(default=settings.WIKI_DEFAULT_LANGUAGE)

    taken_by = models.ForeignKey(User, blank=True, null=True)
    taken_until = models.DateTimeField(blank=True, null=True)

    html_cache_key = u'question:html:%s'
    tags_cache_key = u'question:tags:%s'
    contributors_cache_key = u'question:contributors:%s'

    objects = QuestionManager()

    class Meta:
        ordering = ['-updated']
        permissions = (
            ('tag_question',
             'Can add tags to and remove tags from questions'),
            ('change_solution',
             'Can change/remove the solution to a question'),
        )

    def __unicode__(self):
        return self.title

    def set_needs_info(self):
        """Mark question as NEEDS_INFO."""
        self.tags.add(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    def unset_needs_info(self):
        """Remove NEEDS_INFO."""
        self.tags.remove(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    @property
    def needs_info(self):
        return self.tags.filter(slug=config.NEEDS_INFO_TAG_NAME).count() > 0

    @property
    def content_parsed(self):
        return _content_parsed(self, self.locale)

    def clear_cached_html(self):
        cache.delete(self.html_cache_key % self.id)

    def clear_cached_tags(self):
        cache.delete(self.tags_cache_key % self.id)

    def clear_cached_contributors(self):
        cache.delete(self.contributors_cache_key % self.id)

    def save(self, update=False, *args, **kwargs):
        """Override save method to take care of updated if requested."""
        new = not self.id

        if not new:
            self.clear_cached_html()
            if update:
                self.updated = datetime.now()

        super(Question, self).save(*args, **kwargs)

        if new:
            # Tidings
            # Avoid circular import, events.py imports Question
            from kitsune.questions.events import QuestionReplyEvent
            # Authors should automatically watch their own questions.
            QuestionReplyEvent.notify(self.creator, self)

            # actstream
            # Authors should automatically follow their own questions.
            actstream.actions.follow(self.creator, self, send_action=False, actor_only=False)

    def add_metadata(self, **kwargs):
        """Add (save to db) the passed in metadata.

        Usage:
        question = Question.objects.get(pk=1)
        question.add_metadata(ff_version='3.6.3', os='Linux')

        """
        for key, value in kwargs.items():
            QuestionMetaData.objects.create(question=self, name=key,
                                            value=value)
        self._metadata = None

    def clear_mutable_metadata(self):
        """Clear the mutable metadata.

        This excludes immutable fields: user agent, product, and category.

        """
        self.metadata_set.exclude(name__in=['useragent', 'product',
                                            'category']).delete()
        self._metadata = None

    def remove_metadata(self, name):
        """Delete the specified metadata."""
        self.metadata_set.filter(name=name).delete()
        self._metadata = None

    @property
    def metadata(self):
        """Dictionary access to metadata

        Caches the full metadata dict after first call.

        """
        if not hasattr(self, '_metadata') or self._metadata is None:
            self._metadata = dict((m.name, m.value) for
                                  m in self.metadata_set.all())
        return self._metadata

    @property
    def solver(self):
        """Get the user that solved the question."""
        solver_id = self.metadata.get('solver_id')
        if solver_id:
            return User.objects.get(id=solver_id)

    @property
    def product_config(self):
        """Return the product config this question is about or an empty
        mapping if unknown."""
        md = self.metadata
        if 'product' in md:
            return config.products.get(md['product'], {})
        return {}

    @property
    def product_slug(self):
        """Return the product slug for this question.

        It returns 'all' in the off chance that there are no products."""
        if not hasattr(self, '_product_slug') or self._product_slug is None:
            self._product_slug = self.product.slug if self.product else None

        return self._product_slug

    @property
    def category_config(self):
        """Return the category this question refers to or an empty mapping if
        unknown."""
        md = self.metadata
        if self.product_config and 'category' in md:
            return self.product_config['categories'].get(md['category'], {})
        return {}

    def auto_tag(self):
        """Apply tags to myself that are implied by my metadata.

        You don't need to call save on the question after this.

        """
        to_add = self.product_config.get('tags', []) + self.category_config.get('tags', [])
        version = self.metadata.get('ff_version', '')

        # Remove the beta (b*), aurora (a2) or nightly (a1) suffix.
        version = re.split('[a-b]', version)[0]

        dev_releases = product_details.firefox_history_development_releases

        if (version in dev_releases or
                version in product_details.firefox_history_stability_releases or
                version in product_details.firefox_history_major_releases):
            to_add.append('Firefox %s' % version)
            tenths = _tenths_version(version)
            if tenths:
                to_add.append('Firefox %s' % tenths)
        elif _has_beta(version, dev_releases):
            to_add.append('Firefox %s' % version)
            to_add.append('beta')

        self.tags.add(*to_add)

        # Add a tag for the OS if it already exists as a tag:
        os = self.metadata.get('os')
        if os:
            try:
                add_existing_tag(os, self.tags)
            except Tag.DoesNotExist:
                pass

    def get_absolute_url(self):
        # Note: If this function changes, we need to change it in
        # extract_document, too.
        return reverse('questions.details',
                       kwargs={'question_id': self.id})

    @property
    def num_votes(self):
        """Get the number of votes for this question."""
        if not hasattr(self, '_num_votes'):
            n = QuestionVote.objects.filter(question=self).count()
            self._num_votes = n
        return self._num_votes

    def sync_num_votes_past_week(self):
        """Get the number of votes for this question in the past week."""
        last_week = datetime.now().date() - timedelta(days=7)
        n = QuestionVote.objects.filter(question=self,
                                        created__gte=last_week).count()
        self.num_votes_past_week = n
        return n

    def has_voted(self, request):
        """Did the user already vote?"""
        if request.user.is_authenticated():
            qs = QuestionVote.objects.filter(question=self,
                                             creator=request.user)
        elif request.anonymous.has_id:
            anon_id = request.anonymous.anonymous_id
            qs = QuestionVote.objects.filter(question=self,
                                             anonymous_id=anon_id)
        else:
            return False

        return qs.exists()

    @property
    def helpful_replies(self):
        """Return answers that have been voted as helpful."""
        cursor = connection.cursor()
        cursor.execute('SELECT votes.answer_id, '
                       'SUM(IF(votes.helpful=1,1,-1)) AS score '
                       'FROM questions_answervote AS votes '
                       'JOIN questions_answer AS ans '
                       'ON ans.id=votes.answer_id '
                       'AND ans.question_id=%s '
                       'GROUP BY votes.answer_id '
                       'HAVING score > 0 '
                       'ORDER BY score DESC LIMIT 2', [self.id])

        helpful_ids = [row[0] for row in cursor.fetchall()]

        # Exclude the solution if it is set
        if self.solution and self.solution.id in helpful_ids:
            helpful_ids.remove(self.solution.id)

        if len(helpful_ids) > 0:
            return self.answers.filter(id__in=helpful_ids)
        else:
            return []

    def is_contributor(self, user):
        """Did the passed in user contribute to this question?"""
        if user.is_authenticated():
            return user.id in self.contributors

        return False

    @property
    def contributors(self):
        """The contributors to the question."""
        cache_key = self.contributors_cache_key % self.id
        contributors = cache.get(cache_key)
        if contributors is None:
            contributors = self.answers.all().values_list('creator_id',
                                                          flat=True)
            contributors = list(contributors)
            contributors.append(self.creator_id)
            cache.add(cache_key, contributors, CACHE_TIMEOUT)
        return contributors

    @property
    def is_solved(self):
        return self.solution_id is not None

    @property
    def is_escalated(self):
        return config.ESCALATE_TAG_NAME in [t.name for t in self.my_tags]

    @property
    def is_offtopic(self):
        return config.OFFTOPIC_TAG_NAME in [t.name for t in self.my_tags]

    @property
    def my_tags(self):
        """A caching wrapper around self.tags.all()."""
        cache_key = self.tags_cache_key % self.id
        tags = cache.get(cache_key)
        if tags is None:
            tags = list(self.tags.all().order_by('name'))
            cache.add(cache_key, tags, CACHE_TIMEOUT)
        return tags

    @classmethod
    def get_mapping_type(cls):
        return QuestionMappingType

    @classmethod
    def get_serializer(cls, serializer_type='full'):
        # Avoid circular import
        from kitsune.questions import api
        if serializer_type == 'full':
            return api.QuestionSerializer
        elif serializer_type == 'fk':
            return api.QuestionFKSerializer
        else:
            raise ValueError('Unknown serializer type "{}".'.format(serializer_type))

    @classmethod
    def recent_asked_count(cls, extra_filter=None):
        """Returns the number of questions asked in the last 24 hours."""
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(created__gt=start, creator__is_active=True)
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def recent_unanswered_count(cls, extra_filter=None):
        """Returns the number of questions that have not been answered in the
        last 24 hours.
        """
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(
            num_answers=0, created__gt=start, is_locked=False,
            is_archived=False, creator__is_active=1)
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def from_url(cls, url, id_only=False):
        """Returns the question that the URL represents.

        If the question doesn't exist or the URL isn't a question URL,
        this returns None.

        If id_only is requested, we just return the question id and
        we don't validate the existence of the question (this saves us
        from making a million or so db calls).
        """
        parsed = urlparse(url)
        locale, path = split_path(parsed.path)

        path = '/' + path

        try:
            view, view_args, view_kwargs = resolve(path)
        except Http404:
            return None

        # Avoid circular import. kitsune.question.views import this.
        import kitsune.questions.views
        if view != kitsune.questions.views.question_details:
            return None

        question_id = view_kwargs['question_id']

        if id_only:
            return int(question_id)

        try:
            question = cls.objects.get(id=question_id)
        except cls.DoesNotExist:
            return None

        return question

    @property
    def num_visits(self):
        """Get the number of visits for this question."""
        if not hasattr(self, '_num_visits'):
            try:
                self._num_visits = (QuestionVisits.objects.get(question=self)
                                    .visits)
            except QuestionVisits.DoesNotExist:
                self._num_visits = None

        return self._num_visits

    @property
    def editable(self):
        return not self.is_locked and not self.is_archived

    @property
    def age(self):
        """The age of the question, in seconds."""
        delta = datetime.now() - self.created
        return delta.seconds + delta.days * 24 * 60 * 60

    def set_solution(self, answer, solver):
        """
        Sets the solution, and fires any needed events.

        Does not check permission of the user making the change.
        """
        # Avoid circular import
        from kitsune.questions.events import QuestionSolvedEvent

        self.solution = answer
        self.save()
        self.add_metadata(solver_id=str(solver.id))
        statsd.incr('questions.solution')
        QuestionSolvedEvent(answer).fire(exclude=self.creator)
        actstream.action.send(
            solver, verb='marked as a solution', action_object=answer, target=self)

    @property
    def related_documents(self):
        """Return documents that are 'morelikethis' one"""
        if not self.product:
            return []

        # First try to get the results from the cache
        key = 'questions_question:related_docs:%s' % self.id
        documents = cache.get(key)
        if documents is not None:
            statsd.incr('questions.related_documents.cache.hit')
            log.debug('Getting MLT documents for {question} from cache.'
                      .format(question=repr(self)))
            return documents

        try:
            statsd.incr('questions.related_documents.cache.miss')
            s = Document.get_mapping_type().search()
            documents = (
                s.values_dict('id', 'document_title', 'url')
                .filter(document_locale=self.locale,
                        document_is_archived=False,
                        document_category__in=settings.IA_DEFAULT_CATEGORIES,
                        product__in=[self.product.slug])
                .query(__mlt={
                    'fields': ['document_title', 'document_summary',
                               'document_content'],
                    'like_text': self.title,
                    'min_term_freq': 1,
                    'min_doc_freq': 1})
                [:3])
            documents = list(documents)
            cache.add(key, documents)
        except ES_EXCEPTIONS:
            statsd.incr('questions.related_documents.esexception')
            log.exception('ES MLT related_documents')
            documents = []

        return documents

    @property
    def related_questions(self):
        """Return questions that are 'morelikethis' one"""
        if not self.product:
            return []

        # First try to get the results from the cache
        key = 'questions_question:related_questions:%s' % self.id
        questions = cache.get(key)
        if questions is not None:
            statsd.incr('questions.related_questions.cache.hit')
            log.debug('Getting MLT questions for {question} from cache.'
                      .format(question=repr(self)))
            return questions

        try:
            statsd.incr('questions.related_questions.cache.miss')
            max_age = settings.SEARCH_DEFAULT_MAX_QUESTION_AGE
            start_date = int(time.time()) - max_age

            s = self.get_mapping_type().search()
            questions = (
                s.values_dict('id', 'question_title', 'url')
                .filter(question_locale=self.locale,
                        product__in=[self.product.slug],
                        question_has_helpful=True,
                        created__gte=start_date)
                .query(__mlt={
                    'fields': ['question_title', 'question_content'],
                    'like_text': self.title,
                    'min_term_freq': 1,
                    'min_doc_freq': 1})
                [:3])
            questions = list(questions)
            cache.add(key, questions)
        except ES_EXCEPTIONS:
            statsd.incr('questions.related_questions.esexception')
            log.exception('ES MLT related_questions')
            questions = []

        return questions

    # Permissions

    def allows_edit(self, user):
        """Return whether `user` can edit this question."""
        return (user.has_perm('questions.change_question') or
                (self.editable and self.creator == user))

    def allows_delete(self, user):
        """Return whether `user` can delete this question."""
        return user.has_perm('questions.delete_question')

    def allows_lock(self, user):
        """Return whether `user` can lock this question."""
        return user.has_perm('questions.lock_question')

    def allows_archive(self, user):
        """Return whether `user` can archive this question."""
        return user.has_perm('questions.archive_question')

    def allows_new_answer(self, user):
        """Return whether `user` can answer (reply to) this question."""
        return (user.has_perm('questions.add_answer') or
                (self.editable and user.is_authenticated()))

    def allows_solve(self, user):
        """Return whether `user` can select the solution to this question."""
        return (self.editable and
                (user == self.creator or
                 user.has_perm('questions.change_solution')))

    def allows_unsolve(self, user):
        """Return whether `user` can unsolve this question."""
        return (self.editable and
                (user == self.creator or
                 user.has_perm('questions.change_solution')))

    def allows_flag(self, user):
        """Return whether `user` can flag this question."""
        return (user.is_authenticated() and
                user != self.creator and
                self.editable)

    def mark_as_spam(self, by_user):
        """Mark the question as spam by the specified user."""
        self.is_spam = True
        self.marked_as_spam = datetime.now()
        self.marked_as_spam_by = by_user
        self.save()

    @property
    def is_taken(self):
        """
        Convenience method to check that a question is taken.

        Additionally, if ``self.taken_until`` is in the past, this will reset
        the database fields to expire the setting.
        """
        if self.taken_by is None:
            assert self.taken_until is None
            return False

        assert self.taken_until is not None
        if self.taken_until < datetime.now():
            self.taken_by = None
            self.taken_until = None
            self.save()
            return False

        return True

    def take(self, user, force=False):
        """
        Sets the user that is currently working on this question.

        May raise InvalidUserException if the user is not permitted to take
        the question (such as if the question is owned by the user).

        May raise AlreadyTakenException if the question is already taken
        by a different user, and the force paramater is not True.

        If the user is the same as the user that currently has the question,
        the timer will be updated   .
        """

        if user == self.creator:
            raise InvalidUserException

        if self.taken_by not in [None, user] and not force:
            raise AlreadyTakenException

        self.taken_by = user
        self.taken_until = datetime.now() + timedelta(seconds=config.TAKE_TIMEOUT)
        self.save()
Exemplo n.º 10
0
class Profile(ModelBase, SearchMixin):
    """Profile model for django users."""

    user = models.OneToOneField(User,
                                primary_key=True,
                                verbose_name=_lazy(u'User'))
    name = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy(u'Display name'))
    public_email = models.BooleanField(  # show/hide email
        default=False,
        verbose_name=_lazy(u'Make my email public'))
    avatar = models.ImageField(upload_to=settings.USER_AVATAR_PATH,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Avatar'),
                               max_length=settings.MAX_FILEPATH_LENGTH)
    bio = models.TextField(
        null=True,
        blank=True,
        verbose_name=_lazy(u'Biography'),
        help_text=_lazy(u'Some HTML supported: &#x3C;abbr title&#x3E; ' +
                        '&#x3C;acronym title&#x3E; &#x3C;b&#x3E; ' +
                        '&#x3C;blockquote&#x3E; &#x3C;code&#x3E; ' +
                        '&#x3C;em&#x3E; &#x3C;i&#x3E; &#x3C;li&#x3E; ' +
                        '&#x3C;ol&#x3E; &#x3C;strong&#x3E; &#x3C;ul&#x3E;. ' +
                        'Links are forbidden.'))
    website = models.URLField(max_length=255,
                              null=True,
                              blank=True,
                              verbose_name=_lazy(u'Website'))
    twitter = models.CharField(max_length=15,
                               null=True,
                               blank=True,
                               validators=[TwitterValidator],
                               verbose_name=_lazy(u'Twitter Username'))
    facebook = models.URLField(max_length=255,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Facebook URL'))
    mozillians = models.CharField(max_length=255,
                                  null=True,
                                  blank=True,
                                  verbose_name=_lazy(u'Mozillians Username'))
    irc_handle = models.CharField(max_length=255,
                                  null=True,
                                  blank=True,
                                  verbose_name=_lazy(u'IRC nickname'))
    timezone = TimeZoneField(null=True,
                             blank=True,
                             default='US/Pacific',
                             verbose_name=_lazy(u'Timezone'))
    country = models.CharField(max_length=2,
                               choices=COUNTRIES,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Country'))
    # No city validation
    city = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy(u'City'))
    locale = LocaleField(default=settings.LANGUAGE_CODE,
                         verbose_name=_lazy(u'Preferred language'))
    first_answer_email_sent = models.BooleanField(
        default=False,
        help_text=_lazy(u'Has been sent a first answer contribution email.'))
    first_l10n_email_sent = models.BooleanField(
        default=False,
        help_text=_lazy(u'Has been sent a first revision contribution email.'))
    involved_from = models.DateField(
        null=True,
        blank=True,
        verbose_name=_lazy(u'Involved with Mozilla from'))
    csat_email_sent = models.DateField(
        null=True,
        blank=True,
        verbose_name=_lazy(u'When the user was sent a community '
                           u'health survey'))
    is_fxa_migrated = models.BooleanField(default=False)
    fxa_uid = models.CharField(blank=True,
                               null=True,
                               unique=True,
                               max_length=128)
    fxa_avatar = models.URLField(max_length=512, blank=True, default='')
    has_subscriptions = models.BooleanField(default=False)

    class Meta(object):
        permissions = (
            ('view_karma_points', 'Can view karma points'),
            ('deactivate_users', 'Can deactivate users'),
            ('screen_share', 'Can screen share'),
        )

    def __unicode__(self):
        try:
            return unicode(self.user)
        except Exception as exc:
            return unicode('%d (%r)' % (self.pk, exc))

    def get_absolute_url(self):
        return reverse('users.profile', args=[self.user_id])

    def clear(self):
        """Clears out the users profile"""
        self.name = ''
        self.public_email = False
        self.avatar = None
        self.bio = ''
        self.website = ''
        self.twitter = ''
        self.facebook = ''
        self.mozillians = ''
        self.irc_handle = ''
        self.city = ''
        self.is_fxa_migrated = False
        self.fxa_uid = ''

    @property
    def display_name(self):
        return self.name if self.name else self.user.username

    @property
    def twitter_usernames(self):
        from kitsune.customercare.models import Reply
        return list(
            Reply.objects.filter(user=self.user).values_list(
                'twitter_username', flat=True).distinct())

    @classmethod
    def get_mapping_type(cls):
        return UserMappingType

    @classmethod
    def get_serializer(cls, serializer_type='full'):
        # Avoid circular import
        from kitsune.users import api
        if serializer_type == 'full':
            return api.ProfileSerializer
        elif serializer_type == 'fk':
            return api.ProfileFKSerializer
        else:
            raise ValueError(
                'Unknown serializer type "{}".'.format(serializer_type))

    @property
    def last_contribution_date(self):
        """Get the date of the user's last contribution."""
        from kitsune.customercare.models import Reply
        from kitsune.questions.models import Answer
        from kitsune.wiki.models import Revision

        dates = []

        # Latest Army of Awesome reply:
        try:
            aoa_reply = Reply.objects.filter(user=self.user).latest('created')
            dates.append(aoa_reply.created)
        except Reply.DoesNotExist:
            pass

        # Latest Support Forum answer:
        try:
            answer = Answer.objects.filter(creator=self.user).latest('created')
            dates.append(answer.created)
        except Answer.DoesNotExist:
            pass

        # Latest KB Revision edited:
        try:
            revision = Revision.objects.filter(
                creator=self.user).latest('created')
            dates.append(revision.created)
        except Revision.DoesNotExist:
            pass

        # Latest KB Revision reviewed:
        try:
            revision = Revision.objects.filter(
                reviewer=self.user).latest('reviewed')
            # Old revisions don't have the reviewed date.
            dates.append(revision.reviewed or revision.created)
        except Revision.DoesNotExist:
            pass

        if len(dates) == 0:
            return None

        return max(dates)

    @property
    def settings(self):
        return self.user.settings

    @property
    def answer_helpfulness(self):
        # Avoid circular import
        from kitsune.questions.models import AnswerVote
        return AnswerVote.objects.filter(answer__creator=self.user,
                                         helpful=True).count()
Exemplo n.º 11
0
class Profile(ModelBase, SearchMixin):
    """Profile model for django users, get it with user.get_profile()."""

    user = models.OneToOneField(User,
                                primary_key=True,
                                verbose_name=_lazy(u'User'))
    name = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy(u'Display name'))
    public_email = models.BooleanField(  # show/hide email
        default=False,
        verbose_name=_lazy(u'Make my email public'))
    avatar = models.ImageField(upload_to=settings.USER_AVATAR_PATH,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Avatar'),
                               max_length=settings.MAX_FILEPATH_LENGTH)
    bio = models.TextField(null=True,
                           blank=True,
                           verbose_name=_lazy(u'Biography'))
    website = models.URLField(max_length=255,
                              null=True,
                              blank=True,
                              verbose_name=_lazy(u'Website'))
    twitter = models.URLField(max_length=255,
                              null=True,
                              blank=True,
                              verbose_name=_lazy(u'Twitter URL'))
    facebook = models.URLField(max_length=255,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Facebook URL'))
    irc_handle = models.CharField(max_length=255,
                                  null=True,
                                  blank=True,
                                  verbose_name=_lazy(u'IRC nickname'))
    timezone = TimeZoneField(null=True,
                             blank=True,
                             verbose_name=_lazy(u'Timezone'))
    country = models.CharField(max_length=2,
                               choices=COUNTRIES,
                               null=True,
                               blank=True,
                               verbose_name=_lazy(u'Country'))
    # No city validation
    city = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy(u'City'))
    locale = LocaleField(default=settings.LANGUAGE_CODE,
                         verbose_name=_lazy(u'Preferred language'))

    class Meta(object):
        permissions = (
            ('view_karma_points', 'Can view karma points'),
            ('deactivate_users', 'Can deactivate users'),
        )

    def __unicode__(self):
        try:
            return unicode(self.user)
        except Exception as exc:
            return unicode('%d (%r)' % (self.pk, exc))

    def get_absolute_url(self):
        return reverse('users.profile', args=[self.user_id])

    def clear(self):
        """Clears out the users profile"""
        self.name = ''
        self.public_email = False
        self.avatar = None
        self.bio = ''
        self.website = ''
        self.twitter = ''
        self.facebook = ''
        self.irc_handle = ''
        self.city = ''

    @property
    def display_name(self):
        return self.name if self.name else self.user.username

    @property
    def twitter_usernames(self):
        from kitsune.customercare.models import Reply
        return list(
            Reply.objects.filter(user=self.user).values_list(
                'twitter_username', flat=True).distinct())

    @classmethod
    def get_mapping_type(cls):
        return UserMappingType

    @property
    def last_contribution_date(self):
        """Get the date of the user's last contribution."""
        from kitsune.customercare.models import Reply
        from kitsune.questions.models import Answer
        from kitsune.wiki.models import Revision

        dates = []

        # Latest Army of Awesome reply:
        try:
            aoa_reply = Reply.objects.filter(user=self.user).latest('created')
            dates.append(aoa_reply.created)
        except Reply.DoesNotExist:
            pass

        # Latest Support Forum answer:
        try:
            answer = Answer.objects.filter(creator=self.user).latest('created')
            dates.append(answer.created)
        except Answer.DoesNotExist:
            pass

        # Latest KB Revision edited:
        try:
            revision = Revision.objects.filter(
                creator=self.user).latest('created')
            dates.append(revision.created)
        except Revision.DoesNotExist:
            pass

        # Latest KB Revision reviewed:
        try:
            revision = Revision.objects.filter(
                reviewer=self.user).latest('reviewed')
            # Old revisions don't have the reviewed date.
            dates.append(revision.reviewed or revision.created)
        except Revision.DoesNotExist:
            pass

        if len(dates) == 0:
            return None

        return max(dates)
Exemplo n.º 12
0
class Profile(ModelBase, SearchMixin):
    """Profile model for django users."""

    user = models.OneToOneField(User,
                                on_delete=models.CASCADE,
                                primary_key=True,
                                verbose_name=_lazy("User"))
    name = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy("Display name"))
    public_email = models.BooleanField(  # show/hide email
        default=False,
        verbose_name=_lazy("Make my email address visible to logged in users"))
    avatar = models.ImageField(
        upload_to=settings.USER_AVATAR_PATH,
        null=True,
        blank=True,
        verbose_name=_lazy("Avatar"),
        max_length=settings.MAX_FILEPATH_LENGTH,
    )
    bio = models.TextField(
        null=True,
        blank=True,
        verbose_name=_lazy("Biography"),
        help_text=_lazy("Some HTML supported: &#x3C;abbr title&#x3E; " +
                        "&#x3C;acronym title&#x3E; &#x3C;b&#x3E; " +
                        "&#x3C;blockquote&#x3E; &#x3C;code&#x3E; " +
                        "&#x3C;em&#x3E; &#x3C;i&#x3E; &#x3C;li&#x3E; " +
                        "&#x3C;ol&#x3E; &#x3C;strong&#x3E; &#x3C;ul&#x3E;. " +
                        "Links are forbidden."),
    )
    website = models.URLField(max_length=255,
                              null=True,
                              blank=True,
                              verbose_name=_lazy("Website"))
    twitter = models.CharField(
        max_length=15,
        null=True,
        blank=True,
        validators=[TwitterValidator],
        verbose_name=_lazy("Twitter Username"),
    )
    community_mozilla_org = models.CharField(
        max_length=255,
        default="",
        blank=True,
        verbose_name=_lazy("Community Portal Username"))
    people_mozilla_org = models.CharField(
        max_length=255,
        blank=True,
        default="",
        verbose_name=_lazy("People Directory Username"))
    matrix_handle = models.CharField(max_length=255,
                                     default="",
                                     blank=True,
                                     verbose_name=_lazy("Matrix Nickname"))
    timezone = TimeZoneField(null=True,
                             blank=True,
                             default="US/Pacific",
                             verbose_name=_lazy("Timezone"))
    country = models.CharField(max_length=2,
                               choices=COUNTRIES,
                               null=True,
                               blank=True,
                               verbose_name=_lazy("Country"))
    # No city validation
    city = models.CharField(max_length=255,
                            null=True,
                            blank=True,
                            verbose_name=_lazy("City"))
    locale = LocaleField(default=settings.LANGUAGE_CODE,
                         verbose_name=_lazy("Preferred language"))
    first_answer_email_sent = models.BooleanField(
        default=False,
        help_text=_lazy("Has been sent a first answer contribution email."))
    first_l10n_email_sent = models.BooleanField(
        default=False,
        help_text=_lazy("Has been sent a first revision contribution email."))
    involved_from = models.DateField(
        null=True,
        blank=True,
        verbose_name=_lazy("Involved with Mozilla from"))
    csat_email_sent = models.DateField(
        null=True,
        blank=True,
        verbose_name=_lazy("When the user was sent a community "
                           "health survey"),
    )
    is_fxa_migrated = models.BooleanField(default=False)
    fxa_uid = models.CharField(blank=True,
                               null=True,
                               unique=True,
                               max_length=128)
    fxa_avatar = models.URLField(max_length=512, blank=True, default="")
    products = models.ManyToManyField(Product, related_name="subscribed_users")
    fxa_password_change = models.DateTimeField(blank=True, null=True)

    class Meta(object):
        permissions = (
            ("view_karma_points", "Can view karma points"),
            ("deactivate_users", "Can deactivate users"),
        )

    def __str__(self):
        try:
            return str(self.user)
        except Exception as exc:
            return str("%d (%r)" % (self.pk, exc))

    def get_absolute_url(self):
        return reverse("users.profile", args=[self.user_id])

    def clear(self):
        """Clears out the users profile"""
        self.name = ""
        self.public_email = False
        self.avatar = None
        self.bio = ""
        self.website = ""
        self.twitter = ""
        self.community_mozilla_org = ""
        self.people_mozilla_org = ""
        self.matrix_handle = ""
        self.city = ""
        self.is_fxa_migrated = False
        self.fxa_uid = ""

    @property
    def display_name(self):
        return self.name if self.name else self.user.username

    @property
    def twitter_usernames(self):
        from kitsune.customercare.models import Reply

        return list(
            Reply.objects.filter(user=self.user).values_list(
                "twitter_username", flat=True).distinct())

    @classmethod
    def get_mapping_type(cls):
        return UserMappingType

    @classmethod
    def get_serializer(cls, serializer_type="full"):
        # Avoid circular import
        from kitsune.users import api

        if serializer_type == "full":
            return api.ProfileSerializer
        elif serializer_type == "fk":
            return api.ProfileFKSerializer
        else:
            raise ValueError(
                'Unknown serializer type "{}".'.format(serializer_type))

    @property
    def last_contribution_date(self):
        """Get the date of the user's last contribution."""
        from kitsune.customercare.models import Reply
        from kitsune.questions.models import Answer
        from kitsune.wiki.models import Revision

        dates = []

        # Latest Army of Awesome reply:
        try:
            aoa_reply = Reply.objects.filter(user=self.user).latest("created")
            dates.append(aoa_reply.created)
        except Reply.DoesNotExist:
            pass

        # Latest Support Forum answer:
        try:
            answer = Answer.objects.filter(creator=self.user).latest("created")
            dates.append(answer.created)
        except Answer.DoesNotExist:
            pass

        # Latest KB Revision edited:
        try:
            revision = Revision.objects.filter(
                creator=self.user).latest("created")
            dates.append(revision.created)
        except Revision.DoesNotExist:
            pass

        # Latest KB Revision reviewed:
        try:
            revision = Revision.objects.filter(
                reviewer=self.user).latest("reviewed")
            # Old revisions don't have the reviewed date.
            dates.append(revision.reviewed or revision.created)
        except Revision.DoesNotExist:
            pass

        if len(dates) == 0:
            return None

        return max(dates)

    @property
    def settings(self):
        return self.user.settings

    @property
    def answer_helpfulness(self):
        # Avoid circular import
        from kitsune.questions.models import AnswerVote

        return AnswerVote.objects.filter(answer__creator=self.user,
                                         helpful=True).count()
Exemplo n.º 13
0
class Question(ModelBase, BigVocabTaggableMixin, SearchMixin):
    """A support question."""
    title = models.CharField(max_length=255)
    creator = models.ForeignKey(User, related_name='questions')
    content = models.TextField()

    created = models.DateTimeField(default=datetime.now, db_index=True)
    updated = models.DateTimeField(default=datetime.now, db_index=True)
    updated_by = models.ForeignKey(User,
                                   null=True,
                                   related_name='questions_updated')
    last_answer = models.ForeignKey('Answer',
                                    related_name='last_reply_in',
                                    null=True)
    num_answers = models.IntegerField(default=0, db_index=True)
    solution = models.ForeignKey('Answer',
                                 related_name='solution_for',
                                 null=True)
    is_locked = models.BooleanField(default=False)
    is_archived = models.NullBooleanField(default=False, null=True)
    num_votes_past_week = models.PositiveIntegerField(default=0, db_index=True)

    images = generic.GenericRelation(ImageAttachment)
    flags = generic.GenericRelation(FlaggedObject)

    # List of products this question applies to.
    products = models.ManyToManyField(Product)

    # List of product-specific topics this document applies to.
    topics = models.ManyToManyField(Topic)

    locale = LocaleField(default=settings.WIKI_DEFAULT_LANGUAGE)

    html_cache_key = u'question:html:%s'
    tags_cache_key = u'question:tags:%s'
    contributors_cache_key = u'question:contributors:%s'

    objects = QuestionManager()

    class Meta:
        ordering = ['-updated']
        permissions = (
            ('tag_question', 'Can add tags to and remove tags from questions'),
            ('change_solution',
             'Can change/remove the solution to a question'),
        )

    def __unicode__(self):
        return self.title

    def set_needs_info(self):
        """Mark question as NEEDS_INFO."""
        self.tags.add(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    def unset_needs_info(self):
        """Remove NEEDS_INFO."""
        self.tags.remove(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    @property
    def needs_info(self):
        return self.tags.filter(slug=config.NEEDS_INFO_TAG_NAME).count() > 0

    @property
    def content_parsed(self):
        return _content_parsed(self, self.locale)

    def clear_cached_html(self):
        cache.delete(self.html_cache_key % self.id)

    def clear_cached_tags(self):
        cache.delete(self.tags_cache_key % self.id)

    def clear_cached_contributors(self):
        cache.delete(self.contributors_cache_key % self.id)

    def save(self, update=False, *args, **kwargs):
        """Override save method to take care of updated if requested."""
        new = not self.id

        if not new:
            self.clear_cached_html()
            if update:
                self.updated = datetime.now()

        super(Question, self).save(*args, **kwargs)

        if new:
            # Avoid circular import, events.py imports Question
            from kitsune.questions.events import QuestionReplyEvent
            # Authors should automatically watch their own questions.
            QuestionReplyEvent.notify(self.creator, self)

    def add_metadata(self, **kwargs):
        """Add (save to db) the passed in metadata.

        Usage:
        question = Question.objects.get(pk=1)
        question.add_metadata(ff_version='3.6.3', os='Linux')

        """
        for key, value in kwargs.items():
            QuestionMetaData.objects.create(question=self,
                                            name=key,
                                            value=value)
        self._metadata = None

    def clear_mutable_metadata(self):
        """Clear the mutable metadata.

        This excludes immutable fields: user agent, product, and category.

        """
        self.metadata_set.exclude(
            name__in=['useragent', 'product', 'category']).delete()
        self._metadata = None

    def remove_metadata(self, name):
        """Delete the specified metadata."""
        self.metadata_set.filter(name=name).delete()
        self._metadata = None

    @property
    def metadata(self):
        """Dictionary access to metadata

        Caches the full metadata dict after first call.

        """
        if not hasattr(self, '_metadata') or self._metadata is None:
            self._metadata = dict(
                (m.name, m.value) for m in self.metadata_set.all())
        return self._metadata

    @property
    def solver(self):
        """Get the user that solved the question."""
        solver_id = self.metadata.get('solver_id')
        if solver_id:
            return User.objects.get(id=solver_id)

    @property
    def product(self):
        """Return the product this question is about or an empty mapping if
        unknown."""
        md = self.metadata
        if 'product' in md:
            return config.products.get(md['product'], {})
        return {}

    @property
    def product_slug(self):
        """Return the product slug for this question.

        It returns 'all' in the off chance that there are no products."""
        if not hasattr(self, '_product_slug') or self._product_slug is None:
            prods = self.products.all()
            self._product_slug = prods[0].slug if len(prods) > 0 else 'all'

        return self._product_slug

    @property
    def category(self):
        """Return the category this question refers to or an empty mapping if
        unknown."""
        md = self.metadata
        if self.product and 'category' in md:
            return self.product['categories'].get(md['category'], {})
        return {}

    def auto_tag(self):
        """Apply tags to myself that are implied by my metadata.

        You don't need to call save on the question after this.

        """
        to_add = self.product.get('tags', []) + self.category.get('tags', [])

        version = self.metadata.get('ff_version', '')

        # Remove the beta (b*), aurora (a2) or nightly (a1) suffix.
        version = re.split('[a-b]', version)[0]

        dev_releases = product_details.firefox_history_development_releases
        if version in dev_releases or \
           version in product_details.firefox_history_stability_releases or \
           version in product_details.firefox_history_major_releases:
            to_add.append('Firefox %s' % version)
            tenths = _tenths_version(version)
            if tenths:
                to_add.append('Firefox %s' % tenths)
        elif _has_beta(version, dev_releases):
            to_add.append('Firefox %s' % version)
            to_add.append('beta')

        self.tags.add(*to_add)

        # Add a tag for the OS if it already exists as a tag:
        os = self.metadata.get('os')
        if os:
            try:
                add_existing_tag(os, self.tags)
            except Tag.DoesNotExist:
                pass

    def get_absolute_url(self):
        # Note: If this function changes, we need to change it in
        # extract_document, too.
        return reverse('questions.details', kwargs={'question_id': self.id})

    @property
    def num_votes(self):
        """Get the number of votes for this question."""
        if not hasattr(self, '_num_votes'):
            n = QuestionVote.objects.filter(question=self).count()
            self._num_votes = n
        return self._num_votes

    def sync_num_votes_past_week(self):
        """Get the number of votes for this question in the past week."""
        last_week = datetime.now().date() - timedelta(days=7)
        n = QuestionVote.objects.filter(question=self,
                                        created__gte=last_week).count()
        self.num_votes_past_week = n
        return n

    def has_voted(self, request):
        """Did the user already vote?"""
        if request.user.is_authenticated():
            qs = QuestionVote.objects.filter(question=self,
                                             creator=request.user)
        elif request.anonymous.has_id:
            anon_id = request.anonymous.anonymous_id
            qs = QuestionVote.objects.filter(question=self,
                                             anonymous_id=anon_id)
        else:
            return False

        return qs.exists()

    @property
    def helpful_replies(self):
        """Return answers that have been voted as helpful."""
        cursor = connection.cursor()
        cursor.execute(
            'SELECT votes.answer_id, '
            'SUM(IF(votes.helpful=1,1,-1)) AS score '
            'FROM questions_answervote AS votes '
            'JOIN questions_answer AS ans '
            'ON ans.id=votes.answer_id '
            'AND ans.question_id=%s '
            'GROUP BY votes.answer_id '
            'HAVING score > 0 '
            'ORDER BY score DESC LIMIT 2', [self.id])

        helpful_ids = [row[0] for row in cursor.fetchall()]

        # Exclude the solution if it is set
        if self.solution and self.solution.id in helpful_ids:
            helpful_ids.remove(self.solution.id)

        if len(helpful_ids) > 0:
            return self.answers.filter(id__in=helpful_ids)
        else:
            return []

    def is_contributor(self, user):
        """Did the passed in user contribute to this question?"""
        if user.is_authenticated():
            return user.id in self.contributors

        return False

    @property
    def contributors(self):
        """The contributors to the question."""
        cache_key = self.contributors_cache_key % self.id
        contributors = cache.get(cache_key)
        if contributors is None:
            contributors = self.answers.all().values_list('creator_id',
                                                          flat=True)
            contributors = list(contributors)
            contributors.append(self.creator_id)
            cache.add(cache_key, contributors, CACHE_TIMEOUT)
        return contributors

    @property
    def is_solved(self):
        return not not self.solution_id

    @property
    def is_escalated(self):
        return config.ESCALATE_TAG_NAME in [t.name for t in self.my_tags]

    @property
    def is_offtopic(self):
        return config.OFFTOPIC_TAG_NAME in [t.name for t in self.my_tags]

    @property
    def my_tags(self):
        """A caching wrapper around self.tags.all()."""
        cache_key = self.tags_cache_key % self.id
        tags = cache.get(cache_key)
        if tags is None:
            tags = list(self.tags.all().order_by('name'))
            cache.add(cache_key, tags, CACHE_TIMEOUT)
        return tags

    @classmethod
    def get_mapping_type(cls):
        return QuestionMappingType

    @classmethod
    def recent_asked_count(cls, extra_filter=None):
        """Returns the number of questions asked in the last 24 hours."""
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(created__gt=start, creator__is_active=True)
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def recent_unanswered_count(cls, extra_filter=None):
        """Returns the number of questions that have not been answered in the
        last 24 hours.
        """
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(num_answers=0,
                                created__gt=start,
                                is_locked=False,
                                is_archived=False,
                                creator__is_active=1)
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def from_url(cls, url, id_only=False):
        """Returns the question that the URL represents.

        If the question doesn't exist or the URL isn't a question URL,
        this returns None.

        If id_only is requested, we just return the question id and
        we don't validate the existence of the question (this saves us
        from making a million or so db calls).
        """
        parsed = urlparse(url)
        locale, path = split_path(parsed.path)

        path = '/' + path

        try:
            view, view_args, view_kwargs = resolve(path)
        except Http404:
            return None

        # Avoid circular import. kitsune.question.views import this.
        import kitsune.questions.views
        if view != kitsune.questions.views.question_details:
            return None

        question_id = view_kwargs['question_id']

        if id_only:
            return int(question_id)

        try:
            question = cls.objects.get(id=question_id)
        except cls.DoesNotExist:
            return None

        return question

    @property
    def num_visits(self):
        """Get the number of visits for this question."""
        if not hasattr(self, '_num_visits'):
            try:
                self._num_visits = (QuestionVisits.objects.get(
                    question=self).visits)
            except QuestionVisits.DoesNotExist:
                self._num_visits = None

        return self._num_visits

    @property
    def editable(self):
        return not self.is_locked and not self.is_archived

    @property
    def age(self):
        """The age of the question, in seconds."""
        delta = datetime.now() - self.created
        return delta.seconds + delta.days * 24 * 60 * 60

    def allows_edit(self, user):
        """Return whether `user` can edit this question."""
        return (user.has_perm('questions.change_question')
                or (self.editable and self.creator == user))

    def allows_delete(self, user):
        """Return whether `user` can delete this question."""
        return user.has_perm('questions.delete_question')

    def allows_lock(self, user):
        """Return whether `user` can lock this question."""
        return user.has_perm('questions.lock_question')

    def allows_archive(self, user):
        """Return whether `user` can archive this question."""
        return user.has_perm('questions.archive_question')

    def allows_new_answer(self, user):
        """Return whether `user` can answer (reply to) this question."""
        return (user.has_perm('questions.add_answer')
                or (self.editable and user.is_authenticated()))

    def allows_solve(self, user):
        """Return whether `user` can select the solution to this question."""
        return (self.editable
                and (user == self.creator
                     or user.has_perm('questions.change_solution')))

    def allows_unsolve(self, user):
        """Return whether `user` can unsolve this question."""
        return (self.editable
                and (user == self.creator
                     or user.has_perm('questions.change_solution')))

    def allows_flag(self, user):
        """Return whether `user` can flag this question."""
        return (user.is_authenticated() and user != self.creator
                and self.editable)
Exemplo n.º 14
0
class Question(ModelBase, BigVocabTaggableMixin, SearchMixin):
    """A support question."""

    title = models.CharField(max_length=255)
    creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="questions")
    content = models.TextField()

    created = models.DateTimeField(default=datetime.now, db_index=True)
    updated = models.DateTimeField(default=datetime.now, db_index=True)
    updated_by = models.ForeignKey(
        User, on_delete=models.CASCADE, null=True, blank=True, related_name="questions_updated"
    )
    last_answer = models.ForeignKey(
        "Answer", on_delete=models.CASCADE, related_name="last_reply_in", null=True, blank=True
    )
    num_answers = models.IntegerField(default=0, db_index=True)
    solution = models.ForeignKey(
        "Answer", on_delete=models.CASCADE, related_name="solution_for", null=True
    )
    is_locked = models.BooleanField(default=False)
    is_archived = models.NullBooleanField(default=False, null=True)
    num_votes_past_week = models.PositiveIntegerField(default=0, db_index=True)

    is_spam = models.BooleanField(default=False)
    marked_as_spam = models.DateTimeField(default=None, null=True)
    marked_as_spam_by = models.ForeignKey(
        User, on_delete=models.CASCADE, null=True, related_name="questions_marked_as_spam"
    )

    images = GenericRelation(ImageAttachment)
    flags = GenericRelation(FlaggedObject)

    product = models.ForeignKey(
        Product, on_delete=models.CASCADE, null=True, default=None, related_name="questions"
    )
    topic = models.ForeignKey(Topic, on_delete=models.CASCADE, null=True, related_name="questions")

    locale = LocaleField(default=settings.WIKI_DEFAULT_LANGUAGE)

    taken_by = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
    taken_until = models.DateTimeField(blank=True, null=True)

    html_cache_key = "question:html:%s"
    tags_cache_key = "question:tags:%s"
    images_cache_key = "question:images:%s"
    contributors_cache_key = "question:contributors:%s"

    objects = QuestionManager()
    updated_column_name = "updated"

    class Meta:
        ordering = ["-updated"]
        permissions = (
            ("tag_question", "Can add tags to and remove tags from questions"),
            ("change_solution", "Can change/remove the solution to a question"),
        )

    def __str__(self):
        return self.title

    def set_needs_info(self):
        """Mark question as NEEDS_INFO."""
        self.tags.add(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    def unset_needs_info(self):
        """Remove NEEDS_INFO."""
        self.tags.remove(config.NEEDS_INFO_TAG_NAME)
        self.clear_cached_tags()

    @property
    def needs_info(self):
        return self.tags.filter(slug=config.NEEDS_INFO_TAG_NAME).count() > 0

    @property
    def content_parsed(self):
        return _content_parsed(self, self.locale)

    def clear_cached_html(self):
        cache.delete(self.html_cache_key % self.id)

    def clear_cached_tags(self):
        cache.delete(self.tags_cache_key % self.id)

    def clear_cached_contributors(self):
        cache.delete(self.contributors_cache_key % self.id)

    def clear_cached_images(self):
        cache.delete(self.images_cache_key % self.id)

    def save(self, update=False, *args, **kwargs):
        """Override save method to take care of updated if requested."""
        new = not self.id

        if not new:
            self.clear_cached_html()
            if update:
                self.updated = datetime.now()

        super(Question, self).save(*args, **kwargs)

        if new:
            # actstream
            # Authors should automatically follow their own questions.
            actstream.actions.follow(self.creator, self, send_action=False, actor_only=False)

    def add_metadata(self, **kwargs):
        """Add (save to db) the passed in metadata.

        Usage:
        question = Question.objects.get(pk=1)
        question.add_metadata(ff_version='3.6.3', os='Linux')

        """
        for key, value in list(kwargs.items()):
            QuestionMetaData.objects.create(question=self, name=key, value=value)
        self._metadata = None

    def clear_mutable_metadata(self):
        """Clear the mutable metadata.

        This excludes immutable fields: user agent, product, and category.

        """
        self.metadata_set.exclude(name__in=["useragent", "product", "category"]).delete()
        self._metadata = None

    def remove_metadata(self, name):
        """Delete the specified metadata."""
        self.metadata_set.filter(name=name).delete()
        self._metadata = None

    @property
    def metadata(self):
        """Dictionary access to metadata

        Caches the full metadata dict after first call.

        """
        if not hasattr(self, "_metadata") or self._metadata is None:
            self._metadata = dict((m.name, m.value) for m in self.metadata_set.all())
        return self._metadata

    @property
    def solver(self):
        """Get the user that solved the question."""
        solver_id = self.metadata.get("solver_id")
        if solver_id:
            return User.objects.get(id=solver_id)

    @property
    def product_config(self):
        """Return the product config this question is about or an empty
        mapping if unknown."""
        md = self.metadata
        if "product" in md:
            return config.products.get(md["product"], {})
        return {}

    @property
    def product_slug(self):
        """Return the product slug for this question.

        It returns 'all' in the off chance that there are no products."""
        if not hasattr(self, "_product_slug") or self._product_slug is None:
            self._product_slug = self.product.slug if self.product else None

        return self._product_slug

    @property
    def category_config(self):
        """Return the category this question refers to or an empty mapping if
        unknown."""
        md = self.metadata
        if self.product_config and "category" in md:
            return self.product_config["categories"].get(md["category"], {})
        return {}

    def auto_tag(self):
        """Apply tags to myself that are implied by my metadata.

        You don't need to call save on the question after this.

        """
        to_add = self.product_config.get("tags", []) + self.category_config.get("tags", [])
        version = self.metadata.get("ff_version", "")

        # Remove the beta (b*), aurora (a2) or nightly (a1) suffix.
        version = re.split("[a-b]", version)[0]

        dev_releases = product_details.firefox_history_development_releases

        if (
            version in dev_releases
            or version in product_details.firefox_history_stability_releases
            or version in product_details.firefox_history_major_releases
        ):
            to_add.append("Firefox %s" % version)
            tenths = _tenths_version(version)
            if tenths:
                to_add.append("Firefox %s" % tenths)
        elif _has_beta(version, dev_releases):
            to_add.append("Firefox %s" % version)
            to_add.append("beta")

        self.tags.add(*to_add)

        # Add a tag for the OS if it already exists as a tag:
        os = self.metadata.get("os")
        if os:
            try:
                add_existing_tag(os, self.tags)
            except Tag.DoesNotExist:
                pass

    def get_absolute_url(self):
        # Note: If this function changes, we need to change it in
        # extract_document, too.
        return reverse("questions.details", kwargs={"question_id": self.id})

    @property
    def num_votes(self):
        """Get the number of votes for this question."""
        if not hasattr(self, "_num_votes"):
            n = QuestionVote.objects.filter(question=self).count()
            self._num_votes = n
        return self._num_votes

    def sync_num_votes_past_week(self):
        """Get the number of votes for this question in the past week."""
        last_week = datetime.now().date() - timedelta(days=7)
        n = QuestionVote.objects.filter(question=self, created__gte=last_week).count()
        self.num_votes_past_week = n
        return n

    def has_voted(self, request):
        """Did the user already vote?"""
        if request.user.is_authenticated:
            qs = QuestionVote.objects.filter(question=self, creator=request.user)
        elif request.anonymous.has_id:
            anon_id = request.anonymous.anonymous_id
            qs = QuestionVote.objects.filter(question=self, anonymous_id=anon_id)
        else:
            return False

        return qs.exists()

    @property
    def helpful_replies(self):
        """Return answers that have been voted as helpful."""
        cursor = connection.cursor()
        cursor.execute(
            "SELECT votes.answer_id, "
            "SUM(IF(votes.helpful=1,1,-1)) AS score "
            "FROM questions_answervote AS votes "
            "JOIN questions_answer AS ans "
            "ON ans.id=votes.answer_id "
            "AND ans.question_id=%s "
            "GROUP BY votes.answer_id "
            "HAVING score > 0 "
            "ORDER BY score DESC LIMIT 2",
            [self.id],
        )

        helpful_ids = [row[0] for row in cursor.fetchall()]

        # Exclude the solution if it is set
        if self.solution and self.solution.id in helpful_ids:
            helpful_ids.remove(self.solution.id)

        if len(helpful_ids) > 0:
            return self.answers.filter(id__in=helpful_ids)
        else:
            return []

    def is_contributor(self, user):
        """Did the passed in user contribute to this question?"""
        if user.is_authenticated:
            return user.id in self.contributors

        return False

    @property
    def contributors(self):
        """The contributors to the question."""
        cache_key = self.contributors_cache_key % self.id
        contributors = cache.get(cache_key)
        if contributors is None:
            contributors = self.answers.all().values_list("creator_id", flat=True)
            contributors = list(contributors)
            contributors.append(self.creator_id)
            cache.add(cache_key, contributors, settings.CACHE_MEDIUM_TIMEOUT)
        return contributors

    @property
    def is_solved(self):
        return self.solution_id is not None

    @property
    def is_offtopic(self):
        return config.OFFTOPIC_TAG_NAME in [t.name for t in self.my_tags]

    @property
    def my_tags(self):
        """A caching wrapper around self.tags.all()."""
        cache_key = self.tags_cache_key % self.id
        tags = cache.get(cache_key)
        if tags is None:
            tags = list(self.tags.all().order_by("name"))
            cache.add(cache_key, tags, settings.CACHE_MEDIUM_TIMEOUT)
        return tags

    @classmethod
    def get_mapping_type(cls):
        return QuestionMappingType

    @classmethod
    def get_serializer(cls, serializer_type="full"):
        # Avoid circular import
        from kitsune.questions import api

        if serializer_type == "full":
            return api.QuestionSerializer
        elif serializer_type == "fk":
            return api.QuestionFKSerializer
        else:
            raise ValueError('Unknown serializer type "{}".'.format(serializer_type))

    @classmethod
    def recent_asked_count(cls, extra_filter=None):
        """Returns the number of questions asked in the last 24 hours."""
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(created__gt=start, creator__is_active=True)
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def recent_unanswered_count(cls, extra_filter=None):
        """Returns the number of questions that have not been answered in the
        last 24 hours.
        """
        start = datetime.now() - timedelta(hours=24)
        qs = cls.objects.filter(
            num_answers=0,
            created__gt=start,
            is_locked=False,
            is_archived=False,
            creator__is_active=1,
        )
        if extra_filter:
            qs = qs.filter(extra_filter)
        return qs.count()

    @classmethod
    def from_url(cls, url, id_only=False):
        """Returns the question that the URL represents.

        If the question doesn't exist or the URL isn't a question URL,
        this returns None.

        If id_only is requested, we just return the question id and
        we don't validate the existence of the question (this saves us
        from making a million or so db calls).
        """
        parsed = urlparse(url)
        locale, path = split_path(parsed.path)

        path = "/" + path

        try:
            view, view_args, view_kwargs = resolve(path)
        except Http404:
            return None

        # Avoid circular import. kitsune.question.views import this.
        import kitsune.questions.views

        if view != kitsune.questions.views.question_details:
            return None

        question_id = view_kwargs["question_id"]

        if id_only:
            return int(question_id)

        try:
            question = cls.objects.get(id=question_id)
        except cls.DoesNotExist:
            return None

        return question

    @property
    def num_visits(self):
        """Get the number of visits for this question."""
        if not hasattr(self, "_num_visits"):
            try:
                self._num_visits = QuestionVisits.objects.get(question=self).visits
            except QuestionVisits.DoesNotExist:
                self._num_visits = None

        return self._num_visits

    @property
    def editable(self):
        return not self.is_locked and not self.is_archived

    @property
    def age(self):
        """The age of the question, in seconds."""
        delta = datetime.now() - self.created
        return delta.seconds + delta.days * 24 * 60 * 60

    def set_solution(self, answer, solver):
        """
        Sets the solution, and fires any needed events.

        Does not check permission of the user making the change.
        """
        # Avoid circular import
        from kitsune.questions.events import QuestionSolvedEvent

        self.solution = answer
        self.save()
        self.add_metadata(solver_id=str(solver.id))
        QuestionSolvedEvent(answer).fire(exclude=self.creator)
        actstream.action.send(
            solver, verb="marked as a solution", action_object=answer, target=self
        )

    @property
    def _content_for_related(self):
        """Text to use in elastic more_like_this query."""
        content = [self.title, self.content]
        if self.topic:
            with translation_override(self.locale):
                # use the question's locale, rather than the user's
                content += [pgettext("DB: products.Topic.title", self.topic.title)]

        return content

    @property
    def related_documents(self):
        """Return documents that are 'morelikethis' one"""
        if not self.product:
            return []

        # First try to get the results from the cache
        key = "questions_question:related_docs:%s" % self.id
        documents = cache.get(key)
        if documents is not None:
            log.debug(
                "Getting MLT documents for {question} from cache.".format(question=repr(self))
            )
            return documents

        # avoid circular import issue
        from kitsune.search.v2.documents import WikiDocument

        try:
            search = (
                WikiDocument.search()
                .filter("term", product_ids=self.product.id)
                .query(
                    "more_like_this",
                    fields=[
                        f"title.{self.locale}",
                        f"content.{self.locale}",
                        f"summary.{self.locale}",
                        f"keywords.{self.locale}",
                    ],
                    like=self._content_for_related,
                    max_query_terms=15,
                )
                .source([f"slug.{self.locale}", f"title.{self.locale}"])
            )
            documents = [
                {
                    "url": reverse(
                        "wiki.document", args=[hit.slug[self.locale]], locale=self.locale
                    ),
                    "title": hit.title[self.locale],
                }
                for hit in search[:3].execute().hits
            ]
            cache.set(key, documents, settings.CACHE_LONG_TIMEOUT)
        except ElasticsearchException:
            log.exception("ES MLT related_documents")
            documents = []

        return documents

    @property
    def related_questions(self):
        """Return questions that are 'morelikethis' one"""
        if not self.product:
            return []

        # First try to get the results from the cache
        key = "questions_question:related_questions:%s" % self.id
        questions = cache.get(key)
        if questions is not None:
            log.debug(
                "Getting MLT questions for {question} from cache.".format(question=repr(self))
            )
            return questions

        # avoid circular import issue
        from kitsune.search.v2.documents import QuestionDocument

        try:
            search = (
                QuestionDocument.search()
                .filter("term", question_product_id=self.product.id)
                .exclude("exists", field="updated")
                .exclude("term", _id=self.id)
                .query(
                    "more_like_this",
                    fields=[f"question_title.{self.locale}", f"question_content.{self.locale}"],
                    like=self._content_for_related,
                    max_query_terms=15,
                )
                .source(["question_id", "question_title"])
            )
            questions = [
                {
                    "url": reverse("questions.details", kwargs={"question_id": hit.question_id}),
                    "title": hit.question_title[self.locale],
                }
                for hit in search[:3].execute().hits
            ]
            cache.set(key, questions, settings.CACHE_LONG_TIMEOUT)
        except ElasticsearchException:
            log.exception("ES MLT related_questions")
            questions = []

        return questions

    # Permissions

    def allows_edit(self, user):
        """Return whether `user` can edit this question."""
        return user.has_perm("questions.change_question") or (
            self.editable and self.creator == user
        )

    def allows_delete(self, user):
        """Return whether `user` can delete this question."""
        return user.has_perm("questions.delete_question")

    def allows_lock(self, user):
        """Return whether `user` can lock this question."""
        return user.has_perm("questions.lock_question")

    def allows_archive(self, user):
        """Return whether `user` can archive this question."""
        return user.has_perm("questions.archive_question")

    def allows_new_answer(self, user):
        """Return whether `user` can answer (reply to) this question."""
        return user.has_perm("questions.add_answer") or (self.editable and user.is_authenticated)

    def allows_solve(self, user):
        """Return whether `user` can select the solution to this question."""
        return self.editable and (
            user == self.creator or user.has_perm("questions.change_solution")
        )

    def allows_unsolve(self, user):
        """Return whether `user` can unsolve this question."""
        return self.editable and (
            user == self.creator or user.has_perm("questions.change_solution")
        )

    def allows_flag(self, user):
        """Return whether `user` can flag this question."""
        return user.is_authenticated and user != self.creator and self.editable

    def mark_as_spam(self, by_user):
        """Mark the question as spam by the specified user."""
        self.is_spam = True
        self.marked_as_spam = datetime.now()
        self.marked_as_spam_by = by_user
        self.save()

    @property
    def is_taken(self):
        """
        Convenience method to check that a question is taken.

        Additionally, if ``self.taken_until`` is in the past, this will reset
        the database fields to expire the setting.
        """
        if self.taken_by is None:
            assert self.taken_until is None
            return False

        assert self.taken_until is not None
        if self.taken_until < datetime.now():
            self.taken_by = None
            self.taken_until = None
            self.save()
            return False

        return True

    def take(self, user, force=False):
        """
        Sets the user that is currently working on this question.

        May raise InvalidUserException if the user is not permitted to take
        the question (such as if the question is owned by the user).

        May raise AlreadyTakenException if the question is already taken
        by a different user, and the force paramater is not True.

        If the user is the same as the user that currently has the question,
        the timer will be updated   .
        """

        if user == self.creator:
            raise InvalidUserException

        if self.taken_by not in [None, user] and not force:
            raise AlreadyTakenException

        self.taken_by = user
        self.taken_until = datetime.now() + timedelta(seconds=config.TAKE_TIMEOUT)
        self.save()

    def get_images(self):
        """A cached version of self.images.all()."""
        cache_key = self.images_cache_key % self.id
        images = cache.get(cache_key)
        if images is None:
            images = list(self.images.all())
            cache.add(cache_key, images, settings.CACHE_MEDIUM_TIMEOUT)
        return images
Exemplo n.º 15
0
class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin,
               DocumentPermissionMixin):
    """A localized knowledgebase document, not revision-specific."""

    title = models.CharField(max_length=255, db_index=True)
    slug = models.CharField(max_length=255, db_index=True)

    # Is this document a template or not?
    is_template = models.BooleanField(default=False,
                                      editable=False,
                                      db_index=True)
    # Is this document localizable or not?
    is_localizable = models.BooleanField(default=True, db_index=True)

    # TODO: validate (against settings.SUMO_LANGUAGES?)
    locale = LocaleField(default=settings.WIKI_DEFAULT_LANGUAGE, db_index=True)

    # Latest approved revision. L10n dashboard depends on this being so (rather
    # than being able to set it to earlier approved revisions). (Remove "+" to
    # enable reverse link.)
    current_revision = models.ForeignKey("Revision",
                                         on_delete=models.CASCADE,
                                         null=True,
                                         related_name="current_for+")

    # Latest revision which both is_approved and is_ready_for_localization,
    # This may remain non-NULL even if is_localizable is changed to false.
    latest_localizable_revision = models.ForeignKey(
        "Revision",
        on_delete=models.CASCADE,
        null=True,
        related_name="localizable_for+")

    # The Document I was translated from. NULL iff this doc is in the default
    # locale or it is nonlocalizable. TODO: validate against
    # settings.WIKI_DEFAULT_LANGUAGE.
    parent = models.ForeignKey("self",
                               on_delete=models.CASCADE,
                               related_name="translations",
                               null=True,
                               blank=True)

    # Cached HTML rendering of approved revision's wiki markup:
    html = models.TextField(editable=False)

    # A document's category must always be that of its parent. If it has no
    # parent, it can do what it wants. This invariant is enforced in save().
    category = models.IntegerField(choices=CATEGORIES, db_index=True)

    # A document's is_archived flag must match that of its parent. If it has no
    # parent, it can do what it wants. This invariant is enforced in save().
    is_archived = models.BooleanField(
        default=False,
        db_index=True,
        verbose_name="is obsolete",
        help_text=_lazy(
            "If checked, this wiki page will be hidden from basic searches "
            "and dashboards. When viewed, the page will warn that it is no "
            "longer maintained."),
    )

    # Enable discussion (kbforum) on this document.
    allow_discussion = models.BooleanField(
        default=True,
        help_text=_lazy(
            "If checked, this document allows discussion in an associated "
            "forum. Uncheck to hide/disable the forum."),
    )

    # List of users that have contributed to this document.
    contributors = models.ManyToManyField(User)

    # List of products this document applies to.
    # Children should query their parents for this.
    products = models.ManyToManyField(Product)

    # List of product-specific topics this document applies to.
    # Children should query their parents for this.
    topics = models.ManyToManyField(Topic)

    # Needs change fields.
    needs_change = models.BooleanField(
        default=False,
        help_text=_lazy("If checked, this document needs updates."),
        db_index=True)
    needs_change_comment = models.CharField(max_length=500, blank=True)

    # A 24 character length gives years before having to alter max_length.
    share_link = models.CharField(max_length=24, default="")

    # Dictates the order in which articles are displayed.
    # Children should query their parents for this.
    display_order = models.IntegerField(default=1, db_index=True)

    # List of related documents
    related_documents = models.ManyToManyField("self", blank=True)

    updated_column_name = "current_revision__created"

    # firefox_versions,
    # operating_systems:
    #    defined in the respective classes below. Use them as in
    #    test_firefox_versions.

    # TODO: Rethink indexes once controller code is near complete. Depending on
    # how MySQL uses indexes, we probably don't need individual indexes on
    # title and locale as well as a combined (title, locale) one.
    class Meta(object):
        ordering = ["display_order", "id"]
        unique_together = (("parent", "locale"), ("title", "locale"),
                           ("slug", "locale"))
        permissions = [
            ("archive_document", "Can archive document"),
            ("edit_needs_change", "Can edit needs_change"),
        ]

    def _collides(self, attr, value):
        """Return whether there exists a doc in this locale whose `attr` attr
        is equal to mine."""
        return (Document.objects.filter(locale=self.locale, **{
            attr: value
        }).exclude(id=self.id).exists())

    def _raise_if_collides(self, attr, exception):
        """Raise an exception if a page of this title/slug already exists."""
        if self.id is None or hasattr(self, "old_" + attr):
            # If I am new or my title/slug changed...
            if self._collides(attr, getattr(self, attr)):
                raise exception

    def clean(self):
        """Translations can't be localizable."""
        self._clean_is_localizable()
        self._clean_category()
        self._clean_template_status()
        self._ensure_inherited_attr("is_archived")

    def _clean_is_localizable(self):
        """is_localizable == allowed to have translations. Make sure that isn't
        violated.

        For default language (en-US), is_localizable means it can have
        translations. Enforce:
            * is_localizable=True if it has translations
            * if has translations, unable to make is_localizable=False

        For non-default langauges, is_localizable must be False.

        """
        if self.locale != settings.WIKI_DEFAULT_LANGUAGE:
            self.is_localizable = False

        # Can't save this translation if parent not localizable
        if self.parent and not self.parent.is_localizable:
            raise ValidationError('"%s": parent "%s" is not localizable.' %
                                  (str(self), str(self.parent)))

        # Can't make not localizable if it has translations
        # This only applies to documents that already exist, hence self.pk
        if self.pk and not self.is_localizable and self.translations.exists():
            raise ValidationError(
                '"{0}": document has {1} translations but is not localizable.'.
                format(str(self), self.translations.count()))

    def _ensure_inherited_attr(self, attr):
        """Make sure my `attr` attr is the same as my parent's if I have one.

        Otherwise, if I have children, make sure their `attr` attr is the same
        as mine.

        """
        if self.parent:
            # We always set the child according to the parent rather than vice
            # versa, because we do not expose an Archived checkbox in the
            # translation UI.
            setattr(self, attr, getattr(self.parent, attr))
        else:  # An article cannot have both a parent and children.
            # Make my children the same as me:
            if self.id:
                self.translations.all().update(**{attr: getattr(self, attr)})

    def _clean_category(self):
        """Make sure a doc's category is valid."""
        if not self.parent and self.category not in (
                id for id, name in CATEGORIES):
            # All we really need to do here is make sure category != '' (which
            # is what it is when it's missing from the DocumentForm). The extra
            # validation is just a nicety.
            raise ValidationError(_("Please choose a category."))

        self._ensure_inherited_attr("category")

    def _clean_template_status(self):
        if self.category == TEMPLATES_CATEGORY and not self.title.startswith(
                TEMPLATE_TITLE_PREFIX):
            raise ValidationError(
                _("Documents in the Template category must have titles that "
                  'start with "{prefix}". (Current title is "{title}")').
                format(prefix=TEMPLATE_TITLE_PREFIX, title=self.title))

        if self.title.startswith(
                TEMPLATE_TITLE_PREFIX) and self.category != TEMPLATES_CATEGORY:
            raise ValidationError(
                _('Documents with titles that start with "{prefix}" must be in '
                  'the templates category. (Current category is "{category}". '
                  'Current title is "{title}".)').format(
                      prefix=TEMPLATE_TITLE_PREFIX,
                      category=self.get_category_display(),
                      title=self.title,
                  ))

    def _attr_for_redirect(self, attr, template):
        """Return the slug or title for a new redirect.

        `template` is a Python string template with "old" and "number" tokens
        used to create the variant.

        """
        def unique_attr():
            """Return a variant of getattr(self, attr) such that there is no
            Document of my locale with string attribute `attr` equal to it.

            Never returns the original attr value.

            """
            # "My God, it's full of race conditions!"
            i = 1
            while True:
                new_value = template % dict(old=getattr(self, attr), number=i)
                if not self._collides(attr, new_value):
                    return new_value
                i += 1

        old_attr = "old_" + attr
        if hasattr(self, old_attr):
            # My slug (or title) is changing; we can reuse it for the redirect.
            return getattr(self, old_attr)
        else:
            # Come up with a unique slug (or title):
            return unique_attr()

    def save(self, *args, **kwargs):
        slug_changed = hasattr(self, "old_slug")
        title_changed = hasattr(self, "old_title")

        self.is_template = (self.title.startswith(TEMPLATE_TITLE_PREFIX)
                            or self.category == TEMPLATES_CATEGORY
                            or (self.parent.category if self.parent else None)
                            == TEMPLATES_CATEGORY)
        treat_as_template = self.is_template or (
            self.old_title
            if title_changed else "").startswith(TEMPLATE_TITLE_PREFIX)

        self._raise_if_collides("slug", SlugCollision)
        self._raise_if_collides("title", TitleCollision)

        # These are too important to leave to a (possibly omitted) is_valid
        # call:
        self._clean_is_localizable()
        self._ensure_inherited_attr("is_archived")
        # Everything is validated before save() is called, so the only thing
        # that could cause save() to exit prematurely would be an exception,
        # which would cause a rollback, which would negate any category changes
        # we make here, so don't worry:
        self._clean_category()
        self._clean_template_status()

        if slug_changed:
            # Clear out the share link so it gets regenerated.
            self.share_link = ""

        super(Document, self).save(*args, **kwargs)

        # Make redirects if there's an approved revision and title or slug
        # changed. Allowing redirects for unapproved docs would (1) be of
        # limited use and (2) require making Revision.creator nullable.
        #
        # Having redirects for templates doesn't really make sense, and
        # none of the rest of the KB really deals with it, so don't bother.
        if self.current_revision and (slug_changed or
                                      title_changed) and not treat_as_template:
            try:
                doc = Document.objects.create(
                    locale=self.locale,
                    title=self._attr_for_redirect("title", REDIRECT_TITLE),
                    slug=self._attr_for_redirect("slug", REDIRECT_SLUG),
                    category=self.category,
                    is_localizable=False,
                )
                Revision.objects.create(
                    document=doc,
                    content=REDIRECT_CONTENT % self.title,
                    is_approved=True,
                    reviewer=self.current_revision.creator,
                    creator=self.current_revision.creator,
                )
            except TitleCollision:
                pass

        if slug_changed:
            del self.old_slug
        if title_changed:
            del self.old_title

        self.parse_and_calculate_links()
        self.clear_cached_html()

    def __setattr__(self, name, value):
        """Trap setting slug and title, recording initial value."""
        # Public API: delete the old_title or old_slug attrs after changing
        # title or slug (respectively) to suppress redirect generation.
        if name != "_state" and not self._state.adding:
            # I have been saved and so am worthy of a redirect.
            if name in ("slug", "title"):
                old_name = "old_" + name
                if not hasattr(self, old_name):
                    # Avoid recursive call to __setattr__ when
                    # ``getattr(self, name)`` needs to refresh the
                    # database.
                    setattr(self, old_name, None)
                    # Normal articles are compared case-insensitively
                    if getattr(self, name).lower() != value.lower():
                        setattr(self, old_name, getattr(self, name))
                    else:
                        delattr(self, old_name)

                    # Articles that have a changed title are checked
                    # case-sensitively for the title prefix changing.
                    ttp = TEMPLATE_TITLE_PREFIX
                    if name == "title" and self.title.startswith(
                            ttp) != value.startswith(ttp):
                        # Save original value:
                        setattr(self, old_name, getattr(self, name))

                elif value == getattr(self, old_name):
                    # They changed the attr back to its original value.
                    delattr(self, old_name)
        super(Document, self).__setattr__(name, value)

    @property
    def content_parsed(self):
        if not self.current_revision:
            return ""
        return self.current_revision.content_parsed

    @property
    def summary(self):
        if not self.current_revision:
            return ""
        return self.current_revision.summary

    @property
    def language(self):
        return settings.LANGUAGES_DICT[self.locale.lower()]

    @property
    def related_products(self):
        related_pks = [d.pk for d in self.related_documents.all()]
        related_pks.append(self.pk)
        return Product.objects.filter(document__in=related_pks).distinct()

    @property
    def is_hidden_from_search_engines(self):
        return (self.is_template or self.is_archived or self.category
                in (ADMINISTRATION_CATEGORY, CANNED_RESPONSES_CATEGORY))

    def get_absolute_url(self):
        return reverse("wiki.document", locale=self.locale, args=[self.slug])

    @classmethod
    def from_url(cls,
                 url,
                 required_locale=None,
                 id_only=False,
                 check_host=True):
        """Return the approved Document the URL represents, None if there isn't
        one.

        Return None if the URL is a 404, the URL doesn't point to the right
        view, or the indicated document doesn't exist.

        To limit the universe of discourse to a certain locale, pass in a
        `required_locale`. To fetch only the ID of the returned Document, set
        `id_only` to True.

        If the URL has a host component, we assume it does not point to this
        host and thus does not point to a Document, because that would be a
        needlessly verbose way to specify an internal link. However, if you
        pass check_host=False, we assume the URL's host is the one serving
        Documents, which comes in handy for analytics whose metrics return
        host-having URLs.

        """
        try:
            components = _doc_components_from_url(
                url, required_locale=required_locale, check_host=check_host)
        except _NotDocumentView:
            return None
        if not components:
            return None
        locale, path, slug = components

        doc = cls.objects
        if id_only:
            doc = doc.only("id")
        try:
            doc = doc.get(locale=locale, slug=slug)
        except cls.DoesNotExist:
            try:
                doc = doc.get(locale=settings.WIKI_DEFAULT_LANGUAGE, slug=slug)
                translation = doc.translated_to(locale)
                if translation:
                    return translation
                return doc
            except cls.DoesNotExist:
                return None
        return doc

    def redirect_url(self, source_locale=settings.LANGUAGE_CODE):
        """If I am a redirect, return the URL to which I redirect.

        Otherwise, return None.

        """
        # If a document starts with REDIRECT_HTML and contains any <a> tags
        # with hrefs, return the href of the first one. This trick saves us
        # from having to parse the HTML every time.
        if self.html.startswith(REDIRECT_HTML):
            anchors = PyQuery(self.html)("a[href]")
            if anchors:
                # Articles with a redirect have a link that has the locale
                # hardcoded into it, and so by simply redirecting to the given
                # link, we end up possibly losing the locale. So, instead,
                # we strip out the locale and replace it with the original
                # source locale only in the case where an article is going
                # from one locale and redirecting it to a different one.
                # This only applies when it's a non-default locale because we
                # don't want to override the redirects that are forcibly
                # changing to (or staying within) a specific locale.
                full_url = anchors[0].get("href")
                (dest_locale, url) = split_path(full_url)
                if source_locale != dest_locale and dest_locale == settings.LANGUAGE_CODE:
                    return "/" + source_locale + "/" + url
                return full_url

    def redirect_document(self):
        """If I am a redirect to a Document, return that Document.

        Otherwise, return None.

        """
        url = self.redirect_url()
        if url:
            return self.from_url(url)

    def __str__(self):
        return "[%s] %s" % (self.locale, self.title)

    def allows_vote(self, request):
        """Return whether we should render the vote form for the document."""

        # If the user isn't authenticated, we show the form even if they
        # may have voted. This is because the page can be cached and we don't
        # want to cache the page without the vote form. Users that already
        # voted will see a "You already voted on this Article." message
        # if they try voting again.
        authed_and_voted = (request.user.is_authenticated
                            and self.current_revision
                            and self.current_revision.has_voted(request))

        return (not self.is_archived and self.current_revision
                and not authed_and_voted and not self.redirect_document()
                and self.category != TEMPLATES_CATEGORY
                and not waffle.switch_is_active("hide-voting"))

    def translated_to(self, locale):
        """Return the translation of me to the given locale.

        If there is no such Document, return None.

        """
        if self.locale != settings.WIKI_DEFAULT_LANGUAGE:
            raise NotImplementedError("translated_to() is implemented only on"
                                      "Documents in the default language so"
                                      "far.")
        try:
            return Document.objects.get(locale=locale, parent=self)
        except Document.DoesNotExist:
            return None

    @property
    def original(self):
        """Return the document I was translated from or, if none, myself."""
        return self.parent or self

    def localizable_or_latest_revision(self, include_rejected=False):
        """Return latest ready-to-localize revision if there is one,
        else the latest approved revision if there is one,
        else the latest unrejected (unreviewed) revision if there is one,
        else None.

        include_rejected -- If true, fall back to the latest rejected
            revision if all else fails.

        """
        def latest(queryset):
            """Return the latest item from a queryset (by ID).

            Return None if the queryset is empty.

            """
            try:
                return queryset.order_by("-id")[0:1].get()
            except ObjectDoesNotExist:  # Catching IndexError seems overbroad.
                return None

        rev = self.latest_localizable_revision
        if not rev or not self.is_localizable:
            rejected = Q(is_approved=False, reviewed__isnull=False)

            # Try latest approved revision
            # or not approved revs. Try unrejected
            # or not unrejected revs. Maybe fall back to rejected
            rev = (latest(self.revisions.filter(is_approved=True))
                   or latest(self.revisions.exclude(rejected))
                   or (latest(self.revisions) if include_rejected else None))
        return rev

    def is_outdated(self, level=MEDIUM_SIGNIFICANCE):
        """Return whether an update of a given magnitude has occured
        to the parent document since this translation had an approved
        update and such revision is ready for l10n.

        If this is not a translation or has never been approved, return
        False.

        level: The significance of an edit that is "enough". Defaults to
            MEDIUM_SIGNIFICANCE.

        """
        if not (self.parent and self.current_revision):
            return False

        based_on_id = self.current_revision.based_on_id
        more_filters = {"id__gt": based_on_id} if based_on_id else {}

        return self.parent.revisions.filter(
            is_approved=True,
            is_ready_for_localization=True,
            significance__gte=level,
            **more_filters,
        ).exists()

    def is_majorly_outdated(self):
        """Return whether a MAJOR_SIGNIFICANCE-level update has occurred to the
        parent document since this translation had an approved update and such
        revision is ready for l10n.

        If this is not a translation or has never been approved, return False.

        """
        return self.is_outdated(level=MAJOR_SIGNIFICANCE)

    def is_watched_by(self, user):
        """Return whether `user` is notified of edits to me."""
        from kitsune.wiki.events import EditDocumentEvent

        return EditDocumentEvent.is_notifying(user, self)

    def get_topics(self):
        """Return the list of new topics that apply to this document.

        If the document has a parent, it inherits the parent's topics.
        """
        if self.parent:
            return self.parent.get_topics()

        return Topic.objects.filter(document=self)

    def get_products(self):
        """Return the list of products that apply to this document.

        If the document has a parent, it inherits the parent's products.
        """
        if self.parent:
            return self.parent.get_products()

        return Product.objects.filter(document=self)

    @property
    def recent_helpful_votes(self):
        """Return the number of helpful votes in the last 30 days."""
        start = datetime.now() - timedelta(days=30)
        return HelpfulVote.objects.filter(revision__document=self,
                                          created__gt=start,
                                          helpful=True).count()

    def parse_and_calculate_links(self):
        """Calculate What Links Here data for links going out from this.

        Also returns a parsed version of the current html, because that
        is a byproduct of the process, and is useful.
        """
        if not self.current_revision:
            return ""

        # Remove "what links here" reverse links, because they might be
        # stale and re-rendering will re-add them. This cannot be done
        # reliably in the parser's parse() function, because that is
        # often called multiple times per document.
        self.links_from().delete()

        # Also delete the DocumentImage instances for this document.
        DocumentImage.objects.filter(document=self).delete()

        from kitsune.wiki.parser import wiki_to_html, WhatLinksHereParser

        return wiki_to_html(
            self.current_revision.content,
            locale=self.locale,
            doc_id=self.id,
            parser_cls=WhatLinksHereParser,
        )

    def links_from(self):
        """Get a query set of links that are from this document to another."""
        return DocumentLink.objects.filter(linked_from=self)

    def links_to(self):
        """Get a query set of links that are from another document to this."""
        return DocumentLink.objects.filter(linked_to=self)

    def add_link_to(self, linked_to, kind):
        """Create a DocumentLink to another Document."""
        DocumentLink.objects.get_or_create(linked_from=self,
                                           linked_to=linked_to,
                                           kind=kind)

    @property
    def images(self):
        return Image.objects.filter(documentimage__document=self)

    def add_image(self, image):
        """Create a DocumentImage to connect self to an Image instance."""
        try:
            DocumentImage(document=self, image=image).save()
        except IntegrityError:
            # This DocumentImage already exists, ok.
            pass

    def clear_cached_html(self):
        # Clear out both mobile and desktop templates.
        cache.delete(doc_html_cache_key(self.locale, self.slug))