restrict access to documents that have no approved content (#5181)

* restrict access to documents without approved content

* fix existing tests

* add visibility tests

* adjust based on feedback

* adjust based on feedback

* create common base class for Document and Revision managers
This commit is contained in:
Ryan Johnson 2022-07-28 09:52:56 -07:00 коммит произвёл GitHub
Родитель ae6a9fba78
Коммит 637d71f4b4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
15 изменённых файлов: 1845 добавлений и 173 удалений

Просмотреть файл

@ -5,7 +5,7 @@ from kitsune.kbforums.models import Post, Thread, ThreadLockedError
from kitsune.kbforums.views import sort_threads
from kitsune.sumo.tests import LocalizingClient, TestCase, get
from kitsune.users.tests import UserFactory
from kitsune.wiki.tests import DocumentFactory
from kitsune.wiki.tests import ApprovedRevisionFactory, DocumentFactory
class ThreadFactory(factory.django.DjangoModelFactory):
@ -15,6 +15,12 @@ class ThreadFactory(factory.django.DjangoModelFactory):
creator = factory.SubFactory(UserFactory)
document = factory.SubFactory(DocumentFactory)
@factory.post_generation
def add_approved_revision_to_document(obj, create, extracted, **kwargs):
# Ensure the document has approved content, or else it'll be invisible
# to users without special permission.
ApprovedRevisionFactory(document=obj.document)
class PostFactory(factory.django.DjangoModelFactory):
class Meta:

Просмотреть файл

@ -4,7 +4,7 @@ from pyquery import PyQuery as pq
from kitsune.kbforums.feeds import PostsFeed, ThreadsFeed
from kitsune.kbforums.tests import KBForumTestCase, ThreadFactory, get
from kitsune.wiki.tests import DocumentFactory
from kitsune.wiki.tests import ApprovedRevisionFactory, DocumentFactory
class FeedSortingTestCase(KBForumTestCase):
@ -30,7 +30,7 @@ class FeedSortingTestCase(KBForumTestCase):
def test_multi_feed_titling(self):
"""Ensure that titles are being applied properly to feeds."""
d = DocumentFactory()
d = ApprovedRevisionFactory().document
response = get(self.client, "wiki.discuss.threads", args=[d.slug])
doc = pq(response.content)
given_ = doc('link[type="application/atom+xml"]')[0].attrib["title"]

Просмотреть файл

@ -10,7 +10,7 @@ from kitsune.kbforums.tests import KBForumTestCase, ThreadFactory
from kitsune.sumo.tests import attrs_eq, post, starts_with
from kitsune.users.models import Setting
from kitsune.users.tests import UserFactory
from kitsune.wiki.tests import DocumentFactory
from kitsune.wiki.tests import ApprovedRevisionFactory, DocumentFactory
# Some of these contain a locale prefix on included links, while others don't.
# This depends on whether the tests use them inside or outside the scope of a
@ -75,7 +75,7 @@ class NotificationsTests(KBForumTestCase):
@mock.patch.object(NewThreadEvent, "fire")
def test_fire_on_new_thread(self, fire):
"""The event fires when there is a new thread."""
d = DocumentFactory()
d = ApprovedRevisionFactory().document
u = UserFactory()
self.client.login(username=u.username, password="testpass")
post(
@ -177,7 +177,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
d = DocumentFactory(title="an article title")
d = ApprovedRevisionFactory(document__title="an article title").document
f = self._toggle_watch_kbforum_as(u.username, d, turn_on=True)
u2 = UserFactory(username="jsocol")
self.client.login(username=u2.username, password="testpass")
@ -209,7 +209,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
d = DocumentFactory()
d = ApprovedRevisionFactory().document
f = self._toggle_watch_kbforum_as(u.username, d, turn_on=True)
self.client.login(username=u.username, password="testpass")
post(
@ -227,7 +227,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
d = DocumentFactory(title="an article title")
d = ApprovedRevisionFactory(document__title="an article title").document
f = self._toggle_watch_kbforum_as(u.username, d, turn_on=True)
t = ThreadFactory(title="Sticky Thread", document=d)
u2 = UserFactory(username="jsocol")
@ -253,7 +253,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
d = DocumentFactory(title="an article title")
d = ApprovedRevisionFactory(document__title="an article title").document
f = self._toggle_watch_kbforum_as(u.username, d, turn_on=True)
t = ThreadFactory(document=d)
self.client.login(username=u.username, password="testpass")
@ -267,7 +267,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
d = DocumentFactory(title="an article title")
d = ApprovedRevisionFactory(document__title="an article title").document
f = self._toggle_watch_kbforum_as(u.username, d, turn_on=True)
t = ThreadFactory(title="Sticky Thread", document=d)
self._toggle_watch_thread_as(u.username, t, turn_on=True)
@ -329,7 +329,7 @@ class NotificationsTests(KBForumTestCase):
get_current.return_value.domain = "testserver"
u = UserFactory()
_d = DocumentFactory(title="an article title")
_d = ApprovedRevisionFactory(document__title="an article title").document
d = self._toggle_watch_kbforum_as(u.username, _d, turn_on=True)
t = ThreadFactory(title="Sticky Thread", document=d)
self._toggle_watch_thread_as(u.username, t, turn_on=True)
@ -362,7 +362,7 @@ class NotificationsTests(KBForumTestCase):
notify."""
get_current.return_value.domain = "testserver"
d = DocumentFactory(locale="en-US")
d = ApprovedRevisionFactory(document__locale="en-US").document
u = UserFactory(username="berkerpeksag")
self.client.login(username=u.username, password="testpass")
post(self.client, "wiki.discuss.watch_locale", {"watch": "yes"}, locale="ja")
@ -384,7 +384,9 @@ class NotificationsTests(KBForumTestCase):
"""Watching locale and create a thread."""
get_current.return_value.domain = "testserver"
d = DocumentFactory(title="an article title", locale="en-US")
d = ApprovedRevisionFactory(
document__title="an article title", document__locale="en-US"
).document
u = UserFactory(username="berkerpeksag")
self.client.login(username=u.username, password="testpass")
post(self.client, "wiki.discuss.watch_locale", {"watch": "yes"})
@ -416,7 +418,7 @@ class NotificationsTests(KBForumTestCase):
"""Creating a new thread should email responses"""
get_current.return_value.domain = "testserver"
d = DocumentFactory()
d = ApprovedRevisionFactory().document
u = UserFactory()
self.client.login(username=u.username, password="testpass")
s = Setting.objects.create(user=u, name="kbforums_watch_new_thread", value="False")

Просмотреть файл

@ -199,7 +199,7 @@ class ThreadsTemplateTests(KBForumTestCase):
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
response = post(
self.client,
"wiki.discuss.new_thread",
@ -217,7 +217,7 @@ class ThreadsTemplateTests(KBForumTestCase):
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
response = post(
self.client,
"wiki.discuss.new_thread",
@ -273,7 +273,7 @@ class ThreadsTemplateTests(KBForumTestCase):
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
response = post(self.client, "wiki.discuss.watch_forum", {"watch": "yes"}, args=[d.slug])
self.assertContains(response, "Stop")
@ -285,7 +285,7 @@ class ThreadsTemplateTests(KBForumTestCase):
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
next_url = reverse("wiki.discuss.threads", args=[d.slug])
response = post(
self.client, "wiki.discuss.watch_locale", {"watch": "yes", "next": next_url}
@ -345,7 +345,7 @@ class NewThreadTemplateTests(KBForumTestCase):
"""Preview the thread post."""
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
num_threads = d.thread_set.count()
content = "Full of awesome."
response = post(
@ -380,7 +380,7 @@ class FlaggedPostTests(KBForumTestCase):
class TestRatelimiting(KBForumTestCase):
def test_post_ratelimit(self):
"""Verify that rate limiting kicks in after 4 threads or replies."""
d = DocumentFactory()
d = ApprovedRevisionFactory().document
u = UserFactory()
self.client.login(username=u.username, password="testpass")

Просмотреть файл

@ -3,7 +3,7 @@ from kitsune.kbforums.models import Thread
from kitsune.kbforums.tests import KBForumTestCase, ThreadFactory
from kitsune.sumo.tests import get, post
from kitsune.users.tests import UserFactory, add_permission
from kitsune.wiki.tests import DocumentFactory
from kitsune.wiki.tests import ApprovedRevisionFactory, DocumentFactory
class ThreadTests(KBForumTestCase):
@ -14,7 +14,7 @@ class ThreadTests(KBForumTestCase):
u = UserFactory()
self.client.login(username=u.username, password="testpass")
d = DocumentFactory()
d = ApprovedRevisionFactory().document
post(self.client, "wiki.discuss.watch_forum", {"watch": "yes"}, args=[d.slug])
assert NewThreadEvent.is_notifying(u, d)
# NewPostEvent is not notifying.
@ -83,7 +83,7 @@ class ThreadTests(KBForumTestCase):
"""If document.allow_discussion is false, should return 404."""
u = UserFactory()
self.client.login(username=u.username, password="testpass")
doc = DocumentFactory(allow_discussion=False)
doc = ApprovedRevisionFactory(document__allow_discussion=False).document
def check(url):
response = get(self.client, url, args=[doc.slug])

Просмотреть файл

@ -21,16 +21,16 @@ from kitsune.lib.sumo_locales import LOCALES
from kitsune.sumo.urlresolvers import reverse
from kitsune.sumo.utils import paginate, get_next_url, is_ratelimited
from kitsune.users.models import Setting
from kitsune.wiki.models import Document
from kitsune.wiki.views import get_visible_document_or_404
log = logging.getLogger("k.kbforums")
def get_document(slug, request):
"""Given a slug and a request, get the document or 404."""
return get_object_or_404(
Document, slug=slug, locale=request.LANGUAGE_CODE, allow_discussion=True
"""Given a slug and a request, get the visible document or 404."""
return get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=slug, allow_discussion=True
)

Просмотреть файл

@ -88,7 +88,7 @@ class DocumentDetail(LocaleNegotiationMixin, generics.RetrieveAPIView):
def get_object(self):
queryset = self.get_queryset()
queryset = queryset.filter(locale=self.get_locale())
queryset = queryset.filter(locale=self.get_locale(), current_revision__isnull=False)
obj = get_object_or_404(queryset, **self.kwargs)
self.check_object_permissions(self.request, obj)

69
kitsune/wiki/managers.py Normal file
Просмотреть файл

@ -0,0 +1,69 @@
from django.db import models
from django.db.models import Exists, OuterRef, Q
from kitsune.wiki.permissions import can_delete_documents_or_review_revisions
class VisibilityManager(models.Manager):
"""Abstract base class for the Document and Revision Managers."""
# For managers of models related to documents, provide the name of the model attribute
# that provides the related document. For example, for the manager of revisions, this
# should be "document".
document_relation = None
def get_creator_condition(self, user):
"""
Return a conditional (e.g., a Q or Exists object) that is only true
when the given user is the creator of this document or revision.
"""
raise NotImplementedError
def visible(self, user, **kwargs):
"""
Documents are effectively invisible when they have no approved content,
and the given user is not a superuser, nor allowed to delete documents or
review revisions, nor a creator of one of the (yet unapproved) revisions.
"""
prefix = f"{self.document_relation}__" if self.document_relation else ""
locale = kwargs.get(f"{prefix}locale")
qs = self.filter(**kwargs)
if not user.is_authenticated:
# Anonymous users only see documents with approved content.
return qs.filter(**{f"{prefix}current_revision__isnull": False})
if not (
user.is_superuser or can_delete_documents_or_review_revisions(user, locale=locale)
):
# Authenticated users without permission to see documents that
# have no approved content, can only see those they have created.
return qs.filter(
Q(**{f"{prefix}current_revision__isnull": False})
| self.get_creator_condition(user)
)
return qs
def get_visible(self, user, **kwargs):
return self.visible(user, **kwargs).get()
class DocumentManager(VisibilityManager):
"""The manager for the Document model."""
def get_creator_condition(self, user):
from kitsune.wiki.models import Revision
return Exists(Revision.objects.filter(document=OuterRef("pk"), creator=user))
class RevisionManager(VisibilityManager):
"""The manager for the Revision model."""
document_relation = "document"
def get_creator_condition(self, user):
return Q(creator=user)

Просмотреть файл

@ -40,7 +40,12 @@ from kitsune.wiki.config import (
TEMPLATES_CATEGORY,
TYPO_SIGNIFICANCE,
)
from kitsune.wiki.permissions import DocumentPermissionMixin
from kitsune.wiki.managers import DocumentManager, RevisionManager
from kitsune.wiki.permissions import (
can_delete_documents_or_review_revisions,
DocumentPermissionMixin,
)
log = logging.getLogger("k.wiki")
MAX_REVISION_COMMENT_LENGTH = 255
@ -150,6 +155,8 @@ class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin, DocumentPer
updated_column_name = "current_revision__created"
objects = DocumentManager()
# firefox_versions,
# operating_systems:
# defined in the respective classes below. Use them as in
@ -540,7 +547,7 @@ class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin, DocumentPer
and not waffle.switch_is_active("hide-voting")
)
def translated_to(self, locale):
def translated_to(self, locale, visible_for_user=None):
"""Return the translation of me to the given locale.
If there is no such Document, return None.
@ -553,6 +560,8 @@ class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin, DocumentPer
"far."
)
try:
if visible_for_user:
return Document.objects.get_visible(visible_for_user, locale=locale, parent=self)
return Document.objects.get(locale=locale, parent=self)
except Document.DoesNotExist:
return None
@ -711,6 +720,25 @@ class Document(NotificationsMixin, ModelBase, BigVocabTaggableMixin, DocumentPer
# Clear out both mobile and desktop templates.
cache.delete(doc_html_cache_key(self.locale, self.slug))
def is_visible_for(self, user):
"""
This document is effectively invisible when it has no approved content,
and the given user is not a superuser, nor allowed to delete documents or
review revisions, nor a creator of one of the document's (yet unapproved)
revisions.
"""
return (
self.current_revision
or user.is_superuser
or (
user.is_authenticated
and (
can_delete_documents_or_review_revisions(user, locale=self.locale)
or self.revisions.filter(creator=user).exists()
)
)
)
class AbstractRevision(models.Model):
# **%(class)s** is being used because it will allow a unique reverse name for the field
@ -764,6 +792,8 @@ class Revision(ModelBase, AbstractRevision):
User, on_delete=models.CASCADE, related_name="readied_for_l10n_revisions", null=True
)
objects = RevisionManager()
class Meta(object):
permissions = [
("review_revision", "Can review a revision"),

Просмотреть файл

@ -130,3 +130,16 @@ def _is_reviewer(locale, user):
return False
return user in locale_team.reviewers.all()
def can_delete_documents_or_review_revisions(user, locale=None):
"""
Can the given user delete documents or review revisions. If an optional locale is
provided, will perform the extra check of whether the user is a leader or reviewer
within that locale team.
"""
if locale and (_is_leader(locale, user) or _is_reviewer(locale, user)):
return True
# Fallback to the django permissions.
return user.has_perm("wiki.review_revision") or user.has_perm("wiki.delete_document")

Просмотреть файл

@ -1,9 +1,18 @@
from kitsune.sumo.tests import TestCase
from kitsune.sumo.urlresolvers import reverse
from kitsune.wiki.tests import ApprovedRevisionFactory, DocumentFactory
class TestDocumentListView(TestCase):
def test_it_works(self):
# Create two documents, one with approved content, and one without.
doc1 = DocumentFactory()
doc2 = ApprovedRevisionFactory().document
url = reverse("document-list")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
# Only the document with approved content should be present.
self.assertNotContains(res, doc1.slug)
self.assertNotContains(res, doc1.title)
self.assertContains(res, doc2.slug, count=1)
self.assertContains(res, doc2.title, count=1)

Просмотреть файл

@ -30,7 +30,7 @@ from kitsune.wiki.events import (
ReviewableRevisionInLocaleEvent,
get_diff_for,
)
from kitsune.wiki.models import Document, HelpfulVote, HelpfulVoteMetadata, Revision
from kitsune.wiki.models import Document, HelpfulVote, HelpfulVoteMetadata, Locale, Revision
from kitsune.wiki.tasks import send_reviewed_notification
from kitsune.wiki.tests import (
ApprovedRevisionFactory,
@ -139,6 +139,9 @@ class DocumentTests(TestCaseBase):
def test_english_document_no_approved_content(self):
"""Load an English document with no approved content."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
r = RevisionFactory(content="Some text.", is_approved=False)
response = self.client.get(r.document.get_absolute_url())
self.assertEqual(200, response.status_code)
@ -152,6 +155,9 @@ class DocumentTests(TestCaseBase):
def test_translation_document_no_approved_content(self):
"""Load a non-English document with no approved content, with a parent
with no approved content either."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
r = RevisionFactory(content="Some text.", is_approved=False)
d2 = DocumentFactory(parent=r.document, locale="fr", slug="french")
RevisionFactory(document=d2, content="Moartext", is_approved=False)
@ -166,6 +172,9 @@ class DocumentTests(TestCaseBase):
def test_document_fallback_with_translation(self):
"""The document template falls back to English if translation exists
but it has no approved revisions."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
r = ApprovedRevisionFactory(content="Test")
d2 = DocumentFactory(parent=r.document, locale="fr", slug="french")
RevisionFactory(document=d2, is_approved=False)
@ -185,6 +194,9 @@ class DocumentTests(TestCaseBase):
def test_document_fallback_with_translation_english_slug(self):
"""The document template falls back to English if translation exists
but it has no approved revisions, while visiting the English slug."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
r = ApprovedRevisionFactory(content="Test")
d2 = DocumentFactory(parent=r.document, locale="fr", slug="french")
RevisionFactory(document=d2, is_approved=False)
@ -192,6 +204,7 @@ class DocumentTests(TestCaseBase):
response = self.client.get(url, follow=True)
self.assertEqual("/fr/kb/french", response.redirect_chain[0][0])
doc = pq(response.content)
self.assertEqual(d2.title, doc("h1.sumo-page-heading").text())
# Fallback message is shown.
self.assertEqual(1, len(doc("#doc-pending-fallback")))
# Removing this as it shows up in text(), and we don't want to depend
@ -200,6 +213,13 @@ class DocumentTests(TestCaseBase):
# Included content is English.
self.assertEqual(pq(r.document.html).text(), doc("#doc-content").text())
self.client.logout()
# Users without permission to see unapproved documents will see the
# English document's title.
response = self.client.get(url)
doc = pq(response.content)
self.assertEqual(r.document.title, doc("h1.sumo-page-heading").text())
def test_document_fallback_no_translation(self):
"""The document template falls back to English if no translation exists."""
r = ApprovedRevisionFactory(content="Some text.")
@ -240,6 +260,9 @@ class DocumentTests(TestCaseBase):
Also check the backlink to the redirect page.
"""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
target = DocumentFactory()
target_url = target.get_absolute_url()
@ -261,6 +284,9 @@ class DocumentTests(TestCaseBase):
def test_redirect_no_vote(self):
"""Make sure documents with REDIRECT directives have no vote form."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
target = DocumentFactory()
redirect = RedirectRevisionFactory(target=target).document
redirect_url = redirect.get_absolute_url()
@ -271,6 +297,9 @@ class DocumentTests(TestCaseBase):
def test_redirect_from_nonexistent(self):
"""The template shouldn't crash or print a backlink if the "from" page
doesn't exist."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
d = DocumentFactory()
response = self.client.get(
urlparams(d.get_absolute_url(), redirectlocale="en-US", redirectslug="nonexistent")
@ -279,8 +308,9 @@ class DocumentTests(TestCaseBase):
def test_watch_includes_csrf(self):
"""The watch/unwatch forms should include the csrf tag."""
u = UserFactory()
self.client.login(username=u.username, password="testpass")
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
d = DocumentFactory()
resp = self.client.get(d.get_absolute_url())
doc = pq(resp.content)
@ -288,8 +318,9 @@ class DocumentTests(TestCaseBase):
def test_non_localizable_translate_disabled(self):
"""Non localizable document doesn't show tab for 'Localize'."""
u = UserFactory()
self.client.login(username=u.username, password="testpass")
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
d = DocumentFactory(is_localizable=True)
resp = self.client.get(d.get_absolute_url())
doc = pq(resp.content)
@ -304,6 +335,9 @@ class DocumentTests(TestCaseBase):
def test_obsolete_hide_edit(self):
"""Make sure Edit sidebar link is hidden for obsolete articles."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
d = DocumentFactory(is_archived=True)
r = self.client.get(d.get_absolute_url())
doc = pq(r.content)
@ -413,7 +447,7 @@ class DocumentTests(TestCaseBase):
exists."""
u = UserFactory()
self.client.login(username=u.username, password="testpass")
# Create an English document and a es translated document
# Create an English document and an es translated document
en_rev = ApprovedRevisionFactory(is_ready_for_localization=True)
trans_doc = DocumentFactory(parent=en_rev.document, locale="es")
trans_rev = ApprovedRevisionFactory(document=trans_doc)
@ -567,7 +601,8 @@ class RevisionTests(TestCaseBase):
def test_mark_as_ready_no_approval(self, fire):
"""Mark an unapproved revision as ready for l10n must fail."""
r = RevisionFactory(is_approved=False, is_ready_for_localization=False)
doc = ApprovedRevisionFactory().document
r = RevisionFactory(document=doc, is_approved=False, is_ready_for_localization=False)
u = UserFactory()
add_permission(u, Revision, "mark_ready_for_l10n")
@ -892,6 +927,10 @@ class NewRevisionTests(TestCaseBase):
the document fields are open for editing.
"""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
get_current.return_value.domain = "testserver"
self.d.current_revision = None
@ -911,6 +950,9 @@ class NewRevisionTests(TestCaseBase):
def test_edit_document_POST_removes_old_tags(self):
"""Changing the tags on a document removes the old tags from
that document."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
self.d.current_revision = None
self.d.save()
topics = [TopicFactory(), TopicFactory(), TopicFactory()]
@ -1166,6 +1208,9 @@ class HistoryTests(TestCaseBase):
def test_translation_history_with_english_slug(self):
"""Request in en-US slug but translated locale should redirect to translation history"""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
doc = DocumentFactory(locale=settings.WIKI_DEFAULT_LANGUAGE)
trans = DocumentFactory(parent=doc, locale="bn", slug="bn_trans_slug")
ApprovedRevisionFactory(document=trans)
@ -1179,6 +1224,9 @@ class HistoryTests(TestCaseBase):
def test_translation_history_with_english_slug_while_no_trans(self):
"""Request in en-US slug but untranslated locale should raise 404"""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
doc = DocumentFactory(locale=settings.WIKI_DEFAULT_LANGUAGE)
url = reverse("wiki.document_revisions", args=[doc.slug], locale="bn")
response = self.client.get(url)
@ -2184,6 +2232,9 @@ class TranslateTests(TestCaseBase):
def test_translate_rejected_parent(self):
"""Translate view of rejected English document shows warning."""
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
user = UserFactory()
en_revision = RevisionFactory(is_approved=False, reviewer=user, reviewed=datetime.now())
url = reverse("wiki.translate", locale="es", args=[en_revision.document.slug])
@ -2255,6 +2306,9 @@ class TranslateTests(TestCaseBase):
self.assertEqual(r.id, new_es_rev.based_on_id)
def test_show_translations_page(self):
user = UserFactory()
add_permission(user, Revision, "review_revision")
self.client.login(username=user.username, password="testpass")
en = settings.WIKI_DEFAULT_LANGUAGE
en_doc = DocumentFactory(locale=en, slug="english-slug")
DocumentFactory(locale="de", parent=en_doc)
@ -2731,7 +2785,7 @@ class RevisionDeleteTestCase(TestCaseBase):
# Create document with only 1 revision
doc = DocumentFactory()
rev = RevisionFactory(document=doc)
rev = ApprovedRevisionFactory(document=doc)
# Confirm page should show the message
response = get(self.client, "wiki.delete_revision", args=[doc.slug, rev.id])
@ -2791,6 +2845,7 @@ class DocumentDeleteTestCase(TestCaseBase):
def test_delete_document_without_permissions(self):
"""Deleting a document without permissions sends 403."""
ApprovedRevisionFactory(document=self.document)
self.client.login(username="testuser", password="testpass")
response = get(self.client, "wiki.document_delete", args=[self.document.slug])
self.assertEqual(403, response.status_code)
@ -2863,6 +2918,11 @@ class RecentRevisionsTest(TestCaseBase):
_create_document(title="4", locale="fr", rev_kwargs={"creator": self.u2})
_create_document(title="5", locale="fr", rev_kwargs={"creator": self.u2})
# Create a document without any approved content for visibility testing.
RevisionFactory(
is_approved=False, creator=self.u2, document__title="6", document__locale="fr"
)
self.url = reverse("wiki.revisions")
def test_basic(self):
@ -2919,6 +2979,47 @@ class RecentRevisionsTest(TestCaseBase):
doc = pq(res.content)
self.assertEqual(len(doc("#revisions-fragment ul li:not(.header)")), 1)
def test_visibility(self):
"""
Test that revisions of documents without any approved content are visible
only to their creators, superusers, or users with one of a set of permissions.
"""
with self.subTest("creator"):
self.client.login(username=self.u2.username, password="testpass")
res = self.client.get(self.url)
self.assertEqual(res.status_code, 200)
doc = pq(res.content)
self.assertEqual(len(doc("#revisions-fragment ul li:not(.header)")), 6)
self.client.logout()
for perm in ("superuser", "review_revision", "delete_document"):
with self.subTest(perm):
user = UserFactory(is_superuser=(perm == "superuser"))
if perm == "review_revision":
add_permission(user, Revision, "review_revision")
elif perm == "delete_document":
add_permission(user, Document, "delete_document")
self.client.login(username=user.username, password="testpass")
res = self.client.get(self.url)
self.assertEqual(res.status_code, 200)
doc = pq(res.content)
self.assertEqual(len(doc("#revisions-fragment ul li:not(.header)")), 6)
self.client.logout()
for perm in ("fr__leaders", "fr__reviewers"):
with self.subTest(perm):
user = UserFactory()
locale, role = perm.split("__")
locale_team, _ = Locale.objects.get_or_create(locale=locale)
getattr(locale_team, role).add(user)
self.client.login(username=user.username, password="testpass")
url = urlparams(self.url, locale="fr")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
doc = pq(res.content)
self.assertEqual(len(doc("#revisions-fragment ul li:not(.header)")), 3)
self.client.logout()
# TODO: This should be a factory subclass
def _create_document(

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -4,6 +4,7 @@ import requests
from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Prefetch, Q
from django.shortcuts import get_object_or_404
from django.utils.http import urlencode
from kitsune.dashboards import LAST_7_DAYS
@ -174,3 +175,11 @@ def get_featured_articles(product=None, locale=settings.WIKI_DEFAULT_LANGUAGE):
if len(documents) <= 4:
return documents
return random.sample(documents, 4)
def get_visible_document_or_404(user, **kwargs):
return get_object_or_404(Document.objects.visible(user, **kwargs))
def get_visible_revision_or_404(user, **kwargs):
return get_object_or_404(Revision.objects.visible(user, **kwargs))

Просмотреть файл

@ -67,6 +67,8 @@ from kitsune.wiki.tasks import (
send_contributor_notification,
send_reviewed_notification,
)
from kitsune.wiki.utils import get_visible_document_or_404, get_visible_revision_or_404
log = logging.getLogger("k.wiki")
@ -118,55 +120,56 @@ def document(request, document_slug, document=None):
full_locale_name = None
# If a slug isn't available in the requested locale, fall back to en-US:
try:
doc = Document.objects.get(locale=request.LANGUAGE_CODE, slug=document_slug)
doc = Document.objects.get_visible(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
if not doc.current_revision and doc.parent and doc.parent.current_revision:
# This is a translation but its current_revision is None
# and OK to fall back to parent (parent is approved).
fallback_reason = "translation_not_approved"
elif not doc.current_revision:
# No current_revision, no parent with current revision, so
# nothing to show.
# The document as well as its parent have no approved content, so we won't
# show any content at all.
fallback_reason = "no_content"
except Document.DoesNotExist:
# Look in default language:
doc = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
if request.LANGUAGE_CODE == settings.WIKI_DEFAULT_LANGUAGE:
# Don't repeat the query if it's going to be the same one we just tried.
raise Http404
# No visible document exists in the requested locale so let's try the default locale.
doc = get_visible_document_or_404(
request.user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
# If there's a translation to the requested locale, take it:
translation = doc.translated_to(request.LANGUAGE_CODE)
# If there's a visible translation to the requested locale, redirect to it.
translation = doc.translated_to(request.LANGUAGE_CODE, visible_for_user=request.user)
if translation:
url = translation.get_absolute_url()
url = urlparams(url, query_dict=request.GET)
return HttpResponseRedirect(url)
elif doc.current_revision:
# There is no translation
# and OK to fall back to parent (parent is approved).
# There is no translation, so we'll fall back to the approved parent,
# unless we find an approved translation in a fallback locale.
fallback_reason = "no_translation"
# Find and show the defined fallback locale rather than the English version of the document
# The fallback locale is defined based on the ACCEPT_LANGUAGE header,
# site-wide locale mapping and custom fallback locale
# The custom fallback locale is defined in the FALLBACK_LOCALES array in
# kitsune/wiki/config.py. See bug 800880 for more details
if fallback_reason == "no_translation":
fallback_locale = get_fallback_locale(doc, request)
# Find and show the defined fallback locale rather than the English
# version of the document. The fallback locale is defined based on
# the ACCEPT_LANGUAGE header, site-wide locale mapping and custom
# fallback locale. The custom fallback locale is defined in the
# FALLBACK_LOCALES array in kitsune/wiki/config.py. See bug 800880
# for more details
fallback_locale = get_fallback_locale(doc, request)
# If a fallback locale is defined, show the document in that locale.
if fallback_locale is not None:
# Get the fallback Locale and show doc in the locale
translation = doc.translated_to(fallback_locale)
doc = translation
# For showing the fallback locale explanation message to the user
fallback_reason = "fallback_locale"
full_locale_name = {
request.LANGUAGE_CODE: LOCALES[request.LANGUAGE_CODE].native,
fallback_locale: LOCALES[fallback_locale].native,
}
# If there is no defined fallback locale, show the document in English
else:
doc = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
# If a fallback locale is defined, show the document in that locale,
# otherwise continue with the document in the default language.
if fallback_locale:
# If we have a fallback locale, we're certain to have an approved
# translation in that locale.
doc = doc.translated_to(fallback_locale)
# For showing the fallback locale explanation message to the user
fallback_reason = "fallback_locale"
full_locale_name = {
request.LANGUAGE_CODE: LOCALES[request.LANGUAGE_CODE].native,
fallback_locale: LOCALES[fallback_locale].native,
}
any_localizable_revision = doc.revisions.filter(
is_approved=True, is_ready_for_localization=True
@ -271,15 +274,23 @@ def document(request, document_slug, document=None):
def revision(request, document_slug, revision_id):
"""View a wiki document revision."""
rev = get_object_or_404(Revision, pk=revision_id, document__slug=document_slug)
data = {"document": rev.document, "revision": rev}
rev = get_visible_revision_or_404(
request.user,
pk=revision_id,
document__slug=document_slug,
document__locale=request.LANGUAGE_CODE,
)
doc = rev.document
data = {"document": doc, "revision": rev}
return render(request, "wiki/revision.html", data)
@require_GET
def list_documents(request, category=None):
"""List wiki documents."""
docs = Document.objects.filter(locale=request.LANGUAGE_CODE).order_by("title")
user = request.user
docs = Document.objects.visible(user, locale=request.LANGUAGE_CODE).order_by("title")
if category:
docs = docs.filter(category=category)
try:
@ -415,9 +426,8 @@ def _document_lock(doc_id, username):
@login_required
@require_http_methods(["POST"])
def steal_lock(request, document_slug, revision_id=None):
doc = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
user = request.user
doc = get_visible_document_or_404(user, locale=request.LANGUAGE_CODE, slug=document_slug)
ok = _document_lock_steal(doc.id, user.username)
return HttpResponse("", status=200 if ok else 400)
@ -426,28 +436,31 @@ def steal_lock(request, document_slug, revision_id=None):
@login_required
def edit_document(request, document_slug, revision_id=None):
"""Create a new revision of a wiki document, or edit document metadata."""
user = request.user
try:
doc = Document.objects.get(locale=request.LANGUAGE_CODE, slug=document_slug)
doc = Document.objects.get_visible(user, locale=request.LANGUAGE_CODE, slug=document_slug)
except Document.DoesNotExist:
# Check if the document slug is available in default language.
parent_doc = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
if request.LANGUAGE_CODE == settings.WIKI_DEFAULT_LANGUAGE:
# Don't repeat the query if it's going to be the same one we just tried.
raise Http404
# Check if the document slug is available in the default language.
parent_doc = get_visible_document_or_404(
user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
# If the document is available in default language, show the user the translation page
# of the requested locale
translation = parent_doc.translated_to(request.LANGUAGE_CODE)
# If the document is translated into the requested locale, show them the edit article
# page of that translated document
# We've found the parent using the given slug, so let's see if there's a translation
# in the requested locale that's using a different slug.
translation = parent_doc.translated_to(request.LANGUAGE_CODE, visible_for_user=user)
if translation:
# The document is translated into the requested locale, so show them the edit
# article page of the translated document.
doc = translation
# If the document is not translated into the requested locale, redirect them to translate
# the article page.
else:
# The document is not translated into the requested locale, so redirect them to
# translate the article page.
url = reverse("wiki.translate", locale=request.LANGUAGE_CODE, args=[document_slug])
return HttpResponseRedirect(url)
user = request.user
can_edit_needs_change = doc.allows(user, "edit_needs_change")
can_archive = doc.allows(user, "archive")
@ -580,7 +593,7 @@ def preview_revision(request):
locale = request.POST.get("locale")
if slug and locale:
doc = get_object_or_404(Document, slug=slug, locale=locale)
doc = get_visible_document_or_404(request.user, locale=locale, slug=slug)
products = doc.get_products()
else:
products = Product.objects.all()
@ -594,15 +607,18 @@ def document_revisions(request, document_slug, contributor_form=None):
"""List all the revisions of a given document."""
locale = request.GET.get("locale", request.LANGUAGE_CODE)
try:
doc = Document.objects.get(locale=locale, slug=document_slug)
doc = Document.objects.get_visible(request.user, locale=locale, slug=document_slug)
except Document.DoesNotExist:
if locale == settings.WIKI_DEFAULT_LANGUAGE:
# Don't repeat the query if it's going to be the same one we just tried.
raise Http404
# Check if the document slug is available in default language.
parent_doc = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
parent_doc = get_visible_document_or_404(
request.user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
# If the document is available in default language, show the user the history page
# of the requested locale
translation = parent_doc.translated_to(locale)
# of the requested locale.
translation = parent_doc.translated_to(locale, visible_for_user=request.user)
if translation:
url = reverse("wiki.document_revisions", args=[translation.slug], locale=locale)
return HttpResponseRedirect(url)
@ -769,7 +785,7 @@ def compare_revisions(request, document_slug):
"""
locale = request.GET.get("locale", request.LANGUAGE_CODE)
doc = get_object_or_404(Document, locale=locale, slug=document_slug)
doc = get_visible_document_or_404(request.user, locale=locale, slug=document_slug)
if "from" not in request.GET or "to" not in request.GET:
raise Http404
@ -793,7 +809,9 @@ def compare_revisions(request, document_slug):
@login_required
def select_locale(request, document_slug):
"""Select a locale to translate the document to."""
doc = get_object_or_404(Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug)
doc = get_visible_document_or_404(
request.user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
translated_locales_code = [] # Translated Locales list with Locale Code only
translated_locales = []
untranslated_locales = []
@ -829,10 +847,10 @@ def translate(request, document_slug, revision_id=None):
"""
# Inialization and checks
parent_doc = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
user = request.user
parent_doc = get_visible_document_or_404(
user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
if settings.WIKI_DEFAULT_LANGUAGE == request.LANGUAGE_CODE:
# Don't translate to the default language.
@ -855,6 +873,12 @@ def translate(request, document_slug, revision_id=None):
except Document.DoesNotExist:
doc = None
disclose_description = True
else:
if not doc.is_visible_for(user):
# A translation has been started, but isn't approved yet for
# public visibility, and this user doesn't have permission
# to see/work on it.
raise PermissionDenied
user_has_doc_perm = not doc or doc.allows(user, "edit")
user_has_rev_perm = not doc or doc.allows(user, "create_revision")
@ -1031,7 +1055,9 @@ def translate(request, document_slug, revision_id=None):
@login_required
def watch_document(request, document_slug):
"""Start watching a document for edits."""
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
EditDocumentEvent.notify(request.user, document)
return HttpResponseRedirect(document.get_absolute_url())
@ -1040,7 +1066,9 @@ def watch_document(request, document_slug):
@login_required
def unwatch_document(request, document_slug):
"""Stop watching a document for edits."""
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
EditDocumentEvent.stop_notifying(request.user, document)
return HttpResponseRedirect(document.get_absolute_url())
@ -1162,6 +1190,11 @@ def helpful_vote(request, document_slug):
return HttpResponseBadRequest()
revision = get_object_or_404(Revision, id=smart_int(request.POST["revision_id"]))
if not revision.is_approved:
# I don't think it makes sense to vote for an unapproved revision.
raise PermissionDenied
survey = None
if revision.document.category == TEMPLATES_CATEGORY:
@ -1234,7 +1267,9 @@ def unhelpful_survey(request):
@require_GET
def get_helpful_votes_async(request, document_slug):
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
datums = []
flag_data = []
@ -1334,7 +1369,12 @@ def get_helpful_votes_async(request, document_slug):
@login_required
def delete_revision(request, document_slug, revision_id):
"""Delete a revision."""
revision = get_object_or_404(Revision, pk=revision_id, document__slug=document_slug)
revision = get_visible_revision_or_404(
request.user,
pk=revision_id,
document__slug=document_slug,
document__locale=request.LANGUAGE_CODE,
)
document = revision.document
if not document.allows(request.user, "delete_revision"):
@ -1370,7 +1410,12 @@ def delete_revision(request, document_slug, revision_id):
@require_POST
def mark_ready_for_l10n_revision(request, document_slug, revision_id):
"""Mark a revision as ready for l10n."""
revision = get_object_or_404(Revision, pk=revision_id, document__slug=document_slug)
revision = get_visible_revision_or_404(
request.user,
pk=revision_id,
document__slug=document_slug,
document__locale=settings.WIKI_DEFAULT_LANGUAGE,
)
if not revision.document.allows(request.user, "mark_ready_for_l10n"):
raise PermissionDenied
@ -1392,8 +1437,10 @@ def mark_ready_for_l10n_revision(request, document_slug, revision_id):
@login_required
def delete_document(request, document_slug):
"""Delete a revision."""
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
"""Delete a document."""
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
# Check permission
if not document.allows(request.user, "delete"):
@ -1420,7 +1467,9 @@ def delete_document(request, document_slug):
@require_POST
def add_contributor(request, document_slug):
"""Add a contributor to a document."""
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
if not document.allows(request.user, "edit"):
raise PermissionDenied
@ -1445,7 +1494,9 @@ def add_contributor(request, document_slug):
@require_http_methods(["GET", "POST"])
def remove_contributor(request, document_slug, user_id):
"""Remove a contributor from a document."""
document = get_object_or_404(Document, locale=request.LANGUAGE_CODE, slug=document_slug)
document = get_visible_document_or_404(
request.user, locale=request.LANGUAGE_CODE, slug=document_slug
)
if not document.allows(request.user, "edit"):
raise PermissionDenied
@ -1466,9 +1517,10 @@ def remove_contributor(request, document_slug, user_id):
def show_translations(request, document_slug):
document = get_object_or_404(
Document, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
document = get_visible_document_or_404(
request.user, locale=settings.WIKI_DEFAULT_LANGUAGE, slug=document_slug
)
translated_locales = []
untranslated_locales = []
@ -1544,23 +1596,25 @@ def recent_revisions(request):
fragment = request.GET.pop("fragment", None)
form = RevisionFilterForm(request.GET)
revs = Revision.objects.order_by("-created")
# We are going to ignore validation errors for the most part, but
# this is needed to call the functions that generate `cleaned_data`
# This helps in particular when bad user names are typed in.
form.is_valid()
filters = {}
# If something has gone very wrong, `cleaned_data` won't be there.
if hasattr(form, "cleaned_data"):
if form.cleaned_data.get("locale"):
revs = revs.filter(document__locale=form.cleaned_data["locale"])
filters.update(document__locale=form.cleaned_data["locale"])
if form.cleaned_data.get("users"):
revs = revs.filter(creator__in=form.cleaned_data["users"])
filters.update(creator__in=form.cleaned_data["users"])
if form.cleaned_data.get("start"):
revs = revs.filter(created__gte=form.cleaned_data["start"])
filters.update(created__gte=form.cleaned_data["start"])
if form.cleaned_data.get("end"):
revs = revs.filter(created__lte=form.cleaned_data["end"])
filters.update(created__lte=form.cleaned_data["end"])
revs = Revision.objects.visible(request.user, **filters).order_by("-created")
revs = paginate(request, revs)
@ -1580,7 +1634,7 @@ def recent_revisions(request):
def what_links_here(request, document_slug):
"""List all documents that link to a document."""
locale = request.GET.get("locale", request.LANGUAGE_CODE)
doc = get_object_or_404(Document, locale=locale, slug=document_slug)
doc = get_visible_document_or_404(request.user, locale=locale, slug=document_slug)
links = {}
for link_to in doc.links_to():