├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── example-homepage.png ├── example.rst ├── generic-m2m-rel-objs.png ├── generic-model-relationships.png ├── genericm2m-tagging.png ├── index.rst ├── installation.rst ├── overview.rst └── requirements.txt ├── example ├── __init__.py ├── manage.py ├── media │ └── photos │ │ ├── 1337x.jpg │ │ └── CarrotKitty.jpg ├── requirements.txt ├── semtags.db ├── settings.py ├── site_app │ ├── __init__.py │ ├── forms.py │ ├── models.py │ ├── providers.py │ ├── urls.py │ └── views.py ├── static │ ├── css │ │ └── style.css │ └── js │ │ └── completion.js ├── templates │ ├── 404.html │ ├── base.html │ ├── blog │ │ ├── create_post.html │ │ └── post_detail.html │ ├── homepage.html │ └── media │ │ ├── create_photo.html │ │ └── photo_detail.html └── urls.py ├── genericm2m ├── __init__.py ├── genericm2m_tests │ ├── __init__.py │ ├── models.py │ └── tests.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── utils.py ├── runtests.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .ropeproject/* 3 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - requirements: docs/requirements.txt 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Charles Leifer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include MANIFEST.in 3 | include README.rst 4 | 5 | prune example 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-generic-m2m 3 | ================== 4 | 5 | relate anything to anything. the image below is a screenshot of the `example app `_ 6 | and shows a blog post that has been "related" to 2 "Place" models and a "City" model: 7 | 8 | .. image:: http://media.charlesleifer.com/blog/photos/genericm2m-tagging.png 9 | 10 | check the `documentation `_ for 11 | more examples and an in-depth description of the app (or keep reading for 12 | the 30 second version). 13 | 14 | 15 | what it does 16 | ------------ 17 | 18 | the purpose of this project is to allow you to create database-level 19 | relationships between various objects using a consistent api. 20 | 21 | 22 | example app 23 | ----------- 24 | 25 | bundled with the source code is an example app which shows how generic-m2m 26 | can be used to create "tags" between models. it uses `nathanborror's basic apps `_ 27 | with `django-completion `_ (shameless plug) 28 | to allow users to "autocomplete" various relationships between models, so if I'm 29 | a user and want to create a new blog post I can tag it with 30 | relationships to objects representing a city, a place, a funny photo of a cat, etc. 31 | 32 | .. image:: http://media.charlesleifer.com/blog/photos/generic-m2m-rel-objs.png 33 | 34 | 35 | quick overview 36 | -------------- 37 | 38 | say you have a couple models:: 39 | 40 | class Food(models.Model): 41 | name = models.CharField(max_length=255) 42 | 43 | related = RelatedObjectsDescriptor() 44 | 45 | def __unicode__(self): 46 | return self.name 47 | 48 | 49 | class Beverage(models.Model): 50 | name = models.CharField(max_length=255) 51 | 52 | related = RelatedObjectsDescriptor() 53 | 54 | def __unicode__(self): 55 | return self.name 56 | 57 | Here's a sample interactive interpreter session to show the basic API:: 58 | 59 | >>> pizza = Food.objects.create(name='pizza') 60 | >>> pepperoni = Food.objects.create(name='pepperoni') 61 | >>> beer = Beverage.objects.create(name='beer') 62 | >>> soda = Beverage.objects.create(name='soda') 63 | 64 | >>> pizza.related.connect(pepperoni) 65 | 66 | 67 | >>> pizza.related.connect(beer) 68 | 69 | 70 | >>> pepperoni.related.related_to() 71 | [] 72 | 73 | >>> pizza.related.all() 74 | [, ] 75 | 76 | >>> pizza.related.all().generic_objects() 77 | [, ] 78 | 79 | >>> Food.related.all() 80 | [, ] 81 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-generic-m2m.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-generic-m2m.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-generic-m2m" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-generic-m2m" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-generic-m2m documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jul 11 16:59:12 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-generic-m2m' 44 | copyright = u'2011, charles leifer' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.2.1' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.2.1' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'nature' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-generic-m2mdoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | # The paper size ('letter' or 'a4'). 173 | #latex_paper_size = 'letter' 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | #latex_font_size = '10pt' 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, author, documentclass [howto/manual]). 180 | latex_documents = [ 181 | ('index', 'django-generic-m2m.tex', u'django-generic-m2m Documentation', 182 | u'charles leifer', 'manual'), 183 | ] 184 | 185 | # The name of an image file (relative to this directory) to place at the top of 186 | # the title page. 187 | #latex_logo = None 188 | 189 | # For "manual" documents, if this is true, then toplevel headings are parts, 190 | # not chapters. 191 | #latex_use_parts = False 192 | 193 | # If true, show page references after internal links. 194 | #latex_show_pagerefs = False 195 | 196 | # If true, show URL addresses after external links. 197 | #latex_show_urls = False 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #latex_preamble = '' 201 | 202 | # Documents to append as an appendix to all manuals. 203 | #latex_appendices = [] 204 | 205 | # If false, no module index is generated. 206 | #latex_domain_indices = True 207 | 208 | 209 | # -- Options for manual page output -------------------------------------------- 210 | 211 | # One entry per manual page. List of tuples 212 | # (source start file, name, description, authors, manual section). 213 | man_pages = [ 214 | ('index', 'django-generic-m2m', u'django-generic-m2m Documentation', 215 | [u'charles leifer'], 1) 216 | ] 217 | -------------------------------------------------------------------------------- /docs/example-homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/docs/example-homepage.png -------------------------------------------------------------------------------- /docs/example.rst: -------------------------------------------------------------------------------- 1 | Example App 2 | =========== 3 | 4 | The example app demonstrates how you can use `django-generic-m2m `_ to create "tags" 5 | between different types of models. 6 | 7 | It uses several apps from `django basic apps `_ 8 | to provide some various content models. Then it uses `django-completion `_ 9 | to allow users to "autocomplete" various objects in the database, making it easy 10 | for users to tag one piece of content with other content from the database. 11 | 12 | Below is a screen-shot of a user creating a new blog post. The "relationships" 13 | text input does autocompletion making it easy to add "tags" to various models. 14 | When the form is submitted, those "tags" become stored using the generic-m2m API. 15 | 16 | .. image:: genericm2m-tagging.png 17 | 18 | How to run the example app 19 | -------------------------- 20 | 21 | The example app is bundled with django-generic-m2m, but running it requires 22 | several external dependencies. For this reason, I'd recommend running it in 23 | its own dedicated virtualenv:: 24 | 25 | virtualenv --no-site-packages genericm2m-example 26 | cd genericm2m-example 27 | source bin/activate 28 | 29 | Now install the latest version of django-generic-m2m from github:: 30 | 31 | pip install -e git+git://github.com/coleifer/django-generic-m2m.git#egg=genericm2m 32 | 33 | You should see a few lines of text followed by "Successfully installed genericm2m". 34 | Now you'll need to install the example app dependencies:: 35 | 36 | pip install -r src/genericm2m/example/requirements.txt 37 | 38 | This will install the 1.3.X branch of django, django-basic-apps, and django-completion. 39 | Once these are installed you are ready to run the example:: 40 | 41 | cd src/genericm2m/example 42 | ./manage.py runserver 43 | 44 | Now navigate to http://127.0.0.1:8000/ in your browser and you will see the 45 | example app's homepage: 46 | 47 | .. image:: example-homepage.png 48 | 49 | If you want to see examples of "model tagging", browse the photos or blogs. There 50 | is a section titled "Related to" with links to whatever the object was tagged 51 | with: 52 | 53 | .. image:: generic-m2m-rel-objs.png 54 | 55 | I'd encourage you to click around, create a few posts or photos and try tagging 56 | them with various models. 57 | 58 | 59 | What is in the example app? 60 | --------------------------- 61 | 62 | The example app is centered around a few small pieces: 63 | 64 | * custom form classes and views to handle creating the relationships 65 | * javascript that handles autocompletion and storing data in the form 66 | * autocomplete providers that make it possible to do autocompletion on our models 67 | * code in template to show the related objects for a post or photo 68 | 69 | We'll tackle this stuff one bit at a time starting with the form classes and 70 | views, since thats all normal django stuff we're all probably familiar with. 71 | 72 | Forms and views 73 | ^^^^^^^^^^^^^^^ 74 | 75 | If you open up `example/site_app/forms.py `_ 76 | in your favorite editor, you'll see a normal ``ModelForm`` subclass which has a 77 | couple additional fields on it:: 78 | 79 | from django.contrib.contenttypes.models import ContentType 80 | 81 | 82 | class BaseRelationshipsForm(forms.ModelForm): 83 | relationships = forms.CharField(required=False) 84 | hidden_relationships = forms.CharField(required=False, widget=forms.HiddenInput()) 85 | 86 | def clean_hidden_relationships(self): 87 | hidden = self.cleaned_data.get('hidden_relationships') or '' 88 | 89 | cts_and_ids = [ct_id for ct_id in hidden.split(',') if ct_id.strip()] 90 | objects = [] 91 | 92 | for ct_id in cts_and_ids: 93 | content_type_id, object_id = ct_id.split(':') 94 | 95 | ctype = ContentType.objects.get_for_id(int(content_type_id)) 96 | obj = ctype.model_class()._default_manager.get(pk=object_id) 97 | 98 | objects.append(obj) 99 | 100 | return objects 101 | 102 | This subclass will be used to implement a ``generic-m2m``-aware ``ModelForm`` for 103 | blog posts and photos. As you can see from the clean_hidden_relationships method, 104 | all we're doing is deserializing a comma-separated list of content-type/primary-key 105 | pairs and returning a list of actual objects. 106 | 107 | Here's what the code for the ``Photo`` form class looks like...Nothing too weird -- it uses a mixin to auto-generate the slug upon save, but 108 | other than that pretty plain-jane:: 109 | 110 | class PhotoForm(BaseRelationshipsForm, SlugifyMixin): 111 | class Meta: 112 | model = Photo 113 | fields = ('title', 'photo',) 114 | 115 | 116 | These forms are used by two views which handle displaying a template and, if 117 | everything looks good, creating a new object. The interesting part is right 118 | after the initial model save where the newly-created objects gets connected 119 | to whatever objects it was tagged with:: 120 | 121 | def generic_completion_view(request, form_class, template): 122 | form = form_class(request.POST or None, request.FILES or None) 123 | 124 | if request.method == 'POST' and form.is_valid(): 125 | # save the new object instance 126 | new_obj = form.save() 127 | 128 | # grab the related objects from the form and add them 129 | # to the new post instance 130 | for obj in form.cleaned_data['hidden_relationships']: 131 | new_obj.related.connect(obj) 132 | 133 | return redirect(new_obj) 134 | 135 | return render_to_response(template, {'form': form}, 136 | context_instance=RequestContext(request)) 137 | 138 | def create_photo(request): 139 | return generic_completion_view(request, PhotoForm, 'media/create_photo.html') 140 | 141 | 142 | Some JavaScript 143 | ^^^^^^^^^^^^^^^ 144 | 145 | On the client-side, we need to do three things: 146 | 147 | 1. fetch data from our autocomplete view when the user types into the relationships input 148 | 2. upon selecting an item, update a hidden field so the form on the server-side can figure 149 | out what objects we're talking about 150 | 3. provide a mechanism for removing previously selected objects 151 | 152 | These tasks are accomplished by using `jQuery UI's autocomplete widget `_. 153 | The trick I used is cribbed from django-basic-apps, wherein the id of the object selected 154 | is stored in the hash of the link to "remove" that object from the list selected 155 | items. So you end up with a hidden input full of any number of identifiers, and links 156 | with a generic listener that removes the id in question from the hidden input. 157 | 158 | 159 | Autocomplete providers 160 | ^^^^^^^^^^^^^^^^^^^^^^ 161 | 162 | `django-completion `_ (shameless plug) is 163 | an attempt at simplifying the process of providing autocompletion for a set of models. 164 | I used it to enable autocompletion on a handful of models from django-basic-apps. 165 | The process should look familiar if you've created custom ``ModelAdmin`` classes 166 | before. Here's a representative example:: 167 | 168 | 169 | from completion import site, DjangoModelProvider 170 | 171 | from basic.blog.models import Post 172 | # ... other imports ... 173 | 174 | 175 | class PostProvider(DjangoModelProvider): 176 | def get_title(self, obj): 177 | return obj.title 178 | 179 | def get_pub_date(self, obj): 180 | return obj.publish 181 | 182 | def get_data(self, obj): 183 | return { 184 | 'title': obj.title, 185 | 'url': obj.get_absolute_url(), 186 | } 187 | 188 | # ... other providers ... 189 | 190 | site.register(Post, PostProvider) 191 | 192 | Signal handlers ensure that the autocomplete data is kept fresh whenever a model 193 | instance is saved or deleted. 194 | 195 | 196 | Template code 197 | ^^^^^^^^^^^^^ 198 | 199 | If you look in `the template code `_, 200 | all we do is loop over the relationships of the object. The template uses 201 | an optimized lookup to traverse the GFK relationships by calling ``generic_objects()``. 202 | This returns the actual objects that the blog post is connected to. 203 | 204 | .. code-block:: html 205 | 206 |

Related to:

207 |
    208 | {% for obj in object.related.all.generic_objects %} 209 |
  • {{ obj }}
  • 210 | {% empty %} 211 |
  • Nothing here
  • 212 | {% endfor %} 213 |
214 | 215 | 216 | And that about wraps it up! 217 | -------------------------------------------------------------------------------- /docs/generic-m2m-rel-objs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/docs/generic-m2m-rel-objs.png -------------------------------------------------------------------------------- /docs/generic-model-relationships.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/docs/generic-model-relationships.png -------------------------------------------------------------------------------- /docs/genericm2m-tagging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/docs/genericm2m-tagging.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-generic-m2m 2 | 3 | django-generic-m2m 4 | ================== 5 | 6 | relate anything to anything. behind the scenes the app uses a table containing 7 | two `generic foreign keys `_. 8 | 9 | why would I use this? 10 | --------------------- 11 | 12 | the purpose of this project is to allow you to create database-level 13 | relationships between various objects using a consistent api. 14 | 15 | 16 | Contents: 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | 21 | installation 22 | example 23 | overview 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | 32 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installation 5 | ------------ 6 | 7 | You can pip install django-generic-m2m:: 8 | 9 | pip install django-generic-m2m 10 | 11 | Alternatively, you can use the version hosted on GitHub, which may contain new 12 | or undocumented features:: 13 | 14 | git clone git://github.com/coleifer/django-generic-m2m.git 15 | cd django-generic-m2m 16 | python setup.py install 17 | 18 | 19 | Adding to your Django Project 20 | -------------------------------- 21 | 22 | After installing, adding genericm2m to your projects is a snap. First, 23 | add it to your projects' `INSTALLED_APPS` and run `django-admin.py syncdb`:: 24 | 25 | # settings.py 26 | INSTALLED_APPS = [ 27 | ... 28 | 'genericm2m' 29 | ] 30 | 31 | 32 | Up and running stupid fast 33 | -------------------------- 34 | 35 | You need to add a ``RelatedObjectsDescriptor`` to any model you intend to relate 36 | objects from. For example, a news site may want to relate its news stories to 37 | various other models:: 38 | 39 | from django.db import models 40 | 41 | from genericm2m.models import RelatedObjectsDescriptor 42 | 43 | 44 | class Story(models.Model): 45 | # ... story fields ... 46 | 47 | related = RelatedObjectsDescriptor() 48 | 49 | # rest of model definition follows 50 | 51 | 52 | Now you can relate your stories to other objects:: 53 | 54 | >>> story.related.connect(some_city) # create a relationship between story and some_city 55 | >>> story.related.connect(some_public_figure) # ... between story and some_public_figure 56 | 57 | These relationships can be queried:: 58 | 59 | >>> story.related.all() # find out what "story" has been related to 60 | [, 61 | ] 62 | 63 | And you can use a custom method on the ``QuerySet`` to get at those related 64 | objects using an optimized query:: 65 | 66 | >>> story.related.all().generic_objects() # traverse the GFK to get the actual objects 67 | [, ] 68 | 69 | 70 | Monkeypatching 71 | ^^^^^^^^^^^^^^ 72 | 73 | If the model definition isn't accessible, whether because it is in a 3rd party 74 | app or because it is in a contrib app, you can monkeypatch:: 75 | 76 | from django.contrib.auth.models import User 77 | 78 | from genericm2m.utils import monkey_patch 79 | 80 | monkey_patch(User, 'related') 81 | 82 | 83 | Now you can create relationships from ``User`` objects:: 84 | 85 | >>> some_guy = User.objects.get(username='some_guy') # get a user object 86 | >>> pizza = Food.objects.get(name='pizza') # get a food object 87 | >>> some_guy.related.connect(pizza) # connect the user to the food 88 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | What its all about is connecting models together and, if you want, creating some 5 | metadata about the meaning of that relationship (i.e. a tag). 6 | 7 | .. image:: generic-model-relationships.png 8 | 9 | To this end, django-generic-m2m does three things to make this behavior easier: 10 | 11 | 1. wraps up all querying and connecting logic in a single attribute that acts on both model instances and the model class 12 | 2. allows any model to be used as the intermediary "through" model 13 | 3. provides an optimized lookup when ``GenericForeignKeys`` are used 14 | 15 | 16 | Adding to a model 17 | ----------------- 18 | 19 | Before you start creating relationships, you'll need to add a ``RelatedObjectsDescriptor`` 20 | to any model you plan on relating to other models. 21 | 22 | Here's a quick example:: 23 | 24 | from django.db import models 25 | 26 | from genericm2m.models import RelatedObjectsDescriptor 27 | 28 | 29 | class Food(models.Model): 30 | name = models.CharField(max_length=255) 31 | 32 | related = RelatedObjectsDescriptor() 33 | 34 | def __unicode__(self): 35 | return self.name 36 | 37 | 38 | class Beverage(models.Model): 39 | name = models.CharField(max_length=255) 40 | 41 | related = RelatedObjectsDescriptor() 42 | 43 | def __unicode__(self): 44 | return self.name 45 | 46 | 47 | If you'd like to add relationships to a model that you don't control (for example 48 | the ``User`` model from ``django.contrib.auth``), you can use the ``monkey_patch`` 49 | utility:: 50 | 51 | 52 | from django.contrib.auth.models import User 53 | 54 | from genericm2m.utils import monkey_patch 55 | 56 | monkey_patch(User, name='related') 57 | 58 | 59 | What is this "related" attribute? 60 | ---------------------------------- 61 | 62 | The "related" attribute from the previous examples is the way the generic many-to-many 63 | is exposed for each model. Behind-the-scenes it is using ``genericm2m.models.BaseGFKRelatedObject``, 64 | which looks like this:: 65 | 66 | 67 | class BaseGFKRelatedObject(models.Model): 68 | """ 69 | A generic many-to-many implementation where diverse objects are related 70 | across a single model to other diverse objects -> using a dual GFK 71 | """ 72 | # SOURCE OBJECT: 73 | parent_type = models.ForeignKey(ContentType, related_name="child_%(class)s") 74 | parent_id = models.IntegerField(db_index=True) 75 | parent = GenericForeignKey(ct_field="parent_type", fk_field="parent_id") 76 | 77 | # ACTUAL RELATED OBJECT: 78 | object_type = models.ForeignKey(ContentType, related_name="related_%(class)s") 79 | object_id = models.IntegerField(db_index=True) 80 | object = GenericForeignKey(ct_field="object_type", fk_field="object_id") 81 | 82 | class Meta: 83 | abstract = True 84 | 85 | 86 | There's not really too much that should be weird about this model. It contains 87 | two ``GenericForeignKeys``, one to represent the "from" object, the source of the 88 | connection, and another to represent to "to" object (what "from" is being connected 89 | with). 90 | 91 | Because "abstract" models cannot store actual objects, the project comes with a 92 | default implementation which has two additional fields, ``alias`` and ``creation_date``:: 93 | 94 | class RelatedObject(BaseGFKRelatedObject): 95 | """ 96 | A subclass of BaseGFKRelatedObject which adds two fields used for tracking 97 | some metadata about the relationship, an alias and the date the relationship 98 | was created 99 | """ 100 | alias = models.CharField(max_length=255, blank=True) 101 | creation_date = models.DateTimeField(auto_now_add=True) 102 | 103 | class Meta: 104 | ordering = ('-creation_date',) 105 | 106 | def __unicode__(self): 107 | return '%s related to %s ("%s")' % (self.parent, self.object, self.alias) 108 | 109 | 110 | Creating and querying relationships 111 | ----------------------------------- 112 | 113 | A custom model manager is exposed on each model via the ``RelatedObjectsDescriptor``. 114 | The API for creating and querying relationships is exposed via this descriptor. 115 | 116 | Here is a sample interactive terminal session:: 117 | 118 | >>> # create a handful of objects to use in our demo 119 | >>> pizza = Food.objects.create(name='pizza') 120 | >>> cereal = Food.objects.create(name='cereal') 121 | >>> beer = Beverage.objects.create(name='beer') 122 | >>> soda = Beverage.objects.create(name='soda') 123 | >>> milk = Beverage.objects.create(name='milk') 124 | >>> healthy_eater = User.objects.create_user('healthy_eater', 'healthy@health.com', 'secret') 125 | >>> chocula = User.objects.create_user('chocula', 'chocula@postcereal.com', 'garlic') 126 | 127 | Now that we have some Food, Beverage and User objects, create some connections between them:: 128 | 129 | >>> rel_obj = pizza.related.connect(beer, alias='Beer and pizza are good') 130 | >>> type(rel_obj) # what did we just create? 131 | 132 | 133 | The object that represents the connection is an instance of whatever is passed to 134 | the ``RelatedObjectDescriptor`` when it is added to a model, but the default 135 | is ``genericm2m.models.RelatedObject``. Here are the interesting properties of the 136 | new related object:: 137 | 138 | >>> rel_obj.parent 139 | 140 | >>> rel_obj.object 141 | 142 | >>> rel_obj.alias 143 | 'Beer and pizza are good' 144 | 145 | These relationships can be queried:: 146 | 147 | >>> pizza.related.all() # find all objects that pizza has been related to 148 | [] 149 | 150 | When the `RelatedObject` is a GFK, as is the case here, the ``RelatedObjectsDescriptor`` will 151 | return a special ``QuerySet`` class that provides an optimized lookup of any GFK-ed objects:: 152 | 153 | >>> type(pizza.related.all()) 154 | 155 | >>> pizza.related.all().generic_objects() # traverse the GFK relationships 156 | [] 157 | 158 | If the object on the back-side of the relationship also has a ``RelatedObjectsDescriptor`` with 159 | the same intermediary model, reverse lookups are possible: 160 | 161 | >>> beer.related.related_to() # query the back-side of the relationship 162 | [] 163 | 164 | Create some more connections - any combination of models can be used. Below I'm 165 | connectiong a Food (cereal) to both Beverage objects (milk) and User objects (Chocula):: 166 | 167 | >>> cereal.related.connect(milk) # connecting to a beverage 168 | 169 | >>> cereal.related.connect(chocula) # connecting to a user 170 | 171 | 172 | >>> cereal.related.all() # show what cereal is related to 173 | [, 174 | ] 175 | 176 | >>> chocula.related.all() # relationships are ONE WAY 177 | [] 178 | >>> chocula.related.related_to() # querying the backside shows what has been connected to chocula 179 | [] 180 | 181 | Also worth noting is that the ``RelatedObjectsDescriptor`` works on both the 182 | instance-level and the class-level, so if we wanted to see all objects related to foods:: 183 | 184 | >>> Food.related.all() # anything that has been related to a food 185 | [, 186 | , 187 | ] 188 | 189 | 190 | Using a custom "through" model 191 | ------------------------------ 192 | 193 | It's possible to use a custom "through" model in place of the default ``RelatedObject``. 194 | If you know you're only going to be using a couple models, this can be a handy way 195 | to save queries. Looking at the tests, here's another silly example where we 196 | have a ``RelatedBeverage`` model that our Food model will use:: 197 | 198 | class RelatedBeverage(models.Model): 199 | food = models.ForeignKey('Food') 200 | beverage = models.ForeignKey('Beverage') 201 | 202 | class Meta: 203 | ordering = ('-id',) 204 | 205 | class Food(models.Model): 206 | # ... same as above except for this new attribute: 207 | related_beverages = RelatedObjectsDescriptor(RelatedBeverage, 'food', 'beverage') 208 | 209 | The "related_beverages" attribute is an instance of ``RelatedObjectsDescriptor``, 210 | but it is instantiated with a couple of arguments: 211 | 212 | * ``RelatedBeverage``: the model to be used to hold the "connections" 213 | * 'food': the field name on the above model which maps to the "from" object 214 | * 'beverage': the field name which maps to the "to" object 215 | 216 | Continuing the shell session from above with the same models, foods can be 217 | connected to beverages using the new "related_beverages" attribute:: 218 | 219 | >>> pizza.related_beverages.connect(soda) 220 | 221 | 222 | Querying provides the same interface, but since the "to" object is a direct 223 | ``ForeignKey`` to Beverage, a normal django ``QuerySet`` is used:: 224 | 225 | >>> pizza.related_beverages.all() 226 | [] 227 | >>> type(pizza.related_beverages.all()) 228 | 229 | 230 | A ``TypeError`` will be raised if you try to connect an invalid object, such as 231 | a Person to the "related_beverages":: 232 | 233 | >>> pizza.related_beverages.connect(mario) 234 | *** TypeError: Unable to query ... 235 | 236 | And lastly, just like before, its possible to query on the class to get all the 237 | ``RelatedBeverage`` objects for our foods:: 238 | 239 | >>> Food.related_beverages.all() 240 | [] 241 | 242 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | docutils<0.18 2 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/example/__init__.py -------------------------------------------------------------------------------- /example/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 | -------------------------------------------------------------------------------- /example/media/photos/1337x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/example/media/photos/1337x.jpg -------------------------------------------------------------------------------- /example/media/photos/CarrotKitty.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/example/media/photos/CarrotKitty.jpg -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | -e svn+http://code.djangoproject.com/svn/django/branches/releases/1.3.X/#egg=django_1.3.X 2 | -e git+https://github.com/nathanborror/django-basic-apps.git#egg=basic 3 | -e git+git://github.com/coleifer/django-completion.git#egg=completion 4 | 5 | # needed by basic 6 | django-tagging 7 | python-dateutil 8 | 9 | # should already be present 10 | #-e git+git://github.com/coleifer/django-generic-m2m.git#egg=genericm2m 11 | -------------------------------------------------------------------------------- /example/semtags.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/example/semtags.db -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | SITE_ROOT = os.path.dirname(os.path.realpath(__file__)) 7 | 8 | ADMIN_MEDIA_PREFIX = '/static/admin/' 9 | 10 | MEDIA_ROOT = '%s/media/' % (SITE_ROOT) 11 | MEDIA_URL = '/media/' 12 | 13 | STATIC_URL = '/static/' 14 | STATICFILES_DIRS = ( 15 | '%s/static/' % (SITE_ROOT), 16 | ) 17 | 18 | CACHE_BACKEND = 'dummy:///' 19 | CACHE_KEY_PREFIX = 'semtags' 20 | CACHE_MIDDLEWARE_KEY_PREFIX = CACHE_KEY_PREFIX 21 | CACHE_MIDDLEWARE_SECONDS = 60 22 | 23 | LOGIN_REDIRECT_URL = '/' 24 | 25 | SITE_NAME = 'semtags.com' 26 | 27 | DATABASES = { 28 | 'default': { 29 | 'ENGINE' : 'django.db.backends.sqlite3', 30 | 'HOST' : '', 31 | 'PORT' : '', 32 | 'NAME' : '%s/semtags.db' % SITE_ROOT, 33 | 'USER' : '', 34 | 'PASSWORD' : '' 35 | } 36 | } 37 | 38 | TIME_ZONE = 'America/Chicago' 39 | LANGUAGE_CODE = 'en-us' 40 | USE_I18N = True 41 | SITE_ID = 1 42 | SECRET_KEY = 'gv^gjq&kwrs3uqmd*s-is7%8z7@bc9^#4$txthzx$ta3nrn6(&' 43 | MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' 44 | 45 | STATICFILES_FINDERS = ( 46 | 'django.contrib.staticfiles.finders.FileSystemFinder', 47 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 48 | ) 49 | 50 | TEMPLATE_LOADERS = ( 51 | 'django.template.loaders.filesystem.load_template_source', 52 | 'django.template.loaders.app_directories.load_template_source', 53 | ) 54 | 55 | TEMPLATE_CONTEXT_PROCESSORS = ( 56 | 'django.core.context_processors.auth', 57 | 'django.core.context_processors.debug', 58 | 'django.core.context_processors.media', 59 | 'django.core.context_processors.static', 60 | 'django.contrib.messages.context_processors.messages', 61 | 'django.core.context_processors.request' 62 | ) 63 | 64 | MIDDLEWARE_CLASSES = ( 65 | 'django.middleware.cache.UpdateCacheMiddleware', 66 | 'django.middleware.common.CommonMiddleware', 67 | 'django.middleware.cache.FetchFromCacheMiddleware', 68 | 'django.contrib.sessions.middleware.SessionMiddleware', 69 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 70 | 'django.middleware.csrf.CsrfViewMiddleware', 71 | 'django.contrib.messages.middleware.MessageMiddleware', 72 | ) 73 | 74 | ROOT_URLCONF = 'example.urls' 75 | 76 | TEMPLATE_DIRS = ( 77 | '%s/templates/' % (SITE_ROOT), 78 | ) 79 | 80 | AUTOCOMPLETE_BACKEND = 'completion.backends.db_backend.DatabaseAutocomplete' 81 | AUTOCOMPLETE_MIN_WORDS = 1 82 | 83 | INSTALLED_APPS = ( 84 | 'django.contrib.auth', 85 | 'django.contrib.admin', 86 | 'django.contrib.contenttypes', 87 | 'django.contrib.markup', 88 | 'django.contrib.messages', 89 | 'django.contrib.sessions', 90 | 'django.contrib.sites', 91 | 'django.contrib.staticfiles', 92 | 'basic.blog', 93 | 'basic.inlines', 94 | 'basic.media', 95 | 'basic.people', 96 | 'basic.places', 97 | 'tagging', # needed by basic 98 | 'completion', 99 | 'genericm2m', 100 | 101 | # lastly, just a single app for this site 102 | 'example.site_app', 103 | ) 104 | -------------------------------------------------------------------------------- /example/site_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/example/site_app/__init__.py -------------------------------------------------------------------------------- /example/site_app/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.template.defaultfilters import slugify 4 | 5 | from basic.blog.models import Post 6 | from basic.media.models import Photo 7 | 8 | 9 | class BaseRelationshipsForm(forms.ModelForm): 10 | relationships = forms.CharField(required=False) 11 | hidden_relationships = forms.CharField(required=False, widget=forms.HiddenInput()) 12 | 13 | def clean_hidden_relationships(self): 14 | hidden = self.cleaned_data.get('hidden_relationships') or '' 15 | 16 | cts_and_ids = [ct_id for ct_id in hidden.split(',') if ct_id.strip()] 17 | objects = [] 18 | 19 | for ct_id in cts_and_ids: 20 | content_type_id, object_id = ct_id.split(':') 21 | 22 | ctype = ContentType.objects.get_for_id(int(content_type_id)) 23 | obj = ctype.model_class()._default_manager.get(pk=object_id) 24 | 25 | objects.append(obj) 26 | 27 | return objects 28 | 29 | 30 | class SlugifyMixin(object): 31 | def clean_title(self): 32 | title = self.cleaned_data.get('title') 33 | self.instance.slug = slugify(title) 34 | return title 35 | 36 | 37 | class PostForm(BaseRelationshipsForm, SlugifyMixin): 38 | class Meta: 39 | model = Post 40 | fields = ('title', 'body',) 41 | 42 | 43 | class PhotoForm(BaseRelationshipsForm, SlugifyMixin): 44 | class Meta: 45 | model = Photo 46 | fields = ('title', 'photo',) 47 | -------------------------------------------------------------------------------- /example/site_app/models.py: -------------------------------------------------------------------------------- 1 | from providers import * 2 | 3 | from basic.blog.models import Post 4 | from basic.media.models import Photo 5 | from completion.listeners import start_listening 6 | from genericm2m.utils import monkey_patch 7 | 8 | 9 | # monkey patch the Post model with a related objects descriptor 10 | monkey_patch(Post) 11 | monkey_patch(Photo) 12 | 13 | # configure our signal handlers so we can update the autocomplete index on 14 | # model save & delete 15 | start_listening() 16 | -------------------------------------------------------------------------------- /example/site_app/providers.py: -------------------------------------------------------------------------------- 1 | from completion import site, DjangoModelProvider 2 | 3 | from basic.blog.models import Post 4 | from basic.media.models import Photo 5 | from basic.people.models import Person 6 | from basic.places.models import City, Place 7 | 8 | 9 | class PostProvider(DjangoModelProvider): 10 | def get_title(self, obj): 11 | return obj.title 12 | 13 | def get_pub_date(self, obj): 14 | return obj.publish 15 | 16 | def get_data(self, obj): 17 | return { 18 | 'title': obj.title, 19 | 'url': obj.get_absolute_url(), 20 | } 21 | 22 | 23 | class PhotoProvider(DjangoModelProvider): 24 | def get_title(self, obj): 25 | return obj.title 26 | 27 | def get_pub_date(self, obj): 28 | return obj.uploaded 29 | 30 | def get_data(self, obj): 31 | return { 32 | 'title': obj.title, 33 | 'url': obj.get_absolute_url(), 34 | } 35 | 36 | 37 | class PersonProvider(DjangoModelProvider): 38 | def get_title(self, obj): 39 | return obj.full_name 40 | 41 | def get_pub_date(self, obj): 42 | return obj.birth_date 43 | 44 | def get_data(self, obj): 45 | return { 46 | 'title': obj.full_name, 47 | 'url': obj.get_absolute_url(), 48 | } 49 | 50 | 51 | class PlaceProvider(DjangoModelProvider): 52 | def get_title(self, obj): 53 | return obj.title 54 | 55 | def get_pub_date(self, obj): 56 | return obj.modified 57 | 58 | def get_data(self, obj): 59 | return { 60 | 'title': obj.title, 61 | 'url': obj.get_absolute_url(), 62 | } 63 | 64 | 65 | class CityProvider(DjangoModelProvider): 66 | def get_title(self, obj): 67 | return unicode(obj) 68 | 69 | def get_pub_date(self, obj): 70 | return None 71 | 72 | def get_data(self, obj): 73 | return { 74 | 'title': unicode(obj), 75 | 'url': obj.get_absolute_url(), 76 | } 77 | 78 | 79 | site.register(Post, PostProvider) 80 | site.register(Photo, PhotoProvider) 81 | site.register(Person, PersonProvider) 82 | site.register(Place, PlaceProvider) 83 | site.register(City, CityProvider) 84 | -------------------------------------------------------------------------------- /example/site_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | 4 | urlpatterns = patterns('example.site_app.views', 5 | url(r'^photo/$', 'create_photo', name='create_photo'), 6 | url(r'^post/$', 'create_post', name='create_post'), 7 | ) 8 | -------------------------------------------------------------------------------- /example/site_app/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect, render_to_response 2 | from django.template import RequestContext 3 | 4 | from forms import PhotoForm, PostForm 5 | 6 | 7 | def generic_completion_view(request, form_class, template): 8 | form = form_class(request.POST or None, request.FILES or None) 9 | 10 | if request.method == 'POST' and form.is_valid(): 11 | # save the new object instance 12 | new_obj = form.save() 13 | 14 | # grab the related objects from the form and add them 15 | # to the new post instance 16 | for obj in form.cleaned_data['hidden_relationships']: 17 | new_obj.related.connect(obj) 18 | 19 | return redirect(new_obj) 20 | 21 | return render_to_response(template, {'form': form}, 22 | context_instance=RequestContext(request)) 23 | 24 | def create_photo(request): 25 | return generic_completion_view(request, PhotoForm, 'media/create_photo.html') 26 | 27 | def create_post(request): 28 | return generic_completion_view(request, PostForm, 'blog/create_post.html') 29 | -------------------------------------------------------------------------------- /example/static/css/style.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | abbr, address, cite, code, 4 | del, dfn, em, img, ins, kbd, q, samp, 5 | small, strong, sub, sup, var, 6 | b, i, 7 | dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, canvas, details, figcaption, figure, 11 | footer, header, hgroup, menu, nav, section, summary, 12 | time, mark, audio, video { 13 | margin:0; 14 | padding:0; 15 | border:0; 16 | outline:0; 17 | font-size:100%; 18 | vertical-align:baseline; 19 | background:transparent; 20 | } 21 | 22 | article, aside, details, figcaption, figure, 23 | footer, header, hgroup, menu, nav, section { 24 | display:block; 25 | } 26 | 27 | nav ul { list-style:none; } 28 | 29 | blockquote, q { quotes:none; } 30 | 31 | blockquote:before, blockquote:after, 32 | q:before, q:after { content:''; content:none; } 33 | 34 | a { margin:0; padding:0; font-size:100%; vertical-align:baseline; background:transparent; } 35 | 36 | ins { background-color:#ff9; color:#000; text-decoration:none; } 37 | 38 | mark { background-color:#ff9; color:#000; font-style:italic; font-weight:bold; } 39 | 40 | del { text-decoration: line-through; } 41 | 42 | abbr[title], dfn[title] { border-bottom:1px dotted; cursor:help; } 43 | 44 | /* tables still need cellspacing="0" in the markup */ 45 | table { border-collapse:collapse; border-spacing:0; } 46 | 47 | hr { display:block; height:1px; border:0; border-top:1px solid #ccc; margin:1em 0; padding:0; } 48 | 49 | input, select { vertical-align:middle; } 50 | /* END RESET CSS */ 51 | 52 | 53 | /* fonts.css from the YUI Library: developer.yahoo.com/yui/ 54 | Please refer to developer.yahoo.com/yui/fonts/ for font sizing percentages 55 | 56 | There are three custom edits: 57 | * remove arial, helvetica from explicit font stack 58 | * we normalize monospace styles ourselves 59 | * table font-size is reset in the HTML5 reset above so there is no need to repeat 60 | */ 61 | body { font:13px/1.231 sans-serif; *font-size:small; } /* hack retained to preserve specificity */ 62 | 63 | select, input, textarea, button { font:99% sans-serif; } 64 | 65 | /* normalize monospace sizing 66 | * en.wikipedia.org/wiki/MediaWiki_talk:Common.css/Archive_11#Teletype_style_fix_for_Chrome 67 | */ 68 | pre, code, kbd, samp { font-family: monospace, sans-serif; } 69 | 70 | 71 | /* 72 | * minimal base styles 73 | */ 74 | 75 | 76 | body, select, input, textarea, button { 77 | /* #444 looks better than black: twitter.com/H_FJ/statuses/11800719859 */ 78 | color: #444; 79 | font-family: Helvetica, sans; 80 | } 81 | 82 | /* Headers (h1,h2,etc) have no default font-size or margin, 83 | you'll want to define those yourself. */ 84 | h1,h2,h3,h4,h5,h6 { font-weight: bold; } 85 | 86 | /* always force a scrollbar in non-IE */ 87 | html { overflow-y: scroll; } 88 | 89 | 90 | a:hover, a:active { outline: none; } 91 | a, a:active, a:visited { color: #607890; } 92 | a:hover { color: #036; } 93 | 94 | 95 | ul, ol { margin-left: 1.8em; } 96 | ol { list-style-type: decimal; } 97 | 98 | small { font-size: 85%; } 99 | strong, th { font-weight: bold; } 100 | 101 | td, td img { vertical-align: top; } 102 | 103 | sub { vertical-align: sub; font-size: smaller; } 104 | sup { vertical-align: super; font-size: smaller; } 105 | 106 | pre { 107 | padding: 15px; 108 | 109 | /* www.pathf.com/blogs/2008/05/formatting-quoted-code-in-blog-posts-css21-white-space-pre-wrap/ */ 110 | white-space: pre; /* CSS2 */ 111 | white-space: pre-wrap; /* CSS 2.1 */ 112 | white-space: pre-line; /* CSS 3 (and 2.1 as well, actually) */ 113 | word-wrap: break-word; /* IE */ 114 | } 115 | 116 | textarea { overflow: auto; } /* thnx ivannikolic! www.sitepoint.com/blogs/2010/08/20/ie-remove-textarea-scrollbars/ */ 117 | 118 | .ie6 legend, .ie7 legend { margin-left: -7px; } /* thnx ivannikolic! */ 119 | 120 | /* align checkboxes, radios, text inputs with their label 121 | by: Thierry Koblentz tjkdesign.com/ez-css/css/base.css */ 122 | input[type="radio"] { vertical-align: text-bottom; } 123 | input[type="checkbox"] { vertical-align: bottom; } 124 | .ie7 input[type="checkbox"] { vertical-align: baseline; } 125 | .ie6 input { vertical-align: text-bottom; } 126 | 127 | /* hand cursor on clickable input elements */ 128 | label, input[type=button], input[type=submit], button { cursor: pointer; } 129 | 130 | /* webkit browsers add a 2px margin outside the chrome of form elements */ 131 | button, input, select, textarea { margin: 0; } 132 | 133 | 134 | /* These selection declarations have to be separate. 135 | No text-shadow: twitter.com/miketaylr/status/12228805301 136 | Also: hot pink. */ 137 | ::-moz-selection{ background: #FF5E99; color:#fff; text-shadow: none; } 138 | ::selection { background:#FF5E99; color:#fff; text-shadow: none; } 139 | 140 | /* j.mp/webkit-tap-highlight-color */ 141 | a:link { -webkit-tap-highlight-color: #FF5E99; } 142 | 143 | /* make buttons play nice in IE: 144 | www.viget.com/inspire/styling-the-button-element-in-internet-explorer/ */ 145 | button { width: auto; overflow: visible; } 146 | 147 | /* bicubic resizing for non-native sized IMG: 148 | code.flickr.com/blog/2008/11/12/on-ui-quality-the-little-things-client-side-image-resizing/ */ 149 | .ie7 img { -ms-interpolation-mode: bicubic; } 150 | 151 | 152 | 153 | /* 154 | * Non-semantic helper classes 155 | */ 156 | 157 | /* for image replacement */ 158 | .ir { display: block; text-indent: -999em; overflow: hidden; background-repeat: no-repeat; text-align: left; direction: ltr; } 159 | 160 | /* Hide for both screenreaders and browsers 161 | css-discuss.incutio.com/wiki/Screenreader_Visibility */ 162 | .hidden { display: none; visibility: hidden; } 163 | 164 | /* Hide only visually, but have it available for screenreaders 165 | www.webaim.org/techniques/css/invisiblecontent/ 166 | Solution from: j.mp/visuallyhidden - Thanks Jonathan Neal! */ 167 | .visuallyhidden { position: absolute !important; 168 | clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ 169 | clip: rect(1px, 1px, 1px, 1px); } 170 | 171 | /* Hide visually and from screenreaders, but maintain layout */ 172 | .invisible { visibility: hidden; } 173 | 174 | /* >> The Magnificent CLEARFIX << j.mp/phayesclearfix */ 175 | .clearfix:after { content: "\0020"; display: block; height: 0; clear: both; visibility: hidden; } 176 | /* Fix clearfix: blueprintcss.lighthouseapp.com/projects/15318/tickets/5-extra-margin-padding-bottom-of-page */ 177 | .clearfix { zoom: 1; } 178 | 179 | 180 | 181 | /* Primary Styles 182 | Author: 183 | */ 184 | 185 | body { width: 600px; margin: 0 auto; background-color: #8B0006; } 186 | 187 | div#nav { background-color: #2d2d2d; } 188 | div#nav ul { list-style-type: none; } 189 | div#nav ul li { float: left; position: relative; } 190 | div#nav ul li a { display: block; float: left; line-height: 20px; padding: 5px 7.5px; color: #dfdfdf; text-decoration: none; } 191 | div#nav ul li a:hover { color: #fff; background-color: #8b0006; } 192 | 193 | h1,h2,h3 { margin: 0 0 12px; color: #BC2A2D; padding: 3px 0; } 194 | h1 { font-size: 24px; border-bottom: 1px solid #BC2A2D; } 195 | h2 { font-size: 22px; border-bottom: 1px solid #BC2A2D; } 196 | h3 { font-size: 16px; color: #464646; } 197 | p { font-size: 12px; margin: 12px 0 12px; } 198 | 199 | form { margin: 10px 0; } 200 | form label { display: block; padding: 3px 0; } 201 | input, button, select, textarea { border: 1px solid #dfdfdf; padding: 3px; -moz-border-radius: 4px; -webkit-border-radius: 4px; } 202 | 203 | div#container { background-color: #fff; padding: 20px; border-left: 6px solid #2D2D2D; border-right: 6px solid #2D2D2D; border-bottom: 6px solid #2D2D2D; } 204 | 205 | a,a:active { color: #464646; font-weight: bold; } 206 | a:hover,a:visited { color: #bc2a2d; } 207 | a.ui-autocomplete-result { font-size: 11px; display: block, float: left; padding: 3px 12px; margin: 0 0 0 10px; text-decoration: none; border: 1px solid #BC2A2D; -webkit-border-radius: 5px; } 208 | 209 | body .ui-menu-item { font-size: 12px; } 210 | body .ui-menu-item a { font-weight: normal; } 211 | -------------------------------------------------------------------------------- /example/static/js/completion.js: -------------------------------------------------------------------------------- 1 | Site = window.Site || {}; 2 | 3 | (function(S, $) { 4 | 5 | var Autocompletion = function(options) { 6 | this.options = options || {}; 7 | this.default_url = '/autocomplete/'; 8 | 9 | this.result_class = this.options.result_class || 'ui-autocomplete-result'; 10 | this.remove_selector = this.options.remove_selector || '.' + this.result_class; 11 | }; 12 | 13 | Autocompletion.prototype.bind_listener = function(input_sel, hidden_sel) { 14 | var self = this; 15 | 16 | this.input_element = $(input_sel); 17 | this.hidden_element = $(hidden_sel); 18 | 19 | this.input_element.autocomplete({ 20 | minLength: self.options.min_length || 2, 21 | select: function(e, ui) {self.select_result(ui.item);}, 22 | source: function(request, response) {self.fetch_results(request, response);}, 23 | }); 24 | 25 | $(this.remove_selector).live('click', function(e) { 26 | e.preventDefault(); 27 | var django_id = this.hash.slice(1); 28 | var hidden_id_list = self.hidden_element.val().split(','); 29 | new_ids = remove_from_list(hidden_id_list, django_id); 30 | self.hidden_element.val(new_ids.join(',')); 31 | $(this).remove(); 32 | }); 33 | }; 34 | 35 | Autocompletion.prototype.select_result = function(item) { 36 | var elem = $(''+item.label+' x'); 37 | elem.insertAfter(this.input_element); 38 | 39 | var current = this.hidden_element.val(); 40 | this.hidden_element.val(current+item.id+','); 41 | }; 42 | 43 | Autocompletion.prototype.fetch_results = function(request, response) { 44 | var url = this.options.url || this.default_url, 45 | term = request.term; 46 | 47 | $.getJSON(url, {'q': term}, function(data) { 48 | var results = []; 49 | $.each(data, function(k, v) { 50 | v.label = v.title; 51 | v.value = ''; 52 | v.id = v.django_ct + ':' + v.object_id; 53 | results.push(v); 54 | }); 55 | response(results); 56 | }); 57 | }; 58 | 59 | function remove_from_list(list, val) { 60 | var new_list = []; 61 | for (var i = 0; i < list.length; i++) { 62 | if (list[i] != val) { 63 | new_list.push(list[i]); 64 | } 65 | } 66 | return new_list; 67 | }; 68 | 69 | S.Autocompletion = Autocompletion; 70 | 71 | })(Site, jQuery); 72 | -------------------------------------------------------------------------------- /example/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | not found 3 | 4 | 15 | 16 | 17 | 18 | 19 |
20 |

Not found

21 |

:(

22 |
-------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} | {{ site_name }} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block extra_script %}{% endblock %} 15 | 16 | 17 | 18 | 28 | 29 |
30 | {% block content_wrapper %} 31 |
32 | {% block content_title %}{% endblock %} 33 | {% block content %}{% endblock %} 34 |
35 | {% endblock %} 36 | 37 | {% block sidebar_wrapper %} 38 | 41 | {% endblock %} 42 | 43 | {% block footer_wrapper %} 44 | 47 | {% endblock %} 48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /example/templates/blog/create_post.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extra_script %} 4 | 5 | 11 | {% endblock %} 12 | 13 | {% block title %}Create post{% endblock %} 14 | 15 | {% block content_title %}

