diff --git a/CHANGES.txt b/CHANGES.txt index 229eea5..0618069 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,7 @@ CHANGES tip (unreleased) ---------------- +- added SplitField - added ChoiceEnum - added South support for custom model fields diff --git a/README.txt b/README.txt index 7d5526c..c3f59d5 100644 --- a/README.txt +++ b/README.txt @@ -57,6 +57,73 @@ Be careful not to add new choices in the middle of the list, as that will change the numerical ids for all subsequent choices, which could impact existing data. +fields.SplitField +================= + +A ``TextField`` subclass that automatically pulls an excerpt out of +its content (based on a "split here" marker or a default number of +initial paragraphs) and stores both its content and excerpt values in +the database. + +A ``SplitField`` is easy to add to any model definition:: + + from django.db import models + from model_utils.fields import SplitField + + class Article(models.Model): + title = models.CharField(max_length=100) + body = SplitField() + +``SplitField`` automatically creates an extra non-editable field +``_body_excerpt`` to store the excerpt. This field doesn't need to be +accessed directly; see below. + +Accessing a SplitField on a model +--------------------------------- + +When accessing an attribute of a model that was declared as a +``SplitField``, a ``SplitText`` object is returned. The ``SplitText`` +object has two attributes: + +``content``: + The full field contents. +``excerpt``: + The excerpt of ``content`` (read-only). + +This object also has a ``__unicode__`` method that returns the full +content, allowing ``SplitField`` attributes to appear in templates +without having to access ``content`` directly. + +Assuming the ``Article`` model above:: + + >>> a = Article.objects.all()[0] + >>> a.body.content + u'some text\n\n\n\nmore text' + >>> a.body.excerpt + u'some text\n' + >>> unicode(a.body) + u'some text\n\n\n\nmore text' + +Assignment to ``a.body`` is equivalent to assignment to +``a.body.content``. + +.. note:: + a.body.excerpt is only updated when a.save() is called + + +Customized excerpting +--------------------- + +By default, ``SplitField`` looks for the marker ```` +alone on a line and takes everything before that marker as the +excerpt. This marker can be customized by setting the ``SPLIT_MARKER`` +setting. + +If no marker is found in the content, the first two paragraphs (where +paragraphs are blocks of text separated by a blank line) are taken to +be the excerpt. This number can be customized by setting the +``SPLIT_DEFAULT_PARAGRAPHS`` setting. + models.InheritanceCastModel =========================== diff --git a/model_utils/fields.py b/model_utils/fields.py index ee492d9..1a49359 100644 --- a/model_utils/fields.py +++ b/model_utils/fields.py @@ -1,6 +1,7 @@ from datetime import datetime from django.db import models +from django.conf import settings class AutoCreatedField (models.DateTimeField): """ @@ -26,7 +27,98 @@ class AutoLastModifiedField (AutoCreatedField): value = datetime.now() setattr(model_instance, self.attname, value) return value - + +SPLIT_MARKER = getattr(settings, 'SPLIT_MARKER', '') + +# the number of paragraphs after which to split if no marker +SPLIT_DEFAULT_PARAGRAPHS = getattr(settings, 'SPLIT_DEFAULT_PARAGRAPHS', 2) + +_excerpt_field_name = lambda name: '_%s_excerpt' % name + +def get_excerpt(content): + excerpt = [] + default_excerpt = [] + paras_seen = 0 + for line in content.splitlines(): + if not line.strip(): + paras_seen += 1 + if paras_seen < SPLIT_DEFAULT_PARAGRAPHS: + default_excerpt.append(line) + if line.strip() == SPLIT_MARKER: + return '\n'.join(excerpt) + excerpt.append(line) + + return '\n'.join(default_excerpt) + +class SplitText(object): + def __init__(self, instance, field_name, excerpt_field_name): + # instead of storing actual values store a reference to the instance + # along with field names, this makes assignment possible + self.instance = instance + self.field_name = field_name + self.excerpt_field_name = excerpt_field_name + + # content is read/write + def _get_content(self): + return self.instance.__dict__[self.field_name] + def _set_content(self, val): + setattr(self.instance, self.field_name, val) + content = property(_get_content, _set_content) + + # excerpt is a read only property + def _get_excerpt(self): + return getattr(self.instance, self.excerpt_field_name) + excerpt = property(_get_excerpt) + + # allows display via templates without .content necessary + def __unicode__(self): + return self.content + +class SplitDescriptor(object): + def __init__(self, field): + self.field = field + self.excerpt_field_name = _excerpt_field_name(self.field.name) + + def __get__(self, instance, owner): + if instance is None: + raise AttributeError('Can only be accessed via an instance.') + content = instance.__dict__[self.field.name] + if content is None: + return None + return SplitText(instance, self.field.name, self.excerpt_field_name) + + def __set__(self, obj, value): + if isinstance(value, SplitText): + obj.__dict__[self.field.name] = value.content + setattr(obj, self.excerpt_field_name, value.excerpt) + else: + obj.__dict__[self.field.name] = value + +class SplitField(models.TextField): + def contribute_to_class(self, cls, name): + excerpt_field = models.TextField(editable=False) + excerpt_field.creation_counter = self.creation_counter+1 + cls.add_to_class(_excerpt_field_name(name), excerpt_field) + super(SplitField, self).contribute_to_class(cls, name) + setattr(cls, self.name, SplitDescriptor(self)) + + def pre_save(self, model_instance, add): + value = super(SplitField, self).pre_save(model_instance, add) + excerpt = get_excerpt(value.content) + setattr(model_instance, _excerpt_field_name(self.attname), excerpt) + return value.content + + def value_to_string(self, obj): + value = self._get_val_from_obj(obj) + return value.content + + def get_db_prep_value(self, value): + try: + return value.content + except AttributeError: + return value + + # allow South to handle these fields smoothly try: from south.modelsinspector import add_introspection_rules diff --git a/model_utils/tests/models.py b/model_utils/tests/models.py index cb2796d..9a5e674 100644 --- a/model_utils/tests/models.py +++ b/model_utils/tests/models.py @@ -2,6 +2,7 @@ from django.db import models from model_utils.models import InheritanceCastModel, TimeStampedModel from model_utils.managers import QueryManager +from model_utils.fields import SplitField class InheritParent(InheritanceCastModel): @@ -26,3 +27,10 @@ class Post(models.Model): class Meta: ordering = ('order',) + +class Article(models.Model): + title = models.CharField(max_length=50) + body = SplitField() + + def __unicode__(self): + return self.title diff --git a/model_utils/tests/tests.py b/model_utils/tests/tests.py index 197f7c3..8cd11b6 100644 --- a/model_utils/tests/tests.py +++ b/model_utils/tests/tests.py @@ -2,9 +2,68 @@ from django.test import TestCase from django.contrib.contenttypes.models import ContentType from model_utils import ChoiceEnum +from model_utils.fields import get_excerpt from model_utils.tests.models import InheritParent, InheritChild, TimeStamp, \ - Post + Post, Article + + +class GetExcerptTests(TestCase): + def test_split(self): + e = get_excerpt("some content\n\n\n\nsome more") + self.assertEquals(e, 'some content\n') + + def test_auto_split(self): + e = get_excerpt("para one\n\npara two\n\npara three") + self.assertEquals(e, 'para one\n\npara two') + + def test_middle_of_para(self): + e = get_excerpt("some text\n\nmore text") + self.assertEquals(e, 'some text') + + def test_middle_of_line(self): + e = get_excerpt("some text more text") + self.assertEquals(e, "some text more text") +class SplitFieldTests(TestCase): + full_text = u'summary\n\n\n\nmore' + excerpt = u'summary\n' + + def setUp(self): + self.post = Article.objects.create( + title='example post', body=self.full_text) + + def test_unicode_content(self): + self.assertEquals(unicode(self.post.body), self.full_text) + + def test_excerpt(self): + self.assertEquals(self.post.body.excerpt, self.excerpt) + + def test_content(self): + self.assertEquals(self.post.body.content, self.full_text) + + def test_load_back(self): + post = Article.objects.get(pk=self.post.pk) + self.assertEquals(post.body.content, self.post.body.content) + self.assertEquals(post.body.excerpt, self.post.body.excerpt) + + def test_assign_to_body(self): + new_text = u'different\n\n\n\nother' + self.post.body = new_text + self.post.save() + self.assertEquals(unicode(self.post.body), new_text) + + def test_assign_to_content(self): + new_text = u'different\n\n\n\nother' + self.post.body.content = new_text + self.post.save() + self.assertEquals(unicode(self.post.body), new_text) + + def test_assign_to_excerpt(self): + def _invalid_assignment(): + self.post.body.excerpt = 'this should fail' + self.assertRaises(AttributeError, _invalid_assignment) + + class ChoiceEnumTests(TestCase): def setUp(self): self.STATUS = ChoiceEnum('DRAFT', 'PUBLISHED') @@ -17,7 +76,8 @@ class ChoiceEnumTests(TestCase): def test_iteration(self): self.assertEquals(tuple(self.STATUS), ((0, 'DRAFT'), (1, 'PUBLISHED'))) - + + class InheritanceCastModelTests(TestCase): def setUp(self): self.parent = InheritParent.objects.create()