Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge pull request #151 from ryanpitts/development

Support for split tagfields on Article, Code models
  • Loading branch information...
commit 7bf890efe5f76f191513d662fed04607f159937f 2 parents c1cc682 + 5296c0d
@ryanpitts ryanpitts authored
View
6 .gitmodules
@@ -10,9 +10,6 @@
[submodule "vendor-local/src/django-south"]
path = vendor-local/src/django-south
url = git://github.com/lambdafu/django-south.git
-[submodule "vendor-local/src/django-taggit"]
- path = vendor-local/src/django-taggit
- url = git://github.com/alex/django-taggit.git
[submodule "vendor-local/src/django-cache-machine"]
path = vendor-local/src/django-cache-machine
url = git://github.com/jbalogh/django-cache-machine.git
@@ -25,3 +22,6 @@
[submodule "vendor-local/src/sorl-thumbnail"]
path = vendor-local/src/sorl-thumbnail
url = git://github.com/sorl/sorl-thumbnail.git
+[submodule "vendor-local/src/django-taggit"]
+ path = vendor-local/src/django-taggit
+ url = https://github.com/ryanpitts/django-taggit.git
View
16 source/articles/admin.py
@@ -26,16 +26,26 @@ class ArticleAdmin(AdminImageMixin, admin.ModelAdmin):
search_fields = ('title', 'body', 'summary',)
date_hierarchy = 'pubdate'
fieldsets = (
- ('', {'fields': (('title', 'slug'), 'subhead', ('pubdate', 'is_live'),)}),
+ ('', {'fields': (('title', 'slug'), 'subhead', ('pubdate', 'is_live'), ('article_type', 'tags'), 'technology_tags', 'concept_tags', )}),
('Article relationships', {'fields': ('authors', 'people', 'organizations', 'code',)}),
- ('Article body', {'fields': ('article_type', 'tags', 'image', 'image_caption', 'image_credit', 'summary', 'body', 'disable_auto_linebreaks')}),
+ ('Article body', {'fields': ('image', 'image_caption', 'image_credit', 'summary', 'body', 'disable_auto_linebreaks')}),
)
inlines = [ArticleBlockInline,]
+ readonly_fields = ('tags',)
+
+ def save_model(self, request, obj, form, change):
+ technology_tags_list = form.cleaned_data['technology_tags']
+ concept_tags_list = form.cleaned_data['concept_tags']
+ merged_tags = technology_tags_list + concept_tags_list
+ if merged_tags:
+ form.cleaned_data['tags'] = merged_tags
+
+ super(ArticleAdmin, self).save_model(request, obj, form, change)
def formfield_for_dbfield(self, db_field, **kwargs):
# More usable heights and widths in admin form fields
field = super(ArticleAdmin, self).formfield_for_dbfield(db_field, **kwargs)
- if db_field.name in ['subhead','tags']:
+ if db_field.name in ['subhead','tags','technology_tags','concept_tags']:
field.widget.attrs['style'] = 'width: 45em;'
if db_field.name in ['title','slug']:
field.widget.attrs['style'] = 'width: 30em;'
View
14 source/articles/models.py
@@ -1,12 +1,14 @@
from datetime import datetime
+import itertools
from django.db import models
from django.template.defaultfilters import date as dj_date, linebreaks
from caching.base import CachingManager, CachingMixin
from sorl.thumbnail import ImageField
-from source.people.models import Person, Organization
from source.code.models import Code
+from source.people.models import Person, Organization
+from source.tags.models import TechnologyTaggedItem, ConceptTaggedItem
from taggit.managers import TaggableManager
@@ -44,7 +46,9 @@ class Article(CachingMixin, models.Model):
people = models.ManyToManyField(Person, blank=True, null=True)
organizations = models.ManyToManyField(Organization, blank=True, null=True)
code = models.ManyToManyField(Code, blank=True, null=True)
- tags = TaggableManager(blank=True)
+ tags = TaggableManager(blank=True, help_text='Automatic combined list of Technology Tags and Concept Tags, for easy searching')
+ technology_tags = TaggableManager(verbose_name='Technology Tags', help_text='A comma-separated list of tags describing relevant technologies', through=TechnologyTaggedItem, blank=True)
+ concept_tags = TaggableManager(verbose_name='Concept Tags', help_text='A comma-separated list of tags describing relevant concepts', through=ConceptTaggedItem, blank=True)
objects = models.Manager()
live_objects = LiveArticleManager()
disable_auto_linebreaks = models.BooleanField(default=False, help_text='Check this if body and article blocks already have HTML paragraph tags.')
@@ -83,7 +87,11 @@ def pretty_body_text(self):
# that already contains <p> tags
_body = linebreaks(_body)
return _body
-
+
+ @property
+ def merged_tag_list(self):
+ '''return a combined list of technology_tags and concept_tags'''
+ return [item for item in itertools.chain(self.technology_tags.all(), self.concept_tags.all())]
def get_live_organization_set(self):
return self.organizations.filter(is_live=True)
View
10 source/articles/templates/articles/_article_category_and_tags_overline.html
@@ -1 +1,9 @@
-<span class="category">{% if article.article_type %}<a href="{{ url('article_list_by_category', article.article_type) }}">{{ article.get_article_type_display() }}</a>{% endif %}{% if article.tags.all().exists() %} <span class="tags">{% for tag in article.tags.all() %}<a href="{{ url('article_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span>{% endif %}</span>
+<span class="category">{% if article.article_type %}<a href="{{ url('article_list_by_category', article.article_type) }}">{{ article.get_article_type_display() }}</a>{% endif %}{% if article.tags.all().exists() %} <span class="tags">{% for tag in article.tags.all() %}<a href="{{ url('article_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span>{% endif %}</span>
+
+{#
+
+ If we ever completely drop the original `tags` field, the code below will
+ generate the proper list of tags for an article object
+
+<span class="category">{% if article.article_type %}<a href="{{ url('article_list_by_category', article.article_type) }}">{{ article.get_article_type_display() }}</a>{% endif %}{% if article.merged_tag_list %} <span class="tags">{% for tag in article.merged_tag_list %}<a href="{{ url('article_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span>{% endif %}</span>
+#}
View
13 source/articles/views.py
@@ -4,7 +4,7 @@
from .models import Article
from source.base.utils import paginate
-from taggit.models import Tag
+from source.tags.utils import filter_queryset_by_tags
# Current iteration does not use this in nav, but leaving dict
# in place for feed, url imports until we make a permanent call
@@ -77,9 +77,8 @@ class ArticleList(ListView):
def dispatch(self, *args, **kwargs):
self.section = kwargs.get('section', None)
self.category = kwargs.get('category', None)
- self.tags = None
self.tag_slugs = kwargs.get('tag_slugs', None)
- self.tag_slug_list = []
+ self.tags = []
return super(ArticleList, self).dispatch(*args, **kwargs)
def get_queryset(self):
@@ -90,11 +89,7 @@ def get_queryset(self):
elif self.category:
queryset = queryset.filter(article_type=self.category)
elif self.tag_slugs:
- self.tag_slug_list = self.tag_slugs.split('+')
- # need to fail if any item in slug list references nonexistent tag
- self.tags = [get_object_or_404(Tag, slug=tag_slug) for tag_slug in self.tag_slug_list]
- for tag_slug in self.tag_slug_list:
- queryset = queryset.filter(tags__slug=tag_slug)
+ queryset, self.tags = filter_queryset_by_tags(queryset, self.tag_slugs, self.tags)
return queryset
@@ -116,7 +111,7 @@ def get_section_links(self, context):
context.update({
'section': SECTION_MAP['articles'],
'active_nav': SECTION_MAP['articles']['slug'],
- 'tags':self.tags,
+ 'tags': self.tags,
'rss_link': reverse('article_list_by_tag_feed', kwargs={'tag_slugs': self.tag_slugs}),
})
else:
View
35 source/base/feeds.py
@@ -1,27 +1,30 @@
from django.contrib.syndication.views import Feed
from django.core.urlresolvers import reverse
+from django.db.models import Q
+from django.http import Http404
from django.shortcuts import get_object_or_404
from source.articles.models import Article
from source.articles.views import CATEGORY_MAP, SECTION_MAP
from source.code.models import Code
+from source.tags.models import TechnologyTag, ConceptTag
+from source.tags.utils import get_validated_tag_list, get_tag_filtered_queryset
from taggit.models import Tag
-class ArticleFeed(Feed):
- description_template = "feeds/article_description.html"
-
+class ObjectWithTagsFeed(Feed):
+ '''common get_object for Article and Code feeds to handle tag queries'''
def get_object(self, request, *args, **kwargs):
self.section = kwargs.get('section', None)
self.category = kwargs.get('category', None)
- self.tags = None
self.tag_slugs = kwargs.get('tag_slugs', None)
- self.tag_slug_list = []
if self.tag_slugs:
self.tag_slug_list = self.tag_slugs.split('+')
- # need to fail if any item in slug list references nonexistent tag
- self.tags = [get_object_or_404(Tag, slug=tag_slug) for tag_slug in self.tag_slug_list]
+ self.tags = get_validated_tag_list(self.tag_slug_list, tags=[])
return ''
+class ArticleFeed(ObjectWithTagsFeed):
+ description_template = "feeds/article_description.html"
+
def title(self, obj):
if self.section:
return "Source: %s" % SECTION_MAP[self.section]['name']
@@ -73,21 +76,10 @@ def items(self, obj):
elif self.category:
queryset = queryset.filter(article_type=self.category)
elif self.tag_slugs:
- for tag_slug in self.tag_slug_list:
- queryset = queryset.filter(tags__slug=tag_slug)
+ queryset = get_tag_filtered_queryset(queryset, self.tag_slug_list)
return queryset[:20]
-class CodeFeed(Feed):
- def get_object(self, request, *args, **kwargs):
- self.tags = None
- self.tag_slugs = kwargs.get('tag_slugs', None)
- self.tag_slug_list = []
- if self.tag_slugs:
- self.tag_slug_list = self.tag_slugs.split('+')
- # need to fail if any item in slug list references nonexistent tag
- self.tags = [get_object_or_404(Tag, slug=tag_slug) for tag_slug in self.tag_slug_list]
- return ''
-
+class CodeFeed(ObjectWithTagsFeed):
def title(self, obj):
identifier = ""
if self.tags:
@@ -113,7 +105,6 @@ def item_description(self, item):
def items(self, obj):
queryset = Code.live_objects.order_by('-created')
- for tag_slug in self.tag_slug_list:
- queryset = queryset.filter(tags__slug=tag_slug)
+ queryset = get_tag_filtered_queryset(queryset, self.tag_slug_list)
return queryset[:20]
View
14 source/code/admin.py
@@ -24,15 +24,25 @@ class CodeAdmin(AdminImageMixin, admin.ModelAdmin):
list_filter = ('is_live', 'is_active',)
search_fields = ('name', 'description',)
fieldsets = (
- ('', {'fields': (('name', 'slug'), ('is_live', 'is_active', 'seeking_contributors'), 'url', 'tags', 'screenshot', 'description', 'summary',)}),
+ ('', {'fields': (('name', 'slug'), ('is_live', 'is_active', 'seeking_contributors'), 'url', 'tags', 'technology_tags', 'concept_tags', 'screenshot', 'description', 'summary',)}),
('Related objects', {'fields': ('people', 'organizations',)}),
)
inlines = [CodeLinkInline,]
+ readonly_fields = ('tags',)
+
+ def save_model(self, request, obj, form, change):
+ technology_tags_list = form.cleaned_data['technology_tags']
+ concept_tags_list = form.cleaned_data['concept_tags']
+ merged_tags = technology_tags_list + concept_tags_list
+ if merged_tags:
+ form.cleaned_data['tags'] = merged_tags
+
+ super(CodeAdmin, self).save_model(request, obj, form, change)
def formfield_for_dbfield(self, db_field, **kwargs):
# More usable heights and widths in admin form fields
field = super(CodeAdmin, self).formfield_for_dbfield(db_field, **kwargs)
- if db_field.name in ['url','tags']:
+ if db_field.name in ['url','tags','technology_tags','concept_tags']:
field.widget.attrs['style'] = 'width: 45em;'
if db_field.name in ['name','slug']:
field.widget.attrs['style'] = 'width: 30em;'
View
11 source/code/models.py
@@ -1,4 +1,5 @@
from datetime import datetime
+import itertools
from django.db import models
from django.template.defaultfilters import striptags, truncatewords
@@ -6,6 +7,7 @@
from caching.base import CachingManager, CachingMixin
from sorl.thumbnail import ImageField
from source.people.models import Person, Organization
+from source.tags.models import TechnologyTaggedItem, ConceptTaggedItem
from taggit.managers import TaggableManager
@@ -32,7 +34,9 @@ class Code(CachingMixin, models.Model):
repo_forks = models.PositiveIntegerField(blank=True, null=True)
repo_watchers = models.PositiveIntegerField(blank=True, null=True)
repo_description = models.TextField(blank=True)
- tags = TaggableManager(blank=True)
+ tags = TaggableManager(blank=True, help_text='Automatic combined list of Technology Tags and Concept Tags, for easy searching')
+ technology_tags = TaggableManager(verbose_name='Technology Tags', help_text='A comma-separated list of tags describing relevant technologies', through=TechnologyTaggedItem, blank=True)
+ concept_tags = TaggableManager(verbose_name='Concept Tags', help_text='A comma-separated list of tags describing relevant concepts', through=ConceptTaggedItem, blank=True)
objects = models.Manager()
live_objects = LiveCodeManager()
@@ -81,6 +85,11 @@ def description_or_summary(self):
elif self.summary:
return self.summary.strip()
return ''
+
+ @property
+ def merged_tag_list(self):
+ '''return a combined list of technology_tags and concept_tags'''
+ return [item for item in itertools.chain(self.technology_tags.all(), self.concept_tags.all())]
def get_live_article_set(self):
return self.article_set.filter(is_live=True, pubdate__lte=datetime.now)
View
11 source/code/templates/code/_code_category_and_tags_overline.html
@@ -1 +1,10 @@
-{% if code.tags.all().exists() %}<span class="category">Tags <span class="tags">{% for tag in code.tags.all() %}<a href="{{ url('code_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span></span> {% endif %}
+{% if code.tags.all().exists() %}<span class="category">Tags <span class="tags">{% for tag in code.tags.all() %}<a href="{{ url('code_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span></span> {% endif %}
+
+{#
+
+ If we ever completely drop the original `tags` field, the code below will
+ generate the proper list of tags for a code object
+
+{% if code.merged_tag_list %}<span class="category">Tags <span class="tags">{% for tag in code.merged_tag_list %}<a href="{{ url('code_list_by_tag', tag.slug) }}">{{ tag }}</a>{% if not loop.last %} {% endif %}{% endfor %}</span></span> {% endif %}
+
+#}
View
2  source/code/templates/code/code_list.html
@@ -34,7 +34,7 @@ <h2 class="grouper-header"><span class="category">{{ alpha.grouper }}</span></h2
{% endfor %}
</div>
{% endfor %}
-
+ {% if not object_list %}<p>No matching code index entries found.</p>{% endif %}
{% endif %}
</div>
{% endblock content %}
View
20 source/code/templates/code/code_list.json
@@ -9,7 +9,25 @@
"seeking_contributors": {{ code.seeking_contributors|lower }},
"summary": {% if not code.summary %}null{% else %}"{{ code.summary|trim|striptags|e }}"{% endif %},
"description": {% if not code.description %}null{% else %}"{{ code.description|trim|striptags|e }}"{% endif %},
- "tags": {% if code.tags.all().exists() %}[ {% for tag in code.tags.all() %}
+ "tags": {% if code.tags.all().exists() %}[ {% for tag in code.tags.all() %}{# TODO: remove `tags` #}
+
+ {
+ "name": "{{ tag|e }}",
+ "source_url": "{{ HTTP_PROTOCOL }}://{{ request.get_host() }}{{ url('code_list_by_tag', tag.slug) }}"
+ }{% if not loop.last %},{% endif %}
+ {% endfor %}
+
+ ]{% else %}null{% endif %},
+ "technology_tags": {% if code.technology_tags.all().exists() %}[ {% for tag in code.technology_tags.all() %}
+
+ {
+ "name": "{{ tag|e }}",
+ "source_url": "{{ HTTP_PROTOCOL }}://{{ request.get_host() }}{{ url('code_list_by_tag', tag.slug) }}"
+ }{% if not loop.last %},{% endif %}
+ {% endfor %}
+
+ ]{% else %}null{% endif %},
+ "concept_tags": {% if code.concept_tags.all().exists() %}[ {% for tag in code.concept_tags.all() %}
{
"name": "{{ tag|e }}",
View
11 source/code/views.py
@@ -5,7 +5,7 @@
from .models import Code
from source.base.utils import paginate
-from taggit.models import Tag
+from source.tags.utils import filter_queryset_by_tags
class CodeList(ListView):
@@ -13,20 +13,15 @@ class CodeList(ListView):
def dispatch(self, *args, **kwargs):
self.render_json = kwargs.get('render_json', False)
- self.tags = None
self.tag_slugs = kwargs.get('tag_slugs', None)
- self.tag_slug_list = []
+ self.tags = []
return super(CodeList, self).dispatch(*args, **kwargs)
def get_queryset(self):
queryset = Code.live_objects.prefetch_related('organizations')
if self.tag_slugs:
- self.tag_slug_list = self.tag_slugs.split('+')
- # need to fail if any item in slug list references nonexistent tag
- self.tags = [get_object_or_404(Tag, slug=tag_slug) for tag_slug in self.tag_slug_list]
- for tag_slug in self.tag_slug_list:
- queryset = queryset.filter(tags__slug=tag_slug)
+ queryset, self.tags = filter_queryset_by_tags(queryset, self.tag_slugs, self.tags)
return queryset
View
1  source/settings/base.py
@@ -20,6 +20,7 @@
'%s.articles' % PROJECT_MODULE,
'%s.code' % PROJECT_MODULE,
'%s.people' % PROJECT_MODULE,
+ '%s.tags' % PROJECT_MODULE,
'caching',
'haystack',
'sorl.thumbnail',
View
0  source/tags/__init__.py
No changes.
View
25 source/tags/admin.py
@@ -0,0 +1,25 @@
+from django.contrib import admin
+
+from .models import TechnologyTag, TechnologyTaggedItem, ConceptTag, ConceptTaggedItem
+
+
+class TechnologyTaggedItemInline(admin.StackedInline):
+ model = TechnologyTaggedItem
+
+class TechnologyTagAdmin(admin.ModelAdmin):
+ list_display = ['name']
+ inlines = [
+ TechnologyTaggedItemInline
+ ]
+
+class ConceptTaggedItemInline(admin.StackedInline):
+ model = ConceptTaggedItem
+
+class ConceptTagAdmin(admin.ModelAdmin):
+ list_display = ['name']
+ inlines = [
+ ConceptTaggedItemInline
+ ]
+
+admin.site.register(TechnologyTag, TechnologyTagAdmin)
+admin.site.register(ConceptTag, ConceptTagAdmin)
View
96 source/tags/migrations/0001_initial.py
@@ -0,0 +1,96 @@
+# -*- coding: 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):
+ # Adding model 'TechnologyTag'
+ db.create_table('tags_technologytag', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
+ ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=100)),
+ ))
+ db.send_create_signal('tags', ['TechnologyTag'])
+
+ # Adding model 'TechnologyTaggedItem'
+ db.create_table('tags_technologytaggeditem', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('object_id', self.gf('django.db.models.fields.IntegerField')(db_index=True)),
+ ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(related_name='tags_technologytaggeditem_tagged_items', to=orm['contenttypes.ContentType'])),
+ ('tag', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['tags.TechnologyTag'])),
+ ))
+ db.send_create_signal('tags', ['TechnologyTaggedItem'])
+
+ # Adding model 'ConceptTag'
+ db.create_table('tags_concepttag', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('name', self.gf('django.db.models.fields.CharField')(max_length=100)),
+ ('slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=100)),
+ ))
+ db.send_create_signal('tags', ['ConceptTag'])
+
+ # Adding model 'ConceptTaggedItem'
+ db.create_table('tags_concepttaggeditem', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('object_id', self.gf('django.db.models.fields.IntegerField')(db_index=True)),
+ ('content_type', self.gf('django.db.models.fields.related.ForeignKey')(related_name='tags_concepttaggeditem_tagged_items', to=orm['contenttypes.ContentType'])),
+ ('tag', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['tags.ConceptTag'])),
+ ))
+ db.send_create_signal('tags', ['ConceptTaggedItem'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'TechnologyTag'
+ db.delete_table('tags_technologytag')
+
+ # Deleting model 'TechnologyTaggedItem'
+ db.delete_table('tags_technologytaggeditem')
+
+ # Deleting model 'ConceptTag'
+ db.delete_table('tags_concepttag')
+
+ # Deleting model 'ConceptTaggedItem'
+ db.delete_table('tags_concepttaggeditem')
+
+
+ models = {
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", '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'})
+ },
+ 'tags.concepttag': {
+ 'Meta': {'object_name': 'ConceptTag'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'})
+ },
+ 'tags.concepttaggeditem': {
+ 'Meta': {'object_name': 'ConceptTaggedItem'},
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'tags_concepttaggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
+ 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['tags.ConceptTag']"})
+ },
+ 'tags.technologytag': {
+ 'Meta': {'object_name': 'TechnologyTag'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'})
+ },
+ 'tags.technologytaggeditem': {
+ 'Meta': {'object_name': 'TechnologyTaggedItem'},
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'tags_technologytaggeditem_tagged_items'", 'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}),
+ 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['tags.TechnologyTag']"})
+ }
+ }
+
+ complete_apps = ['tags']
View
0  source/tags/migrations/__init__.py
No changes.
View
68 source/tags/models.py
@@ -0,0 +1,68 @@
+'''
+We currently use tags for filtering on these content models:
+
+- Article
+- Code
+
+Originally there was but one `tags` field on each model, and it was good.
+But lo and verily, @slifty asked for split tagfields, to differentiate
+between technologies and concepts. And this seemed beautiful to our eyes.
+
+It also seemed so very simple to implement. But as I have discovered,
+django-taggit is in no way designed to accommodate multiple TaggableManagers
+on a given model. This app gives us a way to work around this limitation.
+But beautiful it is not.
+
+To implement split tagfields, a model should keep its original `tags` field,
+and then add `technology_tags` and `concept_tags` fields as TaggableManagers
+with `through` properties pointing at this app's FooTaggedItem fields.
+
+Additionally, that model's admin class should set the original `tags` field
+to readonly, and implement a `save_model` that automatically populates `tags`
+with a concatenated list of `technology_tags` and `concept_tags`.
+
+Yes, this denormalizes things somewhat. But it enables much simpler code for
+displaying lists of tags, and because of the way django-taggit operates,
+having *something* in that `tags` field turns out to be critical for proper
+filtering of querysets, *even against the two split tagfields*. You would
+think that a filter like so ...
+
+ queryset.filter(
+ Q(tags__slug=tag_slug) |
+ Q(technology_tags__slug=tag_slug) |
+ Q(concept_tags__slug=tag_slug)
+ )
+
+... would work just fine, but because of something deep down inside
+django-taggit, this filter will *not* return an object that has a null
+value in `tags`. Even if you entirely remove the `tags` field from
+the filter, this problem still exists. Even if you remove the `tags` field
+from the model itself, the problem still exists; it just migrates to
+`technology_tags` (or whichever TaggableManager appears first in the model
+code.)
+
+Patches against django-taggit have thus far failed to solve the problem. And
+"problem" might not be the right way to put it; that app was never designed
+to support a use case with multiple kinds of tags. Because the denormalized
+version that keeps `tags` around *does* offer some benefits, however, this is
+where we stand for now.
+
+TODO: Figure out that filter bug wtf
+'''
+from django.db import models
+
+from taggit.models import GenericTaggedItemBase, TagBase
+
+
+class TechnologyTag(TagBase):
+ pass
+
+class TechnologyTaggedItem(GenericTaggedItemBase):
+ tag = models.ForeignKey(TechnologyTag, related_name="%(app_label)s_%(class)s_techtag_items")
+
+class ConceptTag(TagBase):
+ pass
+
+class ConceptTaggedItem(GenericTaggedItemBase):
+ tag = models.ForeignKey(ConceptTag, related_name="%(app_label)s_%(class)s_concepttag_items")
+
View
141 source/tags/tests.py
@@ -0,0 +1,141 @@
+from django.http import Http404
+from django.test import TestCase
+
+from source.code.models import Code
+from source.tags.models import TechnologyTag, ConceptTag
+from source.tags.utils import (get_validated_tag_list,
+ get_tag_filtered_queryset, filter_queryset_by_tags)
+
+
+class BaseTestCase(TestCase):
+ def assertQuerysetEqual(self, qs1, qs2):
+ pk = lambda o: o.pk
+ return self.assertEqual(
+ list(sorted(qs1, key=pk)),
+ list(sorted(qs2, key=pk))
+ )
+
+class TestCodeTagAdd(BaseTestCase):
+ code_model = Code
+ tech_tag_model = TechnologyTag
+ concept_tag_model = ConceptTag
+
+ def setUp(self):
+ self.tech_tag = self.tech_tag_model.objects.create(name="javascript", slug="javascript")
+ self.concept_tag = self.concept_tag_model.objects.create(name="mapping", slug="mapping")
+ self.code_one = self.code_model.objects.create(name="supermaps", slug="supermaps")
+ self.code_two = self.code_model.objects.create(name="justmaps", slug="justmaps")
+ self.code_three = self.code_model.objects.create(name="justjs", slug="justjs")
+
+ def test_code_entries(self):
+ self.assertEqual(self.code_one.title, "supermaps")
+ self.assertEqual(self.code_two.title, "justmaps")
+ self.assertEqual(self.code_three.title, "justjs")
+
+ def test_add_tags(self):
+ # make sure code_one has two empty tagfields
+ self.assertEqual(list(self.code_one.technology_tags.all()), [])
+ self.assertEqual(list(self.code_one.concept_tags.all()), [])
+ # add one tag of each kind
+ self.code_one.technology_tags.add("javascript")
+ self.code_one.concept_tags.add("mapping")
+ # make sure code_one has the right tags in each tagfield
+ self.assertEqual(list(self.code_one.technology_tags.all()), [self.tech_tag])
+ self.assertEqual(list(self.code_one.concept_tags.all()), [self.concept_tag])
+
+ # make sure code_two has two empty tagfields
+ self.assertEqual(list(self.code_two.technology_tags.all()), [])
+ self.assertEqual(list(self.code_two.concept_tags.all()), [])
+ # add just one concept tag
+ self.code_two.concept_tags.add("mapping")
+ # make sure code_two has the right tags in each tagfield
+ self.assertEqual(list(self.code_two.technology_tags.all()), [])
+ self.assertEqual(list(self.code_two.concept_tags.all()), [self.concept_tag])
+
+ # make sure code_three has two empty tagfields
+ self.assertEqual(list(self.code_three.technology_tags.all()), [])
+ self.assertEqual(list(self.code_three.concept_tags.all()), [])
+ # add just one technology tag
+ self.code_three.technology_tags.add("javascript")
+ # make sure code_three has the right tags in each tagfield
+ self.assertEqual(list(self.code_three.technology_tags.all()), [self.tech_tag])
+ self.assertEqual(list(self.code_three.concept_tags.all()), [])
+
+class TestCodeTagQueries(BaseTestCase):
+ code_model = Code
+ tech_tag_model = TechnologyTag
+ concept_tag_model = ConceptTag
+
+ def setUp(self):
+ self.tech_tag = self.tech_tag_model.objects.create(name="javascript", slug="javascript")
+ self.concept_tag = self.concept_tag_model.objects.create(name="mapping", slug="mapping")
+ # first code entry gets one tag of each kind
+ self.code_one = self.code_model.objects.create(name="supermaps", slug="supermaps")
+ self.code_one.technology_tags.add("javascript")
+ self.code_one.concept_tags.add("mapping")
+ # second code entry gets just one concept tag
+ self.code_two = self.code_model.objects.create(name="justmaps", slug="justmaps")
+ self.code_two.concept_tags.add("mapping")
+ # third code entry gets just one tech tag
+ self.code_three = self.code_model.objects.create(name="justjs", slug="justjs")
+ self.code_three.technology_tags.add("javascript")
+ # this is not ideal, but because of django-taggit internals,
+ # we can't filter querysets based on tags unless we compile all
+ # technology_tags and concept_tags into a common `tags` list too
+ self.code_one.tags.add("javascript","mapping")
+ self.code_two.tags.add("mapping")
+ self.code_three.tags.add("javascript")
+
+
+ def test_tags_added_properly(self):
+ self.assertEqual(list(self.code_one.technology_tags.all()), [self.tech_tag])
+ self.assertEqual(list(self.code_one.concept_tags.all()), [self.concept_tag])
+ self.assertEqual(list(self.code_two.concept_tags.all()), [self.concept_tag])
+ self.assertEqual(list(self.code_three.technology_tags.all()), [self.tech_tag])
+
+ def test_get_validated_tag_list(self):
+ tag_slug_list_one = ["javascript", "mapping"]
+ tags_one = get_validated_tag_list(tag_slug_list_one)
+ self.assertEqual(tags_one, [self.tech_tag, self.concept_tag])
+
+ tag_slug_list_two = ["javascript", "mapping", "this_tag_does_not_exist"]
+ self.assertRaises(Http404, lambda: get_validated_tag_list(tag_slug_list_two))
+
+ def test_get_tag_filtered_queryset(self):
+ code_objects = self.code_model.objects.all()
+
+ tag_slug_list_one = ["javascript", "mapping"]
+ queryset_one = get_tag_filtered_queryset(code_objects, tag_slug_list_one)
+ self.assertQuerysetEqual(queryset_one, [self.code_one])
+
+ tag_slug_list_two = ["mapping"]
+ queryset_two = get_tag_filtered_queryset(code_objects, tag_slug_list_two)
+ self.assertQuerysetEqual(queryset_two, [self.code_one, self.code_two])
+
+ tag_slug_list_three = ["javascript"]
+ queryset_three = get_tag_filtered_queryset(code_objects, tag_slug_list_three)
+ self.assertQuerysetEqual(queryset_three, [self.code_one, self.code_three])
+
+ def test_filter_queryset_by_tags(self):
+ code_objects = self.code_model.objects.all()
+
+ tag_slugs_one = "javascript+mapping"
+ queryset_one, tags_one = filter_queryset_by_tags(code_objects, tag_slugs_one, tags=[])
+ self.assertQuerysetEqual(queryset_one, [self.code_one])
+ self.assertEqual(tags_one, [self.tech_tag, self.concept_tag])
+
+ tag_slugs_two = "mapping"
+ queryset_two, tags_two = filter_queryset_by_tags(code_objects, tag_slugs_two, tags=[])
+ self.assertQuerysetEqual(queryset_two, [self.code_one, self.code_two])
+ self.assertEqual(tags_two, [self.concept_tag])
+
+ tag_slugs_three = "javascript"
+ queryset_three, tags_three = filter_queryset_by_tags(code_objects, tag_slugs_three, tags=[])
+ self.assertQuerysetEqual(queryset_three, [self.code_one, self.code_three])
+ self.assertEqual(tags_three, [self.tech_tag])
+
+ tag_slugs_four = "javascript+this_tag_does_not_exist"
+ self.assertRaises(Http404, lambda: filter_queryset_by_tags(
+ code_objects, tag_slugs_four, tags=[])
+ )
+
View
75 source/tags/utils.py
@@ -0,0 +1,75 @@
+from django.db.models import Q
+from django.http import Http404
+
+from .models import TechnologyTag, ConceptTag
+from taggit.models import Tag
+
+def filter_queryset_by_tags(queryset, tag_slugs, tags=[]):
+ '''
+ This takes a queryset and a set of tag slugs, and:
+ - does the proper checks to make sure that each tag actually exists
+ - filters the provided queryset based on those tags
+ - returns that queryset along with a list of matched tag model instances
+ for use in page context
+
+ Because we need to match against multiple tag models, we have to do
+ some loops I wish we didn't have to do. This is why we cache.
+
+ The `tag_slugs` argument should be a string captured by a url param,
+ with individual tags separated by a "+" character. The `tags` argument
+ should very likely be an empty list, which will end up holding a set
+ of model instances for the tags from `tag_slugs`.
+
+ This assumes the model for the queryset includes `tags`, `technology_tags`
+ and `concept_tags` fields.
+
+ The `get_validated_tag_list` and `get_tag_filtered_queryset` functions
+ are split into separate pieces so they can be used independently. By the
+ feeds framework, for example.
+
+ TODO: remove support for original `tags` field
+ '''
+
+ _tag_slug_list = tag_slugs.split('+')
+ tags = get_validated_tag_list(_tag_slug_list, tags)
+ queryset = get_tag_filtered_queryset(queryset, _tag_slug_list)
+
+ # make sure we actually have matches for this intersection of tags
+ if not queryset:
+ raise Http404
+
+ return queryset, tags
+
+
+def get_validated_tag_list(tag_slug_list, tags=[]):
+ _slugs_checked = []
+ for slug in tag_slug_list:
+ for model in [TechnologyTag, ConceptTag, Tag]:
+ try:
+ # see if we have a matching tag
+ found_tag = model.objects.get(slug=slug)
+ # add it to list for page context
+ tags.append(found_tag)
+ # remember that we've checked it
+ _slugs_checked.append(slug)
+ break
+ except:
+ pass
+
+ # make sure that we found everything we checked for
+ if _slugs_checked != tag_slug_list:
+ raise Http404
+
+ return tags
+
+
+def get_tag_filtered_queryset(queryset, tag_slug_list=[]):
+ for tag_slug in tag_slug_list:
+ # Look for matches in both types of tagfields
+ # TODO: Remove original `tags` query once content migrates
+ # to new split tagfields
+ queryset = queryset.filter(Q(tags__slug=tag_slug) | Q(technology_tags__slug=tag_slug) | Q(concept_tags__slug=tag_slug))
+ # A record might match multiple tags, but we only want it once
+ queryset = queryset.distinct()
+
+ return queryset
2  vendor-local/src/django-taggit
@@ -1 +1 @@
-Subproject commit 36f6dabcf10e27c7d9442a94243d4189f2a4f121
+Subproject commit b2572fbbe37bb04e9748929c292908bcb9d34d90
Please sign in to comment.
Something went wrong with that request. Please try again.