├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── MANIFEST.in ├── README.md ├── array_tags ├── __init__.py ├── fields.py ├── lookups.py ├── managers.py ├── static │ └── admin │ │ ├── css │ │ └── array-tag.css │ │ └── js │ │ └── array-tag.js └── widgets.py ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── models.py ├── test.py └── urls.py └── widget.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | env 4 | .coverage 5 | htmlcov 6 | 7 | dist 8 | *.egg-info/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | # - "pypy" 9 | # - "pypy3" 10 | env: 11 | - DJANGO="Django~=1.8.0" 12 | - DJANGO="Django~=1.9.0" 13 | - DJANGO="Django~=1.10.0" 14 | - DJANGO="Django~=1.11.0" 15 | - DJANGO="Django~=2.0.0" 16 | 17 | install: 18 | - pip install -U pip 19 | - if [[ $TRAVIS_PYTHON_VERSION == pypy* ]]; then pip install psycopg2cffi; fi 20 | - if [[ $TRAVIS_PYTHON_VERSION != pypy* ]]; then pip install psycopg2; fi 21 | - pip install $DJANGO 22 | 23 | script: 24 | - python runtests.py 25 | 26 | matrix: 27 | exclude: 28 | - env: DJANGO="Django~=1.9.0" 29 | python: "3.3" 30 | - env: DJANGO="Django~=1.9.0" 31 | python: "pypy3" 32 | - env: DJANGO="Django~=1.10.0" 33 | python: "3.3" 34 | - env: DJANGO="Django~=1.10.0" 35 | python: "pypy3" 36 | - env: DJANGO="Django~=1.11.0" 37 | python: "3.3" 38 | - env: DJANGO="Django~=1.11.0" 39 | python: "pypy3" 40 | - env: DJANGO="Django~=2.0.0" 41 | python: "2.7" 42 | - env: DJANGO="Django~=2.0.0" 43 | python: "3.3" 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 2 | 3 | Make compatible with Django 1.10 4 | Set output_field on ArrayLength db func 5 | 6 | # 0.1.7 7 | 8 | Change manager methods to return querysets. 9 | 10 | # 0.1.6 11 | 12 | Added `get_most_like_by_FIELD` helper method. 13 | Safely replace None with [] 14 | 15 | # 0.1.5 16 | 17 | Added `lower` option to `TagField` 18 | 19 | # 0.1.4 20 | 21 | Added `TagQuerySet.most_like` method. 22 | 23 | # 0.1.3 24 | 25 | Remove debug kruft from fixing previous bug. 26 | 27 | # 0.1.2 28 | 29 | Ensure we remove ordering from querysets we're going to count to avoid extra 30 | fields in the group by ruining the count. 31 | 32 | # 0.1.1 33 | 34 | Fixed setting base_field so it didn't break Migrations 35 | Removed contribute_to_class 36 | 37 | # 0.1 38 | 39 | Initial release 40 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include array_tags/static *.css 2 | recursive-include array_tags/static *.js 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-array-tags 2 | ================= 3 | 4 | A simple Tag solution for Django using 5 | `django.contrib.postgres.fields.ArrayField`. 6 | 7 | Usage 8 | ----- 9 | 10 | Add the ArrayField to your model: 11 | 12 | from array_tags.fields import TagField 13 | from array_tags.managers import TagQuerySet 14 | 15 | 16 | class MyModel(models.Model): 17 | tags = TagField() 18 | 19 | objects = TagQuerySet.as_manager() 20 | 21 | Now you have tags! Values will be stripped and de-duplicated on save. 22 | 23 | You can optionally pass `lower=True` to TagField to lower-case all values 24 | before saving. 25 | 26 | The model will gain a helper method `get_most_like_by_FIELD` where `FIELD` is 27 | replaced with the name of the field. This will call the `most_like` method on 28 | the manager, passing the field name and this instances tags. 29 | 30 | TagQuerySet 31 | ----------- 32 | 33 | For convenience, there is also a `TagQuerySet` which adds three methods: 34 | 35 | `all_tag_values(name)` 36 | 37 | Returns a values queryset of all the tags in the objects in the current 38 | queryset from the TagField named `name`. 39 | 40 | `count_tag_values(name)` 41 | 42 | Returns a values queryset of tags and how many objects have that tag, from the 43 | current queryset. 44 | 45 | `most_like(name, tags)` 46 | 47 | Returns a queryset ordered by the number of tags in field `name` found in 48 | `tags`. The number is annotated in `similarity`. 49 | 50 | Unnest 51 | ------ 52 | 53 | Finally, there is an additional ORM Function `Unnest` for applying the Postgres 54 | Array function `Unnest` in queries. 55 | 56 | 57 | Admin Widget 58 | ============ 59 | 60 | Included is a "smart" tag widget for use in Admin. 61 | 62 | ![Widget Example](widget.png) 63 | 64 | Unlike other uses, this requires `array_tags` to be included in 65 | `settings.INSTALLED_APPS`. 66 | 67 | In your admin.py: 68 | 69 | from django.contrib.postgres.fields import ArrayField 70 | from array_tags import widgets 71 | 72 | 73 | class MyClassAdmin(admin.ModelsAdmin): 74 | formfield_overrides = { 75 | ArrayField: {'widget': widgets.AdminTagWidget}, 76 | } 77 | 78 | -------------------------------------------------------------------------------- /array_tags/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import TagField # NOQA 2 | from .managers import TagQuerySet # NOQA 3 | -------------------------------------------------------------------------------- /array_tags/fields.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField 2 | from django.db import models 3 | 4 | 5 | class TagField(ArrayField): 6 | def __init__(self, **kwargs): 7 | self.lower = kwargs.pop('lower', False) 8 | kwargs.setdefault('blank', True) 9 | kwargs.setdefault('base_field', models.CharField(max_length=50)) 10 | super(TagField, self).__init__(**kwargs) 11 | 12 | def pre_save(self, model_instance, add): 13 | ''' 14 | Trim whitspace and deduplicate values. 15 | ''' 16 | values = super(TagField, self).pre_save(model_instance, add) 17 | if values is None: 18 | return [] 19 | values = {val.strip() for val in values} 20 | if self.lower: 21 | values = {val.lower() for val in values} 22 | return tuple(values) 23 | 24 | def contribute_to_class(self, cls, name, *args, **kwargs): 25 | ''' 26 | Add a 'get_{name}_most_like' method. 27 | ''' 28 | super(TagField, self).contribute_to_class(cls, name, *args, **kwargs) 29 | 30 | def get_most_like_by_FIELD(self, exclude_self=True, field=name): 31 | qset = self.__class__._default_manager.all() 32 | if exclude_self: 33 | qset = qset.exclude(pk=self.pk) 34 | return qset.most_like(field, getattr(self, field)) 35 | 36 | setattr(cls, 'get_most_like_by_%s' % name, get_most_like_by_FIELD) 37 | -------------------------------------------------------------------------------- /array_tags/lookups.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Unnest(models.Func): 5 | function = 'unnest' 6 | arity = 1 7 | 8 | 9 | class ArrayLength(models.Func): 10 | function = 'array_length' 11 | arity = 1 12 | output_field = models.IntegerField() 13 | 14 | 15 | class Intersect(models.Func): 16 | template = 'array_length(ARRAY(SELECT * FROM UNNEST(%(field)s) WHERE UNNEST = ANY(%(value)s)), 1)' 17 | output_field = models.IntegerField() 18 | arity = 2 19 | 20 | def as_sql(self, compiler, connection): 21 | field_sql, field_params = compiler.compile(self.source_expressions[0]) 22 | value_sql, value_params = compiler.compile(self.source_expressions[1]) 23 | 24 | template = self.extra.get('template', self.template) 25 | return ( 26 | template % {'field': field_sql, 'value': value_sql}, 27 | field_params + value_params, 28 | ) 29 | -------------------------------------------------------------------------------- /array_tags/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Count, QuerySet, F 2 | 3 | from .lookups import Unnest, Intersect 4 | 5 | 6 | class TagQuerySet(QuerySet): 7 | ''' 8 | Mix into your tagged model to provide extra helpers 9 | ''' 10 | def all_tag_values(self, name): 11 | return ( 12 | self.order_by() 13 | .annotate(_v=Unnest(name)) 14 | .values_list('_v', flat=True) 15 | .distinct() 16 | ) 17 | 18 | def count_tag_values(self, name): 19 | return ( 20 | self.order_by() 21 | .annotate(_v=Unnest(name)) 22 | .values('_v') 23 | .annotate(count=Count('*')) 24 | .values_list('_v', 'count') 25 | ) 26 | 27 | def most_like(self, field, tags): 28 | ''' 29 | ''' 30 | return ( 31 | self.order_by() 32 | .annotate(similarity=Intersect(F(field), tags)) 33 | .order_by('-similarity') 34 | ) 35 | -------------------------------------------------------------------------------- /array_tags/static/admin/css/array-tag.css: -------------------------------------------------------------------------------- 1 | div.tag-input { 2 | float: left; 3 | } 4 | .tags span { 5 | color: white; 6 | border-radius: 1em; 7 | padding: 0.2em 0.5em; 8 | background-color: #79aec8; 9 | vertical-align: top; 10 | } 11 | -------------------------------------------------------------------------------- /array_tags/static/admin/js/array-tag.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | var ArrayTag = function(el) { 5 | this.el = (typeof el === 'string') ? document.querySelector(el) : el; 6 | this.el.style.display = 'none'; 7 | 8 | this.delim = this.el.dataset.delim || ','; 9 | this.values = new Set(this.split_tags(this.el.value)); 10 | 11 | this.el.closest('form').addEventListener('submit', ev => { 12 | this.el.style.display = undefined; 13 | this.el.value = Array.from(this.values).join(this.delim + ' '); 14 | }); 15 | 16 | var div = document.createElement('div'); 17 | div.classList.add('tag-input'); 18 | 19 | this.inp = document.createElement('input'); 20 | this.inp.setAttribute('type', 'text'); 21 | div.appendChild(this.inp); 22 | 23 | this.button = document.createElement('a'); 24 | this.button.innerText = 'Add'; 25 | this.button.classList.add('button', 'add-tag'); 26 | this.button.addEventListener('click', ev => { 27 | this.split_tags(this.inp.value).forEach(val => this.values.add(val)); 28 | this.inp.value = ''; 29 | this.render_tags(); 30 | }); 31 | div.appendChild(this.button); 32 | 33 | this.tagList = document.createElement('p'); 34 | this.tagList.classList.add('tags'); 35 | this.tagList.addEventListener('click', ev => { 36 | if(!ev.target.matches('.tags a')) return; 37 | this.values.delete(ev.target.parentNode.innerText.trim()); 38 | this.render_tags(); 39 | }); 40 | div.appendChild(this.tagList); 41 | 42 | this.el.parentElement.insertBefore(div, this.el); 43 | 44 | this.render_tags(); 45 | }; 46 | 47 | ArrayTag.prototype.render_tags = function () { 48 | this.tagList.innerHTML = Array.from(this.values) 49 | .sort() 50 | .map(val => '' + val + '') 51 | .join(' '); 52 | }; 53 | 54 | ArrayTag.prototype.split_tags = function (value) { 55 | return value.split(this.delim).map(x => x.trim()).filter(Boolean); 56 | }; 57 | 58 | window.ArrayTag = ArrayTag; 59 | 60 | document.addEventListener('DOMContentLoaded', () => { 61 | Array.from(document.querySelectorAll('.array-tag')).forEach(el => new ArrayTag(el)); 62 | }); 63 | $('formset:added', (ev, row) => { 64 | Array.from(row.querySelectorAll('.array-tag')).forEach(el => new ArrayTag(el)); 65 | }); 66 | })(); 67 | -------------------------------------------------------------------------------- /array_tags/widgets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class AdminTagWidget(forms.TextInput): 5 | @property 6 | def media(self): 7 | return forms.Media( 8 | js=['admin/js/array-tag.js'], 9 | css={ 10 | 'screen': ['admin/css/array-tag.css'], 11 | } 12 | ) 13 | 14 | def __init__(self, attrs=None): 15 | final_attrs = {'class': 'array-tag'} 16 | if attrs: 17 | final_attrs.update(attrs) 18 | super(AdminTagWidget, self).__init__(final_attrs) 19 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Adapted from https://raw.githubusercontent.com/hzy/django-polarize/master/runtests.py 4 | 5 | import sys 6 | 7 | from django.conf import settings 8 | from django.core.management import execute_from_command_line 9 | 10 | import django 11 | 12 | 13 | if not settings.configured: 14 | settings.configure( 15 | DATABASES={ 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 18 | 'NAME': 'test', 19 | } 20 | }, 21 | INSTALLED_APPS=( 22 | 'tests', 23 | ), 24 | MIDDLEWARE_CLASSES=[], 25 | ROOT_URLCONF='tests.urls', 26 | ) 27 | 28 | 29 | def runtests(): 30 | argv = sys.argv[:1] + ['test', 'tests'] 31 | execute_from_command_line(argv) 32 | 33 | 34 | if __name__ == '__main__': 35 | runtests() 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = build,.git 3 | max-line-length = 119 4 | 5 | [isort] 6 | combine_as_imports = true 7 | default_section = THIRDPARTY 8 | include_trailing_comma = true 9 | known_first_party = django 10 | multi_line_output = 5 11 | not_skip = __init__.py 12 | 13 | [wheel] 14 | universal = 1 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md') as fin: 4 | long_description = fin.read() 5 | 6 | setup( 7 | name='django-array-tags', 8 | version='0.2.2', 9 | description='Simple Tagging for Django using PG ArrayField', 10 | long_description=long_description, 11 | author='Curtis Maloney', 12 | author_email='curtis@tinbrain.net', 13 | url='http://github.com/funkybob/django-array-tags', 14 | packages = find_packages(exclude=('tests*',)), 15 | include_package_data=True, 16 | zip_safe=False, 17 | classifiers = [ 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'License :: OSI Approved :: BSD License', 21 | 'Operating System :: OS Independent', 22 | 'Programming Language :: Python', 23 | 'Programming Language :: Python :: 2.7', 24 | 'Programming Language :: Python :: 3.3', 25 | 'Programming Language :: Python :: 3.4', 26 | 'Programming Language :: Python :: 3.5', 27 | 'Programming Language :: Python :: Implementation :: PyPy', 28 | ], 29 | requires = [ 30 | 'Django (>=1.7)', 31 | ], 32 | install_requires = [ 33 | 'Django>=1.7', 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkybob/django-array-tags/5da05228ceaa8f24670c3f9d0afc9f9cd2af2628/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | 2 | from django.db import models 3 | 4 | from array_tags import fields, managers 5 | 6 | 7 | class TestModel(models.Model): 8 | tags = fields.TagField() 9 | lower_tags = fields.TagField(lower=True) 10 | 11 | objects = managers.TagQuerySet.as_manager() 12 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | from django.test import TestCase 4 | 5 | from .models import TestModel 6 | 7 | tags1 = ['one', 'two', 'three'] 8 | tags2 = ['one', 'five', 'eleven'] 9 | all_tags = set(tags1 + tags2) 10 | tag_counts = Counter(tags1) 11 | tag_counts.update(tags2) 12 | 13 | 14 | class LazyTagTestCase(TestCase): 15 | 16 | def setUp(self): 17 | self.objects = [ 18 | TestModel.objects.create(tags=tags1), 19 | TestModel.objects.create(tags=tags2) 20 | ] 21 | 22 | def test_null(self): 23 | v = TestModel.objects.create() 24 | 25 | def test_all_values(self): 26 | v = TestModel.objects.all_tag_values('tags') 27 | self.assertEqual(set(v), all_tags) 28 | 29 | def test_count_values(self): 30 | v = TestModel.objects.count_tag_values('tags') 31 | for key, value in v: 32 | self.assertEqual(value, tag_counts[key]) 33 | 34 | def test_cleanup(self): 35 | TestModel.objects.create(tags=['a', ' a ', 'b', ' b']) 36 | b = TestModel.objects.last() 37 | self.assertEqual(set(b.tags), {'a', 'b'}) 38 | 39 | def test_lower(self): 40 | TestModel.objects.create( 41 | tags=['a', 'B', 'CooL'], 42 | lower_tags=['a', 'B', 'CooL'], 43 | ) 44 | b = TestModel.objects.last() 45 | self.assertEqual(set(b.tags), {'a', 'B', 'CooL'}) 46 | self.assertEqual(set(b.lower_tags), {'a', 'b', 'cool'}) 47 | 48 | def test_filtered_all(self): 49 | v = TestModel.objects.filter(tags__contains=['five']).all_tag_values('tags') 50 | self.assertEqual(set(v), set(tags2)) 51 | 52 | def test_filtered_count(self): 53 | v = TestModel.objects.filter(tags__contains=['five']).count_tag_values('tags') 54 | for key, val in v: 55 | self.assertTrue(key in tags2) 56 | self.assertEqual(val, 1) 57 | 58 | def test_most_like(self): 59 | qset = list(TestModel.objects.most_like('tags', ['one', 'five'])) 60 | self.assertEqual(qset[0].similarity, 2) # one, two 61 | self.assertEqual(qset[0].pk, self.objects[1].pk) 62 | self.assertEqual(qset[1].similarity, 1) # one 63 | self.assertEqual(qset[1].pk, self.objects[0].pk) 64 | 65 | def test_contribute_to_class(self): 66 | qset = list(self.objects[0].get_most_like_by_tags()) 67 | self.assertEqual(len(qset), 1) # Excluding self 68 | self.assertEqual(qset[0].pk, self.objects[1].pk) 69 | self.assertEqual(qset[0].similarity, 1) 70 | 71 | def test_contribute_to_class_exclude(self): 72 | qset = list(self.objects[0].get_most_like_by_tags(False)) 73 | self.assertEqual(len(qset), 2) 74 | self.assertEqual(qset[0].pk, self.objects[0].pk) 75 | self.assertEqual(qset[0].similarity, 3) 76 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | urlpatterns = [] 4 | -------------------------------------------------------------------------------- /widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funkybob/django-array-tags/5da05228ceaa8f24670c3f9d0afc9f9cd2af2628/widget.png --------------------------------------------------------------------------------