Create post

{% endblock %} 16 | 17 | {% block content %} 18 |
{% csrf_token %} 19 | {{ form.as_p }} 20 |

21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /example/templates/blog/post_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "blog/base_blog.html" %} 2 | 3 | 4 | {% block title %}{{ object.title }}{% endblock %} 5 | 6 | {% block content_title %}

{{ object.title }}

{% endblock %} 7 | 8 | {% block content %} 9 |

{{ object.publish|date:"j F Y" }}

10 | 11 |
12 |

{{ object.body|safe }}

13 |
14 | 15 |

Related to:

16 |
    17 | {% for obj in object.related.all.generic_objects %} 18 |
  • {{ obj }}
  • 19 | {% empty %} 20 |
  • Nothing here
  • 21 | {% endfor %} 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /example/templates/homepage.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Home{% endblock %} 4 | 5 | {% block content_title %}

Home

{% endblock %} 6 | 7 | {% block content %} 8 |

Welcome to the example site for django-generic-m2m

9 | 10 |

You can access the admin area using admin:admin

11 | 12 |

Try one of the following links to create content:

13 | 17 | 18 |

Or feel free to browse blogs and photos

19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /example/templates/media/create_photo.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block extra_script %} 4 | 5 | 11 | {% endblock %} 12 | 13 | {% block title %}Create photo{% endblock %} 14 | 15 | {% block content_title %}

