Commits

Anonymous committed 8b63e4b

Added the Auto-Tagging feature. Version bump to 2.1.0.

Starting to use django-south for schema migrations.
Refactored the save method for the Article model quite a bit.
Updated the README.
Added a static media handler for the demo site.

  • Participants
  • Parent commits a97f57c

Comments (0)

Files changed (11)

 .. -*- restructuredtext -*-
 
-django-articles is the blog engine that I use on codekoala.com
+django-articles is a powerful, pluggable blogging application for
+Django-powered sites.  It's also what powers http://www.codekoala.com/ and a
+handful of other awesome sites.
 
 Features
 ========
 
 * Tags for articles, with a tag cloud template tag
 * Auto-completion for tags in the Django admin
+* Auto-tagging: assigning existing tags to articles when they're present in the
+  article content
 * Ability to post in the future
 * Article expiration facilities
 * Articles from email
 * Word count
 * RSS feeds for the latest articles
 * RSS feeds for the latest articles by tag
+* South migrations
 
 Requirements
 ============
 ``django.contrib.humanize``, and ``django.contrib.syndication`` to be properly
 installed.
 
+If you plan to use the schema migrations, you'll need to install `South
+<http://south.aeracode.org/>`_.
+
+.. versionadded:: 2.1.0
+
 Installation
 ============
 
 
 Use one of the following commands::
 
+    hg clone http://bitbucket.org/codekoala/django-articles/
     hg clone http://django-articles.googlecode.com/hg/ django-articles
-    hg clone http://bitbucket.org/codekoala/django-articles/
+
+Checkout from GitHub
+--------------------
+
+Use the following command::
+
+    git clone http://github.com/codekoala/django-articles.git
 
 The CheeseShop
 --------------
         'django.contrib.sessions',
         'django.contrib.sites',
         'django.contrib.syndication',
-        ... 
+        ...
         'articles',
+        'south',
         ...
     )
 
-Run ``manage.py syncdb``.  This creates a few tables in your database that are
-necessary for operation.
+Run ``python manage.py syncdb``.  This creates a few tables in your database
+that are necessary for operation.  If you choose to use South, you'll probably
+need to run ``python manage.py migrate articles`` instead.
 
 Next, set a couple of settings in your ``settings.py``:
 
 Articles From Email
 ===================
 
-.. admonition:: Added In 1.9.2
+.. versionadded:: 1.9.2
 
-    The articles from email feature was added in version **1.9.2**.  It
-    requires Python 2.4 or greater.
+.. admonition:: Version Dependencies
+
+    The articles from email feature requires Python 2.4 or greater.
 
 I've been working on making it possible for ``django-articles`` to post
 articles that you email to a special mailbox.  This seems to be working on the
 Article Attachments
 ===================
 
+.. versionadded:: 1.9.6
+
 You can now attach files to your articles and have them be included with the
 article on the site.  Attachments can be created using the Django admin while
 composing your articles.  You may also attach files to emails that you send to
 Article Statuses
 ================
 
+.. versionadded:: 1.9.6
+
 As of ``1.9.6``, you may specify the state of an article when you save it.
 This allows you to begin composing an article, save it, and come back later to
 finish it.  In the past, this behavior was handled by not setting a publish
 ``ordering`` on that object to be less than the ``ordering`` value for the
 ``Draft`` object (and/or any others you create).
 
