Simplify Tag model, remove django-taggit from the requirements

This commit is contained in:
Roberto Rosario
2014-10-17 19:57:37 -04:00
parent e56ae335e6
commit 0263745482
18 changed files with 370 additions and 59 deletions

View File

@@ -58,6 +58,7 @@ Next upgrade/add the new requirements::
Migrate existing database schema with::
$ mayan-edms.py migrate tags 0001 --fake
$ mayan-edms.py migrate
Add new static media::

View File

@@ -3,7 +3,6 @@ from __future__ import absolute_import
from django.utils.translation import ugettext_lazy as _
from taggit.managers import TaggableManager
from taggit.models import Tag
from acls.api import class_permissions
from common.utils import encapsulate
@@ -17,6 +16,7 @@ from .links import (multiple_documents_selection_tag_remove,
tag_attach, tag_create, tag_delete, tag_document_list,
tag_edit, tag_list, tag_multiple_attach,
tag_multiple_delete, tag_tagged_item_list)
from .models import Tag
from .permissions import (PERMISSION_TAG_ATTACH, PERMISSION_TAG_DELETE,
PERMISSION_TAG_EDIT, PERMISSION_TAG_REMOVE,
PERMISSION_TAG_VIEW)
@@ -24,12 +24,11 @@ from .urls import api_urls
from .widgets import (get_tags_inline_widget_simple, single_tag_widget)
def tag_documents(self):
return Document.objects.filter(tags__in=[self])
def document_tags(self):
return Tag.objects.filter(documents=self)
Document.add_to_class('tags', TaggableManager())
Tag.add_to_class('documents', property(tag_documents))
Document.add_to_class('tags', property(document_tags))
class_permissions(Document, [
PERMISSION_TAG_ATTACH, PERMISSION_TAG_REMOVE,
@@ -49,7 +48,7 @@ register_model_list_columns(Tag, [
},
{
'name': _(u'Tagged items'),
'attribute': encapsulate(lambda x: x.taggit_taggeditem_items.count())
'attribute': encapsulate(lambda x: x.documents.count())
}
])

View File

@@ -2,6 +2,6 @@ from __future__ import absolute_import
from django.contrib import admin
from .models import TagProperties
from .models import Tag
admin.site.register(TagProperties)
admin.site.register(Tag)

View File

@@ -1,6 +1,6 @@
from __future__ import absolute_import
from taggit.models import Tag
from .models import Tag
def cleanup():

View File

@@ -6,23 +6,24 @@ from django import forms
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext_lazy as _
from taggit.models import Tag
from acls.models import AccessEntry
from permissions.models import Permission
from .models import COLOR_CHOICES
from .literals import COLOR_CHOICES
from .models import Tag
from .permissions import PERMISSION_TAG_VIEW
logger = logging.getLogger(__name__)
class TagForm(forms.Form):
class TagForm(forms.ModelForm):
"""
Form to edit an existing tag's properties
"""
name = forms.CharField(label=_(u'Name'))
color = forms.ChoiceField(choices=COLOR_CHOICES, label=_(u'Color'))
class Meta:
fields = ('label', 'color')
model = Tag
class TagListForm(forms.Form):

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as 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 'TagProperties'
db.create_table(u'tags_tagproperties', (
(u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('tag', self.gf('django.db.models.fields.related.ForeignKey')(related_name='properties', to=orm['taggit.Tag'])),
('color', self.gf('django.db.models.fields.CharField')(max_length=3)),
))
db.send_create_signal(u'tags', ['TagProperties'])
def backwards(self, orm):
# Deleting model 'TagProperties'
db.delete_table(u'tags_tagproperties')
models = {
u'taggit.tag': {
'Meta': {'object_name': 'Tag'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'})
},
u'tags.tagproperties': {
'Meta': {'object_name': 'TagProperties'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'properties'", 'to': u"orm['taggit.Tag']"})
}
}
complete_apps = ['tags']

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as 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 'Tag'
db.create_table(u'tags_tag', (
(u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('label', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)),
('color', self.gf('django.db.models.fields.CharField')(max_length=3)),
))
db.send_create_signal(u'tags', ['Tag'])
# Adding M2M table for field document on 'Tag'
m2m_table_name = db.shorten_name(u'tags_tag_document')
db.create_table(m2m_table_name, (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('tag', models.ForeignKey(orm[u'tags.tag'], null=False)),
('document', models.ForeignKey(orm[u'documents.document'], null=False))
))
db.create_unique(m2m_table_name, ['tag_id', 'document_id'])
# Changing field 'TagProperties.tag'
db.alter_column(u'tags_tagproperties', 'tag_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['tags.Tag']))
def backwards(self, orm):
# Deleting model 'Tag'
db.delete_table(u'tags_tag')
# Removing M2M table for field document on 'Tag'
db.delete_table(db.shorten_name(u'tags_tag_document'))
# Changing field 'TagProperties.tag'
db.alter_column(u'tags_tagproperties', 'tag_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['taggit.Tag']))
models = {
u'documents.document': {
'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'},
'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'document_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'documents'", 'null': 'True', 'to': u"orm['documents.DocumentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'})
},
u'documents.documenttype': {
'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'})
},
u'tags.tag': {
'Meta': {'object_name': 'Tag'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
'document': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['documents.Document']", 'symmetrical': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'label': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'})
},
u'tags.tagproperties': {
'Meta': {'object_name': 'TagProperties'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'properties'", 'to': u"orm['tags.Tag']"})
}
}
complete_apps = ['tags']

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import DataMigration
from django.db import models
from taggit.models import Tag as TaggitModel
class Migration(DataMigration):
def forwards(self, orm):
"Write your forwards methods here."
# Note: Don't use "from appname.models import ModelName".
# Use orm.ModelName to refer to models in this application,
# and orm['appname.ModelName'] for models in other applications.
for tag in TaggitModel.objects.all():
color = orm.TagProperties.objects.get(tag=tag).color
orm.Tag.objects.create(label=tag.name, color=color)
def backwards(self, orm):
"Write your backwards methods here."
models = {
u'documents.document': {
'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'},
'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'document_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'documents'", 'null': 'True', 'to': u"orm['documents.DocumentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'})
},
u'documents.documenttype': {
'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'})
},
u'tags.tag': {
'Meta': {'object_name': 'Tag'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
'document': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['documents.Document']", 'symmetrical': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'label': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'})
},
u'tags.tagproperties': {
'Meta': {'object_name': 'TagProperties'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'properties'", 'to': u"orm['tags.Tag']"})
}
}
complete_apps = ['tags']
symmetrical = True

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Removing M2M table for field document on 'Tag'
db.delete_table(db.shorten_name(u'tags_tag_document'))
# Adding M2M table for field documents on 'Tag'
m2m_table_name = db.shorten_name(u'tags_tag_documents')
db.create_table(m2m_table_name, (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('tag', models.ForeignKey(orm[u'tags.tag'], null=False)),
('document', models.ForeignKey(orm[u'documents.document'], null=False))
))
db.create_unique(m2m_table_name, ['tag_id', 'document_id'])
def backwards(self, orm):
# Adding M2M table for field document on 'Tag'
m2m_table_name = db.shorten_name(u'tags_tag_document')
db.create_table(m2m_table_name, (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('tag', models.ForeignKey(orm[u'tags.tag'], null=False)),
('document', models.ForeignKey(orm[u'documents.document'], null=False))
))
db.create_unique(m2m_table_name, ['tag_id', 'document_id'])
# Removing M2M table for field documents on 'Tag'
db.delete_table(db.shorten_name(u'tags_tag_documents'))
models = {
u'documents.document': {
'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'},
'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'document_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'documents'", 'null': 'True', 'to': u"orm['documents.DocumentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'})
},
u'documents.documenttype': {
'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'})
},
u'tags.tag': {
'Meta': {'object_name': 'Tag'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
'documents': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['documents.Document']", 'symmetrical': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'label': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'})
},
u'tags.tagproperties': {
'Meta': {'object_name': 'TagProperties'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'properties'", 'to': u"orm['tags.Tag']"})
}
}
complete_apps = ['tags']

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding unique constraint on 'Tag', fields ['label']
db.create_unique(u'tags_tag', ['label'])
def backwards(self, orm):
# Removing unique constraint on 'Tag', fields ['label']
db.delete_unique(u'tags_tag', ['label'])
models = {
u'documents.document': {
'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'},
'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'document_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'documents'", 'null': 'True', 'to': u"orm['documents.DocumentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'})
},
u'documents.documenttype': {
'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'})
},
u'tags.tag': {
'Meta': {'object_name': 'Tag'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
'documents': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['documents.Document']", 'symmetrical': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'label': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'})
},
u'tags.tagproperties': {
'Meta': {'object_name': 'TagProperties'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'properties'", 'to': u"orm['tags.Tag']"})
}
}
complete_apps = ['tags']

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Deleting model 'TagProperties'
db.delete_table(u'tags_tagproperties')
def backwards(self, orm):
# Adding model 'TagProperties'
db.create_table(u'tags_tagproperties', (
('color', self.gf('django.db.models.fields.CharField')(max_length=3)),
('tag', self.gf('django.db.models.fields.related.ForeignKey')(related_name='properties', to=orm['tags.Tag'])),
(u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
))
db.send_create_signal(u'tags', ['TagProperties'])
models = {
u'documents.document': {
'Meta': {'ordering': "['-date_added']", 'object_name': 'Document'},
'date_added': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'document_type': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'documents'", 'null': 'True', 'to': u"orm['documents.DocumentType']"}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'uuid': ('django.db.models.fields.CharField', [], {'max_length': '48', 'blank': 'True'})
},
u'documents.documenttype': {
'Meta': {'ordering': "['name']", 'object_name': 'DocumentType'},
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'})
},
u'tags.tag': {
'Meta': {'object_name': 'Tag'},
'color': ('django.db.models.fields.CharField', [], {'max_length': '3'}),
'documents': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['documents.Document']", 'symmetrical': 'False'}),
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'label': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'})
}
}
complete_apps = ['tags']

View File

View File

@@ -3,22 +3,22 @@ from __future__ import absolute_import
from django.db import models
from django.utils.translation import ugettext_lazy as _
from taggit.models import Tag
from documents.models import Document
from .literals import COLOR_CHOICES, COLOR_CODES
class TagProperties(models.Model):
# TODO: this should be a One to One relation not a ForeignKey
tag = models.ForeignKey(Tag, verbose_name=_(u'Tag'), related_name='properties')
class Tag(models.Model):
label = models.CharField(max_length=128, verbose_name=_(u'Label'), unique=True, db_index=True)
color = models.CharField(max_length=3, choices=COLOR_CHOICES, verbose_name=_(u'Color'))
documents = models.ManyToManyField(Document)
class Meta:
verbose_name = _(u'Tag properties')
verbose_name_plural = _(u'Tags properties')
verbose_name = _(u'Tag')
verbose_name_plural = _(u'Tags')
def __unicode__(self):
return unicode(self.tag)
return self.label
def get_color_code(self):
return dict(COLOR_CODES)[self.color]

View File

@@ -1,13 +1,13 @@
from __future__ import absolute_import
from rest_framework import serializers
from taggit.models import Tag
from .models import Tag
class TagSerializer(serializers.HyperlinkedModelSerializer):
color = serializers.CharField(source='properties.get.color')
documents = serializers.HyperlinkedIdentityField(view_name='tag-document-list')
class Meta:
fields = ('id', 'url', 'name', 'color', 'slug', 'documents')
fields = ('id', 'url', 'label', 'color', 'documents')
model = Tag

View File

@@ -1,18 +1,16 @@
from django.test import TestCase
from taggit.models import Tag
from .literals import COLOR_RED
from .models import TagProperties
from .models import Tag
class TagTestCase(TestCase):
def setUp(self):
self.tag = Tag(name='test')
self.tag = Tag(label='test', color=COLOR_RED)
self.tag.save()
self.tp = TagProperties(tag=self.tag, color=COLOR_RED)
self.tp.save()
def runTest(self):
self.failUnlessEqual(self.tag.name, 'test')
self.failUnlessEqual(self.tp.get_color_code(), 'red')
self.failUnlessEqual(self.tag.label, 'test')
self.failUnlessEqual(self.tag.get_color_code(), 'red')
# TODO: Add test for attaching and removing documents to a tag

View File

@@ -11,8 +11,6 @@ from django.shortcuts import get_object_or_404, render_to_response
from django.template import RequestContext
from django.utils.translation import ugettext_lazy as _
from taggit.models import Tag
from acls.models import AccessEntry
from acls.views import acl_list_for
from acls.utils import apply_default_acls
@@ -22,7 +20,7 @@ from documents.permissions import PERMISSION_DOCUMENT_VIEW
from permissions.models import Permission
from .forms import TagForm, TagListForm
from .models import TagProperties
from .models import Tag
from .permissions import (PERMISSION_TAG_ATTACH, PERMISSION_TAG_CREATE,
PERMISSION_TAG_DELETE, PERMISSION_TAG_EDIT,
PERMISSION_TAG_REMOVE, PERMISSION_TAG_VIEW)
@@ -38,15 +36,7 @@ def tag_create(request):
if request.method == 'POST':
form = TagForm(request.POST)
if form.is_valid():
tag_name = form.cleaned_data['name']
if tag_name in Tag.objects.values_list('name', flat=True):
messages.error(request, _(u'Tag already exists.'))
return HttpResponseRedirect(previous)
tag = Tag(name=tag_name)
tag.save()
TagProperties(tag=tag, color=form.cleaned_data['color']).save()
tag = form.save()
apply_default_acls(tag, request.user)
messages.success(request, _(u'Tag created succesfully.'))
@@ -89,7 +79,7 @@ def tag_attach(request, document_id=None, document_id_list=None):
'document': document, 'tag': tag}
)
else:
document.tags.add(tag)
tag.documents.add(document)
messages.success(request, _(u'Tag "%(tag)s" attached successfully to document "%(document)s".') % {
'document': document, 'tag': tag}
)
@@ -209,20 +199,13 @@ def tag_edit(request, tag_id):
AccessEntry.objects.check_access(PERMISSION_TAG_EDIT, request.user, tag)
if request.method == 'POST':
form = TagForm(request.POST)
form = TagForm(data=request.POST, instance=tag)
if form.is_valid():
tag.name = form.cleaned_data['name']
tag.save()
tag_properties = tag.properties.get()
tag_properties.color = form.cleaned_data['color']
tag_properties.save()
form.save()
messages.success(request, _(u'Tag updated succesfully.'))
return HttpResponseRedirect(reverse('tags:tag_list'))
else:
form = TagForm(initial={
'name': tag.name,
'color': tag.properties.get().color
})
form = TagForm(instance=tag)
return render_to_response('main/generic_form.html', {
'title': _(u'Edit tag: %s') % tag,

View File

@@ -13,7 +13,7 @@ def get_tags_inline_widget(document):
tags_template.append(u'<div class="tc">')
for tag in document.tags.all():
tags_template.append(u'<ul class="tags"><li style="background: %s;">%s</li></ul>' % (tag.properties.get().get_color_code(), escape(tag.name)))
tags_template.append(u'<ul class="tags"><li style="background: %s;">%s</li></ul>' % (tag.get_color_code(), escape(tag.label)))
tags_template.append(u'<div style="clear:both;"></div>')
tags_template.append(u'</div>')
@@ -46,4 +46,4 @@ def single_tag_widget(tag):
def get_single_tag_template(tag):
return '<li style="background: %s">%s</li>' % (tag.properties.get().get_color_code(), escape(tag.name).replace(u' ', u'&nbsp;'))
return '<li style="background: %s">%s</li>' % (tag.get_color_code(), escape(tag.label).replace(u' ', u'&nbsp;'))

View File

@@ -6,7 +6,6 @@ django-filetransfers==0.1.0
django-pagination==1.0.7
django-compressor==1.4
django-cors-headers==0.13
django-taggit==0.12
django-model-utils==2.2
django-mptt==0.6.1
django-rest-swagger==0.1.14