class CannedResponse(ModelBase): """Canned response to tweets.""" title = models.CharField(max_length=255) response = models.CharField(max_length=140) categories = models.ManyToManyField(CannedCategory, related_name='responses', through='CategoryMembership') locale = LocaleField(db_index=True) class Meta: ordering = ('locale', 'title') unique_together = ('title', 'locale') def __unicode__(self): return u'[%s] %s' % (self.locale, self.title)
class CannedCategory(ModelBase): """Category for canned responses.""" title = models.CharField(max_length=255) weight = models.IntegerField( default=0, db_index=True, help_text='Heavier items sink, lighter ones bubble up.') locale = LocaleField(db_index=True) class Meta: ordering = ('locale', 'weight', 'title') unique_together = ('title', 'locale') verbose_name_plural = 'Canned categories' def __unicode__(self): return u'[%s] %s' % (self.locale, self.title)
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, verify_exists=False, verbose_name=_lazy(u'Website')) twitter = models.URLField(max_length=255, null=True, blank=True, verify_exists=False, verbose_name=_lazy(u'Twitter URL')) facebook = models.URLField(max_length=255, null=True, blank=True, verify_exists=False, 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')) livechat_id = models.CharField(default=None, null=True, blank=True, max_length=255, verbose_name=_lazy(u'Livechat ID')) locale = LocaleField(default=settings.LANGUAGE_CODE, verbose_name=_lazy(u'Preferred language for email')) class Meta(object): permissions = (('view_karma_points', 'Can view karma points'),) def __unicode__(self): return unicode(self.user) def get_absolute_url(self): return reverse('users.profile', args=[self.user_id])
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) class Meta: abstract = True ordering = ['-created'] unique_together = ('locale', 'title') def __unicode__(self): return '[%s] %s' % (self.locale, self.title)
class EventWatch(ModelBase): """ Allows anyone to watch a specific item for changes. Uses email instead of user ID so anonymous visitors can also watch things eventually. """ content_type = models.ForeignKey(ContentType) # If watch_id is set to null, then the watch is for the model and not # an instance. watch_id = models.IntegerField(db_index=True, null=True) event_type = models.CharField(max_length=20, db_index=True) locale = LocaleField(default='', db_index=True) email = models.EmailField(db_index=True) hash = models.CharField(max_length=40, null=True, db_index=True) class Meta: unique_together = (('content_type', 'watch_id', 'email', 'event_type', 'locale'), ) @property def key(self): if self.hash: return self.hash key_ = '%s-%s-%s-%s' % (self.content_type.id, self.watch_id, self.email, self.event_type) sha = hashlib.sha1() sha.update(key_) return sha.hexdigest() def save(self, *args, **kwargs): """Overriding save to set the hash.""" self.hash = self.key super(EventWatch, self).save(*args, **kwargs) def get_remove_url(self): """Get the URL to remove an EventWatch.""" from sumo.helpers import urlparams url_ = reverse('notifications.remove', args=[self.key]) return urlparams(url_, email=self.email)
class UserProfile(ModelBase): """ The UserProfile *must* exist for each django.contrib.auth.models.User object. This may be relaxed once Dekiwiki isn't the definitive db for user info. timezone and language fields are syndicated to Dekiwiki """ # Website fields defined for the profile form # TODO: Someday this will probably need to allow arbitrary per-profile # entries, and these will just be suggestions. website_choices = [ ('website', dict( label=_(u'Website'), prefix='http://', regex='^https?://', )), ('twitter', dict( label=_(u'Twitter'), prefix='http://twitter.com/', regex='^https?://twitter.com/', )), ('github', dict( label=_(u'GitHub'), prefix='http://github.com/', regex='^https?://github.com/', )), ('stackoverflow', dict( label=_(u'StackOverflow'), prefix='http://stackoverflow.com/users/', regex='^https?://stackoverflow.com/users/', )), ('linkedin', dict( label=_(u'LinkedIn'), prefix='http://www.linkedin.com/in/', regex='^https?://www.linkedin.com/in/', )), ] class Meta: db_table = 'user_profiles' # This could be a ForeignKey, except wikidb might be # a different db deki_user_id = models.PositiveIntegerField(default=0, editable=False) timezone = TimeZoneField(null=True, blank=True, verbose_name=_(u'Timezone')) locale = LocaleField(null=True, blank=True, db_index=True, verbose_name=_(u'Language')) homepage = models.URLField(max_length=255, blank=True, default='', error_messages={ 'invalid': _(u'This URL has an invalid format. ' u'Valid URLs look like ' u'http://example.com/my_page.') }) title = models.CharField(_(u'Title'), max_length=255, default='', blank=True) fullname = models.CharField(_(u'Name'), max_length=255, default='', blank=True) organization = models.CharField(_(u'Organization'), max_length=255, default='', blank=True) location = models.CharField(_(u'Location'), max_length=255, default='', blank=True) bio = models.TextField(_(u'About Me'), blank=True) irc_nickname = models.CharField(_(u'IRC nickname'), max_length=255, default='', blank=True) tags = NamespacedTaggableManager(_(u'Tags'), blank=True) # should this user receive contentflagging emails? content_flagging_email = models.BooleanField(default=False) user = models.ForeignKey(DjangoUser, null=True, editable=False, blank=True) # HACK: Grab-bag field for future expansion in profiles # We can store arbitrary data in here and later migrate to relational # tables if the data ever needs to be indexed & queried. Otherwise, # this keeps things nicely denormalized. Ideally, access to this field # should be gated through accessors on the model to make that transition # easier. misc = JSONField(blank=True, null=True) @models.permalink def get_absolute_url(self): return ('devmo.views.profile_view', [self.user.username]) @property def websites(self): if 'websites' not in self.misc: self.misc['websites'] = {} return self.misc['websites'] @websites.setter def websites(self, value): self.misc['websites'] = value _deki_user = None @property def deki_user(self): if not settings.DEKIWIKI_ENDPOINT: # There is no deki_user, if the MindTouch API is disabled. return None if not self._deki_user: # Need to find the DekiUser corresponding to the ID from dekicompat.backends import DekiUserBackend self._deki_user = (DekiUserBackend().get_deki_user( self.deki_user_id)) return self._deki_user def gravatar_url(self, secure=True, size=220, rating='pg', default=DEFAULT_AVATAR): """Produce a gravatar image URL from email address.""" base_url = (secure and 'https://secure.gravatar.com' or 'http://www.gravatar.com') m = hashlib.md5(self.user.email.lower().encode('utf8')) return '%(base_url)s/avatar/%(hash)s?%(params)s' % dict( base_url=base_url, hash=m.hexdigest(), params=urllib.urlencode(dict(s=size, d=default, r=rating))) @property def gravatar(self): return self.gravatar_url() def __unicode__(self): return '%s: %s' % (self.id, self.deki_user_id) def allows_editing_by(self, user): if user == self.user: return True if user.is_staff or user.is_superuser: return True return False @property def mindtouch_language(self): if not self.locale: return '' return settings.LANGUAGE_DEKI_MAP[self.locale] @property def mindtouch_timezone(self): if not self.timezone: return '' base_seconds = self.timezone._utcoffset.days * 86400 offset_seconds = self.timezone._utcoffset.seconds offset_hours = (base_seconds + offset_seconds) / 3600 return "%03d:00" % offset_hours def save(self, *args, **kwargs): skip_mindtouch_put = kwargs.get('skip_mindtouch_put', False) if 'skip_mindtouch_put' in kwargs: del kwargs['skip_mindtouch_put'] super(UserProfile, self).save(*args, **kwargs) if skip_mindtouch_put: return if not settings.DEKIWIKI_ENDPOINT: # Skip if the MindTouch API is unavailable return from dekicompat.backends import DekiUserBackend DekiUserBackend.put_mindtouch_user(self.user) def wiki_activity(self): return Revision.objects.filter( creator=self.user).order_by('-created')[:5]
class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin, SearchMixin): """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) # Related documents, based on tags in common. # The RelatedDocument table is populated by # wiki.cron.calculate_related_documents. related_documents = models.ManyToManyField('self', through='RelatedDocument', symmetrical=False) # 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 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')] 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 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()] def get_absolute_url(self): return reverse('wiki.document', locale=self.locale, args=[self.slug]) @staticmethod def from_url(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 host_safe=True, 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 # Map locale-slug pair to Document ID: doc_query = Document.objects.exclude(current_revision__isnull=True) if id_only: doc_query = doc_query.only('id') try: return doc_query.get(locale=locale, slug=slug) except Document.DoesNotExist: return None 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_revision_by(self, user): """Return whether `user` is allowed to create new revisions of me. The motivation behind this method is that templates and other types of docs may have different permissions. """ # TODO: Add tests for templateness or whatever is required. # Leaving this method signature untouched for now in case we do need # to use it in the future. ~james return True def allows_editing_by(self, user): """Return whether `user` is allowed to edit document-level metadata. If the Document doesn't have a current_revision (nothing approved) then all the Document fields are still editable. Once there is an approved Revision, the Document fields can only be edited by privileged users. """ return (not self.current_revision or user.has_perm('wiki.change_document')) def allows_deleting_by(self, user): """Return whether `user` is allowed to delete this document.""" return (user.has_perm('wiki.delete_document') or user.has_perm('wiki.delete_document_{locale}'.format( locale=self.locale))) 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)) 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_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. """ 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=MAJOR_SIGNIFICANCE, **more_filters).exists() def is_watched_by(self, user): """Return whether `user` is notified of edits to me.""" from wiki.events import EditDocumentEvent return EditDocumentEvent.is_notifying(user, self) def get_topics(self, uncached=False): """Return the list of 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() @classmethod def get_query_fields(cls): return ['document_title__text', 'document_content__text', 'document_summary__text', 'document_keywords__text'] @classmethod def get_mapping(cls): return { 'id': {'type': 'long'}, 'model': {'type': 'string', 'index': 'not_analyzed'}, 'url': {'type': 'string', 'index': 'not_analyzed'}, 'indexed_on': {'type': 'integer'}, 'updated': {'type': 'integer'}, 'document_title': {'type': 'string', 'analyzer': 'snowball'}, 'document_locale': {'type': 'string', 'index': 'not_analyzed'}, 'document_current_id': {'type': 'integer'}, 'document_parent_id': {'type': 'integer'}, 'document_content': {'type': 'string', 'analyzer': 'snowball', 'store': 'yes', 'term_vector': 'with_positions_offsets'}, 'document_category': {'type': 'integer'}, 'document_slug': {'type': 'string', 'index': 'not_analyzed'}, 'document_is_archived': {'type': 'boolean'}, 'document_summary': {'type': 'string', 'analyzer': 'snowball'}, 'document_keywords': {'type': 'string', 'analyzer': 'snowball'}, 'document_product': {'type': 'string', 'index': 'not_analyzed'}, 'document_topic': {'type': 'string', 'index': 'not_analyzed'}, 'document_recent_helpful_votes': {'type': 'integer'}} @classmethod def extract_document(cls, obj_id): obj = cls.uncached.select_related( 'current_revision', 'parent').get(pk=obj_id) d = {} d['id'] = obj.id d['model'] = cls.get_model_name() d['url'] = obj.get_absolute_url() d['indexed_on'] = int(time.time()) d['document_title'] = obj.title d['document_locale'] = obj.locale d['document_parent_id'] = obj.parent.id if obj.parent else None d['document_content'] = obj.html d['document_category'] = obj.category d['document_slug'] = obj.slug d['document_is_archived'] = obj.is_archived d['document_topic'] = [t.slug for t in obj.get_topics(True)] d['document_product'] = [p.slug for p in obj.get_products(True)] if obj.current_revision is not None: d['document_summary'] = obj.current_revision.summary d['document_keywords'] = obj.current_revision.keywords d['updated'] = int(time.mktime( obj.current_revision.created.timetuple())) d['document_current_id'] = obj.current_revision.id d['document_recent_helpful_votes'] = obj.recent_helpful_votes else: d['document_summary'] = None d['document_keywords'] = None d['updated'] = None d['document_current_id'] = None d['document_recent_helpful_votes'] = 0 # Don't query for helpful votes if the document doesn't have a current # revision, or is a template, or is a redirect, or is in Navigation # category (50). if (obj.current_revision and not obj.is_template and not obj.html.startswith(REDIRECT_HTML) and not obj.category == 50): d['document_recent_helpful_votes'] = obj.recent_helpful_votes else: d['document_recent_helpful_votes'] = 0 return d @classmethod def get_indexable(cls): # This function returns all the indexable things, but we # really need to handle the case where something was indexable # and isn't anymore. Given that, this returns everything that # has a revision. indexable = super(cls, cls).get_indexable() indexable = indexable.filter(current_revision__isnull=False) return indexable @classmethod def index(cls, document, **kwargs): # If there are no revisions or the current revision is a # redirect, we want to remove it from the index. if (document['document_current_id'] is None or document['document_content'].startswith(REDIRECT_HTML)): cls.unindex(document['id'], es=kwargs.get('es', None)) return super(cls, cls).index(document, **kwargs) @classmethod def search(cls): s = super(Document, cls).search() return (s.query_fields('document_title__text', 'document_content__text', 'document_summary__text', 'document_keywords__text'))
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) 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 topics this question 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' 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 @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 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 @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 product(self): """Return the product this question is about or an empty mapping if unknown.""" md = self.metadata if 'product' in md: return products.get(md['product'], {}) return {} @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.answers', 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 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 = self.tags.all().order_by('name') cache.add(cache_key, tags, CACHE_TIMEOUT) return tags @classmethod def get_query_fields(cls): return ['question_title', 'question_content', 'question_answer_content'] @classmethod def get_mapping(cls): return { 'id': {'type': 'long'}, 'document_id': {'type': 'string', 'index': 'not_analyzed'}, 'model': {'type': 'string', 'index': 'not_analyzed'}, 'url': {'type': 'string', 'index': 'not_analyzed'}, 'indexed_on': {'type': 'integer'}, 'created': {'type': 'integer'}, 'updated': {'type': 'integer'}, 'product': {'type': 'string', 'index': 'not_analyzed'}, 'topic': {'type': 'string', 'index': 'not_analyzed'}, 'question_title': {'type': 'string', 'analyzer': 'snowball'}, 'question_content': {'type': 'string', 'analyzer': 'snowball', # TODO: Stored because originally, this is the # only field we were excerpting on. Standardize # one way or the other. 'store': 'yes', 'term_vector': 'with_positions_offsets'}, 'question_answer_content': {'type': 'string', 'analyzer': 'snowball'}, 'question_num_answers': {'type': 'integer'}, 'question_is_solved': {'type': 'boolean'}, 'question_is_locked': {'type': 'boolean'}, 'question_has_answers': {'type': 'boolean'}, 'question_has_helpful': {'type': 'boolean'}, 'question_creator': {'type': 'string', 'index': 'not_analyzed'}, 'question_answer_creator': {'type': 'string', 'index': 'not_analyzed'}, 'question_num_votes': {'type': 'integer'}, 'question_num_votes_past_week': {'type': 'integer'}, 'question_answer_votes': {'type': 'integer'}, 'question_tag': {'type': 'string', 'index': 'not_analyzed'}, 'question_locale': {'type': 'string', 'index': 'not_analyzed'}, } @classmethod def extract_document(cls, obj_id, obj=None): """Extracts indexable attributes from a Question and its answers.""" fields = ['id', 'title', 'content', 'num_answers', 'solution_id', 'is_locked', 'created', 'updated', 'num_votes_past_week', 'locale'] composed_fields = ['creator__username'] all_fields = fields + composed_fields if obj is None: # Note: Need to keep this in sync with # tasks.update_question_vote_chunk. obj = cls.uncached.values(*all_fields).get(pk=obj_id) else: fixed_obj = dict([(field, getattr(obj, field)) for field in fields]) fixed_obj['creator__username'] = obj.creator.username obj = fixed_obj d = {} d['id'] = obj['id'] d['document_id'] = cls.get_document_id(obj['id']) d['model'] = cls.get_model_name() # We do this because get_absolute_url is an instance method # and we don't want to create an instance because it's a DB # hit and expensive. So we do it by hand. get_absolute_url # doesn't change much, so this is probably ok. d['url'] = reverse('questions.answers', kwargs={'question_id': obj['id']}) d['indexed_on'] = int(time.time()) # TODO: Sphinx stores created and updated as seconds since the # epoch, so we convert them to that format here so that the # search view works correctly. When we ditch Sphinx, we should # see if it's faster to filter on ints or whether we should # switch them to dates. d['created'] = int(time.mktime(obj['created'].timetuple())) d['updated'] = int(time.mktime(obj['updated'].timetuple())) topics = Topic.uncached.filter(question__id=obj['id']) products = Product.uncached.filter(question__id=obj['id']) d['topic'] = [t.slug for t in topics] d['product'] = [p.slug for p in products] d['question_title'] = obj['title'] d['question_content'] = obj['content'] d['question_num_answers'] = obj['num_answers'] d['question_is_solved'] = bool(obj['solution_id']) d['question_is_locked'] = obj['is_locked'] d['question_has_answers'] = bool(obj['num_answers']) d['question_creator'] = obj['creator__username'] d['question_num_votes'] = (QuestionVote.objects .filter(question=obj['id']) .count()) d['question_num_votes_past_week'] = obj['num_votes_past_week'] d['question_tag'] = list(TaggedItem.tags_for( Question, Question(pk=obj_id)).values_list('name', flat=True)) d['question_locale'] = obj['locale'] answer_values = list(Answer.objects .filter(question=obj_id) .values_list('content', 'creator__username')) d['question_answer_content'] = [a[0] for a in answer_values] d['question_answer_creator'] = list(set(a[1] for a in answer_values)) if not answer_values: d['question_has_helpful'] = False else: d['question_has_helpful'] = Answer.objects.filter( question=obj_id).filter(votes__helpful=True).exists() return d @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, creator__is_active=1) if extra_filter: qs = qs.filter(extra_filter) return qs.count()
class UserProfile(ModelBase): """ The UserProfile *must* exist for each django.contrib.auth.models.User object. This may be relaxed once Dekiwiki isn't the definitive db for user info. timezone and language fields are syndicated to Dekiwiki """ # Website fields defined for the profile form # TODO: Someday this will probably need to allow arbitrary per-profile # entries, and these will just be suggestions. website_choices = [('website', dict( label=_(u'Website'), prefix='http://', regex='^https?://', fa_icon='icon-link', )), ('twitter', dict( label=_(u'Twitter'), prefix='https://twitter.com/', regex='^https?://twitter.com/', fa_icon='icon-twitter', )), ('github', dict( label=_(u'GitHub'), prefix='https://github.com/', regex='^https?://github.com/', fa_icon='icon-github', )), ('stackoverflow', dict( label=_(u'Stack Overflow'), prefix='https://stackoverflow.com/users/', regex='^https?://stackoverflow.com/users/', fa_icon='icon-stackexchange', )), ('linkedin', dict( label=_(u'LinkedIn'), prefix='https://www.linkedin.com/in/', regex='^https?://www.linkedin.com/in/', fa_icon='icon-linkedin', )), ('mozillians', dict( label=_(u'Mozillians'), prefix='https://mozillians.org/u/', regex='^https?://mozillians.org/u/', fa_icon='icon-group', )), ('facebook', dict( label=_(u'Facebook'), prefix='https://www.facebook.com/', regex='^https?://www.facebook.com/', fa_icon='icon-facebook', ))] # This could be a ForeignKey, except wikidb might be # a different db deki_user_id = models.PositiveIntegerField(default=0, editable=False) timezone = TimeZoneField(null=True, blank=True, verbose_name=_(u'Timezone')) locale = LocaleField(null=True, blank=True, db_index=True, verbose_name=_(u'Language')) homepage = models.URLField(max_length=255, blank=True, default='', error_messages={ 'invalid': _(u'This URL has an invalid format. ' u'Valid URLs look like ' u'http://example.com/my_page.') }) title = models.CharField(_(u'Title'), max_length=255, default='', blank=True) fullname = models.CharField(_(u'Name'), max_length=255, default='', blank=True) organization = models.CharField(_(u'Organization'), max_length=255, default='', blank=True) location = models.CharField(_(u'Location'), max_length=255, default='', blank=True) bio = models.TextField(_(u'About Me'), blank=True) irc_nickname = models.CharField(_(u'IRC nickname'), max_length=255, default='', blank=True) tags = NamespacedTaggableManager(_(u'Tags'), blank=True) # should this user receive contentflagging emails? content_flagging_email = models.BooleanField(default=False) user = models.ForeignKey(User, null=True, editable=False, blank=True) # HACK: Grab-bag field for future expansion in profiles # We can store arbitrary data in here and later migrate to relational # tables if the data ever needs to be indexed & queried. Otherwise, # this keeps things nicely denormalized. Ideally, access to this field # should be gated through accessors on the model to make that transition # easier. misc = JSONField(blank=True, null=True) class Meta: db_table = 'user_profiles' def __unicode__(self): return '%s: %s' % (self.id, self.deki_user_id) def get_absolute_url(self): return self.user.get_absolute_url() @property def websites(self): if 'websites' not in self.misc: self.misc['websites'] = {} return self.misc['websites'] @websites.setter def websites(self, value): self.misc['websites'] = value @cached_property def beta_tester(self): return (constance.config.BETA_GROUP_NAME in self.user.groups.values_list('name', flat=True)) @property def gravatar(self): return gravatar_url(self.user) def allows_editing_by(self, user): if user == self.user: return True if user.is_staff or user.is_superuser: return True return False def wiki_activity(self): return (Revision.objects.filter( creator=self.user).order_by('-created')[:5])
class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin): """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+') # 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) # Related documents, based on tags in common. # The RelatedDocument table is populated by # wiki.cron.calculate_related_documents. related_documents = models.ManyToManyField('self', through='RelatedDocument', symmetrical=False) # Cached HTML rendering of approved revision's wiki markup: html = models.TextField(editable=False) # A document's category much 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) # 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')) 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() 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 _clean_category(self): """Make sure a doc's category is the same as its parent's.""" parent = self.parent if parent: self.category = parent.category elif 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.')) else: # An article cannot have both a parent and children. # Make my children the same as me: if self.id: self.translations.all().update(category=self.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() # 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 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 None return self.current_revision.content_parsed @property def language(self): return settings.LANGUAGES[self.locale.lower()] # FF version and OS are hung off the original, untranslated document and # dynamically inherited by translations: firefox_versions = _inherited('firefox_versions', 'firefox_version_set') operating_systems = _inherited('operating_systems', 'operating_system_set') def get_absolute_url(self): return reverse('wiki.document', locale=self.locale, args=[self.slug]) @staticmethod def from_url(url, required_locale=None, id_only=False): """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. """ # Extract locale and path from URL: path = urlparse(url)[2] # never has errors AFAICT locale, path = split_path(path) if required_locale and locale != required_locale: return None path = '/' + path try: view, view_args, view_kwargs = resolve(path) except Http404: return None import wiki.views # Views import models; models import views. if view != wiki.views.document: return None # Map locale-slug pair to Document ID: doc_query = Document.objects.exclude(current_revision__isnull=True) if id_only: doc_query = doc_query.only('id') try: return doc_query.get(locale=locale, slug=view_kwargs['document_slug']) except Document.DoesNotExist: return None def redirect_url(self): """If I am a redirect, return the absolute 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: return anchors[0].get('href') 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_revision_by(self, user): """Return whether `user` is allowed to create new revisions of me. The motivation behind this method is that templates and other types of docs may have different permissions. """ # TODO: Add tests for templateness or whatever is required. # Leaving this method signature untouched for now in case we do need # to use it in the future. ~james return True def allows_editing_by(self, user): """Return whether `user` is allowed to edit document-level metadata. If the Document doesn't have a current_revision (nothing approved) then all the Document fields are still editable. Once there is an approved Revision, the Document fields can only be edited by privileged users. """ return (not self.current_revision or user.has_perm('wiki.change_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 has_voted(self, request): """Did the user already vote for this document?""" if request.user.is_authenticated(): qs = HelpfulVote.objects.filter(document=self, creator=request.user) elif request.anonymous.has_id: anon_id = request.anonymous.anonymous_id qs = HelpfulVote.objects.filter(document=self, anonymous_id=anon_id) else: return False return qs.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. If this is not a translation or has never been approved, return False. """ 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, significance__gte=MAJOR_SIGNIFICANCE, **more_filters).exists() def is_watched_by(self, user): """Return whether `user` is notified of edits to me.""" from wiki.events import EditDocumentEvent return EditDocumentEvent.is_notifying(user, self)
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) 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 topics this question 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' 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 @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 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 @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 product(self): """Return the product this question is about or an empty mapping if unknown.""" md = self.metadata if 'product' in md: return products.get(md['product'], {}) return {} @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.answers', 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 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 = 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, 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 import questions.views # Views import models; models import views. if view != questions.views.answers: 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
class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin): """A localized knowledgebase document, not revision-specific.""" objects = DocumentManager() 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+') # 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) # Related documents, based on tags in common. # The RelatedDocument table is populated by # wiki.cron.calculate_related_documents. related_documents = models.ManyToManyField('self', through='RelatedDocument', symmetrical=False) # Cached HTML rendering of approved revision's wiki markup: html = models.TextField(editable=False) # A document's category much 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) # 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')) def _existing(self, attr, value): """Return an existing doc (if any) in this locale whose `attr` attr is equal to mine.""" return Document.uncached.filter(locale=self.locale, **{attr: value}) 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... existing = self._existing(attr, getattr(self, attr)) if existing.exists(): raise exception(existing[0]) def clean(self): """Translations can't be localizable.""" self._clean_is_localizable() self._clean_category() 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 _clean_category(self): """Make sure a doc's category is the same as its parent's.""" parent = self.parent if parent: self.category = parent.category elif 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.')) else: # An article cannot have both a parent and children. # Make my children the same as me: if self.id: self.translations.all().update(category=self.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._existing(attr, new_value).exists(): 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) try: # Check if the slug or title would collide with an existing doc self._raise_if_collides('slug', SlugCollision) self._raise_if_collides('title', TitleCollision) except UniqueCollision, e: if e.existing.redirect_url() is not None: # If the existing doc is a redirect, delete it and clobber it. e.existing.delete() else: raise e # These are too important to leave to a (possibly omitted) is_valid # call: self._clean_is_localizable() # 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 % dict( href=self.get_absolute_url(), title=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
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 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()] 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 wiki.events import EditDocumentEvent return EditDocumentEvent.is_notifying(user, self) def get_topics(self, uncached=False): """Return the list of 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), fields=[ 'document_title', 'document_summary', 'document_content' ])[:3] cache.add(key, documents) except ES_EXCEPTIONS as exc: statsd.incr('wiki.related_documents.esexception') log.error('ES MLT {err} related_documents for {doc}'.format( doc=repr(self), err=str(exc))) 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): 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.error('ES MLT {err} related_questions for {doc}'.format( doc=repr(self), err=str(exc))) 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 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