+Auto-Tagging
+============
+
+.. versionadded:: 2.1.0
+
+The auto-tagging feature allows you to easily apply any of your current tags to
+your articles.  When you save an Article object with auto-tagging enabled for
+that article, ``django-articles`` will go through each of your existing tags to
+see if the entire word appears anywhere in your article's content.  If a match
+is found, that tag will be added to the article.
+
+For example, if you have tags ``test`` and ``art``, and you wrote a new
+auto-tagged Article with the text::
+
+    This is a test article.
+
+``django-articles`` would automatically apply the ``test`` tag to this article,
+but not the ``art`` tag.  It will only apply the ``art`` tag automatically when
+the actual word "art" appears in the content.
+
+Auto-tagging does not remove any tags that are already assigned to an article.
+This means that you can still add tags the good, old-fashioned way in the
+Django Admin without losing them.  Auto-tagging will *only* add to an article's
+existing tags (if needed).
+
+Auto-tagging is enabled for all articles by default.  If you want to disable it
+by default (and enable it on a per-article basis), set ``ARTICLES_AUTO_TAG`` to
+``False`` in your ``settings.py`` file.
+
+===================
+Help & Contributing
+===================
+
 Good luck!  Please contact me with any questions or concerns you have with the
-project!
-
+project!  If you're interested in reporting a bug or feature request, the
+official ticket tracker is at http://bitbucket.org/codekoala/django-articles/

articles/__init__.py

-__version__ = '2.0.6'
+__version__ = '2.1.0'
+
+import listeners
 
 """
 The Pygments reStructuredText directive

articles/admin.py

     ]
 
     fieldsets = (
-        (None, {'fields': ('title', 'content', 'tags', 'markup', 'status')}),
+        (None, {'fields': ('title', 'content', 'tags', 'auto_tag', 'markup', 'status')}),
         ('Metadata', {
             'fields': ('keywords', 'description',),
             'classes': ('collapse',)
 
         obj.save()
 
+        # this requires an Article object already
+        obj.do_auto_tag()
+        form.cleaned_data['tags'] += list(obj.tags.all())
+
     def queryset(self, request):
         """Limit the list of articles to article posted by this user unless they're a superuser"""
 

articles/listeners.py

+from django.db.models import signals
+from models import Article, Tag
+
+def article_post_processing(sender, instance, created, **kwargs):
+    """
+    Performs the auto-tagging for certain Articles and other things
+    that require and Article instance first.
+    """
+
+    requires_save = instance.do_auto_tag()
+    requires_save |= instance.do_tags_to_keywords()
+    requires_save |= instance.do_default_site()
+
+    if requires_save:
+        # bypass the other processing
+        super(Article, instance).save()
+
+def apply_new_tag(sender, instance, created, **kwargs):
+    """
+    Applies new tags to existing articles that are marked for auto-tagging
+    """
+
+    for article in Article.objects.filter(auto_tag=True):
+        if article.do_auto_tag():
+            # bypass the other processing
+            super(Article, article).save()
+
+signals.post_save.connect(article_post_processing, sender=Article)
+signals.post_save.connect(apply_new_tag, sender=Tag)

articles/migrations/0001_initial.py