Create photo

{% endblock %} 16 | 17 | {% block content %} 18 |
{% csrf_token %} 19 | {{ form.as_p }} 20 |

21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /example/templates/media/photo_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "media/base_photos.html" %} 2 | 3 | 4 | {% block title %}{{ object.title }}{% endblock %} 5 | 6 | {% block content_title %}

{{ object.title }}

{% endblock %} 7 | 8 | {% block content %} 9 |

{{ object.uploaded|date:"j F Y" }}

10 | 11 |
12 | 13 |
14 | 15 |

Related to:

16 |
    17 | {% for obj in object.related.all.generic_objects %} 18 |
  • {{ obj }}
  • 19 | {% empty %} 20 |
  • Nothing here
  • 21 | {% endfor %} 22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.defaults import * 3 | from django.contrib import admin 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | url(r'^admin/', include(admin.site.urls)), 9 | url(r'^autocomplete/', include('completion.urls')), 10 | url(r'^blog/', include('basic.blog.urls')), 11 | url(r'^create/', include('example.site_app.urls')), # our custom content creation views 12 | url(r'^media/', include('basic.media.urls.photos')), 13 | url(r'^people/', include('basic.people.urls')), 14 | url(r'^places/', include('basic.places.urls')), 15 | url(r'^media/(?P.*)$', 'django.views.static.serve', { 16 | 'document_root': settings.MEDIA_ROOT, 17 | }), 18 | url(r'^$', 'django.views.generic.simple.direct_to_template', kwargs={'template': 'homepage.html'}), 19 | ) 20 | -------------------------------------------------------------------------------- /genericm2m/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 3, 1) 2 | from sys import version_info 3 | PY3 = version_info[0] == 3 4 | 5 | if PY3: 6 | unicode = str 7 | str = bytes 8 | else: 9 | unicode = unicode 10 | str = str 11 | -------------------------------------------------------------------------------- /genericm2m/genericm2m_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/genericm2m/genericm2m_tests/__init__.py -------------------------------------------------------------------------------- /genericm2m/genericm2m_tests/models.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.contrib.contenttypes.fields import GenericForeignKey 3 | except ImportError: 4 | from django.contrib.contenttypes.generic import GenericForeignKey 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | 8 | from genericm2m.models import RelatedObjectsDescriptor 9 | 10 | 11 | class RelatedBeverage(models.Model): 12 | food = models.ForeignKey('Food') 13 | beverage = models.ForeignKey('Beverage') 14 | 15 | class Meta: 16 | ordering = ('-id',) 17 | 18 | 19 | class Food(models.Model): 20 | name = models.CharField(max_length=255) 21 | 22 | related = RelatedObjectsDescriptor() 23 | related_beverages = RelatedObjectsDescriptor(RelatedBeverage, 'food', 'beverage') 24 | 25 | def __unicode__(self): 26 | return self.name 27 | 28 | 29 | class Beverage(models.Model): 30 | name = models.CharField(max_length=255) 31 | 32 | related = RelatedObjectsDescriptor() 33 | 34 | def __unicode__(self): 35 | return self.name 36 | 37 | 38 | class Person(models.Model): 39 | name = models.CharField(max_length=255) 40 | 41 | related = RelatedObjectsDescriptor() 42 | 43 | def __unicode__(self): 44 | return self.name 45 | 46 | 47 | class Boring(models.Model): 48 | name = models.CharField(max_length=255) 49 | 50 | def __unicode__(self): 51 | return self.name 52 | 53 | 54 | class AnotherRelatedObject(models.Model): 55 | parent_type = models.ForeignKey(ContentType, related_name="child_%(class)s") 56 | parent_id = models.IntegerField(db_index=True) 57 | parent = GenericForeignKey(ct_field="parent_type", fk_field="parent_id") 58 | 59 | object_type = models.ForeignKey(ContentType, related_name="related_%(class)s") 60 | object_id = models.IntegerField(db_index=True) 61 | object = GenericForeignKey(ct_field="object_type", fk_field="object_id") 62 | 63 | alias = models.CharField(max_length=255, blank=True) 64 | description = models.TextField(blank=True) 65 | 66 | creation_date = models.DateTimeField(auto_now_add=True) 67 | 68 | class Meta: 69 | ordering = ('id',) 70 | 71 | 72 | class Note(models.Model): 73 | content = models.TextField() 74 | 75 | related = RelatedObjectsDescriptor(AnotherRelatedObject) 76 | -------------------------------------------------------------------------------- /genericm2m/genericm2m_tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.test import TestCase 3 | 4 | from genericm2m.models import RelatedObject, RelatedObjectsDescriptor, GFKOptimizedQuerySet 5 | from genericm2m.genericm2m_tests.models import ( 6 | Food, Beverage, Person, RelatedBeverage, Boring, AnotherRelatedObject, Note 7 | ) 8 | 9 | 10 | class RelationsTestCase(TestCase): 11 | def setUp(self): 12 | self.pizza = Food.objects.create(name='pizza') 13 | self.sandwich = Food.objects.create(name='sandwich') 14 | self.cereal = Food.objects.create(name='cereal') 15 | 16 | self.soda = Beverage.objects.create(name='soda') 17 | self.beer = Beverage.objects.create(name='beer') 18 | self.milk = Beverage.objects.create(name='milk') 19 | 20 | self.mario = Person.objects.create(name='mario') 21 | self.sam = Person.objects.create(name='sam') 22 | self.chocula = Person.objects.create(name='chocula') 23 | 24 | self.table = Boring.objects.create(name='table') 25 | self.chair = Boring.objects.create(name='chair') 26 | 27 | def assertRelatedEqual(self, rel_qs, tups, from_field='parent', 28 | to_field='object'): 29 | rel_tup = [ 30 | (getattr(rel_obj, from_field), getattr(rel_obj, to_field)) \ 31 | for rel_obj in rel_qs 32 | ] 33 | self.assertEqual(rel_tup, list(tups)) 34 | 35 | def test_connect(self): 36 | """ 37 | Connect model instances to various other model instances, then query 38 | the manager and check the queryset returned is correct 39 | """ 40 | self.pizza.related.connect(self.soda) 41 | self.pizza.related.connect(self.beer) 42 | self.pizza.related.connect(self.mario) 43 | 44 | self.soda.related.connect(self.pizza) 45 | self.soda.related.connect(self.beer) 46 | 47 | related = self.pizza.related.all() 48 | self.assertRelatedEqual(related, ( 49 | (self.pizza, self.mario), 50 | (self.pizza, self.beer), 51 | (self.pizza, self.soda), 52 | )) 53 | 54 | self.sandwich.related.connect(self.soda) 55 | self.sandwich.related.connect(self.milk) 56 | 57 | related = self.sandwich.related.all() 58 | self.assertRelatedEqual(related, ( 59 | (self.sandwich, self.milk), 60 | (self.sandwich, self.soda), 61 | )) 62 | 63 | related = self.cereal.related.all() 64 | self.assertRelatedEqual(related, ()) 65 | 66 | related = self.soda.related.all() 67 | self.assertRelatedEqual(related, ( 68 | (self.soda, self.beer), 69 | (self.soda, self.pizza), 70 | )) 71 | 72 | self.sandwich.related.connect(self.table) 73 | 74 | related = self.sandwich.related.all() 75 | self.assertRelatedEqual(related, ( 76 | (self.sandwich, self.table), 77 | (self.sandwich, self.milk), 78 | (self.sandwich, self.soda), 79 | )) 80 | 81 | def test_related_to(self): 82 | """ 83 | Check the back-side of the double-GFK, note: this only works on objects 84 | that have a RelatedObjectsDescriptor() pointing to the same model 85 | class, in this case the default `RelatedObject` 86 | """ 87 | self.pizza.related.connect(self.soda) 88 | self.pizza.related.connect(self.beer) 89 | self.pizza.related.connect(self.table) 90 | self.sandwich.related.connect(self.soda) 91 | self.sandwich.related.connect(self.milk) 92 | self.mario.related.connect(self.soda) 93 | self.soda.related.connect(self.pizza) 94 | 95 | related = self.soda.related.related_to() 96 | self.assertRelatedEqual(related, ( 97 | (self.mario, self.soda), 98 | (self.sandwich, self.soda), 99 | (self.pizza, self.soda), 100 | )) 101 | 102 | related = self.beer.related.related_to() 103 | self.assertRelatedEqual(related, ( 104 | (self.pizza, self.beer), 105 | )) 106 | 107 | related = self.milk.related.related_to() 108 | self.assertRelatedEqual(related, ( 109 | (self.sandwich, self.milk), 110 | )) 111 | 112 | related = self.pizza.related.related_to() 113 | self.assertRelatedEqual(related, ( 114 | (self.soda, self.pizza), 115 | )) 116 | 117 | def test_symmetrical(self): 118 | self.pizza.related.connect(self.soda) 119 | self.pizza.related.connect(self.beer) 120 | self.pizza.related.connect(self.table) 121 | self.sandwich.related.connect(self.soda) 122 | self.sandwich.related.connect(self.milk) 123 | self.mario.related.connect(self.soda) 124 | self.soda.related.connect(self.pizza) 125 | 126 | related = self.soda.related.symmetrical().order_by('id') 127 | self.assertRelatedEqual(related, ( 128 | (self.pizza, self.soda), 129 | (self.sandwich, self.soda), 130 | (self.mario, self.soda), 131 | (self.soda, self.pizza), 132 | )) 133 | 134 | related = self.beer.related.symmetrical() 135 | self.assertRelatedEqual(related, ( 136 | (self.pizza, self.beer), 137 | )) 138 | 139 | def test_manager_methods(self): 140 | """ 141 | Since the RelatedObjectsDescriptor behaves like a dynamic manager (much 142 | the same as Django's ForeignRelatedObjectsDescriptor) test to ensure 143 | that the manager behaves as expected and correctly implements all the 144 | basic FK methods 145 | """ 146 | # connect pizza to soda and grab the newly-created RelatedObject 147 | self.pizza.related.connect(self.soda) 148 | rel_obj = RelatedObject.objects.all()[0] 149 | 150 | # connect cereal to milk (this is just to make sure that anything 151 | # modified on one Food object doesn't affect another Food object 152 | self.cereal.related.connect(self.milk) 153 | 154 | # create a new RelatedObject but do not save it yet -- note that it does 155 | # not have `parent_object` set 156 | new_rel_obj = RelatedObject(object=self.beer) 157 | 158 | # add this related object to pizza, parent_object gets set and it will 159 | # show up in the queryset as expected 160 | self.pizza.related.add(new_rel_obj) 161 | self.assertRelatedEqual(self.pizza.related.all(), ( 162 | (self.pizza, self.beer), 163 | (self.pizza, self.soda), 164 | )) 165 | 166 | # remove the original RelatedObject `rel_obj`, which was the connection 167 | # from pizza -> soda 168 | self.pizza.related.remove(rel_obj) 169 | self.assertRelatedEqual(self.pizza.related.all(), ( 170 | (self.pizza, self.beer), 171 | )) 172 | 173 | # make sure clearing pizza's related queryset works 174 | self.pizza.related.clear() 175 | self.assertRelatedEqual(self.pizza.related.all(), ()) 176 | 177 | # make sure clearing the pizza objects didn't affect cereal 178 | self.assertRelatedEqual(self.cereal.related.all(), ( 179 | (self.cereal, self.milk), 180 | )) 181 | 182 | # there should be just one row in the table 183 | self.assertEqual(RelatedObject.objects.count(), 1) 184 | 185 | def test_model_level(self): 186 | """ 187 | The RelatedObjectsDescriptor can work at the class-level as well and 188 | applies to all instances of the model - check that when connections are 189 | made between individual instances and then are queried via the class, 190 | that all connections are returned from that model type 191 | """ 192 | self.pizza.related.connect(self.beer) 193 | self.cereal.related.connect(self.milk) 194 | 195 | self.mario.related.connect(self.pizza) 196 | self.sam.related.connect(self.beer) 197 | self.soda.related.connect(self.pizza) 198 | 199 | self.assertRelatedEqual(Food.related.all(), ( 200 | (self.cereal, self.milk), 201 | (self.pizza, self.beer), 202 | )) 203 | 204 | self.assertRelatedEqual(Beverage.related.all(), ( 205 | (self.soda, self.pizza), 206 | )) 207 | 208 | self.assertRelatedEqual(Person.related.all(), ( 209 | (self.sam, self.beer), 210 | (self.mario, self.pizza), 211 | )) 212 | 213 | def test_custom_connect(self): 214 | """ 215 | Mimic the test_connect() method, but instead use the custom descriptor, 216 | `related_beverages` which goes through the RelatedBeverage model 217 | """ 218 | self.pizza.related_beverages.connect(self.soda) 219 | self.pizza.related_beverages.connect(self.beer) 220 | 221 | related = self.pizza.related_beverages.all() 222 | self.assertRelatedEqual(related, ( 223 | (self.pizza, self.beer), 224 | (self.pizza, self.soda), 225 | ), 'food', 'beverage') 226 | 227 | self.sandwich.related_beverages.connect(self.soda) 228 | self.sandwich.related_beverages.connect(self.milk) 229 | 230 | related = self.sandwich.related_beverages.all() 231 | self.assertRelatedEqual(related, ( 232 | (self.sandwich, self.milk), 233 | (self.sandwich, self.soda), 234 | ), 'food', 'beverage') 235 | 236 | related = self.cereal.related_beverages.all() 237 | self.assertRelatedEqual(related, ()) 238 | 239 | def test_custom_model_manager(self): 240 | """ 241 | Mimic the test_model_manager() method, but instead use the custom 242 | descriptor and through model 243 | """ 244 | self.pizza.related_beverages.connect(self.soda) 245 | rel_obj = RelatedBeverage.objects.all()[0] # grab the new related obj 246 | 247 | self.cereal.related_beverages.connect(self.milk) 248 | 249 | new_rel_obj = RelatedBeverage(beverage=self.beer) 250 | 251 | self.pizza.related_beverages.add(new_rel_obj) 252 | self.assertRelatedEqual(self.pizza.related_beverages.all(), ( 253 | (self.pizza, self.beer), 254 | (self.pizza, self.soda), 255 | ), 'food', 'beverage') 256 | 257 | self.pizza.related_beverages.remove(rel_obj) 258 | self.assertRelatedEqual(self.pizza.related_beverages.all(), ( 259 | (self.pizza, self.beer), 260 | ), 'food', 'beverage') 261 | 262 | self.pizza.related_beverages.clear() 263 | self.assertRelatedEqual(self.pizza.related_beverages.all(), ()) 264 | 265 | # make sure clearing the pizza objects didn't affect cereal 266 | self.assertRelatedEqual(self.cereal.related_beverages.all(), ( 267 | (self.cereal, self.milk), 268 | ), 'food', 'beverage') 269 | 270 | self.assertEqual(RelatedBeverage.objects.count(), 1) 271 | 272 | def test_custom_model_level(self): 273 | """ 274 | And lastly, test that the custom descriptor/through-model work as 275 | expected at the model-level (previous tests were instance-level) 276 | """ 277 | self.pizza.related_beverages.connect(self.soda) 278 | self.pizza.related_beverages.connect(self.beer) 279 | self.sandwich.related_beverages.connect(self.soda) 280 | self.cereal.related_beverages.connect(self.milk) 281 | 282 | self.assertRelatedEqual(Food.related_beverages.all(), ( 283 | (self.cereal, self.milk), 284 | (self.sandwich, self.soda), 285 | (self.pizza, self.beer), 286 | (self.pizza, self.soda), 287 | ), 'food', 'beverage') 288 | 289 | def test_generic_traversal(self): 290 | """ 291 | Ensure that the RelatedObjectsDescriptor returns a GFKOptimizedQuerySet 292 | when the through model contains a GFK -- also check that the queryset's 293 | optimized lookup works as expected 294 | """ 295 | self.pizza.related.connect(self.beer) 296 | self.pizza.related.connect(self.soda) 297 | self.pizza.related.connect(self.mario) 298 | 299 | # the manager returns instances of GFKOptimizedQuerySet 300 | related = self.pizza.related.all() 301 | self.assertEqual(type(related), GFKOptimizedQuerySet) 302 | 303 | # check the queryset is using the right field 304 | self.assertEqual(related.get_gfk().name, 'object') 305 | 306 | # the custom queryset's optimized lookup works correctly 307 | objects = related.generic_objects() 308 | self.assertEqual(objects, [self.mario, self.soda, self.beer]) 309 | 310 | # check the reverse does not hold, documenting existing behavior since 311 | # it looks at only the "default" manager on the back-side 312 | related = self.soda.related.related_to() 313 | self.assertEqual(type(related), GFKOptimizedQuerySet) 314 | 315 | # check the queryset is using the right field 316 | self.assertEqual(related.get_gfk().name, 'parent') 317 | 318 | # the custom queryset's optimized lookup works correctly 319 | objects = related.generic_objects() 320 | self.assertEqual(objects, [self.pizza]) 321 | 322 | def test_filtering(self): 323 | """ 324 | Check that filtering on RelatedObject fields (or through model fields) 325 | works as expected 326 | """ 327 | self.pizza.related.connect(self.beer, alias='bud lite') 328 | self.pizza.related.connect(self.soda, alias='pepsi') 329 | self.pizza.related.connect(self.mario) 330 | 331 | rel_qs = self.pizza.related.filter(alias='bud lite') 332 | self.assertRelatedEqual(rel_qs, ( 333 | (self.pizza, self.beer), 334 | )) 335 | 336 | rel_qs = self.pizza.related.filter(object_type=ContentType.objects.get_for_model(Beverage)) 337 | self.assertRelatedEqual(rel_qs, ( 338 | (self.pizza, self.soda), 339 | (self.pizza, self.beer), 340 | )) 341 | 342 | rel_qs = self.beer.related.related_to().filter(alias='bud lite') 343 | self.assertRelatedEqual(rel_qs, ( 344 | (self.pizza, self.beer), 345 | )) 346 | 347 | def test_custom_model_using_gfks(self): 348 | """ 349 | Check that using a custom through model with GFKs works as expected 350 | (looking at models.py, Note uses `AnotherRelatedObject` as its through) 351 | """ 352 | self.note_a = Note.objects.create(content='a') 353 | self.note_b = Note.objects.create(content='b') 354 | self.note_c = Note.objects.create(content='c') 355 | 356 | self.note_a.related.connect(self.pizza) 357 | self.note_a.related.connect(self.note_b) 358 | 359 | self.pizza.related.connect(self.note_b) 360 | 361 | # create some notes with custom attributes 362 | self.note_b.related.connect(self.cereal, alias='cereal note', description='lucky charms!') 363 | self.note_b.related.connect(self.milk, alias='milk note', description='goes good with cereal') 364 | 365 | # ensure that the queryset is using the correct model and automatically 366 | # determines that a GFKOptimizedQuerySet can be used 367 | queryset = self.note_a.related.all() 368 | self.assertEqual(queryset.model, AnotherRelatedObject) 369 | self.assertTrue(isinstance(queryset, GFKOptimizedQuerySet)) 370 | 371 | related_a = self.note_a.related.all() 372 | self.assertRelatedEqual(related_a, ( 373 | (self.note_a, self.pizza), 374 | (self.note_a, self.note_b), 375 | )) 376 | 377 | related_b = self.note_b.related.all() 378 | self.assertRelatedEqual(related_b, ( 379 | (self.note_b, self.cereal), 380 | (self.note_b, self.milk), 381 | )) 382 | 383 | related_to = self.note_b.related.related_to() 384 | # note that pizza does not show up here even though it is related to note b 385 | # this is because that relationship was stored in a different table (RelatedObject) 386 | # as opposed to AnotherRelatedObject 387 | self.assertEqual(related_to.generic_objects(), [self.note_a]) 388 | 389 | cereal_rel, milk_rel = related_b 390 | 391 | # check that the custom attributes were saved correctly 392 | self.assertEqual(cereal_rel.alias, 'cereal note') 393 | self.assertEqual(cereal_rel.description, 'lucky charms!') 394 | 395 | self.assertEqual(milk_rel.alias, 'milk note') 396 | self.assertEqual(milk_rel.description, 'goes good with cereal') 397 | 398 | # check that we can filter on fields as expected 399 | self.assertRelatedEqual(self.note_b.related.filter(alias='cereal note'), ( 400 | (self.note_b, self.cereal), 401 | )) 402 | 403 | related_c = self.note_c.related.all() 404 | self.assertRelatedEqual(related_c, ()) 405 | 406 | # lastly, check that the GFKOptimizedQuerySet returns the expected 407 | # results when doing the optimized lookup 408 | self.assertEqual(related_a.generic_objects(), [ 409 | self.pizza, self.note_b 410 | ]) 411 | 412 | self.assertEqual(related_b.generic_objects(), [ 413 | self.cereal, self.milk 414 | ]) 415 | 416 | self.assertEqual(related_c.generic_objects(), []) 417 | 418 | def test_generic_objects_filtered(self): 419 | """ 420 | Get generic objects filtered by Model. 421 | """ 422 | self.pizza.related.connect(self.beer) 423 | self.pizza.related.connect(self.soda) 424 | self.pizza.related.connect(self.mario) 425 | 426 | # Get all generic related content 427 | related = self.pizza.related.all() 428 | objects = related.generic_objects() 429 | self.assertEqual(objects, [self.mario, self.soda, self.beer]) 430 | 431 | # Get Person generic related content only. 432 | related = self.pizza.related.all() 433 | objects = related.generic_objects(Person) 434 | self.assertEqual(objects, [self.mario]) 435 | -------------------------------------------------------------------------------- /genericm2m/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-01-01 20:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('contenttypes', '0002_remove_content_type_name'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='RelatedObject', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('parent_id', models.IntegerField(db_index=True)), 23 | ('object_id', models.IntegerField(db_index=True)), 24 | ('alias', models.CharField(blank=True, max_length=255)), 25 | ('creation_date', models.DateTimeField(auto_now_add=True)), 26 | ('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='related_relatedobject', to='contenttypes.ContentType')), 27 | ('parent_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='child_relatedobject', to='contenttypes.ContentType')), 28 | ], 29 | options={ 30 | 'ordering': ('-creation_date',), 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /genericm2m/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleifer/django-generic-m2m/cb67e1cf7700421761e9c144653ff233300ec611/genericm2m/migrations/__init__.py -------------------------------------------------------------------------------- /genericm2m/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django 3 | try: 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | except ImportError: 6 | from django.contrib.contenttypes.generic import GenericForeignKey 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.db import models 9 | from django.db.models import Q 10 | from django.db.models.query import QuerySet 11 | 12 | from sys import version_info 13 | from genericm2m import PY3, unicode, str 14 | 15 | class GFKOptimizedQuerySet(QuerySet): 16 | def __init__(self, *args, **kwargs): 17 | # pop the gfk_field from the kwargs if its passed in explicitly 18 | self._gfk_field = kwargs.pop('gfk_field', None) 19 | 20 | # call the parent class' initializer 21 | super(GFKOptimizedQuerySet, self).__init__(*args, **kwargs) 22 | 23 | def _clone(self, *args, **kwargs): 24 | clone = super(GFKOptimizedQuerySet, self)._clone(*args, **kwargs) 25 | clone._gfk_field = self._gfk_field 26 | return clone 27 | 28 | def get_gfk(self): 29 | if not self._gfk_field: 30 | for field in self.model._meta.virtual_fields: 31 | if isinstance(field, GenericForeignKey): 32 | self._gfk_field = field 33 | break 34 | 35 | return self._gfk_field 36 | 37 | def generic_objects(self, model=None): 38 | clone = self._clone() 39 | 40 | ctypes_and_fks = {} 41 | 42 | gfk_field = self.get_gfk() 43 | ctype_field = '%s_id' % gfk_field.ct_field 44 | fk_field = gfk_field.fk_field 45 | 46 | for obj in clone: 47 | ctype = ContentType.objects.get_for_id(getattr(obj, ctype_field)) 48 | obj_id = getattr(obj, fk_field) 49 | 50 | ctypes_and_fks.setdefault(ctype, []) 51 | ctypes_and_fks[ctype].append(obj_id) 52 | 53 | gfk_objects = {} 54 | for ctype, obj_ids in ctypes_and_fks.items(): 55 | gfk_objects[ctype.pk] = ctype.model_class()._default_manager.in_bulk(obj_ids) 56 | 57 | obj_list = [] 58 | for obj in clone: 59 | obj = gfk_objects[getattr(obj, ctype_field)][getattr(obj, fk_field)] 60 | if not model or (model and isinstance(obj, model)): 61 | obj_list.append(obj) 62 | 63 | return obj_list 64 | 65 | 66 | class RelatedObjectsDescriptor(object): 67 | def __init__(self, model=None, from_field='parent', to_field='object'): 68 | self.related_model = model or RelatedObject 69 | self.from_field = self.get_related_model_field(from_field) 70 | self.to_field = self.get_related_model_field(to_field) 71 | 72 | def get_related_model_field(self, field_name): 73 | opts = self.related_model._meta 74 | for virtual_field in opts.virtual_fields: 75 | if virtual_field.name == field_name: 76 | return virtual_field 77 | return opts.get_field(field_name) 78 | 79 | def is_gfk(self, field): 80 | return isinstance(field, GenericForeignKey) 81 | 82 | def get_query_for_field(self, instance, field): 83 | if self.is_gfk(field): 84 | ctype = ContentType.objects.get_for_model(instance) 85 | return { 86 | field.ct_field: ctype, 87 | field.fk_field: instance.pk 88 | } 89 | elif isinstance(instance, field.rel.to): 90 | return {field.name: instance} 91 | 92 | raise TypeError(u'Unable to query %s with %s' % (field, instance)) 93 | 94 | def get_query_from(self, instance): 95 | return self.get_query_for_field(instance, self.from_field) 96 | 97 | def get_query_to(self, instance): 98 | return self.get_query_for_field(instance, self.to_field) 99 | 100 | def contribute_to_class(self, cls, name): 101 | self.name = name 102 | self.model_class = cls 103 | setattr(cls, self.name, self) 104 | 105 | def __get__(self, instance, cls=None): 106 | if instance is None: 107 | return self 108 | 109 | ManagerClass = type(self.related_model._default_manager) 110 | return self.create_manager(instance, ManagerClass) 111 | 112 | def __set__(self, instance, value): 113 | if instance is None: 114 | raise AttributeError("Manager must be accessed via instance") 115 | 116 | manager = self.__get__(instance) 117 | manager.add(*value) 118 | 119 | def delete_manager(self, instance): 120 | return self.create_manager(instance, 121 | self.related_model._base_manager.__class__) 122 | 123 | def create_manager(self, instance, superclass, cf_from=True): 124 | rel_obj = self 125 | if cf_from: 126 | core_filters = self.get_query_from(instance) 127 | rel_field = self.to_field 128 | else: 129 | core_filters = self.get_query_to(instance) 130 | rel_field = self.from_field 131 | uses_gfk = self.is_gfk(rel_field) 132 | 133 | class RelatedManager(superclass): 134 | def get_queryset(self): 135 | if uses_gfk: 136 | qs = GFKOptimizedQuerySet(self.model, gfk_field=rel_field) 137 | return qs.filter(**(core_filters)) 138 | else: 139 | if django.VERSION < (1, 6): 140 | method = superclass.get_query_set 141 | else: 142 | method = superclass.get_queryset 143 | 144 | return method(self).filter(**(core_filters)) 145 | 146 | if django.VERSION < (1, 6): 147 | get_query_set = get_queryset 148 | 149 | def add(self, *objs): 150 | for obj in objs: 151 | if not isinstance(obj, self.model): 152 | raise TypeError(u"'%s' instance expected" % self.model._meta.object_name) 153 | if not PY3: 154 | for (k, v) in core_filters.iteritems(): 155 | setattr(obj, k, v) 156 | else: 157 | for (k, v) in core_filters.items(): 158 | setattr(obj, k, v) 159 | obj.save() 160 | add.alters_data = True 161 | 162 | def create(self, **kwargs): 163 | kwargs.update(core_filters) 164 | return super(RelatedManager, self).create(**kwargs) 165 | create.alters_data = True 166 | 167 | def get_or_create(self, **kwargs): 168 | kwargs.update(core_filters) 169 | return super(RelatedManager, self).get_or_create(**kwargs) 170 | get_or_create.alters_data = True 171 | 172 | def remove(self, *objs): 173 | for obj in objs: 174 | # Is obj actually part of this descriptor set? 175 | if obj in self.all(): 176 | obj.delete() 177 | else: 178 | raise rel_obj.related_model.DoesNotExist( 179 | u"%r is not related to %r." % (obj, instance)) 180 | remove.alters_data = True 181 | 182 | def clear(self): 183 | self.all().delete() 184 | clear.alters_data = True 185 | 186 | def connect(self, obj, **kwargs): 187 | kwargs.update(rel_obj.get_query_to(obj)) 188 | connection, created = self.get_or_create(**kwargs) 189 | return connection 190 | 191 | def related_to(self): 192 | mgr = rel_obj.create_manager(instance, superclass, False) 193 | return mgr.filter( 194 | **rel_obj.get_query_to(instance) 195 | ) 196 | 197 | def symmetrical(self): 198 | if django.VERSION < (1, 6): 199 | method = superclass.get_query_set 200 | else: 201 | method = superclass.get_queryset 202 | return method(self).filter( 203 | Q(**rel_obj.get_query_from(instance)) | 204 | Q(**rel_obj.get_query_to(instance)) 205 | ).distinct() 206 | 207 | manager = RelatedManager() 208 | manager.core_filters = core_filters 209 | manager.model = self.related_model 210 | 211 | return manager 212 | 213 | def all(self): 214 | if self.is_gfk(self.from_field): 215 | ctype = ContentType.objects.get_for_model(self.model_class) 216 | query = {self.from_field.ct_field: ctype} 217 | else: 218 | query = {} 219 | return self.related_model._default_manager.filter(**query) 220 | 221 | 222 | class BaseGFKRelatedObject(models.Model): 223 | """ 224 | A generic many-to-many implementation where diverse objects are related 225 | across a single model to other diverse objects -> using a dual GFK 226 | """ 227 | # SOURCE OBJECT: 228 | parent_type = models.ForeignKey(ContentType, related_name="child_%(class)s") 229 | parent_id = models.IntegerField(db_index=True) 230 | parent = GenericForeignKey(ct_field="parent_type", fk_field="parent_id") 231 | 232 | # ACTUAL RELATED OBJECT: 233 | object_type = models.ForeignKey(ContentType, related_name="related_%(class)s") 234 | object_id = models.IntegerField(db_index=True) 235 | object = GenericForeignKey(ct_field="object_type", fk_field="object_id") 236 | 237 | class Meta: 238 | abstract = True 239 | 240 | 241 | class RelatedObject(BaseGFKRelatedObject): 242 | """ 243 | A subclass of BaseGFKRelatedObject which adds two fields used for tracking 244 | some metadata about the relationship, an alias and the date the relationship 245 | was created 246 | """ 247 | alias = models.CharField(max_length=255, blank=True) 248 | creation_date = models.DateTimeField(auto_now_add=True) 249 | 250 | class Meta: 251 | ordering = ('-creation_date',) 252 | 253 | def __unicode__(self): 254 | return unicode(u'%s related to %s ("%s")' % (self.parent, self.object, self.alias)) 255 | -------------------------------------------------------------------------------- /genericm2m/utils.py: -------------------------------------------------------------------------------- 1 | from genericm2m.models import RelatedObjectsDescriptor 2 | 3 | 4 | def monkey_patch(model_class, name='related', descriptor=None): 5 | rel_obj = descriptor or RelatedObjectsDescriptor() 6 | rel_obj.contribute_to_class(model_class, name) 7 | setattr(model_class, name, rel_obj) 8 | return True 9 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from os.path import dirname, abspath 4 | 5 | import django 6 | from django.conf import settings 7 | 8 | 9 | if len(sys.argv) > 1 and 'postgres' in sys.argv: 10 | sys.argv.remove('postgres') 11 | db_engine = 'django.db.backends.postgresql_psycopg2' 12 | db_name = 'test_main' 13 | else: 14 | db_engine = 'django.db.backends.sqlite3' 15 | db_name = '' 16 | 17 | if not settings.configured: 18 | settings.configure( 19 | DATABASES=dict(default=dict(ENGINE=db_engine, NAME=db_name)), 20 | INSTALLED_APPS = [ 21 | 'django.contrib.contenttypes', 22 | 'genericm2m', 23 | 'genericm2m.genericm2m_tests', 24 | ], 25 | MIDDLEWARE_CLASSES = (), 26 | ) 27 | 28 | from django.test.utils import get_runner 29 | 30 | try: 31 | django.setup() 32 | except AttributeError: 33 | pass 34 | 35 | def runtests(*test_args): 36 | if not test_args: 37 | if sys.version_info[0] > 2: 38 | test_args = ['genericm2m.genericm2m_tests'] 39 | else: 40 | test_args = ["genericm2m_tests"] 41 | parent = dirname(abspath(__file__)) 42 | sys.path.insert(0, parent) 43 | TestRunner = get_runner(settings) 44 | test_runner = TestRunner(verbosity=1, interactive=True) 45 | failures = test_runner.run_tests(test_args) 46 | sys.exit(failures) 47 | 48 | if __name__ == '__main__': 49 | runtests(*sys.argv[1:]) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | from genericm2m import VERSION 5 | 6 | f = open(os.path.join(os.path.dirname(__file__), 'README.rst')) 7 | readme = f.read() 8 | f.close() 9 | 10 | setup( 11 | name='django-generic-m2m', 12 | version=".".join(map(str, VERSION)), 13 | description='relate anything to anything', 14 | long_description=readme, 15 | author='Charles Leifer', 16 | author_email='coleifer@gmail.com', 17 | url='https://github.com/coleifer/django-generic-m2m', 18 | packages=find_packages(), 19 | package_data = { 20 | 'genericm2m': [ 21 | ], 22 | }, 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'Environment :: Web Environment', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Operating System :: OS Independent', 29 | 'Programming Language :: Python', 30 | 'Framework :: Django', 31 | ], 32 | test_suite='runtests.runtests', 33 | ) 34 | --------------------------------------------------------------------------------