From bdd3050bfd5ebb826adf4b0d7c8ad2b021c491b5 Mon Sep 17 00:00:00 2001 From: Tasos Katsoulas Date: Fri, 15 Nov 2024 16:59:03 +0200 Subject: [PATCH] Introduce a custom Tag model. --- kitsune/questions/api.py | 4 +- kitsune/questions/feeds.py | 4 +- ...fig_associated_tags_alter_question_tags.py | 30 ++++++++ kitsune/questions/models.py | 7 +- kitsune/questions/tests/test_models.py | 10 +-- kitsune/questions/tests/test_templates.py | 4 +- kitsune/questions/views.py | 14 ++-- kitsune/search/signals/questions.py | 13 ++-- kitsune/tags/forms.py | 9 +-- kitsune/tags/migrations/0001_initial.py | 72 +++++++++++++++++++ kitsune/tags/models.py | 28 ++++++-- kitsune/tags/templatetags/jinja_helpers.py | 5 +- kitsune/tags/tests/__init__.py | 7 +- kitsune/tags/tests/test_templatetags.py | 5 +- kitsune/tags/utils.py | 6 +- .../wiki/management/commands/dump_topics.py | 5 +- .../migrations/0017_alter_document_tags.py | 25 +++++++ kitsune/wiki/tests/test_models.py | 6 +- 18 files changed, 200 insertions(+), 54 deletions(-) create mode 100644 kitsune/questions/migrations/0019_alter_aaqconfig_associated_tags_alter_question_tags.py create mode 100644 kitsune/tags/migrations/0001_initial.py create mode 100644 kitsune/wiki/migrations/0017_alter_document_tags.py diff --git a/kitsune/questions/api.py b/kitsune/questions/api.py index dd382e3db..86243f2c3 100644 --- a/kitsune/questions/api.py +++ b/kitsune/questions/api.py @@ -9,7 +9,6 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import pagination, permissions, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from taggit.models import Tag from kitsune.products.api_utils import TopicField from kitsune.products.models import Product, Topic @@ -30,6 +29,7 @@ from kitsune.sumo.api_utils import ( SplitSourceField, ) from kitsune.sumo.utils import is_ratelimited +from kitsune.tags.models import SumoTag from kitsune.tags.utils import add_existing_tag from kitsune.users.api import ProfileFKSerializer from kitsune.users.models import Profile @@ -386,7 +386,7 @@ class QuestionViewSet(viewsets.ModelViewSet): for tag in tags: try: add_existing_tag(tag, question.tags) - except Tag.DoesNotExist: + except SumoTag.DoesNotExist: raise GenericAPIException( status.HTTP_403_FORBIDDEN, "You are not authorized to create new tags." ) diff --git a/kitsune/questions/feeds.py b/kitsune/questions/feeds.py index 03284e530..868f4bd52 100644 --- a/kitsune/questions/feeds.py +++ b/kitsune/questions/feeds.py @@ -2,7 +2,6 @@ from django.shortcuts import get_object_or_404 from django.utils.feedgenerator import Atom1Feed from django.utils.html import escape, strip_tags from django.utils.translation import gettext as _ -from taggit.models import Tag from kitsune.products.models import Product, Topic from kitsune.questions import config @@ -10,6 +9,7 @@ from kitsune.questions.models import Question from kitsune.sumo.feeds import Feed from kitsune.sumo.templatetags.jinja_helpers import urlparams from kitsune.sumo.urlresolvers import reverse +from kitsune.tags.models import SumoTag class QuestionsFeed(Feed): @@ -80,7 +80,7 @@ class QuestionsFeed(Feed): class TaggedQuestionsFeed(QuestionsFeed): def get_object(self, request, tag_slug): - return get_object_or_404(Tag, slug=tag_slug) + return get_object_or_404(SumoTag, slug=tag_slug) def title(self, tag): return _("Recently updated questions tagged %s" % tag.name) diff --git a/kitsune/questions/migrations/0019_alter_aaqconfig_associated_tags_alter_question_tags.py b/kitsune/questions/migrations/0019_alter_aaqconfig_associated_tags_alter_question_tags.py new file mode 100644 index 000000000..9ac1fefdb --- /dev/null +++ b/kitsune/questions/migrations/0019_alter_aaqconfig_associated_tags_alter_question_tags.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.16 on 2024-11-15 06:58 + +from django.db import migrations, models +import kitsune.tags.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0001_initial"), + ("questions", "0018_auto_20241105_0532"), + ] + + operations = [ + migrations.AlterField( + model_name="aaqconfig", + name="associated_tags", + field=models.ManyToManyField(blank=True, null=True, to="tags.sumotag"), + ), + migrations.AlterField( + model_name="question", + name="tags", + field=kitsune.tags.models.BigVocabTaggableManager( + help_text="A comma-separated list of tags.", + through="tags.SumoTaggedItem", + to="tags.SumoTag", + verbose_name="Tags", + ), + ), + ] diff --git a/kitsune/questions/models.py b/kitsune/questions/models.py index 8d7a4d2dd..ce2e07eb4 100755 --- a/kitsune/questions/models.py +++ b/kitsune/questions/models.py @@ -21,7 +21,6 @@ from django.utils import translation from django.utils.translation import pgettext from elasticsearch import ElasticsearchException from product_details import product_details -from taggit.models import Tag from kitsune.flagit.models import FlaggedObject from kitsune.products.models import Product, Topic @@ -33,7 +32,7 @@ from kitsune.sumo.models import LocaleField, ModelBase from kitsune.sumo.templatetags.jinja_helpers import urlparams, wiki_to_html from kitsune.sumo.urlresolvers import reverse from kitsune.sumo.utils import chunked -from kitsune.tags.models import BigVocabTaggableManager +from kitsune.tags.models import BigVocabTaggableManager, SumoTag from kitsune.tags.utils import add_existing_tag from kitsune.upload.models import ImageAttachment from kitsune.wiki.models import Document @@ -312,7 +311,7 @@ class Question(AAQBase): if os := self.metadata.get("os"): try: add_existing_tag(os, self.tags) - except Tag.DoesNotExist: + except SumoTag.DoesNotExist: pass product_md = self.metadata.get("product") topic_md = self.metadata.get("category") @@ -825,7 +824,7 @@ class AAQConfig(ModelBase): title = models.CharField(max_length=255, default="") product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="aaq_configs") pinned_articles = models.ManyToManyField(Document, null=True, blank=True) - associated_tags = models.ManyToManyField(Tag, null=True, blank=True) + associated_tags = models.ManyToManyField(SumoTag, null=True, blank=True) enabled_locales = models.ManyToManyField(QuestionLocale) # Whether the configuration is active or not. Only one can be active per product is_active = models.BooleanField(default=False) diff --git a/kitsune/questions/tests/test_models.py b/kitsune/questions/tests/test_models.py index 3c5dfe5fc..e39dba045 100644 --- a/kitsune/questions/tests/test_models.py +++ b/kitsune/questions/tests/test_models.py @@ -6,7 +6,6 @@ from actstream.models import Action, Follow from django.contrib.contenttypes.models import ContentType from django.core.management import call_command from django.db.models import Q -from taggit.models import Tag import kitsune.sumo.models from kitsune.flagit.models import FlaggedObject @@ -33,6 +32,7 @@ from kitsune.questions.tests import ( from kitsune.search.tests import Elastic7TestCase from kitsune.sumo import googleanalytics from kitsune.sumo.tests import TestCase +from kitsune.tags.models import SumoTag from kitsune.tags.tests import TagFactory from kitsune.tags.utils import add_existing_tag from kitsune.users.tests import UserFactory @@ -226,9 +226,9 @@ class TestQuestionMetadata(TestCase): def test_auto_tagging(self): """Make sure tags get applied based on metadata on first save.""" - Tag.objects.create(slug="green", name="green") - Tag.objects.create(slug="troubleshooting", name="Troubleshooting") - Tag.objects.create(slug="firefox", name="Firefox") + SumoTag.objects.get_or_create(name="green", defaults={"slug": "green"}) + SumoTag.objects.get_or_create(name="Troubleshooting", defaults={"slug": "troubleshooting"}) + SumoTag.objects.get_or_create(name="Firefox", defaults={"slug": "firefox"}) q = self.question q.product = ProductFactory(slug="firefox") q.topic = TopicFactory(slug="troubleshooting") @@ -520,7 +520,7 @@ class AddExistingTagTests(TestCase): def test_add_existing_no_such_tag(self): """Assert add_existing_tag doesn't work when the tag doesn't exist.""" - with self.assertRaises(Tag.DoesNotExist): + with self.assertRaises(SumoTag.DoesNotExist): add_existing_tag("nonexistent tag", self.untagged_question.tags) diff --git a/kitsune/questions/tests/test_templates.py b/kitsune/questions/tests/test_templates.py index 03c1c1f95..e61cf1f12 100644 --- a/kitsune/questions/tests/test_templates.py +++ b/kitsune/questions/tests/test_templates.py @@ -9,7 +9,6 @@ from django.conf import settings from django.contrib.auth.models import User from django.core import mail from pyquery import PyQuery as pq -from taggit.models import Tag from kitsune.products.tests import ProductFactory, TopicFactory from kitsune.questions.events import QuestionReplyEvent, QuestionSolvedEvent @@ -19,6 +18,7 @@ from kitsune.questions.views import NO_TAG, UNAPPROVED_TAG from kitsune.sumo.templatetags.jinja_helpers import urlparams from kitsune.sumo.tests import TestCase, attrs_eq, emailmessage_raise_smtp, get, post from kitsune.sumo.urlresolvers import reverse +from kitsune.tags.models import SumoTag from kitsune.tags.tests import TagFactory from kitsune.tidings.models import Watch from kitsune.upload.models import ImageAttachment @@ -965,7 +965,7 @@ class TaggingViewTestsAsAdmin(TestCase): u = UserFactory() add_permission(u, Question, "tag_question") - add_permission(u, Tag, "add_tag") + add_permission(u, SumoTag, "add_tag") self.client.login(username=u.username, password="testpass") self.question = QuestionFactory() diff --git a/kitsune/questions/views.py b/kitsune/questions/views.py index c4485f123..acca08b3f 100644 --- a/kitsune/questions/views.py +++ b/kitsune/questions/views.py @@ -30,7 +30,6 @@ from django.utils.translation import gettext_lazy as _lazy from django.views.decorators.http import require_GET, require_http_methods, require_POST from django_user_agents.utils import get_user_agent from sentry_sdk import capture_exception -from taggit.models import Tag from zenpy.lib.exception import APIException from kitsune.access.decorators import login_required, permission_required @@ -62,6 +61,7 @@ from kitsune.sumo.utils import ( set_aaq_context, simple_paginate, ) +from kitsune.tags.models import SumoTag from kitsune.tags.utils import add_existing_tag from kitsune.tidings.events import ActivationRequestFailed from kitsune.tidings.models import Watch @@ -242,7 +242,7 @@ def question_list(request, product_slug=None, topic_slug=None): if tagged: tag_slugs = tagged.split(",") - tags = Tag.objects.filter(slug__in=tag_slugs) + tags = SumoTag.objects.filter(slug__in=tag_slugs) if tags: for t in tags: question_qs = question_qs.filter(tags__name__in=[t.name]) @@ -1006,7 +1006,7 @@ def add_tag(request, question_id): try: question, canonical_name = _add_tag(request, question_id) - except Tag.DoesNotExist: + except SumoTag.DoesNotExist: template_data = _answers_data(request, question_id) template_data["tag_adding_error"] = UNAPPROVED_TAG template_data["tag_adding_value"] = request.POST.get("tag-name", "") @@ -1032,14 +1032,14 @@ def add_tag_async(request, question_id): """ try: question, canonical_name = _add_tag(request, question_id) - except Tag.DoesNotExist: + except SumoTag.DoesNotExist: return HttpResponse( json.dumps({"error": str(UNAPPROVED_TAG)}), content_type="application/json", status=400 ) if canonical_name: question.clear_cached_tags() - tag = Tag.objects.get(name=canonical_name) + tag = SumoTag.objects.get(name=canonical_name) tag_url = urlparams( reverse("questions.list", args=[question.product_slug]), tagged=tag.slug ) @@ -1429,13 +1429,13 @@ def _add_tag(request, question_id): Tag name (case-insensitive) must be in request.POST['tag-name']. - If no tag name is provided or Tag.DoesNotExist is raised, return None. + If no tag name is provided or SumoTag.DoesNotExist is raised, return None. Otherwise, return the canonicalized tag name. """ if tag_name := request.POST.get("tag-name", "").strip(): question = get_object_or_404(Question, pk=question_id) - # This raises Tag.DoesNotExist if the tag doesn't exist. + # This raises SumoTag.DoesNotExist if the tag doesn't exist. canonical_name = add_existing_tag(tag_name, question.tags) return question, canonical_name diff --git a/kitsune/search/signals/questions.py b/kitsune/search/signals/questions.py index b3d55f930..42471dce0 100644 --- a/kitsune/search/signals/questions.py +++ b/kitsune/search/signals/questions.py @@ -1,13 +1,14 @@ -from django.db.models.signals import post_save, post_delete, m2m_changed +from django.db.models.signals import m2m_changed, post_delete, post_save + +from kitsune.questions.models import Answer, AnswerVote, Question, QuestionVote +from kitsune.search.decorators import search_receiver from kitsune.search.es_utils import ( - index_object, delete_object, + index_object, index_objects_bulk, remove_from_field, ) -from kitsune.search.decorators import search_receiver -from kitsune.questions.models import Question, QuestionVote, Answer, AnswerVote -from taggit.models import Tag +from kitsune.tags.models import SumoTag @search_receiver(post_save, Question) @@ -30,7 +31,7 @@ def handle_answer_delete(instance, **kwargs): index_object.delay("QuestionDocument", instance.question_id) -@search_receiver(post_delete, Tag) +@search_receiver(post_delete, SumoTag) def handle_tag_delete(instance, **kwargs): remove_from_field.delay("QuestionDocument", "question_tag_ids", instance.pk) diff --git a/kitsune/tags/forms.py b/kitsune/tags/forms.py index e7b6b6f99..2680d1973 100644 --- a/kitsune/tags/forms.py +++ b/kitsune/tags/forms.py @@ -7,7 +7,8 @@ from django.utils.encoding import force_str from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from taggit.models import Tag + +from kitsune.tags.models import SumoTag # TODO: Factor out dependency on taggit so it can be a generic large-vocab @@ -73,7 +74,7 @@ class TagWidget(Widget): output += "" return output - tags = Tag.objects.filter(name__in=tag_names) + tags = SumoTag.objects.filter(name__in=tag_names) representations = [render_one(t) for t in tags] return "\n".join(representations) @@ -83,7 +84,7 @@ class TagWidget(Widget): "" if self.read_only or self.async_urls else " deferred" ) if not self.read_only: - vocab = [t.name for t in Tag.objects.only("name").all()] + vocab = [t.name for t in SumoTag.objects.only("name").all()] output += ' data-tag-vocab-json="%s"' % escape(json.dumps(vocab)) output += ">" @@ -150,7 +151,7 @@ class TagField(MultipleChoiceField): def valid_value(self, value): """Check the validity of a single tag.""" - return Tag.objects.filter(name=value).exists() + return SumoTag.objects.filter(name=value).exists() def to_python(self, value): """Ignore the input field if it's blank; don't make a tag called ''.""" diff --git a/kitsune/tags/migrations/0001_initial.py b/kitsune/tags/migrations/0001_initial.py new file mode 100644 index 000000000..d84ebd977 --- /dev/null +++ b/kitsune/tags/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.16 on 2024-11-15 02:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="SumoTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=100, unique=True, verbose_name="name")), + ( + "slug", + models.SlugField( + allow_unicode=True, max_length=100, unique=True, verbose_name="slug" + ), + ), + ("is_archived", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["name", "-updated"], + }, + ), + migrations.CreateModel( + name="SumoTaggedItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("object_id", models.IntegerField(db_index=True, verbose_name="object ID")), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_tagged_items", + to="contenttypes.contenttype", + verbose_name="content type", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tagged_items", + to="tags.sumotag", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/kitsune/tags/models.py b/kitsune/tags/models.py index dcc2bb2e2..be9d303d4 100644 --- a/kitsune/tags/models.py +++ b/kitsune/tags/models.py @@ -1,6 +1,6 @@ +from django.db import models from taggit.managers import TaggableManager - -from kitsune.tags.forms import TagField +from taggit.models import GenericTaggedItemBase, TagBase class BigVocabTaggableManager(TaggableManager): @@ -10,6 +10,26 @@ class BigVocabTaggableManager(TaggableManager): """ - def formfield(self, form_class=TagField, **kwargs): + def __init__(self, *args, **kwargs): + kwargs.setdefault("through", SumoTaggedItem) + super().__init__(*args, **kwargs) + + def formfield(self, form_class=None, **kwargs): """Swap in our custom TagField.""" - return super(BigVocabTaggableManager, self).formfield(form_class, **kwargs) + from kitsune.tags.forms import TagField + + form_class = form_class or TagField + return super().formfield(form_class, **kwargs) + + +class SumoTag(TagBase): + is_archived = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name", "-updated"] + + +class SumoTaggedItem(GenericTaggedItemBase): + tag = models.ForeignKey(SumoTag, related_name="tagged_items", on_delete=models.CASCADE) diff --git a/kitsune/tags/templatetags/jinja_helpers.py b/kitsune/tags/templatetags/jinja_helpers.py index 9fa039adf..e488dc796 100644 --- a/kitsune/tags/templatetags/jinja_helpers.py +++ b/kitsune/tags/templatetags/jinja_helpers.py @@ -1,7 +1,8 @@ import json from django_jinja import library -from taggit.models import Tag + +from kitsune.tags.models import SumoTag @library.global_function @@ -13,4 +14,4 @@ def tags_to_text(tags): @library.global_function def tag_vocab(): """Returns the tag vocabulary as a JSON object.""" - return json.dumps(dict((t[0], t[1]) for t in Tag.objects.values_list("name", "slug"))) + return json.dumps(dict((t[0], t[1]) for t in SumoTag.objects.values_list("name", "slug"))) diff --git a/kitsune/tags/tests/__init__.py b/kitsune/tags/tests/__init__.py index 6172277b6..9520e88ba 100644 --- a/kitsune/tags/tests/__init__.py +++ b/kitsune/tags/tests/__init__.py @@ -1,14 +1,13 @@ +import factory from django.template.defaultfilters import slugify -import factory -from taggit.models import Tag - from kitsune.sumo.tests import FuzzyUnicode +from kitsune.tags.models import SumoTag class TagFactory(factory.django.DjangoModelFactory): class Meta: - model = Tag + model = SumoTag name = FuzzyUnicode() slug = factory.LazyAttribute(lambda o: slugify(o.name)) diff --git a/kitsune/tags/tests/test_templatetags.py b/kitsune/tags/tests/test_templatetags.py index 4995dc827..1a9f8834d 100644 --- a/kitsune/tags/tests/test_templatetags.py +++ b/kitsune/tags/tests/test_templatetags.py @@ -1,8 +1,7 @@ from unittest.mock import Mock -from taggit.models import Tag - from kitsune.sumo.tests import TestCase +from kitsune.tags.models import SumoTag from kitsune.tags.templatetags.jinja_helpers import tags_to_text @@ -23,6 +22,6 @@ class TestTagsToText(TestCase): def _tag(slug): - tag = Mock(spec=Tag) + tag = Mock(spec=SumoTag) tag.slug = slug return tag diff --git a/kitsune/tags/utils.py b/kitsune/tags/utils.py index d3937d50a..d4eaffc8f 100644 --- a/kitsune/tags/utils.py +++ b/kitsune/tags/utils.py @@ -1,6 +1,6 @@ """Fairly generic tagging utilities headed toward a dedicated app""" -from taggit.models import Tag +from kitsune.tags.models import SumoTag def add_existing_tag(tag_name, tag_manager): @@ -8,12 +8,12 @@ def add_existing_tag(tag_name, tag_manager): Given a tag name and a TaggableManager, have the manager add the tag of that name. The tag is matched case-insensitively. If there is no such tag, - raise Tag.DoesNotExist. + raise SumoTag.DoesNotExist. Return the canonically cased name of the tag. """ # TODO: Think about adding a new method to _TaggableManager upstream. - tag = Tag.objects.get(name__iexact=tag_name) + tag = SumoTag.objects.get(name__iexact=tag_name) tag_manager.add(tag) return tag.name diff --git a/kitsune/wiki/management/commands/dump_topics.py b/kitsune/wiki/management/commands/dump_topics.py index 1aa3490a3..11e58274a 100644 --- a/kitsune/wiki/management/commands/dump_topics.py +++ b/kitsune/wiki/management/commands/dump_topics.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand -from taggit.models import TaggedItem - +from kitsune.tags.models import SumoTaggedItem from kitsune.wiki.models import Document @@ -13,5 +12,5 @@ class Command(BaseCommand): print("### This file is generated by ./manage.py dump_topics. ###") print("##########################################################") print("from django.utils.translation import pgettext\n") - for tag in TaggedItem.tags_for(Document): + for tag in SumoTaggedItem.tags_for(Document): print('pgettext("KB Topic", """{tag}""")'.format(tag=tag.name)) diff --git a/kitsune/wiki/migrations/0017_alter_document_tags.py b/kitsune/wiki/migrations/0017_alter_document_tags.py new file mode 100644 index 000000000..20436b702 --- /dev/null +++ b/kitsune/wiki/migrations/0017_alter_document_tags.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.16 on 2024-11-15 02:38 + +from django.db import migrations +import kitsune.tags.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0001_initial"), + ("wiki", "0016_alter_document_contributors"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="tags", + field=kitsune.tags.models.BigVocabTaggableManager( + help_text="A comma-separated list of tags.", + through="tags.SumoTaggedItem", + to="tags.SumoTag", + verbose_name="Tags", + ), + ), + ] diff --git a/kitsune/wiki/tests/test_models.py b/kitsune/wiki/tests/test_models.py index efbdd30b3..9a7eb1dac 100644 --- a/kitsune/wiki/tests/test_models.py +++ b/kitsune/wiki/tests/test_models.py @@ -6,12 +6,12 @@ from datetime import datetime from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError -from taggit.models import TaggedItem from kitsune.products.tests import ProductFactory, TopicFactory from kitsune.sumo.apps import ProgrammingError from kitsune.sumo.tests import TestCase from kitsune.sumo.urlresolvers import reverse +from kitsune.tags.models import SumoTaggedItem from kitsune.users.tests import GroupFactory, UserFactory, add_permission from kitsune.wiki.config import ( CATEGORIES, @@ -69,10 +69,10 @@ class DocumentTests(TestCase): # This works because Django's delete() sees the `tags` many-to-many # field (actually a manager) and follows the reference. d = DocumentFactory(tags=["grape"]) - self.assertEqual(1, TaggedItem.objects.count()) + self.assertEqual(1, SumoTaggedItem.objects.count()) d.delete() - self.assertEqual(0, TaggedItem.objects.count()) + self.assertEqual(0, SumoTaggedItem.objects.count()) def test_category_inheritance(self): """A document's categories must always be those of its parent."""