+# 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):
+        
+        # Adding model 'Tag'
+        db.create_table('articles_tag', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=64)),
+        ))
+        db.send_create_signal('articles', ['Tag'])
+
+        # Adding model 'ArticleStatus'
+        db.create_table('articles_articlestatus', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=50)),
+            ('ordering', self.gf('django.db.models.fields.IntegerField')(default=0)),
+            ('is_live', self.gf('django.db.models.fields.BooleanField')(default=False)),
+        ))
+        db.send_create_signal('articles', ['ArticleStatus'])
+
+        # Adding model 'Article'
+        db.create_table('articles_article', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('title', self.gf('django.db.models.fields.CharField')(max_length=100)),
+            ('slug', self.gf('django.db.models.fields.SlugField')(max_length=50, db_index=True)),
+            ('status', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['articles.ArticleStatus'])),
+            ('author', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+            ('keywords', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('description', self.gf('django.db.models.fields.TextField')(blank=True)),
+            ('markup', self.gf('django.db.models.fields.CharField')(default='h', max_length=1)),
+            ('content', self.gf('django.db.models.fields.TextField')()),
+            ('rendered_content', self.gf('django.db.models.fields.TextField')()),
+            ('publish_date', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)),
+            ('expiration_date', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)),
+            ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('login_required', self.gf('django.db.models.fields.BooleanField')(default=False)),
+            ('use_addthis_button', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('addthis_use_author', self.gf('django.db.models.fields.BooleanField')(default=True)),
+            ('addthis_username', self.gf('django.db.models.fields.CharField')(default=None, max_length=50, blank=True)),
+        ))
+        db.send_create_signal('articles', ['Article'])
+
+        # Adding M2M table for field sites on 'Article'
+        db.create_table('articles_article_sites', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('article', models.ForeignKey(orm['articles.article'], null=False)),
+            ('site', models.ForeignKey(orm['sites.site'], null=False))
+        ))
+        db.create_unique('articles_article_sites', ['article_id', 'site_id'])
+
+        # Adding M2M table for field tags on 'Article'
+        db.create_table('articles_article_tags', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('article', models.ForeignKey(orm['articles.article'], null=False)),
+            ('tag', models.ForeignKey(orm['articles.tag'], null=False))
+        ))
+        db.create_unique('articles_article_tags', ['article_id', 'tag_id'])
+
+        # Adding M2M table for field followup_for on 'Article'
+        db.create_table('articles_article_followup_for', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('from_article', models.ForeignKey(orm['articles.article'], null=False)),
+            ('to_article', models.ForeignKey(orm['articles.article'], null=False))
+        ))
+        db.create_unique('articles_article_followup_for', ['from_article_id', 'to_article_id'])
+
+        # Adding M2M table for field related_articles on 'Article'
+        db.create_table('articles_article_related_articles', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('from_article', models.ForeignKey(orm['articles.article'], null=False)),
+            ('to_article', models.ForeignKey(orm['articles.article'], null=False))
+        ))
+        db.create_unique('articles_article_related_articles', ['from_article_id', 'to_article_id'])
+
+        # Adding model 'Attachment'
+        db.create_table('articles_attachment', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('article', self.gf('django.db.models.fields.related.ForeignKey')(related_name='attachments', to=orm['articles.Article'])),
+            ('attachment', self.gf('django.db.models.fields.files.FileField')(max_length=100)),
+            ('caption', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)),
+        ))
+        db.send_create_signal('articles', ['Attachment'])
+
+
+    def backwards(self, orm):
+        
+        # Deleting model 'Tag'
+        db.delete_table('articles_tag')
+
+        # Deleting model 'ArticleStatus'
+        db.delete_table('articles_articlestatus')
+
+        # Deleting model 'Article'
+        db.delete_table('articles_article')
+
+        # Removing M2M table for field sites on 'Article'
+        db.delete_table('articles_article_sites')
+
+        # Removing M2M table for field tags on 'Article'
+        db.delete_table('articles_article_tags')
+
+        # Removing M2M table for field followup_for on 'Article'
+        db.delete_table('articles_article_followup_for')
+
+        # Removing M2M table for field related_articles on 'Article'
+        db.delete_table('articles_article_related_articles')
+
+        # Deleting model 'Attachment'
+        db.delete_table('articles_attachment')
+
+
+    models = {
+        'articles.article': {
+            'Meta': {'ordering': "('-publish_date', 'title')", 'object_name': 'Article'},
+            'addthis_use_author': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'addthis_username': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '50', 'blank': 'True'}),
+            'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'content': ('django.db.models.fields.TextField', [], {}),
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'expiration_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'followup_for': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'followups'", 'blank': 'True', 'to': "orm['articles.Article']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'keywords': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'login_required': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'markup': ('django.db.models.fields.CharField', [], {'default': "'h'", 'max_length': '1'}),
+            'publish_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'related_articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'related_articles_rel_+'", 'blank': 'True', 'to': "orm['articles.Article']"}),
+            'rendered_content': ('django.db.models.fields.TextField', [], {}),
+            'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'symmetrical': 'False', 'blank': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}),
+            'status': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['articles.ArticleStatus']"}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['articles.Tag']", 'symmetrical': 'False', 'blank': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'use_addthis_button': ('django.db.models.fields.BooleanField', [], {'default': 'True'})
+        },
+        'articles.articlestatus': {
+            'Meta': {'ordering': "('ordering', 'name')", 'object_name': 'ArticleStatus'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_live': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'ordering': ('django.db.models.fields.IntegerField', [], {'default': '0'})
+        },
+        'articles.attachment': {
+            'Meta': {'ordering': "('-article', 'id')", 'object_name': 'Attachment'},
+            'article': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attachments'", 'to': "orm['articles.Article']"}),
+            'attachment': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'caption': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'articles.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'})
+        },
+        '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']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        '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'})
+        },
+        'sites.site': {
+            'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        }
+    }
+
+    complete_apps = ['articles']

