├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_counter_field ├── __init__.py ├── counter.py ├── fields.py └── management │ ├── __init__.py │ └── commands │ ├── __init__.py │ ├── list_counters.py │ └── rebuild_counter.py ├── docs ├── Makefile ├── __init__.py └── source │ ├── __init__.py │ ├── conf.py │ ├── django_model_changes.rst │ └── index.rst ├── runtests.py ├── setup.py └── tests ├── __init__.py ├── models.py ├── settings.py └── tests.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include django_counter_field * 4 | recursive-include docs * -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | django-counter-field 3 | ==================== 4 | 5 | It is sometimes useful to cache the total number of objects associated with another object through a ForeignKey 6 | relation. For example the total number of comments associated with an article. 7 | 8 | django-counter-field makes it easy to denormalize and keep such counters up to date. 9 | 10 | Quick start 11 | ----------- 12 | 13 | 1. Install django-counter-field:: 14 | 15 | pip install django-counter-field 16 | 17 | 2. Add "django_counter_field" to your INSTALLED_APPS setting:: 18 | 19 | INSTALLED_APPS = ( 20 | ... 21 | 'django_counter_field', 22 | ) 23 | 24 | 3. Add a CounterField to your model:: 25 | 26 | from django_counter_field import CounterField 27 | 28 | 29 | class Article(models.Model): 30 | comment_count = CounterField() 31 | 32 | 4. Add the CounterMixin to the related model:: 33 | 34 | from django_counter_field import CounterMixin, connect_counter 35 | 36 | 37 | class Comment(CounterMixin, models.Model): 38 | article = models.ForeignKey(Article) 39 | 40 | 5. Connect the related foreign key field with the counter field:: 41 | 42 | connect_counter('comment_count', Comment.article) 43 | 44 | Whenever a comment is created the comment_count on the associated Article will be incremented. If the comment is 45 | deleted, the comment_count will be automatically decremented. 46 | 47 | 48 | Overview 49 | -------- 50 | 51 | Creating a new counter requires three simple steps: 52 | 53 | 1. Add a `CounterField` field to the parent model. 54 | 2. Add the `CounterMixin` mixin to the child model. 55 | 3. Use `connect_counter` to connect the child model with the new counter. 56 | 57 | Most counters are simple in the sense that you want to count all child objects. Sometimes, however, objects should be 58 | counted based on one or several conditions. For example you may not wish to count *all* comments on an article but 59 | only comments that have been approved. You can create conditional counters by providing a third `is_in_counter_func` 60 | argument to `connect_counter`: 61 | 62 | connect_counter('comment_count', Comment.article, lambda comment: comment.is_approved) 63 | 64 | The `is_in_counter_func` function will be called with `Comment` objects and must return `True` if the given comment 65 | should be counted. It must not concern itself with checking if the comment is deleted or not, django-counter-field 66 | does that by default. 67 | 68 | Backfilling 69 | ----------- 70 | 71 | Often you will add a `CounterField` to a model that already has a large number of associated objects. When a counter 72 | is created, it's value is initialized to zero. This value is likely incorrect. django-counter-field provides a couple 73 | of management commands that allow you to rebuild the value of a counter: 74 | 75 | 1. List all available counters: 76 | 77 | $ python manage.py list_counters 78 | 79 | 2. Rebuild a counter using one of the counter names given by `list_counters`: 80 | 81 | $ python manage.py rebuild_counter 82 | 83 | Note: The `rebuild_counter` management command will only update counters on objects that have at least one child 84 | object. For example articles with at least one comment. Articles with no comments will not be updated. This 85 | is a conscious limitation; the use cases for such a feature seem very limited, if existent at all. 86 | 87 | 88 | Documentation 89 | ------------- 90 | 91 | $ pip install Sphinx 92 | $ cd docs 93 | $ make html 94 | Open build/html/index.html 95 | -------------------------------------------------------------------------------- /django_counter_field/__init__.py: -------------------------------------------------------------------------------- 1 | # As a convenience, export ChangesMixin aliased as CounterMixin 2 | from django_model_changes.changes import ChangesMixin as CounterMixin 3 | 4 | from .counter import connect_counter 5 | from .fields import CounterField -------------------------------------------------------------------------------- /django_counter_field/counter.py: -------------------------------------------------------------------------------- 1 | from django.db.models import F 2 | 3 | from django_model_changes import post_change 4 | 5 | from .fields import CounterField 6 | 7 | counters = {} 8 | 9 | 10 | class Counter(object): 11 | """ 12 | Counter keeps the CounterField counter named *counter_name* up to 13 | date. Whenever changes are made to instances of the counted child 14 | model, i.e. the model that defines the foreign field 15 | *foreign_field*, the counter is potentially incremented/decremented. 16 | A optional callback function *is_in_counter* can be supplied for 17 | control over exactly which child model instances are to be counted. 18 | By default, all non-deleted instances are counted. 19 | """ 20 | def __init__(self, counter_name, foreign_field, is_in_counter=None): 21 | self.counter_name = counter_name 22 | self.foreign_field = foreign_field.field 23 | self.child_model = self.foreign_field.model 24 | self.parent_model = self.foreign_field.rel.to 25 | 26 | if not is_in_counter: 27 | is_in_counter = lambda instance: True 28 | self.is_in_counter = is_in_counter 29 | 30 | self.connect() 31 | 32 | def validate(self): 33 | """ 34 | Validate that this counter is indeed defined on the parent 35 | model. 36 | """ 37 | counter_field, _, _, _ = self.parent_model._meta.get_field_by_name( 38 | self.counter_name 39 | ) 40 | if not isinstance(counter_field, CounterField): 41 | raise TypeError("%s should be a CounterField on %s, but is %s" % ( 42 | self.counter_name, self.parent_model, type(counter_field))) 43 | 44 | def receive_change(self, instance): 45 | """ 46 | Called when child model instances are saved/destroyed. 47 | Increments/decrements the underlying counter based on whether 48 | the child was/is in the counter. 49 | """ 50 | was_in_counter = instance.was_persisted() and \ 51 | self.is_in_counter(instance.old_instance()) 52 | is_in_counter = instance.is_persisted() and \ 53 | self.is_in_counter(instance) 54 | 55 | if not was_in_counter and is_in_counter: 56 | self.increment(instance, 1) 57 | elif was_in_counter and not is_in_counter: 58 | self.increment(instance, -1) 59 | 60 | def connect(self): 61 | """ 62 | Register a counter between a child model and a parent. 63 | """ 64 | self.validate() 65 | 66 | def receiver(sender, instance, **kwargs): 67 | self.receive_change(instance) 68 | post_change.connect(receiver, sender=self.child_model, weak=False) 69 | 70 | name = "%s.%s.%s" % ( 71 | self.parent_model._meta.model_name, 72 | self.child_model._meta.model_name, 73 | self.foreign_field.name 74 | ) 75 | counted_name = "%s-%s" % (name, self.counter_name) 76 | counters[counted_name] = self 77 | 78 | def parent_id(self, child): 79 | """ 80 | Returns the id of the parent that includes the given *child* 81 | instance in its counter. 82 | """ 83 | return getattr(child, self.foreign_field.attname) 84 | 85 | def set_counter_field(self, parent_id, value): 86 | """ 87 | Set the value of a counter field on *parent_id* to *value*. 88 | """ 89 | return self.parent_model.objects.filter(pk=parent_id).update(**{ 90 | self.counter_name: value 91 | }) 92 | 93 | def increment(self, child, amount): 94 | """ 95 | Increment a counter using a *child* instance to find the the 96 | parent. Pass a negative amount to decrement. 97 | """ 98 | parent_id = self.parent_id(child) 99 | return self.set_counter_field(parent_id, F(self.counter_name)+amount) 100 | 101 | 102 | def connect_counter(counter_name, foreign_field, is_in_counter=None): 103 | """ 104 | Register a counter between a child model and a parent. The parent 105 | must define a CounterField field called *counter_name* and the child 106 | must reference its parent using a ForeignKey *foreign_field*. Supply 107 | an optional callback function *is_in_counter* for over which child 108 | instances to count. 109 | By default, all persisted (non-deleted) child instances are counted. 110 | 111 | Arguments: 112 | counter_name - The name of the counter. A CounterField field with 113 | this name must be defined on the parent model. 114 | foreign_field - A ForeignKey field defined on the counted child 115 | model. The foreign key must reference the parent model. 116 | is_in_counter - The callback function is_in_counter will be given 117 | instances of the counted model. It must return True if the instance 118 | qualifies to be counted, and False otherwise. The callback should 119 | not concern itself with checking if the instance is deleted or not. 120 | """ 121 | return Counter(counter_name, foreign_field, is_in_counter) 122 | -------------------------------------------------------------------------------- /django_counter_field/fields.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class CounterField(models.IntegerField): 5 | """ 6 | CounterField wraps the standard django IntegerField. It exists primarily to allow for easy validation of 7 | counter fields. The default value of a counter field is 0. 8 | """ 9 | def __init__(self, *args, **kwargs): 10 | kwargs['default'] = kwargs.get('default', 0) 11 | super(CounterField, self).__init__(*args, **kwargs) 12 | 13 | try: 14 | from south.modelsinspector import add_introspection_rules 15 | except ImportError: 16 | pass 17 | else: 18 | add_introspection_rules([], ["^django_counter_field\.fields\.CounterField"]) 19 | -------------------------------------------------------------------------------- /django_counter_field/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kajic/django-counter-field/4a8b0497240ef626191af8bd0290eb1e19c9a9b5/django_counter_field/management/__init__.py -------------------------------------------------------------------------------- /django_counter_field/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kajic/django-counter-field/4a8b0497240ef626191af8bd0290eb1e19c9a9b5/django_counter_field/management/commands/__init__.py -------------------------------------------------------------------------------- /django_counter_field/management/commands/list_counters.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import NoArgsCommand 2 | from django_counter_field.counter import counters 3 | 4 | 5 | class Command(NoArgsCommand): 6 | help = 'List all registered counters.' 7 | 8 | def handle(self, **kwargs): 9 | for i, counter_name in enumerate(counters.keys(), 1): 10 | print "%s. %s" % (i, counter_name) -------------------------------------------------------------------------------- /django_counter_field/management/commands/rebuild_counter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db.models import Count 5 | 6 | from django_counter_field.counter import counters 7 | 8 | 9 | class Command(BaseCommand): 10 | args = '' 11 | help = """ 12 | Rebuild the specified counter. Use python manage.py list_counters 13 | for a list of available counters. 14 | """ 15 | 16 | def handle(self, *args, **options): 17 | if len(args) != 1: 18 | sys.exit("Usage: python manage.py rebuild_counter ") 19 | 20 | counter_name = args[0] 21 | if not counter_name in counters: 22 | sys.exit("%s is not a registered counter" % counter_name) 23 | 24 | counter = counters[counter_name] 25 | 26 | parent_field = counter.foreign_field.name 27 | objects = counter.parent_model.objects.all() 28 | total = objects.count() 29 | for i, parent in enumerate(objects, 1): 30 | if total > 1000 and i % 1000 == 0: 31 | sys.stdout.write('%s of %s\n' % (i, total)) 32 | parent_id = parent.id 33 | count = counter.child_model.objects.filter(**{ parent_field:parent_id}).count() 34 | counter.set_counter_field(parent_id, count) 35 | sys.stdout.write('Completed!\n') 36 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-counter-field.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-counter-field.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-counter-field" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-counter-field" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kajic/django-counter-field/4a8b0497240ef626191af8bd0290eb1e19c9a9b5/docs/__init__.py -------------------------------------------------------------------------------- /docs/source/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kajic/django-counter-field/4a8b0497240ef626191af8bd0290eb1e19c9a9b5/docs/source/__init__.py -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-counter-field documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Dec 26 02:26:21 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or django-counter-field to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('../../')) 20 | 21 | # For convenience configure settings if they are not pre-configured or if we 22 | # haven't been provided settings to use by environment variable. 23 | from django.conf import settings 24 | if not settings.configured and not os.environ.get('DJANGO_SETTINGS_MODULE'): 25 | settings.configure() 26 | 27 | # -- General configuration ----------------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be extensions 33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix of source filenames. 40 | source_suffix = '.rst' 41 | 42 | # The encoding of source files. 43 | #source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = u'django-counter-field' 50 | copyright = u'2013, Robert Kajic' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = '0.1' 58 | # The full version, including alpha/beta/rc tags. 59 | release = '0.1' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | #language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | #today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | #today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = [] 74 | 75 | # The reST default role (used for this markup: `text`) to use for all documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | 96 | # -- Options for HTML output --------------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | html_theme = 'default' 101 | 102 | # Theme options are theme-specific and customize the look and feel of a theme 103 | # further. For a list of options available for each theme, see the 104 | # documentation. 105 | #html_theme_options = {} 106 | 107 | # Add any paths that contain custom themes here, relative to this directory. 108 | #html_theme_path = [] 109 | 110 | # The name for this set of Sphinx documents. If None, it defaults to 111 | # " v documentation". 112 | #html_title = None 113 | 114 | # A shorter title for the navigation bar. Default is the same as html_title. 115 | #html_short_title = None 116 | 117 | # The name of an image file (relative to this directory) to place at the top 118 | # of the sidebar. 119 | #html_logo = None 120 | 121 | # The name of an image file (within the static path) to use as favicon of the 122 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 123 | # pixels large. 124 | #html_favicon = None 125 | 126 | # Add any paths that contain custom static files (such as style sheets) here, 127 | # relative to this directory. They are copied after the builtin static files, 128 | # so a file named "default.css" will overwrite the builtin "default.css". 129 | html_static_path = ['_static'] 130 | 131 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 132 | # using the given strftime format. 133 | #html_last_updated_fmt = '%b %d, %Y' 134 | 135 | # If true, SmartyPants will be used to convert quotes and dashes to 136 | # typographically correct entities. 137 | #html_use_smartypants = True 138 | 139 | # Custom sidebar templates, maps document names to template names. 140 | #html_sidebars = {} 141 | 142 | # Additional templates that should be rendered to pages, maps page names to 143 | # template names. 144 | #html_additional_pages = {} 145 | 146 | # If false, no module index is generated. 147 | #html_domain_indices = True 148 | 149 | # If false, no index is generated. 150 | #html_use_index = True 151 | 152 | # If true, the index is split into individual pages for each letter. 153 | #html_split_index = False 154 | 155 | # If true, links to the reST sources are added to the pages. 156 | #html_show_sourcelink = True 157 | 158 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 159 | #html_show_sphinx = True 160 | 161 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 162 | #html_show_copyright = True 163 | 164 | # If true, an OpenSearch description file will be output, and all pages will 165 | # contain a tag referring to it. The value of this option must be the 166 | # base URL from which the finished HTML is served. 167 | #html_use_opensearch = '' 168 | 169 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 170 | #html_file_suffix = None 171 | 172 | # Output file base name for HTML help builder. 173 | htmlhelp_basename = 'django-counter-fielddoc' 174 | 175 | 176 | # -- Options for LaTeX output -------------------------------------------------- 177 | 178 | latex_elements = { 179 | # The paper size ('letterpaper' or 'a4paper'). 180 | #'papersize': 'letterpaper', 181 | 182 | # The font size ('10pt', '11pt' or '12pt'). 183 | #'pointsize': '10pt', 184 | 185 | # Additional stuff for the LaTeX preamble. 186 | #'preamble': '', 187 | } 188 | 189 | # Grouping the document tree into LaTeX files. List of tuples 190 | # (source start file, target name, title, author, documentclass [howto/manual]). 191 | latex_documents = [ 192 | ('index', 'django-counter-field.tex', u'django-counter-field Documentation', 193 | u'Robert Kajic', 'manual'), 194 | ] 195 | 196 | # The name of an image file (relative to this directory) to place at the top of 197 | # the title page. 198 | #latex_logo = None 199 | 200 | # For "manual" documents, if this is true, then toplevel headings are parts, 201 | # not chapters. 202 | #latex_use_parts = False 203 | 204 | # If true, show page references after internal links. 205 | #latex_show_pagerefs = False 206 | 207 | # If true, show URL addresses after external links. 208 | #latex_show_urls = False 209 | 210 | # Documents to append as an appendix to all manuals. 211 | #latex_appendices = [] 212 | 213 | # If false, no module index is generated. 214 | #latex_domain_indices = True 215 | 216 | 217 | # -- Options for manual page output -------------------------------------------- 218 | 219 | # One entry per manual page. List of tuples 220 | # (source start file, name, description, authors, manual section). 221 | man_pages = [ 222 | ('index', 'django-counter-field', u'django-counter-field Documentation', 223 | [u'Robert Kajic'], 1) 224 | ] 225 | 226 | # If true, show URL addresses after external links. 227 | #man_show_urls = False 228 | 229 | 230 | # -- Options for Texinfo output ------------------------------------------------ 231 | 232 | # Grouping the document tree into Texinfo files. List of tuples 233 | # (source start file, target name, title, author, 234 | # dir menu entry, description, category) 235 | texinfo_documents = [ 236 | ('index', 'django-counter-field', u'django-counter-field Documentation', 237 | u'Robert Kajic', 'django-counter-field', 'One line description of project.', 238 | 'Miscellaneous'), 239 | ] 240 | 241 | # Documents to append as an appendix to all manuals. 242 | #texinfo_appendices = [] 243 | 244 | # If false, no module index is generated. 245 | #texinfo_domain_indices = True 246 | 247 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 248 | #texinfo_show_urls = 'footnote' 249 | 250 | 251 | # Example configuration for intersphinx: refer to the Python standard library. 252 | intersphinx_mapping = {'http://docs.python.org/': None} 253 | -------------------------------------------------------------------------------- /docs/source/django_model_changes.rst: -------------------------------------------------------------------------------- 1 | django-counter-field 2 | ==================== 3 | 4 | :mod:`changes` Module 5 | --------------------- 6 | 7 | .. automodule:: django_counter_field.changes 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | :mod:`signals` Module 13 | --------------------- 14 | 15 | .. automodule:: django_counter_field.signals 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-counter-field documentation master file, created by 2 | sphinx-quickstart on Thu Dec 26 02:26:21 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-counter-field's documentation! 7 | ================================================ 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 4 13 | 14 | django_counter_field.rst 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | 24 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import os 4 | import django 5 | from os.path import dirname, abspath 6 | from optparse import OptionParser 7 | 8 | from django.conf import settings 9 | 10 | # For convenience configure settings if they are not pre-configured or if we 11 | # haven't been provided settings to use by environment variable. 12 | if not settings.configured and not os.environ.get('DJANGO_SETTINGS_MODULE'): 13 | settings.configure( 14 | DATABASES={ 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | } 18 | }, 19 | INSTALLED_APPS=[ 20 | 'tests', 21 | ], 22 | DEBUG=False, 23 | ) 24 | 25 | from django.test.runner import DiscoverRunner 26 | 27 | 28 | def runtests(*test_args, **kwargs): 29 | 30 | django.setup() 31 | 32 | if 'south' in settings.INSTALLED_APPS: 33 | from south.management.commands import patch_for_test_db_setup 34 | patch_for_test_db_setup() 35 | 36 | if not test_args: 37 | test_args = ['tests'] 38 | parent = dirname(abspath(__file__)) 39 | sys.path.insert(0, parent) 40 | test_runner = DiscoverRunner(verbosity=kwargs.get('verbosity', 1), interactive=kwargs.get('interactive', False), failfast=kwargs.get('failfast')) 41 | failures = test_runner.run_tests(test_args) 42 | sys.exit(failures) 43 | 44 | if __name__ == '__main__': 45 | parser = OptionParser() 46 | parser.add_option('--failfast', action='store_true', default=False, dest='failfast') 47 | 48 | (options, args) = parser.parse_args() 49 | 50 | runtests(failfast=options.failfast, *args) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | from pip.req import parse_requirements 4 | 5 | README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() 6 | 7 | # allow setup.py to be run from any path 8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 9 | 10 | setup( 11 | name='django-counter-field', 12 | version='0.3.2', 13 | packages=find_packages(exclude=['tests']), 14 | license='MIT License', 15 | description='django-counter-field makes it extremely easy to denormalize and keep track of related model counts.', 16 | long_description=README, 17 | url='http://github.com/kajic/django-counter-field', 18 | author='Robert Kajic', 19 | author_email='robert@kajic.com', 20 | classifiers=[ 21 | 'Environment :: Web Environment', 22 | 'Framework :: Django', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 31 | ], 32 | install_requires=[ 33 | 'django-model-changes>=0.15', 34 | ], 35 | test_suite='runtests.runtests', 36 | tests_require=[ 37 | 'pysqlite', 38 | 'django' 39 | ], 40 | zip_safe=False, 41 | ) 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kajic/django-counter-field/4a8b0497240ef626191af8bd0290eb1e19c9a9b5/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_counter_field import CounterField, CounterMixin, connect_counter 4 | 5 | 6 | class User(models.Model): 7 | name = models.CharField(max_length=100) 8 | following_count = CounterField() 9 | followers_count = CounterField() 10 | published_count = CounterField() 11 | draft_count = CounterField() 12 | 13 | 14 | class Relationship(CounterMixin, models.Model): 15 | consumer = models.ForeignKey('User', related_name='producer_set') 16 | producer = models.ForeignKey('User', related_name='consumer_set') 17 | 18 | connect_counter('following_count', Relationship.consumer) 19 | connect_counter('followers_count', Relationship.producer) 20 | 21 | 22 | class Article(CounterMixin, models.Model): 23 | user = models.ForeignKey('User') 24 | is_draft = models.BooleanField(default=True) 25 | 26 | connect_counter('published_count', Article.user, lambda article: not article.is_draft) 27 | connect_counter('draft_count', Article.user, lambda article: article.is_draft) -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | } 5 | } 6 | 7 | 8 | INSTALLED_APPS=[ 9 | 'tests', 10 | ] 11 | 12 | DEBUG = False 13 | 14 | SITE_ID = 1 -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .models import User, Relationship, Article 4 | 5 | 6 | class RelationshipsTestCase(TestCase): 7 | def test_increment_decrement(self): 8 | me = User() 9 | me.save() 10 | you = User() 11 | you.save() 12 | 13 | self.assertEqual(me.following_count, 0) 14 | self.assertEqual(you.followers_count, 0) 15 | 16 | i_follow_you = Relationship(consumer=me, producer=you) 17 | i_follow_you.save() 18 | 19 | you_follow_me = Relationship(consumer=you, producer=me) 20 | you_follow_me.save() 21 | 22 | me = User.objects.get(pk=me.pk) 23 | you = User.objects.get(pk=you.pk) 24 | 25 | self.assertEqual(me.following_count, 1) # i follow you 26 | self.assertEqual(me.followers_count, 1) # you follow me 27 | self.assertEqual(you.following_count, 1) # you follow me 28 | self.assertEqual(you.followers_count, 1) # i follow you 29 | 30 | i_follow_you.delete() 31 | 32 | me = User.objects.get(pk=me.pk) 33 | you = User.objects.get(pk=you.pk) 34 | 35 | self.assertEqual(me.following_count, 0) 36 | self.assertEqual(me.followers_count, 1) # you follow me 37 | self.assertEqual(you.following_count, 1) # you follow me 38 | self.assertEqual(you.followers_count, 0) 39 | 40 | 41 | class CommentsTestCase(TestCase): 42 | def test_is_in_counter(self): 43 | user = User() 44 | user.save() 45 | 46 | article = Article(user=user) 47 | article.save() 48 | 49 | user = User.objects.get(pk=user.pk) 50 | 51 | self.assertEqual(user.draft_count, 1) 52 | self.assertEqual(user.published_count, 0) 53 | 54 | article = Article.objects.get(pk=article.pk) 55 | article.is_draft = False 56 | article.save() 57 | 58 | user = User.objects.get(pk=user.pk) 59 | 60 | self.assertEqual(user.draft_count, 0) 61 | self.assertEqual(user.published_count, 1) 62 | --------------------------------------------------------------------------------