kitsune/wiki/tasks.py (233 lines of code) (raw):

import logging from datetime import date from typing import Dict, List import waffle from celery import shared_task from django.conf import settings from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.mail import mail_admins from django.db import transaction from django.urls import reverse as django_reverse from django.utils.translation import gettext as _ from requests.exceptions import HTTPError from sentry_sdk import capture_exception from kitsune.kbadge.utils import get_or_create_badge from kitsune.sumo import email_utils from kitsune.sumo.decorators import skip_if_read_only_mode from kitsune.sumo.urlresolvers import reverse from kitsune.sumo.utils import chunked from kitsune.wiki.badges import WIKI_BADGES from kitsune.wiki.models import ( Document, Revision, SlugCollision, TitleCollision, resolves_to_document_view, ) from kitsune.wiki.utils import generate_short_url log = logging.getLogger("k.task") @shared_task def send_reviewed_notification(revision_id: int, document_id: int, message: str): """Send notification of review to the revision creator.""" try: revision = Revision.objects.get(id=revision_id) document = Document.objects.get(id=document_id) except (Revision.DoesNotExist, Document.DoesNotExist) as err: capture_exception(err) return if revision.reviewer == revision.creator: log.debug("Revision (id=%s) reviewed by creator, skipping email" % revision.id) return log.debug("Sending reviewed email for revision (id=%s)" % revision.id) url = reverse("wiki.document_revisions", locale=document.locale, args=[document.slug]) c = { "document_title": document.title, "approved": revision.is_approved, "reviewer": revision.reviewer, "message": message, "revisions_url": url, "host": Site.objects.get_current().domain, } msgs = [] @email_utils.safe_translation def _make_mail(locale, user): if revision.is_approved: subject = _("Your revision has been approved: {title}") else: subject = _("Your revision has been reviewed: {title}") subject = subject.format(title=document.title) mail = email_utils.make_mail( subject=subject, text_template="wiki/email/reviewed.ltxt", html_template="wiki/email/reviewed.html", context_vars=c, from_email=settings.TIDINGS_FROM_ADDRESS, to_email=user.email, ) msgs.append(mail) for user in [revision.creator, revision.reviewer]: if hasattr(user, "profile"): locale = user.profile.locale else: locale = settings.WIKI_DEFAULT_LANGUAGE _make_mail(locale, user) email_utils.send_messages(msgs) @shared_task def send_contributor_notification( based_on_ids: List[int], revision_id: int, document_id: int, message: str ): """Send notification of review to the contributors of revisions.""" try: revision = Revision.objects.get(id=revision_id) document = Document.objects.get(id=document_id) except (Revision.DoesNotExist, Document.DoesNotExist) as err: capture_exception(err) return based_on = Revision.objects.filter(id__in=based_on_ids) text_template = "wiki/email/reviewed_contributors.ltxt" html_template = "wiki/email/reviewed_contributors.html" url = reverse("wiki.document_revisions", locale=document.locale, args=[document.slug]) c = { "document_title": document.title, "approved": revision.is_approved, "reviewer": revision.reviewer, "message": message, "revisions_url": url, "host": Site.objects.get_current().domain, } msgs = [] @email_utils.safe_translation def _make_mail(locale, user): if revision.is_approved: subject = _("A revision you contributed to has been approved: {title}") else: subject = _("A revision you contributed to has been reviewed: {title}") subject = subject.format(title=document.title) mail = email_utils.make_mail( subject=subject, text_template=text_template, html_template=html_template, context_vars=c, from_email=settings.TIDINGS_FROM_ADDRESS, to_email=user.email, ) msgs.append(mail) for r in based_on: # Send email to all contributors except the reviewer and the creator # of the approved revision. if r.creator in [revision.creator, revision.reviewer]: continue user = r.creator if hasattr(user, "profile"): locale = user.profile.locale else: locale = settings.WIKI_DEFAULT_LANGUAGE _make_mail(locale, user) email_utils.send_messages(msgs) @skip_if_read_only_mode def schedule_rebuild_kb(): """Try to schedule a KB rebuild, if we're allowed to.""" if not waffle.switch_is_active("wiki-rebuild-on-demand") or settings.CELERY_TASK_ALWAYS_EAGER: return if cache.get(settings.WIKI_REBUILD_TOKEN): log.debug("Rebuild task already scheduled.") return cache.set(settings.WIKI_REBUILD_TOKEN, True) rebuild_kb.delay() @shared_task @skip_if_read_only_mode def add_short_links(doc_ids): """Create short_url's for a list of docs.""" base_url = "https://{0}%s".format(Site.objects.get_current().domain) docs = Document.objects.filter(id__in=doc_ids) try: for doc in docs: # Use Django's reverse so the locale isn't included. # Since we're not including the locale in the URL, we # should always use the English slug. That ensures that # we'll find and redirect to the translation based on the # locale of the user using the short link. slug = doc.parent.slug if doc.parent else doc.slug endpoint = django_reverse("wiki.document", args=[slug]) doc.update(share_link=generate_short_url(base_url % endpoint)) except HTTPError: # The next run of the `generate_missing_share_links` cron job will # catch all documents that were unable to be processed. pass @shared_task(rate_limit="3/h") @skip_if_read_only_mode def rebuild_kb(): """Re-render all documents in the KB in chunks.""" cache.delete(settings.WIKI_REBUILD_TOKEN) d = ( Document.objects.using("default") .filter(current_revision__isnull=False) .values_list("id", flat=True) ) for chunk in chunked(d, 50): _rebuild_kb_chunk.apply_async(args=[chunk]) @shared_task(rate_limit="5/m") def _rebuild_kb_chunk(data): """Re-render a chunk of documents. Note: Don't use host components when making redirects to wiki pages; those redirects won't be auto-pruned when they're 404s. """ log.info("Rebuilding %s documents." % len(data)) messages = [] for pk in data: message = None try: document = Document.objects.get(pk=pk) # If we know a redirect link to be broken (i.e. if it looks like a # link to a document but the document isn't there), log an error: url = document.redirect_url() if url and resolves_to_document_view(url) and not document.redirect_document(): log.warn("Invalid redirect document: %d" % pk) html = document.parse_and_calculate_links() if document.html != html: # We are calling update here to so we only update the html # column instead of all of them. This bypasses post_save # signal handlers like the one that triggers reindexing. # See bug 797038 and bug 797352. Document.objects.filter(pk=pk).update(html=html) except Document.DoesNotExist: message = "Missing document: %d" % pk except Revision.DoesNotExist: message = "Missing revision for document: %d" % pk except ValidationError as e: message = "ValidationError for %d: %s" % (pk, e.messages[0]) except SlugCollision: message = "SlugCollision: %d" % pk except TitleCollision: message = "TitleCollision: %d" % pk if message: log.debug(message) messages.append(message) if messages: subject = "[%s] Exceptions raised in _rebuild_kb_chunk()" % settings.PLATFORM_NAME mail_admins(subject=subject, message="\n".join(messages)) if not transaction.get_connection().in_atomic_block: transaction.commit() @shared_task @skip_if_read_only_mode def maybe_award_badge(badge_template: Dict, year: int, user_id: int): """Award the specific badge to the user if they've earned it.""" try: user = User.objects.get(id=user_id) except User.DoesNotExist: return badge = get_or_create_badge(badge_template, year) # If the user already has the badge, there is nothing else to do. if badge.is_awarded_to(user): return # Count the number of approved revisions in the appropriate locales # for the current year. qs = Revision.objects.filter( creator=user, is_approved=True, created__gte=date(year, 1, 1), created__lt=date(year + 1, 1, 1), ) if badge_template["slug"] == WIKI_BADGES["kb-badge"]["slug"]: # kb-badge qs = qs.filter(document__locale=settings.WIKI_DEFAULT_LANGUAGE) else: # l10n-badge qs = qs.exclude(document__locale=settings.WIKI_DEFAULT_LANGUAGE) # If the count is 10 or higher, award the badge. if qs.count() >= settings.BADGE_LIMIT_L10N_KB: badge.award_to(user) return True @shared_task @skip_if_read_only_mode def render_document_cascade(base_doc_id): """Given a document, render it and all documents that may be affected.""" # This walks along the graph of links between documents. If there is # a document A that includes another document B as a template, then # there is an edge from A to B in this graph. The goal here is to # process every node exactly once. This is robust to cycles and # diamonds in the graph, since it keeps track of what nodes have # been visited already. try: base_doc = Document.objects.get(id=base_doc_id) except Document.DoesNotExist as err: capture_exception(err) return todo = {base_doc} done = set() while todo: d = todo.pop() if d in done: # Don't process a node twice. continue d.html = d.parse_and_calculate_links() d.save() done.add(d) todo.update( link_to.linked_from for link_to in d.links_to().filter(kind__in=["template", "include"]) )