articles/migrations/0002_auto__add_field_article_auto_tag.py

+# 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):
+        
+        # Adding field 'Article.auto_tag'
+        db.add_column('articles_article', 'auto_tag', self.gf('django.db.models.fields.BooleanField')(default=True), keep_default=False)
+
+
+    def backwards(self, orm):
+        
+        # Deleting field 'Article.auto_tag'
+        db.delete_column('articles_article', 'auto_tag')
+
+
+    models = {
+        'articles.article': {
+            'Meta': {'ordering': "('-publish_date', 'title')", 'object_name': 'Article'},
+            'addthis_use_author': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'addthis_username': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '50', 'blank': 'True'}),
+            'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'auto_tag': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'content': ('django.db.models.fields.TextField', [], {}),
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'expiration_date': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'followup_for': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'followups'", 'blank': 'True', 'to': "orm['articles.Article']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'keywords': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'login_required': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'markup': ('django.db.models.fields.CharField', [], {'default': "'h'", 'max_length': '1'}),
+            'publish_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'related_articles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'related_articles_rel_+'", 'blank': 'True', 'to': "orm['articles.Article']"}),
+            'rendered_content': ('django.db.models.fields.TextField', [], {}),
+            'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'symmetrical': 'False', 'blank': 'True'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'}),
+            'status': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['articles.ArticleStatus']"}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['articles.Tag']", 'symmetrical': 'False', 'blank': 'True'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'use_addthis_button': ('django.db.models.fields.BooleanField', [], {'default': 'True'})
+        },
+        'articles.articlestatus': {
+            'Meta': {'ordering': "('ordering', 'name')", 'object_name': 'ArticleStatus'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_live': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
+            'ordering': ('django.db.models.fields.IntegerField', [], {'default': '0'})
+        },
+        'articles.attachment': {
+            'Meta': {'ordering': "('-article', 'id')", 'object_name': 'Attachment'},
+            'article': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attachments'", 'to': "orm['articles.Article']"}),
+            'attachment': ('django.db.models.fields.files.FileField', [], {'max_length': '100'}),
+            'caption': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'articles.tag': {
+            'Meta': {'ordering': "('name',)", 'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'})
+        },
+        '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']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            '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']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        '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'})
+        },
+        'sites.site': {
+            'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+            'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        }
+    }
+
+    complete_apps = ['articles']

articles/migrations/__init__.py

Empty file added.

articles/models.py

 import urllib
 
 WORD_LIMIT = getattr(settings, 'ARTICLES_TEASER_LIMIT', 75)
+AUTO_TAG = getattr(settings, 'ARTICLES_AUTO_TAG', True)
 
 MARKUP_HTML = 'h'
 MARKUP_MARKDOWN = 'm'
     rendered_content = models.TextField()
 
     tags = models.ManyToManyField(Tag, help_text=_('Tags that describe this article'), blank=True)
