├── .coveragerc ├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── observer.rst ├── observer.tests.rst ├── observer.tests.test_utils.rst ├── observer.tests.test_watchers.rst ├── observer.utils.rst └── observer.watchers.rst ├── manage.py ├── requirements-docs.txt ├── requirements-test-py26.txt ├── requirements-test.txt ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── src └── observer │ ├── __init__.py │ ├── compat.py │ ├── conf.py │ ├── decorators.py │ ├── investigator.py │ ├── models.py │ ├── shortcuts.py │ ├── tests │ ├── __init__.py │ ├── compat.py │ ├── factories.py │ ├── models.py │ ├── test_investigator.py │ ├── test_utils │ │ ├── __init__.py │ │ ├── test_models.py │ │ └── test_signals.py │ └── test_watchers │ │ ├── __init__.py │ │ ├── test_base.py │ │ ├── test_related.py │ │ └── test_value.py │ ├── utils │ ├── __init__.py │ ├── models.py │ └── signals.py │ └── watchers │ ├── __init__.py │ ├── auto.py │ ├── base.py │ ├── related.py │ └── value.py ├── tests ├── __init__.py ├── manage.py ├── settings.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | include= 3 | src/*.py 4 | tests/*.py 5 | omit= 6 | src/observer/__init__.py 7 | src/observer/models.py 8 | src/observer/tests/compat.py 9 | exclude_lines= 10 | pragma: no cover 11 | if __name__ == .__main__.: 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ropeproject 2 | .project 3 | *.pyc 4 | *.egg 5 | *.egg-info 6 | .tox 7 | .coverage 8 | database.db 9 | local_settings.py 10 | /dist 11 | /sdist 12 | /tests/*.db 13 | /tests/static/collection 14 | /tests/media 15 | /docs/_build 16 | /issues 17 | /build 18 | .idea 19 | .python-version 20 | *.mo 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install tox 4 | - pip install django==1.6 5 | - pip install coverage coveralls 6 | 7 | script: 8 | - tox 9 | 10 | after_success: 11 | - coverage report 12 | - coveralls 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include requirements.txt 3 | include requirements-test.txt 4 | include requirements-docs.txt 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-observer 2 | ========================== 3 | .. image:: https://secure.travis-ci.org/lambdalisue/django-observer.png?branch=master 4 | :target: http://travis-ci.org/lambdalisue/django-observer 5 | :alt: Build status 6 | 7 | .. image:: https://coveralls.io/repos/lambdalisue/django-observer/badge.png?branch=master 8 | :target: https://coveralls.io/r/lambdalisue/django-observer/ 9 | :alt: Coverage 10 | 11 | .. image:: https://img.shields.io/pypi/dm/django-observer.svg 12 | :target: https://pypi.python.org/pypi/django-observer/ 13 | :alt: Downloads 14 | 15 | .. image:: https://img.shields.io/pypi/v/django-observer.svg 16 | :target: https://pypi.python.org/pypi/django-observer/ 17 | :alt: Latest version 18 | 19 | .. image:: https://img.shields.io/pypi/wheel/django-observer.svg 20 | :target: https://pypi.python.org/pypi/django-observer/ 21 | :alt: Wheel Status 22 | 23 | .. image:: https://pypip.in/egg/django-observer/badge.png 24 | :target: https://pypi.python.org/pypi/django-observer/ 25 | :alt: Egg Status 26 | 27 | .. image:: https://img.shields.io/pypi/l/django-observer.svg 28 | :target: https://pypi.python.org/pypi/django-observer/ 29 | :alt: License 30 | 31 | Author 32 | Alisue 33 | Supported python versions 34 | Python 2.6, 2.7, 3.2, 3.3 35 | Supported django versions 36 | Django 1.2 - 1.6 37 | 38 | Observe django model attribute modifications and call the specified callback. 39 | django-observer can recognize the modifications of 40 | 41 | - Any value type of fields (CharField, IntegerField, etc.) 42 | - Any relational fields (ForeignKey, OneToOneField, ManyToManyField) 43 | - Any reverse relational fields (fields given by `related_name`) 44 | - Any generic relational fields (GenericForeignKey, GenericRelation) 45 | 46 | 47 | Documentation 48 | ------------- 49 | http://django-observer.readthedocs.org/en/latest/ 50 | 51 | Installation 52 | ------------ 53 | Use pip_ like:: 54 | 55 | $ pip install django-observer 56 | 57 | .. _pip: https://pypi.python.org/pypi/pip 58 | 59 | Usage 60 | ----- 61 | 62 | Configuration 63 | ~~~~~~~~~~~~~ 64 | 1. Add ``observer`` to the ``INSTALLED_APPS`` in your settings 65 | module 66 | 67 | .. code:: python 68 | 69 | INSTALLED_APPS = ( 70 | # ... 71 | 'observer', 72 | ) 73 | 74 | Example 75 | ~~~~~~~~~~~ 76 | 77 | .. code:: python 78 | 79 | from django.db import models 80 | from django.contrib.auth.models import User 81 | 82 | def status_changed(sender, obj, attr): 83 | if obj.status == 'draft': 84 | obj.title = "Draft %s" % obj.title 85 | else: 86 | obj.title = obj.title.replace("Draft ") 87 | obj.save() 88 | 89 | # watch status attribute via decorator 90 | from observer.decorators import watch 91 | @watch('status', status_changed, call_on_created=True) 92 | class Entry(models.Model): 93 | title = models.CharField(max_length=30) 94 | status = models.CharFiled(max_length=10) 95 | 96 | body = models.TextField('title', max_length=100) 97 | author = models.ForeignKey(User, null=True, blank=True) 98 | 99 | 100 | def author_changed(sender, obj, attr): 101 | if obj.author is None: 102 | obj.status = "draft" 103 | obj.save() 104 | 105 | # watch author attribute via function 106 | from observer.shortcuts import watch 107 | watch(Entry, 'author', author_changed) 108 | -------------------------------------------------------------------------------- /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 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/src.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/src.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/src" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/src" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # src documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 9 22:20:46 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | BASE = os.path.dirname(os.path.abspath(__file__)) 23 | ROOT = os.path.dirname(BASE) 24 | sys.path.insert(0, os.path.join(ROOT, 'src')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinxcontrib.napoleon', 37 | 'sphinx.ext.viewcode', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = u'django-observer' 54 | copyright = u'2014, Alisue' 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | import observer 61 | # The short X.Y version. 62 | version = observer.__version__ 63 | # The full version, including alpha/beta/rc tags. 64 | release = '' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | #language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | #today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ['_build'] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | #keep_warnings = False 103 | 104 | 105 | # -- Options for HTML output ---------------------------------------------- 106 | 107 | # The theme to use for HTML and HTML Help pages. See the documentation for 108 | # a list of builtin themes. 109 | html_theme = 'default' 110 | 111 | # Theme options are theme-specific and customize the look and feel of a theme 112 | # further. For a list of options available for each theme, see the 113 | # documentation. 114 | #html_theme_options = {} 115 | 116 | # Add any paths that contain custom themes here, relative to this directory. 117 | #html_theme_path = [] 118 | 119 | # The name for this set of Sphinx documents. If None, it defaults to 120 | # " v documentation". 121 | #html_title = None 122 | 123 | # A shorter title for the navigation bar. Default is the same as html_title. 124 | #html_short_title = None 125 | 126 | # The name of an image file (relative to this directory) to place at the top 127 | # of the sidebar. 128 | #html_logo = None 129 | 130 | # The name of an image file (within the static path) to use as favicon of the 131 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 132 | # pixels large. 133 | #html_favicon = None 134 | 135 | # Add any paths that contain custom static files (such as style sheets) here, 136 | # relative to this directory. They are copied after the builtin static files, 137 | # so a file named "default.css" will overwrite the builtin "default.css". 138 | html_static_path = ['_static'] 139 | 140 | # Add any extra paths that contain custom files (such as robots.txt or 141 | # .htaccess) here, relative to this directory. These files are copied 142 | # directly to the root of the documentation. 143 | #html_extra_path = [] 144 | 145 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 146 | # using the given strftime format. 147 | #html_last_updated_fmt = '%b %d, %Y' 148 | 149 | # If true, SmartyPants will be used to convert quotes and dashes to 150 | # typographically correct entities. 151 | #html_use_smartypants = True 152 | 153 | # Custom sidebar templates, maps document names to template names. 154 | #html_sidebars = {} 155 | 156 | # Additional templates that should be rendered to pages, maps page names to 157 | # template names. 158 | #html_additional_pages = {} 159 | 160 | # If false, no module index is generated. 161 | #html_domain_indices = True 162 | 163 | # If false, no index is generated. 164 | #html_use_index = True 165 | 166 | # If true, the index is split into individual pages for each letter. 167 | #html_split_index = False 168 | 169 | # If true, links to the reST sources are added to the pages. 170 | #html_show_sourcelink = True 171 | 172 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 173 | #html_show_sphinx = True 174 | 175 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 176 | #html_show_copyright = True 177 | 178 | # If true, an OpenSearch description file will be output, and all pages will 179 | # contain a tag referring to it. The value of this option must be the 180 | # base URL from which the finished HTML is served. 181 | #html_use_opensearch = '' 182 | 183 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 184 | #html_file_suffix = None 185 | 186 | # Output file base name for HTML help builder. 187 | htmlhelp_basename = 'djangoobserverdoc' 188 | 189 | 190 | # -- Options for LaTeX output --------------------------------------------- 191 | 192 | latex_elements = { 193 | # The paper size ('letterpaper' or 'a4paper'). 194 | #'papersize': 'letterpaper', 195 | 196 | # The font size ('10pt', '11pt' or '12pt'). 197 | #'pointsize': '10pt', 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #'preamble': '', 201 | } 202 | 203 | # Grouping the document tree into LaTeX files. List of tuples 204 | # (source start file, target name, title, 205 | # author, documentclass [howto, manual, or own class]). 206 | latex_documents = [ 207 | ('index', 'django-observer.tex', u'django-observer Documentation', 208 | u'Alisue', 'manual'), 209 | ] 210 | 211 | # The name of an image file (relative to this directory) to place at the top of 212 | # the title page. 213 | #latex_logo = None 214 | 215 | # For "manual" documents, if this is true, then toplevel headings are parts, 216 | # not chapters. 217 | #latex_use_parts = False 218 | 219 | # If true, show page references after internal links. 220 | #latex_show_pagerefs = False 221 | 222 | # If true, show URL addresses after external links. 223 | #latex_show_urls = False 224 | 225 | # Documents to append as an appendix to all manuals. 226 | #latex_appendices = [] 227 | 228 | # If false, no module index is generated. 229 | #latex_domain_indices = True 230 | 231 | 232 | # -- Options for manual page output --------------------------------------- 233 | 234 | # One entry per manual page. List of tuples 235 | # (source start file, name, description, authors, manual section). 236 | man_pages = [ 237 | ('index', 'django-observer', u'django-observer Documentation', 238 | [u'Alisue'], 1) 239 | ] 240 | 241 | # If true, show URL addresses after external links. 242 | #man_show_urls = False 243 | 244 | 245 | # -- Options for Texinfo output ------------------------------------------- 246 | 247 | # Grouping the document tree into Texinfo files. List of tuples 248 | # (source start file, target name, title, author, 249 | # dir menu entry, description, category) 250 | texinfo_documents = [ 251 | ('index', 'django-observer', u'django-observer Documentation', 252 | u'Alisue', 'django-observer', 'One line description of project.', 253 | 'Miscellaneous'), 254 | ] 255 | 256 | # Documents to append as an appendix to all manuals. 257 | #texinfo_appendices = [] 258 | 259 | # If false, no module index is generated. 260 | #texinfo_domain_indices = True 261 | 262 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 263 | #texinfo_show_urls = 'footnote' 264 | 265 | # If true, do not generate a @detailmenu in the "Top" node's menu. 266 | #texinfo_no_detailmenu = False 267 | 268 | 269 | # -- Options for Epub output ---------------------------------------------- 270 | 271 | # Bibliographic Dublin Core info. 272 | epub_title = u'django-observer' 273 | epub_author = u'Alisue' 274 | epub_publisher = u'Alisue' 275 | epub_copyright = u'2014, Alisue' 276 | 277 | # The basename for the epub file. It defaults to the project name. 278 | #epub_basename = u'src' 279 | 280 | # The HTML theme for the epub output. Since the default themes are not optimized 281 | # for small screen space, using the same theme for HTML and epub output is 282 | # usually not wise. This defaults to 'epub', a theme designed to save visual 283 | # space. 284 | #epub_theme = 'epub' 285 | 286 | # The language of the text. It defaults to the language option 287 | # or en if the language is not set. 288 | #epub_language = '' 289 | 290 | # The scheme of the identifier. Typical schemes are ISBN or URL. 291 | #epub_scheme = '' 292 | 293 | # The unique identifier of the text. This can be a ISBN number 294 | # or the project homepage. 295 | #epub_identifier = '' 296 | 297 | # A unique identification for the text. 298 | #epub_uid = '' 299 | 300 | # A tuple containing the cover image and cover page html template filenames. 301 | #epub_cover = () 302 | 303 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 304 | #epub_guide = () 305 | 306 | # HTML files that should be inserted before the pages created by sphinx. 307 | # The format is a list of tuples containing the path and title. 308 | #epub_pre_files = [] 309 | 310 | # HTML files shat should be inserted after the pages created by sphinx. 311 | # The format is a list of tuples containing the path and title. 312 | #epub_post_files = [] 313 | 314 | # A list of files that should not be packed into the epub file. 315 | epub_exclude_files = ['search.html'] 316 | 317 | # The depth of the table of contents in toc.ncx. 318 | #epub_tocdepth = 3 319 | 320 | # Allow duplicate toc entries. 321 | #epub_tocdup = True 322 | 323 | # Choose between 'default' and 'includehidden'. 324 | #epub_tocscope = 'default' 325 | 326 | # Fix unsupported image types using the PIL. 327 | #epub_fix_images = False 328 | 329 | # Scale large images. 330 | #epub_max_image_width = 0 331 | 332 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 333 | #epub_show_urls = 'inline' 334 | 335 | # If false, no index is generated. 336 | #epub_use_index = True 337 | 338 | # on_rtd is whether we are on readthedocs.org 339 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 340 | 341 | if not on_rtd: # only import and set the theme if we're building docs locally 342 | import sphinx_rtd_theme 343 | html_theme = 'sphinx_rtd_theme' 344 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 345 | 346 | # otherwise, readthedocs.org uses their theme by default, so no need to specify it 347 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. src documentation master file, created by 2 | sphinx-quickstart on Mon Jun 9 22:20:46 2014. 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-observer documentation! 7 | ============================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 4 13 | 14 | observer 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | 24 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 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. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\src.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\src.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/observer.rst: -------------------------------------------------------------------------------- 1 | observer package 2 | ================ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | observer.tests 10 | observer.utils 11 | observer.watchers 12 | 13 | Submodules 14 | ---------- 15 | 16 | observer.compat module 17 | ---------------------- 18 | 19 | .. automodule:: observer.compat 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | observer.conf module 25 | -------------------- 26 | 27 | .. automodule:: observer.conf 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | observer.decorators module 33 | -------------------------- 34 | 35 | .. automodule:: observer.decorators 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | 40 | observer.investigator module 41 | ---------------------------- 42 | 43 | .. automodule:: observer.investigator 44 | :members: 45 | :undoc-members: 46 | :show-inheritance: 47 | 48 | observer.models module 49 | ---------------------- 50 | 51 | .. automodule:: observer.models 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | 56 | observer.shortcuts module 57 | ------------------------- 58 | 59 | .. automodule:: observer.shortcuts 60 | :members: 61 | :undoc-members: 62 | :show-inheritance: 63 | 64 | 65 | Module contents 66 | --------------- 67 | 68 | .. automodule:: observer 69 | :members: 70 | :undoc-members: 71 | :show-inheritance: 72 | -------------------------------------------------------------------------------- /docs/observer.tests.rst: -------------------------------------------------------------------------------- 1 | observer.tests package 2 | ====================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | observer.tests.test_utils 10 | observer.tests.test_watchers 11 | 12 | Submodules 13 | ---------- 14 | 15 | observer.tests.compat module 16 | ---------------------------- 17 | 18 | .. automodule:: observer.tests.compat 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | observer.tests.factories module 24 | ------------------------------- 25 | 26 | .. automodule:: observer.tests.factories 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | observer.tests.models module 32 | ---------------------------- 33 | 34 | .. automodule:: observer.tests.models 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | observer.tests.test_investigator module 40 | --------------------------------------- 41 | 42 | .. automodule:: observer.tests.test_investigator 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | 48 | Module contents 49 | --------------- 50 | 51 | .. automodule:: observer.tests 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | -------------------------------------------------------------------------------- /docs/observer.tests.test_utils.rst: -------------------------------------------------------------------------------- 1 | observer.tests.test_utils package 2 | ================================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | observer.tests.test_utils.test_models module 8 | -------------------------------------------- 9 | 10 | .. automodule:: observer.tests.test_utils.test_models 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | observer.tests.test_utils.test_signals module 16 | --------------------------------------------- 17 | 18 | .. automodule:: observer.tests.test_utils.test_signals 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: observer.tests.test_utils 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/observer.tests.test_watchers.rst: -------------------------------------------------------------------------------- 1 | observer.tests.test_watchers package 2 | ==================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | observer.tests.test_watchers.test_base module 8 | --------------------------------------------- 9 | 10 | .. automodule:: observer.tests.test_watchers.test_base 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | observer.tests.test_watchers.test_related module 16 | ------------------------------------------------ 17 | 18 | .. automodule:: observer.tests.test_watchers.test_related 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | observer.tests.test_watchers.test_value module 24 | ---------------------------------------------- 25 | 26 | .. automodule:: observer.tests.test_watchers.test_value 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: observer.tests.test_watchers 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /docs/observer.utils.rst: -------------------------------------------------------------------------------- 1 | observer.utils package 2 | ====================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | observer.utils.models module 8 | ---------------------------- 9 | 10 | .. automodule:: observer.utils.models 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | observer.utils.signals module 16 | ----------------------------- 17 | 18 | .. automodule:: observer.utils.signals 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: observer.utils 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/observer.watchers.rst: -------------------------------------------------------------------------------- 1 | observer.watchers package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | observer.watchers.auto module 8 | ----------------------------- 9 | 10 | .. automodule:: observer.watchers.auto 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | observer.watchers.base module 16 | ----------------------------- 17 | 18 | .. automodule:: observer.watchers.base 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | observer.watchers.related module 24 | -------------------------------- 25 | 26 | .. automodule:: observer.watchers.related 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | observer.watchers.value module 32 | ------------------------------ 33 | 34 | .. automodule:: observer.watchers.value 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | 40 | Module contents 41 | --------------- 42 | 43 | .. automodule:: observer.watchers 44 | :members: 45 | :undoc-members: 46 | :show-inheritance: 47 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Django 1.2 - 1.6 compatible manage.py 4 | Modify this script to make your own manage.py 5 | """ 6 | __author__ = 'Alisue ' 7 | import os 8 | import sys 9 | 10 | 11 | if __name__ == '__main__': 12 | # add extra sys.path 13 | root = os.path.abspath(os.path.dirname(__file__)) 14 | extra_paths = (root, os.path.join(root, 'src')) 15 | for extra_path in extra_paths: 16 | if extra_path in sys.path: 17 | sys.path.remove(extra_path) 18 | sys.path.insert(0, extra_path) 19 | # set DJANGO_SETTINGS_MODULE 20 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 21 | 22 | try: 23 | # django 1.4 and above 24 | # https://docs.djangoproject.com/en/1.4/releases/1.4/ 25 | from django.core.management import execute_from_command_line 26 | execute_from_command_line(sys.argv) 27 | except ImportError: 28 | # check django version 29 | import django 30 | if django.VERSION[:2] >= (1.4): 31 | # there are real problems on importing 32 | raise 33 | from django.core.management import execute_manager 34 | settings = __import__(os.environ['DJANGO_SETTINGS_MODULE']) 35 | execute_manager(settings) 36 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | app_version 2 | django-appconf 3 | mock 4 | django-override-settings 5 | factory_boy 6 | django 7 | sphinx 8 | sphinxcontrib-napoleon 9 | sphinx_rtd_theme 10 | -------------------------------------------------------------------------------- /requirements-test-py26.txt: -------------------------------------------------------------------------------- 1 | unittest2 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | app_version 2 | django-appconf 3 | mock 4 | django-override-settings 5 | factory_boy 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | app_version>=0.1.1 2 | django-appconf 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #============================================================================== 3 | # A generic django app test running script. 4 | # 5 | # Author: Alisue 6 | # License: MIT license 7 | #============================================================================== 8 | import os 9 | import sys 10 | import optparse # argparse is prefered but it require python 2.7 or higher 11 | 12 | # You can defined the default test apps here 13 | DEFAULT_TEST_APPS = ( 14 | 'observer', 15 | ) 16 | 17 | 18 | def console_main(args=None): 19 | parser = optparse.OptionParser(usage="python runtest.py [options] ") 20 | parser.add_option('-v', '--verbosity', default='1', 21 | choices=('0', '1', '2', '3'), 22 | help=("Verbosity level; 0=minimal output, 1=normal " 23 | "output, 2=verbose output, 3=very verbose " 24 | "output")) 25 | parser.add_option('-i', '--interactive', action='store_true') 26 | parser.add_option('-b', '--base-dir', default=None, 27 | help=("The base directory of the code. Used for " 28 | "python 3 compiled codes.")) 29 | opts, apps = parser.parse_args(args) 30 | 31 | if len(apps) == 0: 32 | apps = DEFAULT_TEST_APPS 33 | 34 | run_tests(apps, 35 | verbosity=int(opts.verbosity), 36 | interactive=opts.interactive, 37 | base_dir=opts.base_dir) 38 | 39 | 40 | def run_tests(app_tests, verbosity=1, interactive=False, base_dir=None): 41 | base_dir = base_dir or os.path.dirname(__file__) 42 | sys.path.insert(0, os.path.join(base_dir, 'src')) 43 | sys.path.insert(0, os.path.join(base_dir, 'tests')) 44 | 45 | os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' 46 | 47 | from django.conf import settings 48 | from django.test.utils import get_runner 49 | TestRunner = get_runner(settings) 50 | test_runner = TestRunner(verbosity=verbosity, 51 | interactive=interactive, failfast=False) 52 | failures = test_runner.run_tests(app_tests) 53 | sys.exit(bool(failures)) 54 | 55 | 56 | if __name__ == '__main__': 57 | console_main() 58 | 59 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [upload_docs] 2 | upload-dir = docs/_build/html 3 | 4 | [aliases] 5 | publish = sdist upload 6 | 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, find_packages, Command 4 | 5 | NAME = 'django-observer' 6 | VERSION = '0.4.3' 7 | 8 | 9 | class compile_docs(Command): 10 | description = ("re-compile documentations") 11 | user_options = [] 12 | 13 | def initialize_options(self): 14 | self.cwd = None 15 | 16 | def finalize_options(self): 17 | self.cwd = os.getcwd() 18 | 19 | def run(self): 20 | compile_docs.update_docs() 21 | compile_docs.compile_docs() 22 | 23 | @classmethod 24 | def update_docs(cls): 25 | """ 26 | Update docs via sphinx-apidoc (src -> docs) 27 | """ 28 | os.system('sphinx-docs -o docs src -f') 29 | return True 30 | 31 | @classmethod 32 | def compile_docs(cls): 33 | """ 34 | Compile '.rst' files into '.html' files via Sphinx. 35 | """ 36 | original_cwd = os.getcwd() 37 | BASE = os.path.abspath(os.path.dirname(__file__)) 38 | root = os.path.join(BASE, 'docs') 39 | os.chdir(root) 40 | os.system('make html') 41 | os.system('xdg-open _build/html/index.html') 42 | os.chdir(original_cwd) 43 | return True 44 | 45 | 46 | def read(filename): 47 | BASE_DIR = os.path.dirname(__file__) 48 | filename = os.path.join(BASE_DIR, filename) 49 | with open(filename, 'r') as fi: 50 | return fi.read() 51 | 52 | 53 | def readlist(filename): 54 | rows = read(filename).split("\n") 55 | rows = [x.strip() for x in rows if x.strip()] 56 | return list(rows) 57 | 58 | # if we are running on python 3, enable 2to3 and 59 | # let it use the custom fixers from the custom_fixers 60 | # package. 61 | extra = {} 62 | if sys.version_info >= (3, 0): 63 | extra.update( 64 | use_2to3=True, 65 | ) 66 | 67 | setup( 68 | name=NAME, 69 | version=VERSION, 70 | description=("Watch any object/field/relation/generic relation of django " 71 | "and call the callback when the watched object is modified"), 72 | long_description = read('README.rst'), 73 | classifiers = ( 74 | 'Development Status :: 4 - Beta', 75 | 'Environment :: Web Environment', 76 | 'Framework :: Django', 77 | 'Intended Audience :: Developers', 78 | 'License :: OSI Approved :: MIT License', 79 | 'Operating System :: OS Independent', 80 | 'Programming Language :: Python', 81 | 'Programming Language :: Python :: 2', 82 | 'Programming Language :: Python :: 2.6', 83 | 'Programming Language :: Python :: 2.6', 84 | 'Programming Language :: Python :: 3', 85 | 'Programming Language :: Python :: 3.2', 86 | 'Programming Language :: Python :: 3.3', 87 | 'Topic :: Internet :: WWW/HTTP', 88 | 'Topic :: Software Development :: Libraries', 89 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 90 | 'Topic :: Software Development :: Libraries :: Python Modules', 91 | ), 92 | keywords = "django app registration inspection", 93 | author = 'Alisue', 94 | author_email = 'lambdalisue@hashnote.net', 95 | url = 'https://github.com/lambdalisue/%s' % NAME, 96 | download_url = 'https://github.com/lambdalisue/%s/tarball/master' % NAME, 97 | license = 'MIT', 98 | packages = find_packages('src'), 99 | package_dir = {'': 'src'}, 100 | include_package_data = True, 101 | package_data = { 102 | '': ['README.rst', 103 | 'requirements.txt', 104 | 'requirements-test.txt', 105 | 'requirements-docs.txt'], 106 | }, 107 | zip_safe=True, 108 | install_requires=readlist('requirements.txt'), 109 | test_suite='runtests.run_tests', 110 | tests_require=readlist('requirements-test.txt'), 111 | cmdclass={ 112 | 'compile_docs': compile_docs, 113 | }, 114 | **extra 115 | ) 116 | -------------------------------------------------------------------------------- /src/observer/__init__.py: -------------------------------------------------------------------------------- 1 | from app_version import get_versions 2 | __version__, VERSION = get_versions('django-observer') 3 | -------------------------------------------------------------------------------- /src/observer/compat.py: -------------------------------------------------------------------------------- 1 | # Python 2.7 has an importlib with import_module; for older Pythons, 2 | # Django's bundled copy provides it. 3 | try: 4 | from importlib import import_module 5 | except ImportError: 6 | from django.utils.importlib import import_module 7 | 8 | try: 9 | from functools import lru_cahce 10 | except ImportError: 11 | try: 12 | # use pylru instead 13 | from pylru import lrudecorator as lru_cache 14 | except ImportError: 15 | # use pylru is not installed 16 | def lru_cache(maxsize): 17 | def inner(fn): 18 | return fn 19 | return inner 20 | -------------------------------------------------------------------------------- /src/observer/conf.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Confiurations of django-observer 4 | """ 5 | __author__ = 'Alisue ' 6 | from django.conf import settings 7 | from appconf import AppConf 8 | 9 | 10 | class ObserverAppConf(AppConf): 11 | DEFAULT_WATCHER = 'observer.watchers.ComplexWatcher' 12 | 13 | LRU_CACHE_SIZE = 128 14 | -------------------------------------------------------------------------------- /src/observer/decorators.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def watch(attr, callback, **kwargs): 4 | """ 5 | A decorator function for watching model attribute 6 | """ 7 | def decorator(model): 8 | from observer.watchers.auto import AutoWatcher 9 | watcher = AutoWatcher(model, attr, callback, **kwargs) 10 | watcher.lazy_watch() 11 | if not hasattr(model, '_watchers'): 12 | model._watchers = [] 13 | model._watchers.append(watcher) 14 | return model 15 | return decorator 16 | -------------------------------------------------------------------------------- /src/observer/investigator.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist 2 | 3 | 4 | class Investigator(object): 5 | """ 6 | A model modification investigator 7 | 8 | Create an instance of investigator with a model class and call 'prepare' 9 | method just before save the model. After the model is saved, call 10 | 'investigate' method and the method will yields the field names modified. 11 | """ 12 | def __init__(self, model, include=None, exclude=None): 13 | """ 14 | Construct investigator 15 | 16 | Args: 17 | model (model): A model class of the object interest 18 | include (None, list, tuple): A field name list which will be 19 | investigated 20 | exclude (None, list, tuple): A field name list which wont't be 21 | investigated 22 | """ 23 | self.model = model 24 | self.include = set(include) if include is not None else None 25 | self.exclude = set(exclude) if exclude is not None else None 26 | self._object_cached = {} 27 | 28 | def prepare(self, instance): 29 | """ 30 | Call this function before save the model instance 31 | """ 32 | if instance.pk is None: 33 | return 34 | # find raw instance from the database 35 | raw_instance = self.get_object(instance.pk) 36 | # update object cache 37 | self._object_cached[instance.pk] = raw_instance 38 | 39 | def investigate(self, instance): 40 | """ 41 | Call this function after the model instance is saved. 42 | It yield a name of modified attributes 43 | """ 44 | cached_obj = self.get_cached(instance.pk) 45 | if cached_obj is None: 46 | return 47 | field_names = set(x.name for x in self.model._meta.fields) 48 | if self.include: 49 | field_names.intersection_update(self.include) 50 | if self.exclude: 51 | field_names.difference_update(self.exclude) 52 | # compare field difference 53 | for field_name in field_names: 54 | old = getattr(cached_obj, field_name, None) 55 | new = getattr(instance, field_name, None) 56 | if old != new: 57 | yield field_name 58 | 59 | def get_cached(self, pk, ignore_exception=True): 60 | """ 61 | Get cached object 62 | 63 | Args: 64 | pk (any): A primary key of the object 65 | ignore_exception (bool): Return None if the object is not found, 66 | if this is False, ObjectDoesNotExist raised 67 | 68 | Raises: 69 | ObjectDoesNotExist: When a specified object does not exists in the 70 | cache and `ignore_exception` is False. 71 | 72 | Returns: 73 | object or None 74 | """ 75 | if pk not in self._object_cached and not ignore_exception: 76 | raise ObjectDoesNotExist 77 | return self._object_cached.get(pk, None) 78 | 79 | def get_object(self, pk, ignore_exception=True): 80 | """ 81 | Get latest object 82 | 83 | It try to get the latest (unsaved) object from the database. 84 | If `ignore_exception` is True, return None, otherwise it raise 85 | ObjectDoesNotExist exception when no object is found. 86 | 87 | Args: 88 | pk (any): A primary key of the object 89 | ignore_exception (bool): Return None if the object is not found. 90 | if this is False, ObjectDoesNotExist raised. 91 | 92 | Raises: 93 | ObjectDoesNotExist: When a specified object does not exists 94 | `ignore_exception` is False. 95 | 96 | Returns: 97 | object or None 98 | """ 99 | 100 | default_manager = self.model._default_manager 101 | # try to find the latest (unsaved) object from the database 102 | try: 103 | latest_obj = default_manager.get(pk=pk) 104 | return latest_obj 105 | except ObjectDoesNotExist: 106 | if ignore_exception: 107 | return None 108 | raise 109 | -------------------------------------------------------------------------------- /src/observer/models.py: -------------------------------------------------------------------------------- 1 | # fake 2 | -------------------------------------------------------------------------------- /src/observer/shortcuts.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def watch(model, attr, callback, **kwargs): 4 | """ 5 | A shortcut function for watching model attribute 6 | """ 7 | from observer.watchers.auto import AutoWatcher 8 | watcher = AutoWatcher(model, attr, callback, **kwargs) 9 | watcher.lazy_watch() 10 | return watcher 11 | -------------------------------------------------------------------------------- /src/observer/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from test_utils import * 2 | from test_watchers import * 3 | from test_investigator import * 4 | -------------------------------------------------------------------------------- /src/observer/tests/compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | # Python 3 have mock in unittest 3 | from unittest.mock import MagicMock, patch, DEFAULT, call 4 | except ImportError: 5 | from mock import MagicMock, patch, DEFAULT, call 6 | 7 | try: 8 | from django.test.utils import override_settings 9 | except ImportError: 10 | from override_settings import override_settings 11 | 12 | try: 13 | from unittest import skip 14 | except ImportError: 15 | from unittest2 import skip 16 | 17 | from django.test import TestCase 18 | 19 | # does the TestCase is based on new unittest? 20 | if not hasattr(TestCase, 'addCleanup'): 21 | # convert old TestCase to new TestCase via unittest2 22 | import unittest2 23 | class TestCase(TestCase, unittest2.case.TestCase): 24 | pass 25 | -------------------------------------------------------------------------------- /src/observer/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from observer.tests.models import Article 3 | from observer.tests.models import User, Supplement 4 | from observer.tests.models import Revision, Project, Hyperlink, Tag 5 | 6 | 7 | class SupplementFactory(factory.DjangoModelFactory): 8 | FACTORY_FOR = Supplement 9 | label = factory.Sequence(lambda n: 'label%s' % n) 10 | 11 | 12 | class UserFactory(factory.DjangoModelFactory): 13 | FACTORY_FOR = User 14 | label = factory.Sequence(lambda n: 'john%s' % n) 15 | 16 | 17 | class RevisionFactory(factory.DjangoModelFactory): 18 | FACTORY_FOR = Revision 19 | label = factory.Sequence(lambda n: 'label%s' % n) 20 | 21 | 22 | class ProjectFactory(factory.DjangoModelFactory): 23 | FACTORY_FOR = Project 24 | label = factory.Sequence(lambda n: 'label%s' % n) 25 | 26 | 27 | class HyperlinkFactory(factory.DjangoModelFactory): 28 | FACTORY_FOR = Hyperlink 29 | label = factory.Sequence(lambda n: 'label%s' % n) 30 | 31 | 32 | class TagFactory(factory.DjangoModelFactory): 33 | FACTORY_FOR = Tag 34 | label = factory.Sequence(lambda n: 'label%s' % n) 35 | 36 | 37 | class ArticleFactory(factory.DjangoModelFactory): 38 | FACTORY_FOR = Article 39 | 40 | title = factory.Sequence(lambda n: 'title%s' % n) 41 | content = 'This is an article content' 42 | supplement = factory.SubFactory(SupplementFactory) 43 | author = factory.SubFactory(UserFactory) 44 | revision = factory.RelatedFactory(RevisionFactory, 'article') 45 | 46 | @factory.post_generation 47 | def collaborators(self, create, extracted, **kwargs): 48 | if not create: 49 | # Simple build, do nothing. 50 | return 51 | 52 | if extracted: 53 | # A list of groups were passed in, use them 54 | for collaborator in extracted: 55 | self.collaborators.add(collaborator) 56 | 57 | @factory.post_generation 58 | def projects(self, create, extracted, **kwargs): 59 | if not create: 60 | # Simple build, do nothing. 61 | return 62 | 63 | if extracted: 64 | # A list of groups were passed in, use them 65 | for project in extracted: 66 | self.projects.add(project) 67 | 68 | @factory.post_generation 69 | def hyperlinks(self, create, extracted, **kwargs): 70 | if not create: 71 | # Simple build, do nothing. 72 | return 73 | 74 | if extracted: 75 | # A list of groups were passed in, use them 76 | for hyperlink in extracted: 77 | self.hyperlinks.add(hyperlink) 78 | 79 | @factory.post_generation 80 | def tags(self, create, extracted, **kwargs): 81 | if not create: 82 | # Simple build, do nothing. 83 | return 84 | 85 | if extracted: 86 | # A list of groups were passed in, use them 87 | for tag in extracted: 88 | self.tags.add(tag) 89 | -------------------------------------------------------------------------------- /src/observer/tests/models.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A model class of django-observer unittest 4 | """ 5 | __author__ = 'Alisue ' 6 | from django.db import models 7 | from django.contrib.contenttypes import generic 8 | from django.contrib.contenttypes.models import ContentType 9 | 10 | 11 | def alias(name): 12 | """Define alias name of the class""" 13 | def decorator(cls): 14 | globals()[name] = cls 15 | return cls 16 | return decorator 17 | 18 | 19 | @alias('Article') 20 | class ObserverTestArticle(models.Model): 21 | # concrete fields ======================================================== 22 | # ValueField 23 | title = models.CharField(max_length=50) 24 | content = models.TextField() 25 | 26 | # RelatedField (OneToOneRel) 27 | supplement = models.OneToOneField( 28 | 'observer.ObserverTestSupplement', blank=True, null=True, 29 | related_name='article') 30 | 31 | # RelatedField (OneToManyRel) 32 | author = models.ForeignKey( 33 | 'observer.ObserverTestUser', blank=True, null=True, 34 | related_name='article') 35 | 36 | # ManyRelatedField (ManyToManyRel) 37 | collaborators = models.ManyToManyField( 38 | 'observer.ObserverTestUser', 39 | related_name='articles') 40 | 41 | # relational fields ====================================================== 42 | 43 | # RelatedField (OneToOneRel) 44 | # revision: reverse OneToOneField 45 | 46 | # ManyRelatedField (ManyToOneRel) 47 | # projects: reverse ForeignKey 48 | 49 | # ManyRelatedField (ManyToManyRel) 50 | # hyperlinks: reverse ManyToManyField 51 | 52 | # virtual fields 53 | tags = generic.GenericRelation('observer.ObserverTestTag') 54 | 55 | class Meta: 56 | app_label = 'observer' 57 | 58 | def __unicode__(self): 59 | return "
" % self.title 60 | 61 | 62 | # connected from Article ===================================================== 63 | @alias('User') 64 | class ObserverTestUser(models.Model): 65 | label = models.CharField(max_length=20) 66 | 67 | class Meta: 68 | app_label = 'observer' 69 | 70 | def __unicode__(self): 71 | return "" % self.label 72 | 73 | 74 | @alias('Supplement') 75 | class ObserverTestSupplement(models.Model): 76 | label = models.CharField(max_length=50) 77 | 78 | class Meta: 79 | app_label = 'observer' 80 | 81 | def __unicode__(self): 82 | return "" % self.label 83 | 84 | 85 | # connected to Article ======================================================= 86 | @alias('Revision') 87 | class ObserverTestRevision(models.Model): 88 | label = models.CharField(max_length=50) 89 | article = models.OneToOneField( 90 | 'observer.ObserverTestArticle', blank=True, null=True, 91 | related_name='revision') 92 | 93 | class Meta: 94 | app_label = 'observer' 95 | 96 | def __unicode__(self): 97 | return "" % self.label 98 | 99 | 100 | @alias('Project') 101 | class ObserverTestProject(models.Model): 102 | label = models.CharField(max_length=50) 103 | article = models.ForeignKey( 104 | 'observer.ObserverTestArticle', blank=True, null=True, 105 | related_name='projects') 106 | 107 | class Meta: 108 | app_label = 'observer' 109 | 110 | def __unicode__(self): 111 | return "" % self.label 112 | 113 | 114 | @alias('Hyperlink') 115 | class ObserverTestHyperlink(models.Model): 116 | label = models.CharField(max_length=50) 117 | articles = models.ManyToManyField( 118 | 'observer.ObserverTestArticle', 119 | related_name='hyperlinks') 120 | 121 | class Meta: 122 | app_label = 'observer' 123 | 124 | def __unicode__(self): 125 | return "" % self.label 126 | 127 | 128 | @alias('Tag') 129 | class ObserverTestTag(models.Model): 130 | label = models.CharField(max_length=50) 131 | content_type = models.ForeignKey(ContentType, blank=True, null=True) 132 | object_id = models.PositiveIntegerField(blank=True, null=True) 133 | content_object = generic.GenericForeignKey() 134 | 135 | class Meta: 136 | app_label = 'observer' 137 | 138 | def __unicode__(self): 139 | return "" % self.label 140 | -------------------------------------------------------------------------------- /src/observer/tests/test_investigator.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist 2 | from observer.tests.models import Article 3 | from observer.tests.factories import ArticleFactory 4 | from observer.tests.compat import TestCase 5 | from observer.tests.compat import MagicMock 6 | from observer.investigator import Investigator 7 | 8 | 9 | class ObserverInvestigatorTestCase(TestCase): 10 | def setUp(self): 11 | self.model = Article 12 | self.attr = 'foobar' 13 | self.callback = MagicMock() 14 | self.Investigator = MagicMock(wraps=Investigator) 15 | self.investigator = self.Investigator(self.model) 16 | self.investigator._object_cached[1] = ArticleFactory() 17 | self.investigator._object_cached[2] = ArticleFactory() 18 | self.investigator._object_cached[3] = ArticleFactory() 19 | 20 | def test_get_cached_return_cached_objects(self): 21 | """get_cached should return cached obj""" 22 | for i in range(3): 23 | pk = i + 1 24 | cached_obj = self.investigator._object_cached[pk] 25 | r = self.investigator.get_cached(pk) 26 | self.assertEqual(r, cached_obj) 27 | 28 | def test_get_cached_return_none(self): 29 | """get_cached should return None if no object is found""" 30 | r = self.investigator.get_cached(0) 31 | self.assertEqual(r, None) 32 | 33 | def test_get_cached_raise_exception(self): 34 | """get_cached should raise ObjectDoesNotExist with !ignore_exception""" 35 | self.assertRaises(ObjectDoesNotExist, 36 | self.investigator.get_cached, 37 | 0, ignore_exception=False) 38 | 39 | def test_get_object_return_obj_from_database(self): 40 | """get_object should return obj from database""" 41 | unsaved_obj = ArticleFactory() 42 | unsaved_obj.title = 'unsaved value' 43 | r = self.investigator.get_object(unsaved_obj.pk) 44 | # title should not be different because unsaved_obj has not saved yet 45 | self.assertNotEqual(r.title, unsaved_obj.title) 46 | # save 47 | unsaved_obj.save() 48 | r = self.investigator.get_object(unsaved_obj.pk) 49 | self.assertEqual(r.title, unsaved_obj.title) 50 | 51 | def test_get_object_return_none(self): 52 | """get_object should return None if no object is found""" 53 | r = self.investigator.get_object(-1) 54 | self.assertIsNone(r) 55 | 56 | def test_get_object_raise_exception(self): 57 | """get_object should raise ObjectDoesNotExist if !ignore_exception""" 58 | self.assertRaises(ObjectDoesNotExist, 59 | self.investigator.get_object, 60 | -1, ignore_exception=False) 61 | 62 | def test_prepare_update_cache(self): 63 | """prepare should update cache""" 64 | pk = 1 65 | new_instance = MagicMock(pk=pk) 66 | raw_instance = MagicMock(pk=pk) 67 | old_instance = MagicMock(pk=pk) 68 | # prepare cache manually / patch method 69 | self.investigator._object_cached[pk] = old_instance 70 | self.investigator.get_object = MagicMock(return_value=raw_instance) 71 | # make sure that get_cached(pk) return correct value 72 | self.assertEqual(self.investigator.get_cached(pk), old_instance) 73 | # call prepare with new instance 74 | self.investigator.prepare(new_instance) 75 | self.assertNotEqual(self.investigator.get_cached(pk), old_instance) 76 | self.assertNotEqual(self.investigator.get_cached(pk), new_instance) 77 | self.assertEqual(self.investigator.get_cached(pk), raw_instance) 78 | 79 | def test_prepare_not_update_cache(self): 80 | """prepare should not update cache if instance does not have pk""" 81 | pk = None 82 | new_instance = MagicMock(pk=pk) 83 | raw_instance = MagicMock(pk=pk) 84 | old_instance = MagicMock(pk=pk) 85 | # prepare cache manually / patch method 86 | self.investigator._object_cached[pk] = old_instance 87 | self.investigator.get_object = MagicMock(return_value=raw_instance) 88 | # make sure that get_cached(pk) return correct value 89 | self.assertEqual(self.investigator.get_cached(pk), old_instance) 90 | # call prepare with new instance 91 | self.investigator.prepare(new_instance) 92 | self.assertEqual(self.investigator.get_cached(pk), old_instance) 93 | self.assertNotEqual(self.investigator.get_cached(pk), new_instance) 94 | self.assertNotEqual(self.investigator.get_cached(pk), raw_instance) 95 | 96 | def test_investigate_yield_modified_attributes(self): 97 | """investigate should yields modified attribute names""" 98 | article = ArticleFactory() 99 | article.title = 'modified' 100 | article.content = 'modified' 101 | # call prepare before save 102 | self.investigator.prepare(article) 103 | # save the change 104 | article.save() 105 | # investigate 106 | iterator = self.investigator.investigate(article) 107 | self.assertEqual(set(list(iterator)), set([ 108 | 'title', 'content', 109 | ])) 110 | 111 | def test_investigate_not_yield_created(self): 112 | """investigate should not yields anythong on creation""" 113 | article = ArticleFactory.build() 114 | article.title = 'modified' 115 | article.content = 'modified' 116 | # call prepare before save 117 | self.investigator.prepare(article) 118 | # save the change 119 | article.save() 120 | # investigate 121 | iterator = self.investigator.investigate(article) 122 | self.assertEqual(set(list(iterator)), set()) 123 | 124 | def test_investigate_yield_included_modified_attributes(self): 125 | """investigate should yields only included modified attribute names""" 126 | self.investigator.include = ['content'] 127 | article = ArticleFactory() 128 | article.title = 'modified' 129 | article.content = 'modified' 130 | # call prepare before save 131 | self.investigator.prepare(article) 132 | # save the change 133 | article.save() 134 | # investigate 135 | iterator = self.investigator.investigate(article) 136 | self.assertEqual(set(list(iterator)), set([ 137 | 'content', 138 | ])) 139 | 140 | def test_investigate_not_yield_excluded_modified_attributes(self): 141 | """investigate should not yields excluded modified attribute names""" 142 | self.investigator.exclude = ['content'] 143 | article = ArticleFactory() 144 | article.title = 'modified' 145 | article.content = 'modified' 146 | # call prepare before save 147 | self.investigator.prepare(article) 148 | # save the change 149 | article.save() 150 | # investigate 151 | iterator = self.investigator.investigate(article) 152 | self.assertEqual(set(list(iterator)), set([ 153 | 'title', 154 | ])) 155 | -------------------------------------------------------------------------------- /src/observer/tests/test_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from test_models import * 2 | from test_signals import * 3 | -------------------------------------------------------------------------------- /src/observer/tests/test_utils/test_models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import FieldDoesNotExist 3 | from django.contrib.contenttypes.generic import (GenericRelation, 4 | GenericForeignKey) 5 | from observer.tests.compat import TestCase 6 | from observer.utils.models import get_field 7 | from observer.tests.models import Article, Tag 8 | 9 | 10 | class ObserverUtilsModelsGetFieldTestCase(TestCase): 11 | def setUp(self): 12 | self.model = Article 13 | 14 | def test_get_field_return_none(self): 15 | r = get_field(Article, 'non_existing_field') 16 | self.assertIsNone(r) 17 | 18 | def test_get_field_raise_exception(self): 19 | self.assertRaises(FieldDoesNotExist, 20 | get_field, 21 | Article, 'non_existing_field', 22 | ignore_exception=False) 23 | 24 | def test_get_field_with_concreat_fields(self): 25 | concreate_fields = ( 26 | ('title', models.CharField), 27 | ('content', models.TextField), 28 | ('supplement', models.OneToOneField), 29 | ('author', models.ForeignKey), 30 | ('collaborators', models.ManyToManyField), 31 | ) 32 | for attr, expect in concreate_fields: 33 | field = get_field(Article, attr) 34 | self.assertTrue(isinstance(field, expect)) 35 | # field's model is Article 36 | self.assertEqual(field.model, Article) 37 | 38 | def test_get_field_with_relational_fields(self): 39 | relational_fields = ( 40 | ('revision', models.OneToOneField), 41 | ('projects', models.ForeignKey), 42 | ('hyperlinks', models.ManyToManyField), 43 | ) 44 | for attr, expect in relational_fields: 45 | field = get_field(Article, attr) 46 | self.assertTrue(isinstance(field, expect)) 47 | # field's model is not Article 48 | self.assertNotEqual(field.model, Article) 49 | 50 | def test_get_field_with_virtual_fields(self): 51 | virtual_fields = ( 52 | ('tags', GenericRelation, Article), 53 | ('content_object', GenericForeignKey, Tag), 54 | ) 55 | for attr, expect, model in virtual_fields: 56 | field = get_field(model, attr) 57 | self.assertTrue(isinstance(field, expect)) 58 | # field's model is equal 59 | self.assertEqual(field.model, model) 60 | -------------------------------------------------------------------------------- /src/observer/tests/test_utils/test_signals.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.core.signals import Signal 3 | from observer.tests.compat import TestCase 4 | from observer.tests.compat import MagicMock 5 | from observer.utils.signals import (register_reciever, 6 | unregister_reciever) 7 | 8 | 9 | class ObserverUtilsSignalsRegisterReceiverTestCase(TestCase): 10 | def setUp(self): 11 | self.model = MagicMock(wraps=models.Model) 12 | self.signal = MagicMock(wraps=Signal, 13 | **{'connect.return_value': None}) 14 | self.receiver = MagicMock() 15 | 16 | def test_register_reciever_call_connect(self): 17 | """should call connect to connect signal and receiver""" 18 | register_reciever(self.model, 19 | self.signal, 20 | self.receiver) 21 | # signal.connect should be called with receiver and model 22 | self.signal.connect.assert_called_with( 23 | self.receiver, sender=self.model, weak=False) 24 | 25 | def test_register_reciever_call_connect_once(self): 26 | """should call connect only once to prevent the duplication""" 27 | for i in range(5): 28 | register_reciever(self.model, 29 | self.signal, 30 | self.receiver) 31 | # signal.connect should be called once 32 | self.signal.connect.assert_called_once_with( 33 | self.receiver, sender=self.model, weak=False) 34 | 35 | def test_register_reciever_return_true_for_1st(self): 36 | """should return True for the 1st call""" 37 | r = register_reciever(self.model, 38 | self.signal, 39 | self.receiver) 40 | self.assertTrue(r) 41 | 42 | def test_register_reciever_return_false_for_2nd(self): 43 | """should return False for the 2nd and further call""" 44 | register_reciever(self.model, 45 | self.signal, 46 | self.receiver) 47 | # 2nd call 48 | r = register_reciever(self.model, 49 | self.signal, 50 | self.receiver) 51 | self.assertFalse(r) 52 | # 3rd call 53 | r = register_reciever(self.model, 54 | self.signal, 55 | self.receiver) 56 | self.assertFalse(r) 57 | 58 | def test_register_reciever_call_connect_of_individual_signals(self): 59 | """should call connect when signals are different""" 60 | signals = [MagicMock(wraps=Signal, **{'connect.return_value': None}) 61 | for i in range(5)] 62 | for signal in signals: 63 | register_reciever(self.model, 64 | signal, 65 | self.receiver) 66 | signal.connect.assert_called_once_with( 67 | self.receiver, sender=self.model, weak=False) 68 | 69 | def test_register_reciever_call_connect_of_individual_receivers(self): 70 | """should call connect when receivers are different""" 71 | receivers = [MagicMock() for i in range(5)] 72 | for receiver in receivers: 73 | register_reciever(self.model, 74 | self.signal, 75 | receiver) 76 | self.signal.connect.assert_called_with(receiver, weak=False, 77 | sender=self.model) 78 | # called multiple times 79 | self.assertEqual(self.signal.connect.call_count, 5) 80 | 81 | def test_register_reciever_prefer_specified_sender(self): 82 | """should call connect with specified sender""" 83 | sender = MagicMock() 84 | register_reciever(self.model, 85 | self.signal, 86 | self.receiver, 87 | sender=sender) 88 | # signal.connect should be called once with specified sender 89 | self.signal.connect.assert_called_once_with( 90 | self.receiver, sender=sender, weak=False) 91 | 92 | def test_register_reciever_pass_the_options(self): 93 | """should call connect with specified **kwargs""" 94 | register_reciever(self.model, 95 | self.signal, 96 | self.receiver, 97 | weak=True, 98 | foo='bar') 99 | # signal.connect should be called once with specified **kwargs 100 | # but weak cannot be modified 101 | self.signal.connect.assert_called_once_with( 102 | self.receiver, sender=self.model, 103 | weak=False, foo='bar', 104 | ) 105 | 106 | 107 | class ObserverUtilsSignalsUnregisterReceiverTestCase(TestCase): 108 | def setUp(self): 109 | self.model = MagicMock(wraps=models.Model) 110 | self.signal = MagicMock(wraps=Signal, **{ 111 | 'connect.return_value': None, 112 | 'disconnect.return_value': None, 113 | }) 114 | self.receiver = MagicMock() 115 | register_reciever(self.model, 116 | self.signal, 117 | self.receiver) 118 | 119 | def test_unregister_reciever_call_disconnect(self): 120 | """should call disconnect to disconnect signal and receiver""" 121 | unregister_reciever(self.model, 122 | self.signal, 123 | self.receiver) 124 | # signal.connect should be called with receiver and model 125 | self.signal.disconnect.assert_called_with( 126 | self.receiver) 127 | 128 | def test_unregister_reciever_call_disconnect_once(self): 129 | """should call disconnect only once to prevent the exception""" 130 | for i in range(5): 131 | unregister_reciever(self.model, 132 | self.signal, 133 | self.receiver) 134 | # signal.connect should be called once 135 | self.signal.disconnect.assert_called_once_with( 136 | self.receiver) 137 | 138 | def test_unregister_reciever_return_true_for_1st(self): 139 | """should return True for the 1st call""" 140 | r = unregister_reciever(self.model, 141 | self.signal, 142 | self.receiver) 143 | self.assertTrue(r) 144 | 145 | def test_unregister_reciever_return_false_for_2nd(self): 146 | """should return False for the 2nd and further call""" 147 | unregister_reciever(self.model, 148 | self.signal, 149 | self.receiver) 150 | # 2nd call 151 | r = unregister_reciever(self.model, 152 | self.signal, 153 | self.receiver) 154 | self.assertFalse(r) 155 | # 3rd call 156 | r = unregister_reciever(self.model, 157 | self.signal, 158 | self.receiver) 159 | self.assertFalse(r) 160 | 161 | def test_unregister_reciever_call_disconnect_of_individual_signals(self): 162 | """should call connect when signals are different""" 163 | prop = { 164 | 'connect.return_value': None, 165 | 'disconnect.return_value': None, 166 | } 167 | signals = [MagicMock(wraps=Signal, **prop) 168 | for i in range(5)] 169 | for signal in signals: 170 | register_reciever(self.model, 171 | signal, 172 | self.receiver) 173 | for signal in signals: 174 | unregister_reciever(self.model, 175 | signal, 176 | self.receiver) 177 | signal.disconnect.assert_called_once_with(self.receiver) 178 | 179 | def test_unregister_reciever_call_connect_of_individual_receivers(self): 180 | """should call connect when receivers are different""" 181 | receivers = [MagicMock() for i in range(5)] 182 | for receiver in receivers: 183 | register_reciever(self.model, 184 | self.signal, 185 | receiver) 186 | for receiver in receivers: 187 | unregister_reciever(self.model, 188 | self.signal, 189 | receiver) 190 | self.signal.disconnect.assert_called_with(receiver) 191 | # called multiple times 192 | self.assertEqual(self.signal.disconnect.call_count, 5) 193 | -------------------------------------------------------------------------------- /src/observer/tests/test_watchers/__init__.py: -------------------------------------------------------------------------------- 1 | from test_base import * 2 | from test_value import * 3 | from test_related import * 4 | -------------------------------------------------------------------------------- /src/observer/tests/test_watchers/test_base.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from observer.tests.compat import TestCase 3 | from observer.tests.compat import MagicMock, patch, DEFAULT 4 | from observer.watchers.base import WatcherBase 5 | from observer.tests.models import ObserverTestArticle as Article 6 | 7 | 8 | class ObserverWatchersWatcherBaseTestCase(TestCase): 9 | def setUp(self): 10 | self.model = Article 11 | self.attr = 'title' 12 | self.callback = MagicMock() 13 | self.watcher = WatcherBase(self.model, 14 | self.attr, 15 | self.callback) 16 | 17 | def test_watch_raise_exception(self): 18 | """watch should raise NotImplementedError""" 19 | self.assertRaises(NotImplementedError, 20 | self.watcher.watch) 21 | 22 | def test_unwatch_raise_exception(self): 23 | """unwatch should raise NotImplementedError""" 24 | self.assertRaises(NotImplementedError, 25 | self.watcher.watch) 26 | 27 | def test_call_call_callback(self): 28 | """call should call callback""" 29 | obj = MagicMock() 30 | self.watcher.call(obj) 31 | self.callback.assert_called_once_with( 32 | sender=self.watcher, obj=obj, attr=self.attr) 33 | 34 | def test_get_field_return_field(self): 35 | """get_field should return field instance""" 36 | self.assertTrue(isinstance(self.watcher.get_field(), 37 | models.CharField)) 38 | 39 | def test_get_field_return_field_with_attr(self): 40 | """get_field should return field instance""" 41 | self.assertTrue(isinstance(self.watcher.get_field('author'), 42 | models.ForeignKey)) 43 | 44 | def test_construct_with_string_relation(self): 45 | field = WatcherBase('observer.ObserverTestArticle', 46 | self.attr, self.callback) 47 | self.assertEqual(field.model, Article) 48 | 49 | @patch.multiple('observer.utils.models', 50 | get_model=DEFAULT, class_prepared=DEFAULT) 51 | def test_construct_with_string_relation_lazy_relation(self, get_model, 52 | class_prepared): 53 | from observer.utils.models import _do_pending_lookups 54 | # emulate the situlation that Article has not prepared yet 55 | get_model.return_value = None 56 | field = WatcherBase('observer.ObserverTestArticle', 57 | self.attr, self.callback) 58 | # Article haven't ready yet (get_model return None) 59 | self.assertEqual(field.model, 'observer.ObserverTestArticle') 60 | # emulate class_prepared signal 61 | _do_pending_lookups(Article) 62 | # Article had ready (class_prepared signal call do_pending_lookups) 63 | self.assertEqual(field.model, Article) 64 | 65 | def test_lazy_watch_call_watch(self): 66 | self.watcher.watch = MagicMock() 67 | kwargs = dict( 68 | foo=MagicMock(), 69 | bar=MagicMock(), 70 | hoge=MagicMock(), 71 | ) 72 | self.watcher.lazy_watch(**kwargs) 73 | self.watcher.watch.assert_called_once_with(**kwargs) 74 | 75 | @patch.multiple('observer.utils.models', 76 | get_model=DEFAULT, class_prepared=DEFAULT) 77 | def test_lazy_watch_with_unprepared_model(self, get_model, 78 | class_prepared): 79 | from observer.utils.models import _do_pending_lookups 80 | # emulate the situlation that Article has not prepared yet 81 | get_model.return_value = None 82 | 83 | field = WatcherBase('observer.ObserverTestArticle', 84 | self.attr, self.callback) 85 | field.watch = MagicMock() 86 | kwargs = dict( 87 | foo=MagicMock(), 88 | bar=MagicMock(), 89 | hoge=MagicMock(), 90 | ) 91 | field.lazy_watch(**kwargs) 92 | # the model have not ready yet thus watch should not be called yet 93 | self.assertFalse(field.watch.called) 94 | # emulate class_prepared signal 95 | _do_pending_lookups(Article) 96 | # Article has ready thus watch should be called automatically 97 | field.watch.assert_called_once_with(**kwargs) 98 | 99 | @patch.multiple('observer.utils.models', 100 | get_model=DEFAULT, class_prepared=DEFAULT) 101 | def test_lazy_watch_with_unprepared_relation(self, get_model, 102 | class_prepared): 103 | from observer.utils.models import _do_pending_lookups 104 | from observer.tests.models import User 105 | # emulate the situlation that User has not prepared yet 106 | get_model.return_value = None 107 | self.watcher._attr = 'author' 108 | self.watcher.watch = MagicMock() 109 | self.watcher.get_field().rel.to = 'observer.ObserverTestUser' 110 | kwargs = dict( 111 | foo=MagicMock(), 112 | bar=MagicMock(), 113 | hoge=MagicMock(), 114 | ) 115 | self.watcher.lazy_watch(**kwargs) 116 | # the rel.to have not ready yet thus watch should not be called yet 117 | self.assertFalse(self.watcher.watch.called) 118 | # emulate class_prepared signal 119 | # Note: 120 | # rel.to assignment is proceeded by other function thus it is 121 | # required to do manually, not like model assignment 122 | self.watcher.get_field().rel.to = User 123 | _do_pending_lookups(User) 124 | # User has ready thus watch should be called automatically 125 | self.watcher.watch.assert_called_once_with(**kwargs) 126 | -------------------------------------------------------------------------------- /src/observer/tests/test_watchers/test_related.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ObjectDoesNotExist 2 | from observer.tests.compat import TestCase 3 | from observer.tests.compat import MagicMock, skip 4 | from observer.tests.models import Article, Tag 5 | from observer.tests.factories import (ArticleFactory, 6 | SupplementFactory, 7 | RevisionFactory, 8 | UserFactory, 9 | ProjectFactory, 10 | HyperlinkFactory, 11 | TagFactory) 12 | from observer.watchers.related import (RelatedWatcherBase, 13 | RelatedWatcher, 14 | ManyRelatedWatcher, 15 | GenericRelatedWatcher) 16 | 17 | 18 | # ============================================================================ 19 | # RelatedWatcherBase 20 | # ============================================================================ 21 | class ObserverWatchersRelatedWatcherBaseTestCaseOneToOneRel(TestCase): 22 | def setUp(self): 23 | self.model = Article 24 | self.attr = 'supplement' 25 | self.callback = MagicMock() 26 | self.watcher = RelatedWatcherBase(self.model, 27 | self.attr, 28 | self.callback) 29 | self.addCleanup(self.watcher.unwatch) 30 | 31 | def test_callback_not_called_on_modification_without_watch(self): 32 | new_instance = ArticleFactory() 33 | supplement = new_instance.supplement 34 | supplement.label = 'modified' 35 | supplement.save() 36 | # have not watched, thus callback should not be called 37 | self.assertFalse(self.callback.called) 38 | 39 | def test_callback_called_on_modification_with_watch(self): 40 | new_instance = ArticleFactory() 41 | self.watcher.watch() 42 | supplement = new_instance.supplement 43 | supplement.label = 'modified' 44 | supplement.save() 45 | # callback should be called with instance modification 46 | self.callback.assert_called_once_with( 47 | obj=new_instance, attr=self.attr, sender=self.watcher) 48 | 49 | 50 | class ObserverWatchersRelatedWatcherBaseTestCaseRevOneToOneRel( 51 | TestCase): 52 | def setUp(self): 53 | self.model = Article 54 | self.attr = 'revision' 55 | self.callback = MagicMock() 56 | self.watcher = RelatedWatcherBase(self.model, 57 | self.attr, 58 | self.callback) 59 | self.addCleanup(self.watcher.unwatch) 60 | 61 | def test_callback_not_called_on_modification_without_watch(self): 62 | new_instance = ArticleFactory() 63 | revision = new_instance.revision 64 | revision.label = 'modified' 65 | revision.save() 66 | # have not watched, thus callback should not be called 67 | self.assertFalse(self.callback.called) 68 | 69 | def test_callback_called_on_modification_with_watch(self): 70 | new_instance = ArticleFactory() 71 | self.watcher.watch() 72 | revision = new_instance.revision 73 | revision.label = 'modified' 74 | revision.save() 75 | # callback should be called with instance modification 76 | self.callback.assert_called_once_with( 77 | obj=new_instance, attr=self.attr, sender=self.watcher) 78 | 79 | 80 | class ObserverWatchersRelatedWatcherBaseTestCaseOneToManyRel(TestCase): 81 | def setUp(self): 82 | self.model = Article 83 | self.attr = 'author' 84 | self.callback = MagicMock() 85 | self.watcher = RelatedWatcherBase(self.model, 86 | self.attr, 87 | self.callback) 88 | self.addCleanup(self.watcher.unwatch) 89 | 90 | def test_callback_not_called_on_modification_without_watch(self): 91 | new_instance = ArticleFactory() 92 | user = new_instance.author 93 | user.label = 'modified' 94 | user.save() 95 | # have not watched, thus callback should not be called 96 | self.assertFalse(self.callback.called) 97 | 98 | def test_callback_called_on_modification_with_watch(self): 99 | new_instance = ArticleFactory() 100 | self.watcher.watch() 101 | user = new_instance.author 102 | user.label = 'modified' 103 | user.save() 104 | # callback should be called with instance modification 105 | self.callback.assert_called_once_with( 106 | obj=new_instance, attr=self.attr, sender=self.watcher) 107 | 108 | 109 | class ObserverWatchersRelatedWatcherBaseTestCaseRevManyToOneRel(TestCase): 110 | def setUp(self): 111 | self.projects = ( 112 | ProjectFactory(), 113 | ProjectFactory(), 114 | ProjectFactory(), 115 | ProjectFactory(), 116 | ProjectFactory(), 117 | ) 118 | self.model = Article 119 | self.attr = 'projects' 120 | self.callback = MagicMock() 121 | self.watcher = RelatedWatcherBase(self.model, 122 | self.attr, 123 | self.callback) 124 | self.addCleanup(self.watcher.unwatch) 125 | 126 | def test_callback_not_called_on_modification_without_watch(self): 127 | new_instance = ArticleFactory(projects=self.projects) 128 | project = new_instance.projects.get(pk=1) 129 | project.label = 'modified' 130 | project.save() 131 | # have not watched, thus callback should not be called 132 | self.assertFalse(self.callback.called) 133 | 134 | def test_callback_called_on_modification_with_watch(self): 135 | new_instance = ArticleFactory(projects=self.projects) 136 | self.watcher.watch() 137 | project = new_instance.projects.get(pk=1) 138 | project.label = 'modified' 139 | project.save() 140 | # callback should be called with instance modification 141 | self.callback.assert_called_once_with( 142 | obj=new_instance, attr=self.attr, sender=self.watcher) 143 | 144 | 145 | class ObserverWatchersRelatedWatcherBaseTestCaseManyToManyRel(TestCase): 146 | def setUp(self): 147 | self.users = ( 148 | UserFactory(), 149 | UserFactory(), 150 | UserFactory(), 151 | UserFactory(), 152 | UserFactory(), 153 | ) 154 | self.model = Article 155 | self.attr = 'collaborators' 156 | self.callback = MagicMock() 157 | self.watcher = RelatedWatcherBase(self.model, 158 | self.attr, 159 | self.callback) 160 | self.addCleanup(self.watcher.unwatch) 161 | 162 | def test_callback_not_called_on_modification_without_watch(self): 163 | new_instance = ArticleFactory(collaborators=self.users) 164 | user = new_instance.collaborators.get(pk=1) 165 | user.label = 'modified' 166 | user.save() 167 | # have not watched, thus callback should not be called 168 | self.assertFalse(self.callback.called) 169 | 170 | def test_callback_called_on_modification_with_watch(self): 171 | new_instance = ArticleFactory(collaborators=self.users) 172 | self.watcher.watch() 173 | user = new_instance.collaborators.get(pk=1) 174 | user.label = 'modified' 175 | user.save() 176 | # callback should be called with instance modification 177 | self.callback.assert_called_once_with( 178 | obj=new_instance, attr=self.attr, sender=self.watcher) 179 | 180 | 181 | class ObserverWatchersRelatedWatcherBaseTestCaseRevManyToManyRel(TestCase): 182 | def setUp(self): 183 | self.hyperlinks = ( 184 | HyperlinkFactory(), 185 | HyperlinkFactory(), 186 | HyperlinkFactory(), 187 | HyperlinkFactory(), 188 | HyperlinkFactory(), 189 | ) 190 | self.model = Article 191 | self.attr = 'hyperlinks' 192 | self.callback = MagicMock() 193 | self.watcher = RelatedWatcherBase(self.model, 194 | self.attr, 195 | self.callback) 196 | self.addCleanup(self.watcher.unwatch) 197 | 198 | def test_callback_not_called_on_modification_without_watch(self): 199 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 200 | hyperlink = new_instance.hyperlinks.get(pk=1) 201 | hyperlink.label = 'modified' 202 | hyperlink.save() 203 | # have not watched, thus callback should not be called 204 | self.assertFalse(self.callback.called) 205 | 206 | def test_callback_called_on_modification_with_watch(self): 207 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 208 | self.watcher.watch() 209 | hyperlink = new_instance.hyperlinks.get(pk=1) 210 | hyperlink.label = 'modified' 211 | hyperlink.save() 212 | # callback should be called with instance modification 213 | self.callback.assert_called_once_with( 214 | obj=new_instance, attr=self.attr, sender=self.watcher) 215 | 216 | 217 | # ============================================================================ 218 | # RelatedWatcher 219 | # ============================================================================ 220 | class ObserverWatchersRelatedWatcherTestCaseOneToOneRel(TestCase): 221 | def setUp(self): 222 | self.model = Article 223 | self.attr = 'supplement' 224 | self.callback = MagicMock() 225 | self.watcher = RelatedWatcher(self.model, 226 | self.attr, 227 | self.callback) 228 | self.addCleanup(self.watcher.unwatch) 229 | 230 | def test_callback_not_called_on_create_without_watch(self): 231 | ArticleFactory() 232 | # have not watched, thus callback should not be called 233 | self.assertFalse(self.callback.called) 234 | 235 | def test_callback_called_on_create_with_watch(self): 236 | self.watcher.watch() 237 | new_instance = ArticleFactory() 238 | # callback should be called with newly created instance 239 | self.callback.assert_called_once_with( 240 | obj=new_instance, attr=self.attr, sender=self.watcher) 241 | 242 | def test_callback_not_called_on_create_without_call_on_created(self): 243 | self.watcher.watch(call_on_created=False) 244 | ArticleFactory() 245 | # have not watched, thus callback should not be called 246 | self.assertFalse(self.callback.called) 247 | 248 | def test_callback_not_called_on_modification_without_watch(self): 249 | new_instance = ArticleFactory() 250 | new_instance.supplement = SupplementFactory() 251 | new_instance.save() 252 | # have not watched, thus callback should not be called 253 | self.assertFalse(self.callback.called) 254 | 255 | def test_callback_called_on_modification_with_watch(self): 256 | new_instance = ArticleFactory() 257 | self.watcher.watch() 258 | new_instance.supplement = SupplementFactory() 259 | new_instance.save() 260 | # callback should be called with instance modification 261 | self.callback.assert_called_once_with( 262 | obj=new_instance, attr=self.attr, sender=self.watcher) 263 | 264 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 265 | new_instance = ArticleFactory() 266 | new_instance.content = 'modified' 267 | new_instance.save() 268 | # content is not watched thus callback should not be called 269 | self.assertFalse(self.callback.called) 270 | 271 | def test_callback_not_called_on_related_modification_without_watch(self): 272 | new_instance = ArticleFactory() 273 | supplement = new_instance.supplement 274 | supplement.label = 'modified' 275 | supplement.save() 276 | # have not watched, thus callback should not be called 277 | self.assertFalse(self.callback.called) 278 | 279 | def test_callback_called_on_related_modification_with_watch(self): 280 | new_instance = ArticleFactory() 281 | self.watcher.watch() 282 | supplement = new_instance.supplement 283 | supplement.label = 'modified' 284 | supplement.save() 285 | # callback should be called with instance modification 286 | self.callback.assert_called_once_with( 287 | obj=new_instance, attr=self.attr, sender=self.watcher) 288 | 289 | 290 | class ObserverWatchersRelatedWatcherTestCaseRevOneToOneRel(TestCase): 291 | def setUp(self): 292 | self.model = Article 293 | self.attr = 'revision' 294 | self.callback = MagicMock() 295 | self.watcher = RelatedWatcher(self.model, 296 | self.attr, 297 | self.callback) 298 | self.addCleanup(self.watcher.unwatch) 299 | 300 | def test_callback_not_called_on_create_without_watch(self): 301 | ArticleFactory() 302 | # have not watched, thus callback should not be called 303 | self.assertFalse(self.callback.called) 304 | 305 | def test_callback_called_on_create_with_watch(self): 306 | self.watcher.watch() 307 | new_instance = ArticleFactory() 308 | # callback should be called with newly created instance 309 | self.callback.assert_called_once_with( 310 | obj=new_instance, attr=self.attr, sender=self.watcher) 311 | 312 | def test_callback_not_called_on_create_without_call_on_created(self): 313 | self.watcher.watch(call_on_created=False) 314 | ArticleFactory() 315 | # have not watched, thus callback should not be called 316 | self.assertFalse(self.callback.called) 317 | 318 | def test_callback_not_called_on_modification_without_watch(self): 319 | new_instance = ArticleFactory() 320 | new_instance.revision = RevisionFactory() 321 | new_instance.save() 322 | # have not watched, thus callback should not be called 323 | self.assertFalse(self.callback.called) 324 | 325 | def test_callback_called_on_modification_with_watch(self): 326 | new_instance = ArticleFactory(revision=None) 327 | new_revision = RevisionFactory() 328 | self.watcher.watch() 329 | # reverse assignment does not work (it is django's definition) 330 | new_instance.revision = new_revision 331 | new_instance.save() 332 | self.assertRaises( 333 | ObjectDoesNotExist, 334 | lambda: Article.objects.get(pk=new_instance.pk).revision) 335 | # thus assign directly to new_revision 336 | new_revision.article = new_instance 337 | new_revision.save() 338 | # callback should be called with instance modification 339 | self.callback.assert_called_once_with( 340 | obj=new_instance, attr=self.attr, sender=self.watcher) 341 | 342 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 343 | new_instance = ArticleFactory() 344 | new_instance.content = 'modified' 345 | new_instance.save() 346 | # content is not watched thus callback should not be called 347 | self.assertFalse(self.callback.called) 348 | 349 | def test_callback_not_called_on_related_modification_without_watch(self): 350 | new_instance = ArticleFactory() 351 | revision = new_instance.revision 352 | revision.label = 'modified' 353 | revision.save() 354 | # have not watched, thus callback should not be called 355 | self.assertFalse(self.callback.called) 356 | 357 | def test_callback_called_on_related_modification_with_watch(self): 358 | new_instance = ArticleFactory() 359 | self.watcher.watch() 360 | revision = new_instance.revision 361 | revision.label = 'modified' 362 | revision.save() 363 | # callback should be called with instance modification 364 | self.callback.assert_called_once_with( 365 | obj=new_instance, attr=self.attr, sender=self.watcher) 366 | 367 | 368 | class ObserverWatchersRelatedWatcherTestCaseOneToManyRel(TestCase): 369 | def setUp(self): 370 | self.model = Article 371 | self.attr = 'author' 372 | self.callback = MagicMock() 373 | self.watcher = RelatedWatcher(self.model, 374 | self.attr, 375 | self.callback) 376 | self.addCleanup(self.watcher.unwatch) 377 | 378 | def test_callback_not_called_on_create_without_watch(self): 379 | ArticleFactory() 380 | # have not watched, thus callback should not be called 381 | self.assertFalse(self.callback.called) 382 | 383 | def test_callback_called_on_create_with_watch(self): 384 | self.watcher.watch() 385 | new_instance = ArticleFactory() 386 | # callback should be called with newly created instance 387 | self.callback.assert_called_once_with( 388 | obj=new_instance, attr=self.attr, sender=self.watcher) 389 | 390 | def test_callback_not_called_on_create_without_call_on_created(self): 391 | self.watcher.watch(call_on_created=False) 392 | ArticleFactory() 393 | # have not watched, thus callback should not be called 394 | self.assertFalse(self.callback.called) 395 | 396 | def test_callback_not_called_on_modification_without_watch(self): 397 | new_instance = ArticleFactory() 398 | new_instance.author = UserFactory() 399 | new_instance.save() 400 | # have not watched, thus callback should not be called 401 | self.assertFalse(self.callback.called) 402 | 403 | def test_callback_called_on_modification_with_watch(self): 404 | new_instance = ArticleFactory() 405 | self.watcher.watch() 406 | new_instance.author = UserFactory() 407 | new_instance.save() 408 | # callback should be called with instance modification 409 | self.callback.assert_called_once_with( 410 | obj=new_instance, attr=self.attr, sender=self.watcher) 411 | 412 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 413 | new_instance = ArticleFactory() 414 | new_instance.content = 'modified' 415 | new_instance.save() 416 | # content is not watched thus callback should not be called 417 | self.assertFalse(self.callback.called) 418 | 419 | def test_callback_not_called_on_related_modification_without_watch(self): 420 | new_instance = ArticleFactory() 421 | user = new_instance.author 422 | user.label = 'modified' 423 | user.save() 424 | # have not watched, thus callback should not be called 425 | self.assertFalse(self.callback.called) 426 | 427 | def test_callback_called_on_related_modification_with_watch(self): 428 | new_instance = ArticleFactory() 429 | self.watcher.watch() 430 | user = new_instance.author 431 | user.label = 'modified' 432 | user.save() 433 | # callback should be called with instance modification 434 | self.callback.assert_called_once_with( 435 | obj=new_instance, attr=self.attr, sender=self.watcher) 436 | 437 | 438 | class ObserverWatchersManyRelatedWatcherTestCaseRevManyToOneRel(TestCase): 439 | def setUp(self): 440 | self.projects = ( 441 | ProjectFactory(), 442 | ProjectFactory(), 443 | ProjectFactory(), 444 | ProjectFactory(), 445 | ProjectFactory(), 446 | ) 447 | self.model = Article 448 | self.attr = 'projects' 449 | self.callback = MagicMock() 450 | self.watcher = ManyRelatedWatcher(self.model, 451 | self.attr, 452 | self.callback) 453 | self.addCleanup(self.watcher.unwatch) 454 | 455 | def test_callback_not_called_on_add_without_watch(self): 456 | new_instance = ArticleFactory(projects=self.projects) 457 | new_instance.projects.add(ProjectFactory()) 458 | # have not watched, thus callback should not be called 459 | self.assertFalse(self.callback.called) 460 | 461 | def test_callback_called_on_add_with_watch(self): 462 | new_instance = ArticleFactory(projects=self.projects) 463 | self.watcher.watch() 464 | new_instance.projects.add(ProjectFactory()) 465 | # callback should be called with instance modification 466 | self.callback.assert_called_once_with( 467 | obj=new_instance, attr=self.attr, sender=self.watcher) 468 | 469 | def test_callback_not_called_on_remove_without_watch(self): 470 | new_instance = ArticleFactory(projects=self.projects) 471 | new_instance.projects.remove(self.projects[0]) 472 | # have not watched, thus callback should not be called 473 | self.assertFalse(self.callback.called) 474 | 475 | def test_callback_called_on_remove_with_watch(self): 476 | new_instance = ArticleFactory(projects=self.projects) 477 | self.watcher.watch() 478 | new_instance.projects.remove(self.projects[0]) 479 | # callback should be called with instance modification 480 | self.callback.assert_called_once_with( 481 | obj=new_instance, attr=self.attr, sender=self.watcher) 482 | 483 | def test_callback_not_called_on_reverse_add_without_watch(self): 484 | new_instance = ProjectFactory() 485 | add_instance = ArticleFactory() 486 | new_instance.article = add_instance 487 | new_instance.save() 488 | # have not watched, thus callback should not be called 489 | self.assertFalse(self.callback.called) 490 | 491 | def test_callback_called_on_reverse_add_with_watch(self): 492 | new_instance = ProjectFactory() 493 | add_instance = ArticleFactory() 494 | self.watcher.watch() 495 | new_instance.article = add_instance 496 | new_instance.save() 497 | # callback should be called with instance modification 498 | self.callback.assert_called_once_with( 499 | obj=add_instance, attr=self.attr, sender=self.watcher) 500 | 501 | def test_callback_not_called_on_reverse_remove_without_watch(self): 502 | ArticleFactory(projects=self.projects) 503 | self.projects[0].article = None 504 | self.projects[0].save() 505 | # have not watched, thus callback should not be called 506 | self.assertFalse(self.callback.called) 507 | 508 | def test_callback_called_on_reverse_remove_with_watch(self): 509 | new_instance = ArticleFactory(projects=self.projects) 510 | self.watcher.watch() 511 | self.projects[0].article = None 512 | self.projects[0].save() 513 | # callback should be called with instance modification 514 | self.callback.assert_called_once_with( 515 | obj=new_instance, attr=self.attr, sender=self.watcher) 516 | 517 | def test_callback_not_called_on_create_without_watch(self): 518 | ArticleFactory() 519 | # have not watched, thus callback should not be called 520 | self.assertFalse(self.callback.called) 521 | 522 | def test_callback_called_on_create_with_watch(self): 523 | self.watcher.watch() 524 | new_instance = ArticleFactory() 525 | # callback should be called with newly created instance 526 | self.callback.assert_called_once_with( 527 | obj=new_instance, attr=self.attr, sender=self.watcher) 528 | 529 | def test_callback_not_called_on_create_without_call_on_created(self): 530 | self.watcher.watch(call_on_created=False) 531 | ArticleFactory() 532 | # have not watched, thus callback should not be called 533 | self.assertFalse(self.callback.called) 534 | 535 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 536 | new_instance = ArticleFactory() 537 | new_instance.content = 'modified' 538 | new_instance.save() 539 | # content is not watched thus callback should not be called 540 | self.assertFalse(self.callback.called) 541 | 542 | def test_callback_not_called_on_modification_without_watch(self): 543 | new_instance = ArticleFactory(projects=self.projects) 544 | project = new_instance.projects.get(pk=1) 545 | project.label = 'modified' 546 | project.save() 547 | # have not watched, thus callback should not be called 548 | self.assertFalse(self.callback.called) 549 | 550 | def test_callback_called_on_modification_with_watch(self): 551 | new_instance = ArticleFactory(projects=self.projects) 552 | self.watcher.watch() 553 | project = new_instance.projects.get(pk=1) 554 | project.label = 'modified' 555 | project.save() 556 | # callback should be called with instance modification 557 | self.callback.assert_called_once_with( 558 | obj=new_instance, attr=self.attr, sender=self.watcher) 559 | 560 | 561 | # ============================================================================ 562 | # ManyRelatedWatcher 563 | # ============================================================================ 564 | class ObserverWatchersManyRelatedWatcherTestCaseManyToManyRel(TestCase): 565 | def setUp(self): 566 | self.users = ( 567 | UserFactory(), 568 | UserFactory(), 569 | UserFactory(), 570 | UserFactory(), 571 | UserFactory(), 572 | ) 573 | self.model = Article 574 | self.attr = 'collaborators' 575 | self.callback = MagicMock() 576 | self.watcher = ManyRelatedWatcher(self.model, 577 | self.attr, 578 | self.callback) 579 | self.addCleanup(self.watcher.unwatch) 580 | 581 | def test_callback_not_called_on_add_without_watch(self): 582 | new_instance = ArticleFactory(collaborators=self.users) 583 | new_instance.collaborators.add(UserFactory()) 584 | # have not watched, thus callback should not be called 585 | self.assertFalse(self.callback.called) 586 | 587 | def test_callback_called_on_add_with_watch(self): 588 | new_instance = ArticleFactory(collaborators=self.users) 589 | self.watcher.watch() 590 | new_instance.collaborators.add(UserFactory()) 591 | # callback should be called with instance modification 592 | self.callback.assert_called_once_with( 593 | obj=new_instance, attr=self.attr, sender=self.watcher) 594 | 595 | def test_callback_not_called_on_remove_without_watch(self): 596 | new_instance = ArticleFactory(collaborators=self.users) 597 | new_instance.collaborators.get(pk=1).delete() 598 | # have not watched, thus callback should not be called 599 | self.assertFalse(self.callback.called) 600 | 601 | def test_callback_called_on_remove_with_watch(self): 602 | new_instance = ArticleFactory(collaborators=self.users) 603 | self.watcher.watch() 604 | new_instance.collaborators.remove(self.users[0]) 605 | # callback should be called with instance modification 606 | self.callback.assert_called_once_with( 607 | obj=new_instance, attr=self.attr, sender=self.watcher) 608 | 609 | def test_callback_not_called_on_reverse_add_without_watch(self): 610 | new_instance = UserFactory() 611 | add_instance = ArticleFactory() 612 | new_instance.articles.add(add_instance) 613 | # have not watched, thus callback should not be called 614 | self.assertFalse(self.callback.called) 615 | 616 | def test_callback_called_on_reverse_add_with_watch(self): 617 | new_instance = UserFactory() 618 | add_instance = ArticleFactory() 619 | self.watcher.watch() 620 | new_instance.articles.add(add_instance) 621 | # callback should be called with instance modification 622 | self.callback.assert_called_once_with( 623 | obj=add_instance, attr=self.attr, sender=self.watcher) 624 | 625 | def test_callback_not_called_on_reverse_remove_without_watch(self): 626 | add_instance = ArticleFactory(collaborators=self.users) 627 | self.users[0].articles.remove(add_instance) 628 | # have not watched, thus callback should not be called 629 | self.assertFalse(self.callback.called) 630 | 631 | def test_callback_called_on_reverse_remove_with_watch(self): 632 | add_instance = ArticleFactory(collaborators=self.users) 633 | self.watcher.watch() 634 | self.users[0].articles.remove(add_instance) 635 | # callback should be called with instance modification 636 | self.callback.assert_called_once_with( 637 | obj=add_instance, attr=self.attr, sender=self.watcher) 638 | 639 | def test_callback_not_called_on_create_without_watch(self): 640 | ArticleFactory() 641 | # have not watched, thus callback should not be called 642 | self.assertFalse(self.callback.called) 643 | 644 | def test_callback_called_on_create_with_watch(self): 645 | self.watcher.watch() 646 | new_instance = ArticleFactory() 647 | # callback should be called with newly created instance 648 | self.callback.assert_called_once_with( 649 | obj=new_instance, attr=self.attr, sender=self.watcher) 650 | 651 | def test_callback_not_called_on_create_without_call_on_created(self): 652 | self.watcher.watch(call_on_created=False) 653 | ArticleFactory() 654 | # have not watched, thus callback should not be called 655 | self.assertFalse(self.callback.called) 656 | 657 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 658 | new_instance = ArticleFactory() 659 | new_instance.content = 'modified' 660 | new_instance.save() 661 | # content is not watched thus callback should not be called 662 | self.assertFalse(self.callback.called) 663 | 664 | def test_callback_not_called_on_modification_without_watch(self): 665 | new_instance = ArticleFactory(collaborators=self.users) 666 | user = new_instance.collaborators.get(pk=1) 667 | user.label = 'modified' 668 | user.save() 669 | # have not watched, thus callback should not be called 670 | self.assertFalse(self.callback.called) 671 | 672 | def test_callback_called_on_modification_with_watch(self): 673 | new_instance = ArticleFactory(collaborators=self.users) 674 | self.watcher.watch() 675 | user = new_instance.collaborators.get(pk=1) 676 | user.label = 'modified' 677 | user.save() 678 | # callback should be called with instance modification 679 | self.callback.assert_called_once_with( 680 | obj=new_instance, attr=self.attr, sender=self.watcher) 681 | 682 | 683 | class ObserverWatchersManyRelatedWatcherTestCaseRevManyToManyRel(TestCase): 684 | def setUp(self): 685 | self.hyperlinks = ( 686 | HyperlinkFactory(), 687 | HyperlinkFactory(), 688 | HyperlinkFactory(), 689 | HyperlinkFactory(), 690 | HyperlinkFactory(), 691 | ) 692 | self.model = Article 693 | self.attr = 'hyperlinks' 694 | self.callback = MagicMock() 695 | self.watcher = ManyRelatedWatcher(self.model, 696 | self.attr, 697 | self.callback) 698 | self.addCleanup(self.watcher.unwatch) 699 | 700 | def test_callback_not_called_on_add_without_watch(self): 701 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 702 | new_instance.hyperlinks.add(HyperlinkFactory()) 703 | # have not watched, thus callback should not be called 704 | self.assertFalse(self.callback.called) 705 | 706 | def test_callback_called_on_add_with_watch(self): 707 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 708 | self.watcher.watch() 709 | new_instance.hyperlinks.add(HyperlinkFactory()) 710 | # callback should be called with instance modification 711 | self.callback.assert_called_once_with( 712 | obj=new_instance, attr=self.attr, sender=self.watcher) 713 | 714 | def test_callback_not_called_on_remove_without_watch(self): 715 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 716 | new_instance.hyperlinks.get(pk=1).delete() 717 | # have not watched, thus callback should not be called 718 | self.assertFalse(self.callback.called) 719 | 720 | def test_callback_called_on_remove_with_watch(self): 721 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 722 | self.watcher.watch() 723 | new_instance.hyperlinks.remove(self.hyperlinks[0]) 724 | # callback should be called with instance modification 725 | self.callback.assert_called_once_with( 726 | obj=new_instance, attr=self.attr, sender=self.watcher) 727 | 728 | def test_callback_not_called_on_reverse_add_without_watch(self): 729 | new_instance = HyperlinkFactory() 730 | add_instance = ArticleFactory() 731 | new_instance.articles.add(add_instance) 732 | # have not watched, thus callback should not be called 733 | self.assertFalse(self.callback.called) 734 | 735 | def test_callback_called_on_reverse_add_with_watch(self): 736 | new_instance = HyperlinkFactory() 737 | add_instance = ArticleFactory() 738 | self.watcher.watch() 739 | new_instance.articles.add(add_instance) 740 | # callback should be called with instance modification 741 | self.callback.assert_called_once_with( 742 | obj=add_instance, attr=self.attr, sender=self.watcher) 743 | 744 | def test_callback_not_called_on_reverse_remove_without_watch(self): 745 | add_instance = ArticleFactory(hyperlinks=self.hyperlinks) 746 | self.hyperlinks[0].articles.remove(add_instance) 747 | # have not watched, thus callback should not be called 748 | self.assertFalse(self.callback.called) 749 | 750 | def test_callback_called_on_reverse_remove_with_watch(self): 751 | add_instance = ArticleFactory(hyperlinks=self.hyperlinks) 752 | self.watcher.watch() 753 | self.hyperlinks[0].articles.remove(add_instance) 754 | # callback should be called with instance modification 755 | self.callback.assert_called_once_with( 756 | obj=add_instance, attr=self.attr, sender=self.watcher) 757 | 758 | def test_callback_not_called_on_create_without_watch(self): 759 | ArticleFactory() 760 | # have not watched, thus callback should not be called 761 | self.assertFalse(self.callback.called) 762 | 763 | def test_callback_called_on_create_with_watch(self): 764 | self.watcher.watch() 765 | new_instance = ArticleFactory() 766 | # callback should be called with newly created instance 767 | self.callback.assert_called_once_with( 768 | obj=new_instance, attr=self.attr, sender=self.watcher) 769 | 770 | def test_callback_not_called_on_create_without_call_on_created(self): 771 | self.watcher.watch(call_on_created=False) 772 | ArticleFactory() 773 | # have not watched, thus callback should not be called 774 | self.assertFalse(self.callback.called) 775 | 776 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 777 | new_instance = ArticleFactory() 778 | new_instance.content = 'modified' 779 | new_instance.save() 780 | # content is not watched thus callback should not be called 781 | self.assertFalse(self.callback.called) 782 | 783 | def test_callback_not_called_on_modification_without_watch(self): 784 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 785 | hyperlink = new_instance.hyperlinks.get(pk=1) 786 | hyperlink.label = 'modified' 787 | hyperlink.save() 788 | # have not watched, thus callback should not be called 789 | self.assertFalse(self.callback.called) 790 | 791 | def test_callback_called_on_modification_with_watch(self): 792 | new_instance = ArticleFactory(hyperlinks=self.hyperlinks) 793 | self.watcher.watch() 794 | hyperlink = new_instance.hyperlinks.get(pk=1) 795 | hyperlink.label = 'modified' 796 | hyperlink.save() 797 | # callback should be called with instance modification 798 | self.callback.assert_called_once_with( 799 | obj=new_instance, attr=self.attr, sender=self.watcher) 800 | 801 | 802 | # ============================================================================ 803 | # GenericRelatedWatcher 804 | # ============================================================================ 805 | class ObserverWatchersGenericRelatedWatcherTestCase(TestCase): 806 | def setUp(self): 807 | self.tags = ( 808 | TagFactory(), 809 | TagFactory(), 810 | TagFactory(), 811 | TagFactory(), 812 | TagFactory(), 813 | ) 814 | self.model = Article 815 | self.attr = 'tags' 816 | self.callback = MagicMock() 817 | self.watcher = GenericRelatedWatcher(self.model, 818 | self.attr, 819 | self.callback) 820 | self.addCleanup(self.watcher.unwatch) 821 | 822 | def test_callback_not_called_on_add_without_watch(self): 823 | new_instance = ArticleFactory(tags=self.tags) 824 | new_instance.tags.add(TagFactory()) 825 | # have not watched, thus callback should not be called 826 | self.assertFalse(self.callback.called) 827 | 828 | def test_callback_called_on_add_with_watch(self): 829 | new_instance = ArticleFactory(tags=self.tags) 830 | self.watcher.watch() 831 | new_instance.tags.add(TagFactory()) 832 | # callback should be called with instance modification 833 | self.callback.assert_called_once_with( 834 | obj=new_instance, attr=self.attr, sender=self.watcher) 835 | 836 | def test_callback_not_called_on_remove_without_watch(self): 837 | new_instance = ArticleFactory(tags=self.tags) 838 | new_instance.tags.remove(self.tags[0]) 839 | # have not watched, thus callback should not be called 840 | self.assertFalse(self.callback.called) 841 | 842 | @skip("TODO: Fixme") 843 | def test_callback_called_on_remove_with_watch(self): 844 | new_instance = ArticleFactory(tags=self.tags) 845 | self.watcher.watch() 846 | new_instance.tags.remove(self.tags[0]) 847 | self.assertFalse(self.tags in new_instance.tags.all()) 848 | new_instance.save() 849 | # callback should be called with instance modification 850 | self.callback.assert_called_once_with( 851 | obj=new_instance, attr=self.attr, sender=self.watcher) 852 | 853 | def test_callback_not_called_on_reverse_add_without_watch(self): 854 | new_instance = TagFactory() 855 | add_instance = ArticleFactory() 856 | new_instance.content_object = add_instance 857 | new_instance.save() 858 | # have not watched, thus callback should not be called 859 | self.assertFalse(self.callback.called) 860 | 861 | def test_callback_called_on_reverse_add_with_watch(self): 862 | new_instance = TagFactory() 863 | add_instance = ArticleFactory() 864 | self.watcher.watch() 865 | new_instance.content_object = add_instance 866 | new_instance.save() 867 | # callback should be called with instance modification 868 | self.callback.assert_called_once_with( 869 | obj=add_instance, attr=self.attr, sender=self.watcher) 870 | 871 | def test_callback_not_called_on_reverse_remove_without_watch(self): 872 | ArticleFactory(tags=self.tags) 873 | self.tags[0].article = None 874 | self.tags[0].save() 875 | # have not watched, thus callback should not be called 876 | self.assertFalse(self.callback.called) 877 | 878 | @skip("TODO: Fixme") 879 | def test_callback_called_on_reverse_remove_with_watch(self): 880 | new_instance = ArticleFactory(tags=self.tags) 881 | self.watcher.watch() 882 | self.tags[0].article = None 883 | self.tags[0].save() 884 | # callback should be called with instance modification 885 | self.callback.assert_called_once_with( 886 | obj=new_instance, attr=self.attr, sender=self.watcher) 887 | 888 | def test_callback_not_called_on_create_without_watch(self): 889 | ArticleFactory() 890 | # have not watched, thus callback should not be called 891 | self.assertFalse(self.callback.called) 892 | 893 | def test_callback_called_on_create_with_watch(self): 894 | self.watcher.watch() 895 | new_instance = ArticleFactory() 896 | # callback should be called with newly created instance 897 | self.callback.assert_called_once_with( 898 | obj=new_instance, attr=self.attr, sender=self.watcher) 899 | 900 | def test_callback_not_called_on_create_without_call_on_created(self): 901 | self.watcher.watch(call_on_created=False) 902 | ArticleFactory() 903 | # have not watched, thus callback should not be called 904 | self.assertFalse(self.callback.called) 905 | 906 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 907 | new_instance = ArticleFactory() 908 | new_instance.content = 'modified' 909 | new_instance.save() 910 | # content is not watched thus callback should not be called 911 | self.assertFalse(self.callback.called) 912 | 913 | def test_callback_not_called_on_modification_without_watch(self): 914 | new_instance = ArticleFactory(tags=self.tags) 915 | tag = new_instance.tags.get(pk=1) 916 | tag.label = 'modified' 917 | tag.save() 918 | # have not watched, thus callback should not be called 919 | self.assertFalse(self.callback.called) 920 | 921 | def test_callback_called_on_modification_with_watch(self): 922 | new_instance = ArticleFactory(tags=self.tags) 923 | self.watcher.watch() 924 | tag = new_instance.tags.get(pk=1) 925 | tag.label = 'modified' 926 | tag.save() 927 | # callback should be called with instance modification 928 | self.callback.assert_called_once_with( 929 | obj=new_instance, attr=self.attr, sender=self.watcher) 930 | 931 | 932 | class ObserverWatchersGenericRelatedWatcherTestCaseRev(TestCase): 933 | def setUp(self): 934 | self.model = Tag 935 | self.attr = 'content_object' 936 | self.callback = MagicMock() 937 | self.watcher = GenericRelatedWatcher(self.model, 938 | self.attr, 939 | self.callback) 940 | self.addCleanup(self.watcher.unwatch) 941 | 942 | def test_callback_not_called_on_create_without_watch(self): 943 | TagFactory() 944 | # have not watched, thus callback should not be called 945 | self.assertFalse(self.callback.called) 946 | 947 | def test_callback_called_on_create_with_watch(self): 948 | self.watcher.watch() 949 | new_instance = TagFactory() 950 | # callback should be called with newly created instance 951 | self.callback.assert_called_once_with( 952 | obj=new_instance, attr=self.attr, sender=self.watcher) 953 | 954 | def test_callback_not_called_on_create_without_call_on_created(self): 955 | self.watcher.watch(call_on_created=False) 956 | TagFactory() 957 | # have not watched, thus callback should not be called 958 | self.assertFalse(self.callback.called) 959 | 960 | def test_callback_not_called_on_modification_without_watch(self): 961 | new_instance = TagFactory() 962 | new_instance.content_object = ArticleFactory() 963 | new_instance.save() 964 | # have not watched, thus callback should not be called 965 | self.assertFalse(self.callback.called) 966 | 967 | @skip("TODO: Fix me") 968 | def test_callback_called_on_modification_with_watch(self): 969 | new_instance = TagFactory() 970 | self.watcher.watch() 971 | new_instance.content_object = ArticleFactory() 972 | new_instance.save() 973 | # callback should be called with instance modification 974 | self.callback.assert_called_once_with( 975 | obj=new_instance, attr=self.attr, sender=self.watcher) 976 | 977 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 978 | new_instance = TagFactory() 979 | new_instance.label = 'modified' 980 | new_instance.save() 981 | # content is not watched thus callback should not be called 982 | self.assertFalse(self.callback.called) 983 | 984 | def test_callback_not_called_on_related_modification_without_watch(self): 985 | new_instance = TagFactory(content_object=UserFactory()) 986 | user = new_instance.content_object 987 | user.label = 'modified' 988 | user.save() 989 | # have not watched, thus callback should not be called 990 | self.assertFalse(self.callback.called) 991 | 992 | @skip("TODO: Fix me") 993 | def test_callback_called_on_related_modification_with_watch(self): 994 | new_instance = TagFactory(content_object=UserFactory()) 995 | user = new_instance.content_object 996 | user.label = 'modified' 997 | user.save() 998 | # callback should be called with instance modification 999 | self.callback.assert_called_once_with( 1000 | obj=new_instance, attr=self.attr, sender=self.watcher) 1001 | -------------------------------------------------------------------------------- /src/observer/tests/test_watchers/test_value.py: -------------------------------------------------------------------------------- 1 | from observer.tests.compat import TestCase 2 | from observer.tests.compat import MagicMock 3 | from observer.tests.models import Article 4 | from observer.tests.factories import ArticleFactory 5 | from observer.watchers.value import ValueWatcher 6 | 7 | 8 | class ObserverWatchersValueWatcherTestCase(TestCase): 9 | def setUp(self): 10 | self.model = Article 11 | self.attr = 'title' 12 | self.callback = MagicMock() 13 | self.Watcher = MagicMock(wraps=ValueWatcher) 14 | self.watcher = self.Watcher(self.model, 15 | self.attr, 16 | self.callback) 17 | self.addCleanup(self.watcher.unwatch) 18 | 19 | def test_callback_not_called_on_create_without_watch(self): 20 | ArticleFactory() 21 | # have not watched, thus callback should not be called 22 | self.assertFalse(self.callback.called) 23 | 24 | def test_callback_called_on_create_with_watch(self): 25 | self.watcher.watch() 26 | new_instance = ArticleFactory() 27 | # callback should be called with newly created instance 28 | self.callback.assert_called_once_with( 29 | obj=new_instance, attr=self.attr, sender=self.watcher) 30 | 31 | def test_callback_not_called_on_create_without_call_on_created(self): 32 | self.watcher.watch(call_on_created=False) 33 | ArticleFactory() 34 | # have not watched, thus callback should not be called 35 | self.assertFalse(self.callback.called) 36 | 37 | def test_callback_not_called_on_modification_without_watch(self): 38 | new_instance = ArticleFactory() 39 | new_instance.title = 'modified' 40 | new_instance.save() 41 | # have not watched, thus callback should not be called 42 | self.assertFalse(self.callback.called) 43 | 44 | def test_callback_called_on_modification_with_watch(self): 45 | new_instance = ArticleFactory() 46 | self.watcher.watch() 47 | new_instance.title = 'modified' 48 | new_instance.save() 49 | # callback should be called with instance modification 50 | self.callback.assert_called_once_with( 51 | obj=new_instance, attr=self.attr, sender=self.watcher) 52 | 53 | def test_callback_not_called_on_modification_with_non_interest_attr(self): 54 | new_instance = ArticleFactory() 55 | new_instance.content = 'modified' 56 | new_instance.save() 57 | # content is not watched thus callback should not be called 58 | self.assertFalse(self.callback.called) 59 | -------------------------------------------------------------------------------- /src/observer/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lambdalisue/django-observer/052e0da55eefc8072cc78e0b9d72bc29c4e528c0/src/observer/utils/__init__.py -------------------------------------------------------------------------------- /src/observer/utils/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models.loading import get_model 2 | from django.db.models.fields import FieldDoesNotExist 3 | 4 | 5 | def get_field(model, attr, ignore_exception=True): 6 | """ 7 | Get a Field instance of 'attr' in the model 8 | 9 | This function is required while django builtin `meta.get_field(name)` does 10 | not refer reverse relations, many to many relations, and vritual 11 | fields. 12 | 13 | Notice that this function iterate all fields to find the corresponding 14 | field instance. This may cause a performance issue thus you should use 15 | your own cache system to store the found instance. 16 | If you are using Python 3.2 or above, you might interested in 17 | a `functools.lru_cache` or `pylru.lrudecorator` to memoizing the result 18 | 19 | Args: 20 | model (model): A model or model instance 21 | attr (name): A name of the attribute interest 22 | ignore_exception (bool): return None when no corresponding field is 23 | found if this is True, otherwise raise FieldDoesNotExist 24 | 25 | Raises: 26 | FieldDoesNotExist: raised when no corresponding field is found and 27 | `ignore_exception` is False. 28 | 29 | Returns: 30 | None or an instance of Field. 31 | """ 32 | meta = model._meta 33 | # try to find with get_field 34 | try: 35 | return meta.get_field(attr) 36 | except FieldDoesNotExist: 37 | # reverse relations and virtual fields could not be found 38 | # with `get_field` thus just ignore it. 39 | pass 40 | # from reverse relations of ForeignKey/OneToOneField 41 | for robj in meta.get_all_related_objects(): 42 | if attr == robj.get_accessor_name(): 43 | # notice that the field belongs to related object 44 | return robj.field 45 | # from reverse relations of ManyToMany 46 | for robj in meta.get_all_related_many_to_many_objects(): 47 | if attr == robj.get_accessor_name(): 48 | # notice that the field belongs to related object 49 | return robj.field 50 | # from virtual fields 51 | for field in meta.virtual_fields: 52 | if field.name == attr: 53 | return field 54 | # could not be found 55 | if ignore_exception: 56 | return None 57 | raise FieldDoesNotExist 58 | 59 | 60 | def get_relation(relation): 61 | """ 62 | Resolve relation 63 | 64 | This function resolve a relation indicated as a string (e.g. 65 | 'app_name.Model'). The 'relation' can be a model class for convinience. 66 | It return ``None`` when the relation could not be resolved. It is happend 67 | when the related class is not loaded yet. 68 | 69 | Args: 70 | relation (str or class): A model indicated as a string or class 71 | 72 | Returns: 73 | (None or a class, app_label, model_name) 74 | """ 75 | # Try to split the relation 76 | try: 77 | app_label, model_name = relation.split('.', 1) 78 | except AttributeError: 79 | app_label = relation._meta.app_label 80 | model_name = relation._meta.model_name 81 | model = get_model(app_label, model_name, False) 82 | return model, app_label, model_name 83 | 84 | 85 | _pending_lookups = {} 86 | 87 | 88 | def resolve_relation_lazy(relation, operation, **kwargs): 89 | """ 90 | Resolve relation and call the operation with the specified kwargs. 91 | 92 | The operation will be called when the relation is ready to resolved. 93 | The original idea was copied from Django 1.2.2 source code thus the 94 | license belongs to the Django's license (BSD License) 95 | 96 | Args: 97 | relation (str or class): A relation which you want to resolve 98 | operation (fn): A callback function which will called with resolved 99 | relation (class) and the specified kwargs. 100 | """ 101 | model, app_label, model_name = get_relation(relation) 102 | if model: 103 | operation(model, **kwargs) 104 | else: 105 | key = (app_label, model_name) 106 | value = (operation, kwargs) 107 | _pending_lookups.setdefault(key, []).append(value) 108 | 109 | 110 | def _do_pending_lookups(sender, **kwargs): 111 | key = (sender._meta.app_label, sender.__name__) 112 | for operation, kwargs in _pending_lookups.pop(key, []): 113 | operation(sender, **kwargs) 114 | 115 | 116 | from django.db.models.signals import class_prepared 117 | class_prepared.connect(_do_pending_lookups) 118 | -------------------------------------------------------------------------------- /src/observer/utils/signals.py: -------------------------------------------------------------------------------- 1 | REGISTRATION_CACHE_NAME = '_observer_receivers' 2 | 3 | 4 | def register_reciever(model, signal, receiver, sender=None, **kwargs): 5 | """ 6 | Connect signal and receiver of the model without duplication 7 | 8 | Args: 9 | model (class): A target class 10 | signal (signal): A django signal 11 | receiver (reciever): A django signal receiver 12 | sender (model): A model class 13 | **kwargs: Options passed to the signal.connect method 14 | 15 | Returns: 16 | bool: True for new registration, False for already registered. 17 | """ 18 | # create cache field if it does not exists 19 | if not hasattr(model, REGISTRATION_CACHE_NAME): 20 | setattr(model, REGISTRATION_CACHE_NAME, set()) 21 | # check if the combination of signal and receiver has already registered 22 | # to the model, to prevent duplicate registration 23 | natural_key = (id(signal), id(receiver)) 24 | receivers = getattr(model, REGISTRATION_CACHE_NAME) 25 | if natural_key in receivers: 26 | # the receiver has already registered to the model thus just ignore it 27 | # and return False 28 | return False 29 | # connect signal and receiver 30 | kwargs['weak'] = False 31 | signal.connect(receiver, sender=sender or model, **kwargs) 32 | # memorize this registration and return True 33 | receivers.add(natural_key) 34 | return True 35 | 36 | 37 | def unregister_reciever(model, signal, receiver): 38 | """ 39 | Disconnect signal and receiver of the model without exception 40 | 41 | Args: 42 | model (class): A target class 43 | signal (signal): A django signal 44 | receiver (reciever): A django signal receiver 45 | 46 | Returns: 47 | bool: True for success, False for already disconnected. 48 | """ 49 | # check if the combination of signal and receiver has registered to the 50 | # model or not yet 51 | natural_key = (id(signal), id(receiver)) 52 | receivers = getattr(model, REGISTRATION_CACHE_NAME) 53 | if natural_key not in receivers: 54 | # the receiver has not registered to the model yet thus just ignore it 55 | # and return False 56 | return False 57 | # disconnect signal and receiver 58 | signal.disconnect(receiver) 59 | # forget this receiver and return True 60 | receivers.remove(natural_key) 61 | return True 62 | -------------------------------------------------------------------------------- /src/observer/watchers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lambdalisue/django-observer/052e0da55eefc8072cc78e0b9d72bc29c4e528c0/src/observer/watchers/__init__.py -------------------------------------------------------------------------------- /src/observer/watchers/auto.py: -------------------------------------------------------------------------------- 1 | from django.db.models import ForeignKey, OneToOneField, ManyToManyField 2 | from django.contrib.contenttypes.generic import (GenericForeignKey, 3 | GenericRelation) 4 | from base import WatcherBase 5 | from value import ValueWatcher 6 | from related import RelatedWatcher, ManyRelatedWatcher, GenericRelatedWatcher 7 | 8 | 9 | RELATIONAL_FIELDS = (ForeignKey, OneToOneField,) 10 | MANY_RELATIONAL_FIELDS = (ManyToManyField,) 11 | GENERIC_RELATIONAL_FIELDS = (GenericForeignKey, GenericRelation) 12 | 13 | 14 | class AutoWatcher(WatcherBase): 15 | """ 16 | A base watcher field for relational field such as ForeignKey, ManyToMany 17 | """ 18 | def __init__(self, model, attr, callback, **kwargs): 19 | """ 20 | Construct watcher field 21 | 22 | Args: 23 | model (model): A target model class 24 | attr (str): A name of attribute 25 | callback (fn): A callback function 26 | **kwargs: Passed to sub watchers 27 | """ 28 | super(AutoWatcher, self).__init__(model, attr, callback) 29 | self._kwargs = kwargs 30 | 31 | def watch(self, **kwargs): 32 | if hasattr(self, '_internal_watcher'): 33 | self.unwatch() 34 | Watcher = self.get_suitable_watcher_class() 35 | self._internal_watcher = Watcher(self.model, 36 | self.attr, 37 | self.callback, 38 | **self._kwargs) 39 | self._internal_watcher.watch(**kwargs) 40 | 41 | def unwatch(self): 42 | self._internal_watcher.unwatch() 43 | 44 | def get_suitable_watcher_class(self, field=None): 45 | field = field or self.get_field() 46 | if field in RELATIONAL_FIELDS: 47 | return RelatedWatcher 48 | if field in MANY_RELATIONAL_FIELDS: 49 | return ManyRelatedWatcher 50 | if field in GENERIC_RELATIONAL_FIELDS: 51 | return GenericRelatedWatcher 52 | return ValueWatcher 53 | -------------------------------------------------------------------------------- /src/observer/watchers/base.py: -------------------------------------------------------------------------------- 1 | from observer.conf import settings 2 | from observer.compat import lru_cache 3 | from observer.utils.models import get_field 4 | from observer.utils.models import resolve_relation_lazy 5 | 6 | 7 | def is_relation_ready(relation): 8 | """ 9 | Return if the relation is ready to use 10 | """ 11 | if isinstance(relation, basestring) or relation._meta.pk is None: 12 | return False 13 | return True 14 | 15 | 16 | class WatcherBase(object): 17 | """ 18 | A base watcher field class. Subclass must override `watch` and `unwatch` 19 | methods. 20 | """ 21 | def __init__(self, model, attr, callback): 22 | """ 23 | Construct watcher field 24 | 25 | Args: 26 | model (model or string): A target model class or app_label.Model 27 | attr (str): A name of attribute 28 | callback (fn): A callback function 29 | """ 30 | self._model = model 31 | self._attr = attr 32 | self._callback = callback 33 | 34 | # resolve string model specification 35 | if not is_relation_ready(model): 36 | def resolve_related_class(model, self): 37 | self._model = model 38 | resolve_relation_lazy(model, resolve_related_class, self=self) 39 | 40 | @property 41 | def model(self): 42 | return self._model 43 | 44 | @property 45 | def attr(self): 46 | return self._attr 47 | 48 | @property 49 | def callback(self): 50 | return self._callback 51 | 52 | def lazy_watch(self, **kwargs): 53 | """ 54 | Call watch safely. It wait until everything get ready. 55 | """ 56 | def recall_lazy_watch(sender, self, **kwargs): 57 | self.lazy_watch(**kwargs) 58 | # check if the model is ready 59 | if not is_relation_ready(self.model): 60 | resolve_relation_lazy(self.model, recall_lazy_watch, 61 | self=self, **kwargs) 62 | return 63 | 64 | # check if the related models is ready 65 | field = self.get_field() 66 | if field.rel and not is_relation_ready(field.rel.to): 67 | resolve_relation_lazy(field.rel.to, recall_lazy_watch, 68 | self=self, **kwargs) 69 | return 70 | 71 | # ready to watch 72 | self.watch(**kwargs) 73 | 74 | def watch(self): 75 | """ 76 | Start watching the attribute of the model 77 | """ 78 | raise NotImplementedError 79 | 80 | def unwatch(self): 81 | """ 82 | Stop watching the attribute of the model 83 | """ 84 | raise NotImplementedError 85 | 86 | def call(self, obj): 87 | """ 88 | Call the registered callback function with latest object 89 | 90 | Args: 91 | obj (obj): An object instance 92 | """ 93 | self.callback(sender=self, obj=obj, attr=self.attr) 94 | 95 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 96 | def get_field(self, attr=None): 97 | """ 98 | Get field instance of the attr in the target object 99 | """ 100 | attr = attr or self.attr 101 | return get_field(self.model, attr) 102 | -------------------------------------------------------------------------------- /src/observer/watchers/related.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import pre_save 2 | from django.db.models.signals import post_save 3 | from django.db.models.signals import m2m_changed 4 | from django.core.exceptions import ObjectDoesNotExist 5 | from django.contrib.contenttypes.generic import GenericForeignKey 6 | from observer.conf import settings 7 | from observer.compat import lru_cache 8 | from observer.investigator import Investigator 9 | from observer.utils.signals import register_reciever, unregister_reciever 10 | from base import WatcherBase 11 | from value import ValueWatcher 12 | 13 | 14 | class RelatedWatcherBase(WatcherBase): 15 | """ 16 | A base watcher field for relational field such as ForeignKey, ManyToMany 17 | """ 18 | def __init__(self, model, attr, callback, 19 | call_on_created=True, 20 | include=None, exclude=None): 21 | """ 22 | Construct watcher field 23 | 24 | Args: 25 | model (model): A target model class 26 | attr (str): A name of attribute 27 | callback (fn): A callback function 28 | call_on_created (bool): Call callback when the new instance is 29 | created 30 | include (None, list, tuple): A related object field name list 31 | which will be investigated to determine the modification 32 | exclude (None, list, tuple): A related object field name list 33 | which won't be investigated to determine the modification 34 | """ 35 | super(RelatedWatcherBase, self).__init__(model, attr, callback) 36 | self.include = include 37 | self.exclude = exclude 38 | self._call_on_created = call_on_created 39 | 40 | @property 41 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 42 | def is_reversed(self): 43 | return self.get_field().model != self._model 44 | 45 | @property 46 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 47 | def related_model(self): 48 | field = self.get_field() 49 | if self.is_reversed: 50 | return field.model 51 | return field.related.parent_model 52 | 53 | @property 54 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 55 | def related_attr(self): 56 | field = self.get_field() 57 | if self.is_reversed: 58 | return field.name 59 | return field.related.get_accessor_name() 60 | 61 | def watch(self, call_on_created=None, include=None, exclude=None): 62 | self._call_on_created = (self._call_on_created 63 | if call_on_created is None 64 | else call_on_created) 65 | include = include or self.include 66 | exclude = exclude or self.exclude 67 | self._investigator = Investigator(self.related_model, 68 | include=include, 69 | exclude=exclude) 70 | # register the receivers 71 | register_reciever(self.model, pre_save, 72 | self._pre_save_receiver, 73 | sender=self.related_model) 74 | register_reciever(self.model, post_save, 75 | self._post_save_receiver, 76 | sender=self.related_model) 77 | register_reciever(self.model, post_save, 78 | self._post_save_receiver_for_creation) 79 | 80 | def unwatch(self): 81 | # unregister the receivers 82 | unregister_reciever(self.model, pre_save, 83 | self._pre_save_receiver) 84 | unregister_reciever(self.model, post_save, 85 | self._post_save_receiver) 86 | unregister_reciever(self.model, post_save, 87 | self._post_save_receiver_for_creation) 88 | 89 | def get_value(self, instance): 90 | try: 91 | return getattr(instance, self.related_attr, None) 92 | except ObjectDoesNotExist: 93 | return None 94 | 95 | def get_values(self, instance): 96 | value = self.get_value(instance) 97 | if value is None: 98 | return set() 99 | if hasattr(value, 'iterator'): 100 | value = value.iterator() 101 | elif not hasattr(value, '__iter__'): 102 | value = tuple([value]) 103 | return set(value) 104 | 105 | def _pre_save_receiver(self, sender, instance, **kwargs): 106 | if kwargs.get('row', False): 107 | # should not call any callback while it is called via fixtures or 108 | # so on 109 | return 110 | self._investigator.prepare(instance) 111 | 112 | def _post_save_receiver(self, sender, instance, **kwargs): 113 | if kwargs.get('row', False): 114 | # should not call any callback while it is called via fixtures or 115 | # so on 116 | return 117 | # get a reverse related objects from the instance 118 | instance_cached = self._investigator.get_cached(instance.pk) 119 | values_cached = self.get_values(instance_cached) 120 | values_latest = self.get_values(instance) 121 | object_set = values_cached | values_latest 122 | if any(self._investigator.investigate(instance)): 123 | for obj in object_set: 124 | self.call(obj) 125 | 126 | def _post_save_receiver_for_creation(self, sender, instance, 127 | created, **kwargs): 128 | if kwargs.get('row', False): 129 | # should not call any callback while it is called via fixtures or 130 | # so on 131 | return 132 | if self._call_on_created and created: 133 | self.call(instance) 134 | 135 | 136 | class RelatedWatcher(RelatedWatcherBase): 137 | def __init__(self, model, attr, callback, 138 | call_on_created=True, include=None, exclude=None): 139 | """ 140 | Construct watcher field 141 | 142 | Args: 143 | model (model): A target model class 144 | attr (str): A name of attribute 145 | callback (fn): A callback function 146 | call_on_created (bool): Call callback when the new instance is 147 | created 148 | include (None, list, tuple): A related object field name list 149 | which will be investigated to determine the modification 150 | exclude (None, list, tuple): A related object field name list 151 | which won't be investigated to determine the modification 152 | """ 153 | # add internal valuefiled 154 | super(RelatedWatcher, self).__init__(model, attr, callback, 155 | include, exclude) 156 | self._call_on_created = call_on_created 157 | inner_callback = lambda sender, obj, attr: self.call(obj) 158 | self._inner_watcher = ValueWatcher(self.model, 159 | self.attr, 160 | inner_callback) 161 | 162 | def watch(self, call_on_created=None, 163 | include=None, exclude=None): 164 | super(RelatedWatcher, self).watch(call_on_created, 165 | include, exclude) 166 | # register value watcher if this is not reverse relation 167 | if not self.is_reversed: 168 | # make sure that the inner watcher has correct model instance 169 | # it is required when lazy_watch is used 170 | self._inner_watcher._model = self._model 171 | self._inner_watcher.watch(call_on_created=False) 172 | 173 | def unwatch(self): 174 | super(RelatedWatcher, self).unwatch() 175 | self._inner_watcher.unwatch() 176 | 177 | 178 | class ManyRelatedWatcher(RelatedWatcherBase): 179 | @property 180 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 181 | def through_model(self): 182 | return getattr(self.get_field().rel, 'through', None) 183 | 184 | def watch(self, call_on_created=None): 185 | if call_on_created is not None: 186 | self._call_on_created = call_on_created 187 | super(ManyRelatedWatcher, self).watch(call_on_created) 188 | if self.through_model: 189 | # m2m relation 190 | register_reciever(self.model, m2m_changed, 191 | self._m2m_changed_receiver, 192 | sender=self.through_model) 193 | 194 | def unwatch(self): 195 | super(ManyRelatedWatcher, self).unwatch() 196 | if self.through_model: 197 | unregister_reciever(self.model, m2m_changed, 198 | self._m2m_changed_receiver) 199 | 200 | def _m2m_changed_receiver(self, sender, instance, action, 201 | reverse, model, pk_set, **kwargs): 202 | if kwargs.get('row', False): 203 | # should not call any callback while it is called via fixtures or 204 | # so on 205 | return 206 | if action not in ('post_add', 'post_remove', 'post_clear'): 207 | return 208 | if instance.__class__ == self.model: 209 | self.call(instance) 210 | else: 211 | # TODO: pk_set is None for post_clear thus cache the pk_set 212 | # with 'pre_clear' and use the cache to tell. 213 | manager = self.model._default_manager 214 | for obj in manager.filter(pk__in=pk_set): 215 | self.call(obj) 216 | 217 | 218 | class GenericRelatedWatcher(RelatedWatcher): 219 | @property 220 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 221 | def is_reversed(self): 222 | return isinstance(self.get_field(), GenericForeignKey) 223 | 224 | @property 225 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 226 | def related_model(self): 227 | field = self.get_field() 228 | if self.is_reversed: 229 | return field.model 230 | return field.rel.to 231 | 232 | @property 233 | @lru_cache(settings.OBSERVER_LRU_CACHE_SIZE) 234 | def related_attr(self): 235 | field = self.get_field() 236 | if self.is_reversed: 237 | return field.name 238 | # find GenericForeignKey field 239 | for related_field in self.related_model._meta.virtual_fields: 240 | if isinstance(related_field, GenericForeignKey): 241 | return related_field.name 242 | raise KeyError 243 | 244 | def get_value(self, instance): 245 | try: 246 | if self.is_reversed: 247 | field = self.get_field() 248 | return super(GenericRelatedWatcher, self).get_value(instance) 249 | else: 250 | if instance is None: 251 | return None 252 | field = self.get_field() 253 | ct = getattr(instance, field.content_type_field_name) 254 | pk = getattr(instance, field.object_id_field_name) 255 | if ct is None: 256 | return None 257 | return ct.get_object_for_this_type(pk=pk) 258 | except ObjectDoesNotExist: 259 | return None 260 | -------------------------------------------------------------------------------- /src/observer/watchers/value.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import pre_save 2 | from django.db.models.signals import post_save 3 | from observer.investigator import Investigator 4 | from observer.utils.signals import (register_reciever, 5 | unregister_reciever) 6 | from base import WatcherBase 7 | 8 | 9 | class ValueWatcher(WatcherBase): 10 | """ 11 | Watcher field for watching non relational field such as CharField. 12 | """ 13 | def __init__(self, model, attr, callback, call_on_created=True): 14 | super(ValueWatcher, self).__init__(model, attr, callback) 15 | self._call_on_created = call_on_created 16 | 17 | def watch(self, call_on_created=None): 18 | self._call_on_created = (self._call_on_created 19 | if call_on_created is None 20 | else call_on_created) 21 | # initialize investigator 22 | self._investigator = Investigator(self.model, include=[self.attr]) 23 | # register the receivers 24 | register_reciever(self.model, pre_save, 25 | self._pre_save_receiver) 26 | register_reciever(self.model, post_save, 27 | self._post_save_receiver) 28 | 29 | def unwatch(self): 30 | unregister_reciever(self.model, pre_save, 31 | self._pre_save_receiver) 32 | unregister_reciever(self.model, post_save, 33 | self._post_save_receiver) 34 | 35 | def _pre_save_receiver(self, sender, instance, **kwargs): 36 | if kwargs.get('row', False): 37 | # should not call any callback while it is called via fixtures or 38 | # so on 39 | return 40 | self._investigator.prepare(instance) 41 | 42 | def _post_save_receiver(self, sender, instance, created, **kwargs): 43 | if kwargs.get('row', False): 44 | # should not call any callback while it is called via fixtures or 45 | # so on 46 | return 47 | if self._call_on_created and created: 48 | self.call(instance) 49 | # if investigator yield any field_name, call the callback 50 | if any(self._investigator.investigate(instance)): 51 | self.call(instance) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lambdalisue/django-observer/052e0da55eefc8072cc78e0b9d72bc29c4e528c0/tests/__init__.py -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for tests project. 2 | import os 3 | import sys 4 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 5 | # add `src` 6 | sys.path.insert(0, os.path.join(BASE_DIR, 'src')) 7 | 8 | DEBUG = True 9 | TEMPLATE_DEBUG = DEBUG 10 | 11 | ADMINS = ( 12 | # ('Your Name', 'your_email@domain.com'), 13 | ) 14 | 15 | MANAGERS = ADMINS 16 | 17 | DATABASES = { 18 | 'default': { 19 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 20 | 'NAME': 'database.db', # Or path to database file if using sqlite3. 21 | 'USER': '', # Not used with sqlite3. 22 | 'PASSWORD': '', # Not used with sqlite3. 23 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 24 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 25 | } 26 | } 27 | 28 | # Local time zone for this installation. Choices can be found here: 29 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 30 | # although not all choices may be available on all operating systems. 31 | # On Unix systems, a value of None will cause Django to use the same 32 | # timezone as the operating system. 33 | # If running in a Windows environment this must be set to the same as your 34 | # system time zone. 35 | TIME_ZONE = 'America/Chicago' 36 | 37 | # Language code for this installation. All choices can be found here: 38 | # http://www.i18nguy.com/unicode/language-identifiers.html 39 | LANGUAGE_CODE = 'en-us' 40 | 41 | SITE_ID = 1 42 | 43 | # If you set this to False, Django will make some optimizations so as not 44 | # to load the internationalization machinery. 45 | USE_I18N = True 46 | 47 | # If you set this to False, Django will not format dates, numbers and 48 | # calendars according to the current locale 49 | USE_L10N = True 50 | 51 | # Absolute path to the directory that holds media. 52 | # Example: "/home/media/media.lawrence.com/" 53 | MEDIA_ROOT = '' 54 | 55 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 56 | # trailing slash if there is a path component (optional in other cases). 57 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 58 | MEDIA_URL = '' 59 | 60 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 61 | # trailing slash. 62 | # Examples: "http://foo.com/media/", "/media/". 63 | ADMIN_MEDIA_PREFIX = '/media/' 64 | 65 | # Make this unique, and don't share it with anybody. 66 | SECRET_KEY = 'rbs62_^fuahxz!4k1!&yj$h8a=&-h_%do+3jk&%#v=o2%ep=7@' 67 | 68 | # List of callables that know how to import templates from various sources. 69 | TEMPLATE_LOADERS = ( 70 | 'django.template.loaders.filesystem.Loader', 71 | 'django.template.loaders.app_directories.Loader', 72 | # 'django.template.loaders.eggs.Loader', 73 | ) 74 | 75 | MIDDLEWARE_CLASSES = ( 76 | 'django.middleware.common.CommonMiddleware', 77 | 'django.contrib.sessions.middleware.SessionMiddleware', 78 | 'django.middleware.csrf.CsrfViewMiddleware', 79 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 80 | 'django.contrib.messages.middleware.MessageMiddleware', 81 | ) 82 | 83 | ROOT_URLCONF = 'tests.urls' 84 | 85 | TEMPLATE_DIRS = ( 86 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 87 | # Always use forward slashes, even on Windows. 88 | # Don't forget to use absolute paths, not relative paths. 89 | ) 90 | 91 | INSTALLED_APPS = ( 92 | 'django.contrib.auth', 93 | 'django.contrib.contenttypes', 94 | 'django.contrib.sessions', 95 | 'django.contrib.sites', 96 | 'django.contrib.messages', 97 | # Uncomment the next line to enable the admin: 98 | 'django.contrib.admin', 99 | 'observer', 100 | 'observer.tests', 101 | ) 102 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls import url, patterns, include 3 | except ImportError: 4 | from django.conf.urls.defaults import url, patterns, include 5 | from django.contrib import admin 6 | admin.autodiscover() 7 | 8 | urlpatterns = patterns('', 9 | url(r'^admin/', include(admin.site.urls)), 10 | url(r'^registration/', include('registration.urls')), 11 | ) 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py26-django12, 4 | py26-django13, 5 | py26-django14, 6 | py26-django15, 7 | py26-django16, 8 | py27-django12, 9 | py27-django13, 10 | py27-django14, 11 | py27-django15, 12 | py27-django16, 13 | py32-django15, 14 | py32-django16, 15 | py33-django15, 16 | py33-django16, 17 | docs 18 | 19 | [testenv] 20 | deps= 21 | coverage 22 | -rrequirements-test.txt 23 | commands= 24 | coverage run --source=src/observer runtests.py [] 25 | whitelist_externals= 26 | make 27 | mkdir 28 | cp 29 | 2to3 30 | 31 | [testenv:docs] 32 | basepython=python 33 | changedir=docs 34 | deps=-rrequirements-docs.txt 35 | commands= 36 | make clean 37 | make html 38 | 39 | [django12] 40 | deps= 41 | {[testenv]deps} 42 | django==1.2.7 43 | 44 | [django13] 45 | deps= 46 | {[testenv]deps} 47 | django==1.3.7 48 | 49 | [django14] 50 | deps= 51 | {[testenv]deps} 52 | django==1.4.10 53 | 54 | [django15] 55 | deps= 56 | {[testenv]deps} 57 | django==1.5.5 58 | 59 | [django16] 60 | deps= 61 | {[testenv]deps} 62 | django==1.6 63 | 64 | [testenv:py26-django12] 65 | basepython=python2.6 66 | deps= 67 | unittest2 68 | {[django12]deps} 69 | [testenv:py26-django13] 70 | basepython=python2.6 71 | deps= 72 | unittest2 73 | {[django13]deps} 74 | [testenv:py26-django14] 75 | basepython=python2.6 76 | deps= 77 | unittest2 78 | {[django14]deps} 79 | [testenv:py26-django15] 80 | basepython=python2.6 81 | deps= 82 | unittest2 83 | {[django15]deps} 84 | [testenv:py26-django16] 85 | basepython=python2.6 86 | deps= 87 | unittest2 88 | {[django16]deps} 89 | 90 | [testenv:py27-django12] 91 | basepython=python2.7 92 | deps={[django12]deps} 93 | [testenv:py27-django13] 94 | basepython=python2.7 95 | deps={[django13]deps} 96 | [testenv:py27-django14] 97 | basepython=python2.7 98 | deps={[django14]deps} 99 | [testenv:py27-django15] 100 | basepython=python2.7 101 | deps={[django15]deps} 102 | [testenv:py27-django16] 103 | basepython=python2.7 104 | deps={[django16]deps} 105 | 106 | [testenv:py32-django15] 107 | basepython=python3.2 108 | deps={[django15]deps} 109 | commands= 110 | mkdir -p {envdir}/build 111 | cp -rf src {envdir}/build 112 | cp -rf tests {envdir}/build 113 | 2to3 --output-dir={envdir}/build/src -W -n src 114 | 2to3 --output-dir={envdir}/build/tests -W -n tests 115 | {envpython} runtests.py --base-dir={envdir}/build [] 116 | 117 | [testenv:py32-django16] 118 | basepython=python3.2 119 | deps={[django16]deps} 120 | commands= 121 | mkdir -p {envdir}/build 122 | cp -rf src {envdir}/build 123 | cp -rf tests {envdir}/build 124 | 2to3 --output-dir={envdir}/build/src -W -n src 125 | 2to3 --output-dir={envdir}/build/tests -W -n tests 126 | {envpython} runtests.py --base-dir={envdir}/build [] 127 | 128 | 129 | [testenv:py33-django15] 130 | basepython=python3.3 131 | deps={[django15]deps} 132 | commands= 133 | mkdir -p {envdir}/build 134 | cp -rf src {envdir}/build 135 | cp -rf tests {envdir}/build 136 | 2to3 --output-dir={envdir}/build/src -W -n src 137 | 2to3 --output-dir={envdir}/build/tests -W -n tests 138 | {envpython} runtests.py --base-dir={envdir}/build [] 139 | 140 | [testenv:py33-django16] 141 | basepython=python3.3 142 | deps={[django16]deps} 143 | commands= 144 | mkdir -p {envdir}/build 145 | cp -rf src {envdir}/build 146 | cp -rf tests {envdir}/build 147 | 2to3 --output-dir={envdir}/build/src -W -n src 148 | 2to3 --output-dir={envdir}/build/tests -W -n tests 149 | {envpython} runtests.py --base-dir={envdir}/build [] 150 | 151 | --------------------------------------------------------------------------------