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)
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, )
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"
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 = ''
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)
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)
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
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
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()
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: <abbr title> ' + '<acronym title> <b> ' + '<blockquote> <code> ' + '<em> <i> <li> ' + '<ol> <strong> <ul>. ' + '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()
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)
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: <abbr title> " + "<acronym title> <b> " + "<blockquote> <code> " + "<em> <i> <li> " + "<ol> <strong> <ul>. " + "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()
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)
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
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))