+    auto_tag = models.BooleanField(default=AUTO_TAG, blank=True, help_text=_('Check this if you want to automatically assign any existing tags to this article based on its content.'))
     followup_for = models.ManyToManyField('self', symmetrical=False, blank=True, help_text=_('Select any other articles that this article follows up on.'), related_name='followups')
     related_articles = models.ManyToManyField('self', blank=True)
 
         """
         using = kwargs.get('using', 'default')
 
+        self.do_render_markup()
+        self.do_addthis_button()
+        self.do_meta_description()
+        self.do_unique_slug(using)
+
+        super(Article, self).save(*args, **kwargs)
+
+    def do_render_markup(self):
+        """Turns any markup into HTML"""
+
+        original = self.rendered_content
         if self.markup == MARKUP_MARKDOWN:
             self.rendered_content = markup.markdown(self.content)
         elif self.markup == MARKUP_REST:
         else:
             self.rendered_content = self.content
 
+        return (self.rendered_content != original)
+
+    def do_addthis_button(self):
+        """Sets the AddThis username for this post"""
+
         # if the author wishes to have an "AddThis" button on this article,
         # make sure we have a username to go along with it.
         if self.use_addthis_button and self.addthis_use_author and not self.addthis_username:
             self.addthis_username = self.author.username
+            return True
 
-        # make sure the slug is always unique for the year this article was posted
+        return False
+
+    def do_unique_slug(self, using):
+        """
+        Ensures that the slug is always unique for the year this article was
+        posted
+        """
+
         if not self.id:
             # make sure we have a slug first
             if not len(self.slug.strip()):
                 self.slug = slugify(self.title)
 
             self.slug = self.get_unique_slug(self.slug, using)
+            return True
 
-        super(Article, self).save(*args, **kwargs)
-        requires_save = False
+        return False
 
-        # if we don't have keywords, use the tags
+    def do_tags_to_keywords(self):
+        """
+        If meta keywords is empty, sets them using the article tags.
+
+        Returns True if an additional save is required, False otherwise.
+        """
+
         if len(self.keywords.strip()) == 0:
             self.keywords = ', '.join([t.name for t in self.tags.all()])
-            requires_save = True
+            return True
 
-        # if we don't have a description, use the teaser
+        return False
+
+    def do_meta_description(self):
+        """
+        If meta description is empty, sets it to the article's teaser.
+
+        Returns True if an additional save is required, False otherwise.
+        """
+
         if len(self.description.strip()) == 0:
             self.description = self.teaser
-            requires_save = True
+            return True
 
-        # we have to have an object before we can create relationships like this
+        return False
+
+    def do_auto_tag(self, using=None):
+        """
+        Performs the auto-tagging work if necessary.
+
+        Returns True if an additional save is required, False otherwise.
+        """
+
+        found = False
+        if self.auto_tag:
+            # don't clobber any existing tags!
+            existing_ids = [t.id for t in self.tags.all()]
+            unused = Tag.objects.using(using).exclude(id__in=existing_ids)
+            for tag in unused:
+                if re.search(r'\b%s\b' % tag.name, self.content, re.I):
+                    self.tags.add(tag)
+                    found = True
+
+        return found
+
+    def do_default_site(self, using=None):
+        """
+        If no site was selected, selects the site used to create the article
+        as the default site.
+
+        Returns True if an additional save is required, False otherwise.
+        """
+
         if not len(self.sites.all()):
-            self.sites = [Site.objects.using(using).get(pk=settings.SITE_ID)]
-            requires_save = True
+            self.sites.add(Site.objects.using(using).get(pk=settings.SITE_ID))
+            return True
 
-        if requires_save:
-            super(Article, self).save(*args, **kwargs)
+        return False
 
     def get_unique_slug(self, slug, using):
         """Iterates until a unique slug is found"""

sample/articles_demo/demo.db

Binary file modified.

sample/articles_demo/settings.py

     'django.contrib.syndication',
 
     'articles',
+    'south',
 )
 
 # Change this to be your Disqus site's short name

sample/articles_demo/urls.py

+from django.conf import settings
 from django.conf.urls.defaults import *
 from django.contrib import admin
 admin.autodiscover()
 
 urlpatterns = patterns('',
     (r'^admin/', include(admin.site.urls)),
+    (r'^static/(?P<path>.*)', 'django.views.static.serve', {'document_root': settings.MEDIA_ROOT}),
     (r'^', include('articles.urls')),
 )
+