From d95d8a5fe401479b8adaa10cb77012c130525efd Mon Sep 17 00:00:00 2001 From: Radek Czajka Date: Mon, 14 Jun 2010 13:11:55 +0200 Subject: [PATCH] Allow tags with identical names. Tags are now unique based on slug+category, instead of just slug. Category-qualified tag URL-s introduced. --- .../0008_unique_tag_category_slug.py | 147 ++++++++++++++++++ apps/catalogue/models.py | 44 ++++-- apps/catalogue/templatetags/catalogue_tags.py | 11 +- apps/catalogue/tests.py | 40 +++++ apps/catalogue/views.py | 16 +- wolnelektury/settings.py | 3 + 6 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 apps/catalogue/migrations/0008_unique_tag_category_slug.py diff --git a/apps/catalogue/migrations/0008_unique_tag_category_slug.py b/apps/catalogue/migrations/0008_unique_tag_category_slug.py new file mode 100644 index 000000000..876d0fda5 --- /dev/null +++ b/apps/catalogue/migrations/0008_unique_tag_category_slug.py @@ -0,0 +1,147 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Removing unique constraint on 'Tag', fields ['slug'] + db.delete_unique('catalogue_tag', ['slug']) + + # Adding unique constraint on 'Tag', fields ['category', 'slug'] + db.create_unique('catalogue_tag', ['category', 'slug']) + + + def backwards(self, orm): + + # Adding unique constraint on 'Tag', fields ['slug'] + db.create_unique('catalogue_tag', ['slug']) + + # Removing unique constraint on 'Tag', fields ['category', 'slug'] + db.delete_unique('catalogue_tag', ['category', 'slug']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'catalogue.book': { + 'Meta': {'object_name': 'Book'}, + '_short_html': ('django.db.models.fields.TextField', [], {}), + '_short_html_de': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_en': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_es': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_fr': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_lt': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_pl': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_ru': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_uk': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_tag_counter': ('catalogue.fields.JSONField', [], {'null': 'True'}), + '_theme_counter': ('catalogue.fields.JSONField', [], {'null': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'epub_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'extra_info': ('catalogue.fields.JSONField', [], {}), + 'gazeta_link': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), + 'html_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mp3_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'odt_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'ogg_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['catalogue.Book']"}), + 'parent_number': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'pdf_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '120', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '120'}), + 'txt_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}), + 'wiki_link': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), + 'xml_file': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'blank': 'True'}) + }, + 'catalogue.bookstub': { + 'Meta': {'object_name': 'BookStub'}, + 'author': ('django.db.models.fields.CharField', [], {'max_length': '120'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'pd': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '120', 'db_index': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '120'}), + 'translator': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'translator_death': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + 'catalogue.fragment': { + 'Meta': {'object_name': 'Fragment'}, + '_short_html': ('django.db.models.fields.TextField', [], {}), + '_short_html_de': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_en': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_es': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_fr': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_lt': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_pl': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_ru': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + '_short_html_uk': ('django.db.models.fields.TextField', [], {'null': True, 'blank': True}), + 'anchor': ('django.db.models.fields.CharField', [], {'max_length': '120'}), + 'book': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'fragments'", 'to': "orm['catalogue.Book']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'short_text': ('django.db.models.fields.TextField', [], {}), + 'text': ('django.db.models.fields.TextField', [], {}) + }, + 'catalogue.tag': { + 'Meta': {'unique_together': "(('slug', 'category'),)", 'object_name': 'Tag'}, + 'book_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'category': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'death': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'gazeta_link': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'main_page': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'max_length': '120', 'db_index': 'True'}), + 'sort_key': ('django.db.models.fields.SlugField', [], {'max_length': '120', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'wiki_link': ('django.db.models.fields.CharField', [], {'max_length': '240', 'blank': 'True'}) + }, + 'catalogue.tagrelation': { + 'Meta': {'unique_together': "(('tag', 'content_type', 'object_id'),)", 'object_name': 'TagRelation', 'db_table': "'catalogue_tag_relation'"}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'items'", 'to': "orm['catalogue.Tag']"}) + }, + 'contenttypes.contenttype': { + 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['catalogue'] diff --git a/apps/catalogue/models.py b/apps/catalogue/models.py index 37e6c2e5e..d63581f47 100644 --- a/apps/catalogue/models.py +++ b/apps/catalogue/models.py @@ -43,7 +43,7 @@ class TagSubcategoryManager(models.Manager): class Tag(TagBase): name = models.CharField(_('name'), max_length=50, db_index=True) - slug = models.SlugField(_('slug'), max_length=120, unique=True, db_index=True) + slug = models.SlugField(_('slug'), max_length=120, db_index=True) sort_key = models.SlugField(_('sort key'), max_length=120, db_index=True) category = models.CharField(_('category'), max_length=50, blank=False, null=False, db_index=True, choices=TAG_CATEGORIES) @@ -55,11 +55,22 @@ class Tag(TagBase): death = models.IntegerField(_(u'year of death'), blank=True, null=True) gazeta_link = models.CharField(blank=True, max_length=240) wiki_link = models.CharField(blank=True, max_length=240) + + categories_rev = { + 'autor': 'author', + 'epoka': 'epoch', + 'rodzaj': 'kind', + 'gatunek': 'genre', + 'motyw': 'theme', + 'polka': 'set', + } + categories_dict = dict((item[::-1] for item in categories_rev.iteritems())) class Meta: ordering = ('sort_key',) verbose_name = _('tag') verbose_name_plural = _('tags') + unique_together = (("slug", "category"),) def __unicode__(self): return self.name @@ -69,7 +80,7 @@ class Tag(TagBase): @permalink def get_absolute_url(self): - return ('catalogue.views.tagged_object_list', [self.slug]) + return ('catalogue.views.tagged_object_list', [self.url_chunk]) def has_description(self): return len(self.description) > 0 @@ -90,10 +101,26 @@ class Tag(TagBase): @staticmethod def get_tag_list(tags): if isinstance(tags, basestring): - tag_slugs = tags.split('/') - return [Tag.objects.get(slug=slug) for slug in tag_slugs] + real_tags = [] + category = None + for name in tags.split('/'): + if name in Tag.categories_rev: + category = Tag.categories_rev[name] + else: + if category: + real_tags.append(Tag.objects.get(slug=name, category=category)) + category = None + else: + real_tags.append(Tag.objects.get(slug=name)) + if category: + raise Http404 + return real_tags else: return TagBase.get_tag_list(tags) + + @property + def url_chunk(self): + return '/'.join((Tag.categories_dict[self.category], self.slug)) # TODO: why is this hard-coded ? @@ -172,11 +199,10 @@ class Book(models.Model): def book_tag(self): slug = ('l-' + self.slug)[:120] - book_tag, created = Tag.objects.get_or_create(slug=slug) + book_tag, created = Tag.objects.get_or_create(slug=slug, category='book') if created: book_tag.name = self.title[:50] book_tag.sort_key = slug - book_tag.category = 'book' book_tag.save() return book_tag @@ -289,11 +315,10 @@ class Book(models.Model): if category == 'author': tag_sort_key = tag_name.last_name tag_name = ' '.join(tag_name.first_names) + ' ' + tag_name.last_name - tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name)) + tag, created = Tag.objects.get_or_create(slug=slughifi(tag_name), category=category) if created: tag.name = tag_name tag.sort_key = slughifi(tag_sort_key) - tag.category = category tag.save() book_tags.append(tag) @@ -345,11 +370,10 @@ class Book(models.Model): continue themes = [] for theme_name in theme_names: - tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name)) + tag, created = Tag.objects.get_or_create(slug=slughifi(theme_name), category='theme') if created: tag.name = theme_name tag.sort_key = slughifi(theme_name) - tag.category = 'theme' tag.save() themes.append(tag) new_fragment.save() diff --git a/apps/catalogue/templatetags/catalogue_tags.py b/apps/catalogue/templatetags/catalogue_tags.py index 41be051f2..504ee69d7 100644 --- a/apps/catalogue/templatetags/catalogue_tags.py +++ b/apps/catalogue/templatetags/catalogue_tags.py @@ -11,6 +11,7 @@ from django.utils.encoding import smart_str from django.core.urlresolvers import reverse from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.db.models import Q +from django.conf import settings register = template.Library() @@ -138,7 +139,11 @@ def authentication_form(): def breadcrumbs(tags, search_form=True): from catalogue.forms import SearchForm context = {'tag_list': tags} - if search_form and len(tags) < 6: + try: + max_tag_list = settings.MAX_TAG_LIST + except AttributeError: + max_tag_list = -1 + if search_form and (max_tag_list == -1 or len(tags) < max_tag_list): context['search_form'] = SearchForm(tags=tags) return context @@ -182,10 +187,10 @@ class CatalogueURLNode(Node): else: tags_to_remove.append(tag) - tag_slugs = [tag.slug for tag in tags_to_add] + tag_slugs = [tag.url_chunk for tag in tags_to_add] for tag in tags_to_remove: try: - tag_slugs.remove(tag.slug) + tag_slugs.remove(tag.url_chunk) except KeyError: pass diff --git a/apps/catalogue/tests.py b/apps/catalogue/tests.py index b039e5228..829f8dce2 100644 --- a/apps/catalogue/tests.py +++ b/apps/catalogue/tests.py @@ -434,3 +434,43 @@ class CleanTagRelationTests(TestCase): models.Tag.objects.all().delete() cats = self.client.get('/katalog/lektura/book/').context['categories'] self.assertEqual(cats, {}) + + +class TestIdenticalTag(TestCase): + + def setUp(self): + author = PersonStub(("A",), "B") + + book_info = BookInfoStub(author=author, genre="A B", epoch='A B', kind="A B", + **info_args(u"A B")) + book_text = """ + A BAla ma kota + + + """ + book = models.Book.from_text_and_meta(ContentFile(book_text), book_info) + book.save() + + self.client = Client() + + + def tearDown(self): + models.Book.objects.all().delete() + + + def test_book_tags(self): + """ there should be all related tags in relevant categories """ + + cats = self.client.get('/katalog/lektura/a-b/').context['categories'] + for category in 'author', 'kind', 'genre', 'epoch', 'theme': + self.assertTrue('A B' in [tag.name for tag in cats[category]], + 'missing related tag for %s' % category) + + def test_qualified_url(self): + categories = {'author': 'autor', 'theme': 'motyw', 'epoch': 'epoka', 'kind':'rodzaj', 'genre':'gatunek'} + for cat, localcat in categories.iteritems(): + context = self.client.get('/katalog/%s/a-b/' % localcat).context + self.assertEqual(1, len(context['object_list'])) + self.assertNotEqual({}, context['categories']) + self.assertFalse(cat in context['categories']) + diff --git a/apps/catalogue/views.py b/apps/catalogue/views.py index 4d8ee7e57..745ff10e2 100644 --- a/apps/catalogue/views.py +++ b/apps/catalogue/views.py @@ -74,15 +74,17 @@ def book_list(request): def tagged_object_list(request, tags=''): - # Prevent DoS attacks on our database - if len(tags.split('/')) > 6: - raise Http404 - try: tags = models.Tag.get_tag_list(tags) except models.Tag.DoesNotExist: raise Http404 + try: + if len(tags) > settings.MAX_TAG_LIST: + raise Http404 + except AttributeError: + pass + if len([tag for tag in tags if tag.category == 'book']): raise Http404 @@ -164,8 +166,8 @@ def tagged_object_list(request, tags=''): def book_fragments(request, book_slug, theme_slug): book = get_object_or_404(models.Book, slug=book_slug) - book_tag = get_object_or_404(models.Tag, slug='l-' + book_slug) - theme = get_object_or_404(models.Tag, slug=theme_slug) + book_tag = get_object_or_404(models.Tag, slug='l-' + book_slug, category='book') + theme = get_object_or_404(models.Tag, slug=theme_slug, category='theme') fragments = models.Fragment.tagged.with_all([book_tag, theme]) form = forms.SearchForm() @@ -288,7 +290,7 @@ def _get_result_link(match, tag_list): return match.get_absolute_url() else: return reverse('catalogue.views.tagged_object_list', - kwargs={'tags': '/'.join(tag.slug for tag in tag_list + [match])} + kwargs={'tags': '/'.join(tag.url_chunk for tag in tag_list + [match])} ) def _get_result_type(match): diff --git a/wolnelektury/settings.py b/wolnelektury/settings.py index 66f2f6ce3..db1131aea 100644 --- a/wolnelektury/settings.py +++ b/wolnelektury/settings.py @@ -186,6 +186,9 @@ THUMBNAIL_PROCESSORS = ( TRANSLATION_REGISTRY = "wolnelektury.translation" +# limit number of filtering tags +MAX_TAG_LIST = 6 + # Load localsettings, if they exist try: from localsettings import * -- 2.20.1