├── .gitignore ├── README.rst ├── docs ├── Makefile ├── _static │ └── .gitignore ├── _templates │ └── .gitignore ├── conf.py ├── docstrings.rst └── index.rst ├── eav ├── __init__.py ├── admin.py ├── decorators.py ├── fields.py ├── forms.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20161014_0157.py │ └── __init__.py ├── models.py ├── registry.py ├── tests │ ├── __init__.py │ ├── data_validation.py │ ├── limiting_attributes.py │ ├── misc_models.py │ ├── models.py │ ├── queries.py │ ├── registry.py │ └── set_and_get.py └── validators.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | ~* 3 | *.orig 4 | *.db 5 | *.sqlite* 6 | _build 7 | build 8 | django_eav.egg-info/* 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-eav 2 | ========== 3 | 4 | 5 | Introduction 6 | ------------ 7 | 8 | django-eav provides an Entity-Attribute-Value storage model for django apps. 9 | 10 | For a decent explanation of what an Entity-Attribute-Value storage model is, 11 | check `Wikipedia 12 | `_. 13 | 14 | .. note:: 15 | This software was inspired / derived from the excellent `eav-django 16 | `_ written by Andrey 17 | Mikhaylenko. 18 | 19 | There are a few notable differences between this implementation and the 20 | eav-django implementation. 21 | 22 | * This one is called django-eav, whereas the other is called eav-django. 23 | * This app allows you to to 'attach' EAV attributes to any existing django 24 | model (even from third-party apps) without making any changes to the those 25 | models' code. 26 | * This app has slightly more robust (but still not perfect) filtering. 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | From Github 33 | ~~~~~~~~~~~ 34 | You can install django-eav directly from guthub:: 35 | 36 | pip install -e git+git://github.com/mvpdev/django-eav.git#egg=django-eav 37 | 38 | Prerequisites 39 | ------------- 40 | 41 | Django Sites Framework 42 | ~~~~~~~~~~~~~~~~~~~~~~ 43 | As of Django 1.7, the `Sites framework `_ is not enabled by default; Django-EAV requires this framework. 44 | To enable the sites framework, follow these steps: 45 | 46 | Add ``django.contrib.sites`` to your INSTALLED_APPS setting. Be sure to add sites to the installed apps list BEFORE eav! 47 | 48 | Define a ``SITE_ID`` setting:: 49 | 50 | SITE_ID = 1 51 | 52 | Run ``migrate`` 53 | 54 | 55 | Usage 56 | ----- 57 | 58 | Edit settings.py 59 | ~~~~~~~~~~~~~~~~ 60 | Add ``eav`` to your ``INSTALLED_APPS`` in your project's ``settings.py`` file. Be sure to add eav to the installed apps list AFTER the sites framework! 61 | 62 | Register your model(s) 63 | ~~~~~~~~~~~~~~~~~~~~~~ 64 | Before you can attach eav attributes to your model, you must register your 65 | model with eav:: 66 | 67 | >>> import eav 68 | >>> eav.register(MyModel) 69 | 70 | Generally you would do this in your ``models.py`` immediate after your model 71 | declarations. Alternatively, you can use the registration decorator provided:: 72 | 73 | from eav.decorators import register_eav 74 | @register_eav() 75 | class MyModel(models.Model): 76 | ... 77 | 78 | Create some attributes 79 | ~~~~~~~~~~~~~~~~~~~~~~ 80 | :: 81 | 82 | >>> from eav.models import Attribute 83 | >>> Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_FLOAT) 84 | >>> Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT) 85 | 86 | 87 | Assign eav values 88 | ~~~~~~~~~~~~~~~~~ 89 | :: 90 | 91 | >>> m = MyModel() 92 | >>> m.eav.weight = 15.4 93 | >>> m.eav.color = 'blue' 94 | >>> m.save() 95 | >>> m = MyModel.objects.get(pk=m.pk) 96 | >>> m.eav.weight 97 | 15.4 98 | >>> m.eav.color 99 | blue 100 | 101 | >>> p = MyModel.objects.create(eav__weight = 12, eav__color='red') 102 | 103 | Filter on eav values 104 | ~~~~~~~~~~~~~~~~~~~~ 105 | :: 106 | 107 | >>> MyModel.objects.filter(eav__weight=15.4) 108 | 109 | >>> MyModel.objects.exclude(name='bob', eav__weight=15.4, eav__color='red') 110 | 111 | 112 | Documentation and Examples 113 | -------------------------- 114 | 115 | ``_ 116 | -------------------------------------------------------------------------------- /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 pickle json htmlhelp qthelp latex 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 " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-eav.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-eav.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/_static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvpdev/django-eav/41e99fa366a8712d2efd7006a23bd3b1fdac082a/docs/_static/.gitignore -------------------------------------------------------------------------------- /docs/_templates/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvpdev/django-eav/41e99fa366a8712d2efd7006a23bd3b1fdac082a/docs/_templates/.gitignore -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-eav documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Sep 24 10:48:33 2010. 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.append(os.path.abspath(os.path.join('..','..'))) 20 | 21 | # django setup 22 | import settings 23 | from django.core.management import setup_environ 24 | setup_environ(settings) 25 | 26 | # -- General configuration ----------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be extensions 29 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo','sphinxtogithub'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | #source_encoding = 'utf-8' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'django-eav' 46 | copyright = u'2010, MVP Africa' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = '0.9' 54 | # The full version, including alpha/beta/rc tags. 55 | release = '0.9.1' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of documents that shouldn't be included in the build. 68 | #unused_docs = [] 69 | 70 | # List of directories, relative to source directory, that shouldn't be searched 71 | # for source files. 72 | exclude_trees = ['_build'] 73 | 74 | # The reST default role (used for this markup: `text`) to use for all documents. 75 | #default_role = None 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | #add_function_parentheses = True 79 | 80 | # If true, the current module name will be prepended to all description 81 | # unit titles (such as .. function::). 82 | #add_module_names = True 83 | 84 | # If true, sectionauthor and moduleauthor directives will be shown in the 85 | # output. They are ignored by default. 86 | #show_authors = False 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = 'sphinx' 90 | 91 | # A list of ignored prefixes for module index sorting. 92 | #modindex_common_prefix = [] 93 | 94 | 95 | # -- Options for HTML output --------------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. Major themes that come with 98 | # Sphinx are currently 'default' and 'sphinxdoc'. 99 | html_theme = 'default' 100 | 101 | # Theme options are theme-specific and customize the look and feel of a theme 102 | # further. For a list of options available for each theme, see the 103 | # documentation. 104 | #html_theme_options = {} 105 | 106 | # Add any paths that contain custom themes here, relative to this directory. 107 | #html_theme_path = [] 108 | 109 | # The name for this set of Sphinx documents. If None, it defaults to 110 | # " v documentation". 111 | #html_title = None 112 | 113 | # A shorter title for the navigation bar. Default is the same as html_title. 114 | #html_short_title = None 115 | 116 | # The name of an image file (relative to this directory) to place at the top 117 | # of the sidebar. 118 | #html_logo = None 119 | 120 | # The name of an image file (within the static path) to use as favicon of the 121 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 122 | # pixels large. 123 | #html_favicon = None 124 | 125 | # Add any paths that contain custom static files (such as style sheets) here, 126 | # relative to this directory. They are copied after the builtin static files, 127 | # so a file named "default.css" will overwrite the builtin "default.css". 128 | html_static_path = ['_static'] 129 | 130 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 131 | # using the given strftime format. 132 | #html_last_updated_fmt = '%b %d, %Y' 133 | 134 | # If true, SmartyPants will be used to convert quotes and dashes to 135 | # typographically correct entities. 136 | #html_use_smartypants = True 137 | 138 | # Custom sidebar templates, maps document names to template names. 139 | #html_sidebars = {} 140 | 141 | # Additional templates that should be rendered to pages, maps page names to 142 | # template names. 143 | #html_additional_pages = {} 144 | 145 | # If false, no module index is generated. 146 | #html_use_modindex = True 147 | 148 | # If false, no index is generated. 149 | #html_use_index = True 150 | 151 | # If true, the index is split into individual pages for each letter. 152 | #html_split_index = False 153 | 154 | # If true, links to the reST sources are added to the pages. 155 | #html_show_sourcelink = True 156 | 157 | # If true, an OpenSearch description file will be output, and all pages will 158 | # contain a tag referring to it. The value of this option must be the 159 | # base URL from which the finished HTML is served. 160 | #html_use_opensearch = '' 161 | 162 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 163 | #html_file_suffix = '' 164 | 165 | # Output file base name for HTML help builder. 166 | htmlhelp_basename = 'django-eavdoc' 167 | 168 | 169 | # -- Options for LaTeX output -------------------------------------------------- 170 | 171 | # The paper size ('letter' or 'a4'). 172 | #latex_paper_size = 'letter' 173 | 174 | # The font size ('10pt', '11pt' or '12pt'). 175 | #latex_font_size = '10pt' 176 | 177 | # Grouping the document tree into LaTeX files. List of tuples 178 | # (source start file, target name, title, author, documentclass [howto/manual]). 179 | latex_documents = [ 180 | ('index', 'django-eav.tex', u'django-eav Documentation', 181 | u'MVP Africa', 'manual'), 182 | ] 183 | 184 | # The name of an image file (relative to this directory) to place at the top of 185 | # the title page. 186 | #latex_logo = None 187 | 188 | # For "manual" documents, if this is true, then toplevel headings are parts, 189 | # not chapters. 190 | #latex_use_parts = False 191 | 192 | # Additional stuff for the LaTeX preamble. 193 | #latex_preamble = '' 194 | 195 | # Documents to append as an appendix to all manuals. 196 | #latex_appendices = [] 197 | 198 | # If false, no module index is generated. 199 | #latex_use_modindex = True 200 | -------------------------------------------------------------------------------- /docs/docstrings.rst: -------------------------------------------------------------------------------- 1 | Docstrings 2 | ========== 3 | 4 | .. automodule:: eav 5 | :members: 6 | 7 | .. automodule:: eav.models 8 | :members: 9 | 10 | .. automodule:: eav.validators 11 | :members: 12 | 13 | .. automodule:: eav.fields 14 | :members: 15 | 16 | .. automodule:: eav.forms 17 | :members: 18 | 19 | .. automodule:: eav.managers 20 | :members: 21 | 22 | .. automodule:: eav.registry 23 | :members: 24 | 25 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-eav documentation master file, created by 2 | sphinx-quickstart on Fri Sep 24 10:48:33 2010. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ########## 7 | django-eav 8 | ########## 9 | 10 | 11 | Introduction 12 | ============ 13 | django-eav provides an Entity-Attribute-Value storage model for django apps. 14 | 15 | For a decent explanation of what an Entity-Attribute-Value storage model is, 16 | check `Wikipedia 17 | `_. 18 | 19 | .. note:: 20 | This software was inspired / derived from the excellent `eav-django 21 | `_ written by Andrey 22 | Mikhaylenko. 23 | 24 | There are a few notable differences between this implementation and the 25 | eav-django implementation. 26 | 27 | * This one is called django-eav, whereas the other is called eav-django. 28 | * This app allows you to to 'attach' EAV attributes to any existing django 29 | model (even from third-party apps) without making any changes to the those 30 | models' code. 31 | * This app has slightly more robust (but still not perfect) filtering. 32 | 33 | 34 | Installation 35 | ============ 36 | You can install django-eav directly from guthub:: 37 | 38 | pip install -e git+git://github.com/mvpdev/django-eav.git#egg=django-eav 39 | 40 | After installing, add ``eav`` to your ``INSTALLED_APPS`` in your 41 | project's ``settings.py`` file. 42 | 43 | Usage 44 | ===== 45 | In order to attach EAV attributes to a model, you first need to register it 46 | (just like you may register your models with django.contrib.admin). 47 | 48 | Registration 49 | ------------ 50 | Registering a model with eav does a few things: 51 | 52 | * Adds the eav :class:`eav.managers.EntityManager` to your class. By default, 53 | it will replace the default ``objects`` manager of the model, but you can 54 | choose to have the eav manager named something else if you don't want it to 55 | replace ``objects`` (see :ref:`advancedregistration`). 56 | * Connects the model's ``post_init`` signal to 57 | :meth:`~eav.registry.Registry.attach_eav_attr`. This function will attach 58 | the eav :class:`eav.models.Entity` helper class to every instance of your 59 | model when it is instatiated. By default, it will be attached to your models 60 | as an attribute named ``eav``, which will allow you to access it through 61 | ``my_model_instance.eav``, but you can choose to name it something else if you 62 | want (again see :ref:`advancedregistration`). 63 | * Connect's the model's ``pre_save`` signal to 64 | :meth:`eav.models.Entity.pre_save_handler`. 65 | * Connect's the model's ``post_save`` signal to 66 | :meth:`eav.models.Entity.post_save_handler`. 67 | * Adds a generic relation helper to the class. 68 | * Sets an attribute called ``_eav_config_cls`` on the model class to either 69 | the default :class:`eav.registry.EavConfig` config class, or to the config 70 | class you provided during registration. 71 | 72 | If that all sounds too complicated, don't worry, you really don't need to think 73 | about it. Just thought you should know. 74 | 75 | Simple Registration 76 | ^^^^^^^^^^^^^^^^^^^ 77 | To register any model with EAV, you simply need to add the registration line 78 | somewhere that will be executed when django starts:: 79 | 80 | import eav 81 | eav.register(MyModel) 82 | 83 | Generally, the most appropriate place for this would be in your ``models.py`` 84 | immediately after your model definition. 85 | 86 | .. _advancedregistration: 87 | 88 | Advanced Registration 89 | ^^^^^^^^^^^^^^^^^^^^^ 90 | Advanced registration is only required if: 91 | 92 | * You don't want eav to replace your model's default ``objects`` manager. 93 | * You want to name the :class:`~eav.models.Entity` helper attribute something 94 | other than ``eav`` 95 | * You don't want all eav :class:`~eav.models.Attribute` objects to 96 | be able to be set for all of your registered models. In other words, you 97 | have different types of entities, each with different attributes. 98 | 99 | Advanced registration is simple, and is performed the exact same way 100 | you override the django.contrib.admin registration defaults. 101 | 102 | You just need to define your own config class that subclasses 103 | :class:`~eav.registry.EavConfig` and override the default class attributes 104 | and method. 105 | 106 | There are five :class:`~eav.registry.EavConfig` class attributes you can 107 | override: 108 | 109 | ================================= ================================== ========================================================================== 110 | Class Attribute Default Description 111 | ================================= ================================== ========================================================================== 112 | ``manager_attr`` ``'objects'`` The name of the eav manager 113 | ``manager_only`` ``False`` *boolean* Whether to *only* replace the manager, and do nothing else 114 | ``eav_attr`` ``'eav'`` The attribute name of the entity helper 115 | ``generic_relation_attr`` ``'eav_values'`` The attribute name of the generic relation helper 116 | ``generic_relation_related_name`` The model's ``__class__.__name__`` The related name of the related name of the generic relation to your model 117 | ================================= ================================== ========================================================================== 118 | 119 | An example of just choosing a different name for the manager (and thus leaving 120 | ``objects`` intact):: 121 | 122 | class MyEavConfigClass(EavConfig): 123 | manager_attr = 'eav_objects' 124 | 125 | eav.register(MyModel, MyEavConfigClass) 126 | 127 | Additionally, :class:`~eav.registry.EavConfig` defines a classmethod called 128 | ``get_attributes`` that, by default will return ``Attribute.objects.all()`` 129 | This method is used to determine which :class:`~eav.models.Attribute` can be 130 | applied to the entity model you are registering. If you want to limit which 131 | attributes can be applied to your entity, you would need to override it. 132 | 133 | For example:: 134 | 135 | class MyEavConfigClass(EavConfig): 136 | @classmethod 137 | def get_attributes(cls): 138 | return Attribute.objects.filter(type='person') 139 | 140 | eav.register(MyModel, MyEavConfigClass) 141 | 142 | 143 | Using Attributes 144 | ================ 145 | Once you've registered your model(s), you can begin to use them with EAV 146 | attributes. Let's assume your model is called ``Person`` and it has one 147 | normal django ``CharField`` called name, but you want to be able to dynamically 148 | store other data about each Person. 149 | 150 | First, let's create some attributes:: 151 | 152 | >>> Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_FLOAT) 153 | >>> Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT) 154 | >>> Attribute.objects.create(name='Is pregant?', datatype=Attribute.TYPE_BOOLEAN) 155 | 156 | Now let's create a patient, and set some of these attributes:: 157 | 158 | >>> p = Patient.objects.create(name='Bob') 159 | >>> p.eav.height = 46 160 | >>> p.eav.weight = 42.2 161 | >>> p.eav.is_pregnant = False 162 | >>> p.save() 163 | >>> bob = Patient.objects.get(name='Bob') 164 | >>> bob.eav.height 165 | 46 166 | >>> bob.eav.weight 167 | 42.2 168 | >>> bob.is_pregnant 169 | False 170 | 171 | Additionally, assuming we're using the eav manager, we can also do:: 172 | 173 | >>> p = Patient.objects.create(name='Jen', eav__height=32, eav__pregnant=True) 174 | 175 | 176 | Filtering 177 | ========= 178 | 179 | eav attributes are filterable, using the same __ notation as django foreign 180 | keys:: 181 | 182 | Patient.objects.filter(eav__weight=42.2) 183 | Patient.objects.filter(eav__weight__gt=42) 184 | Patient.objects.filter(name='Bob', eav__weight__gt=42) 185 | Patient.objects.exclude(eav__is_pregnant=False) 186 | 187 | You can even use Q objects, however there are some known issues 188 | (see :ref:`qobjectissue`) with Q object filters:: 189 | 190 | Patient.objects.filter(Q(name='Bob') | Q(eav__is_pregnant=False)) 191 | 192 | What about if you have a foreign key to a model that uses eav, but you want 193 | to filter from a model that doesn't use eav? For example, let's say you have 194 | a ``Patient`` model that **doesn't** use eav, but it has a foreign key to 195 | ``Encounter`` that **does** use eav. You can even filter through eav across 196 | this relationship, but you need to use the eav manager for ``Patient``. 197 | 198 | Just register ``Patient`` with eav, but set ``manager_only = True`` 199 | see (see :ref:`advancedregistration`). Then you can do:: 200 | 201 | Patient.objects.filter(encounter__eav__weight=2) 202 | 203 | 204 | Admin Integration 205 | ================= 206 | 207 | You can even have your eav attributes show up just like normal fields in your 208 | models admin pages. Just register using the eav admin class:: 209 | 210 | from django.contrib import admin 211 | from eav.forms import BaseDynamicEntityForm 212 | from eav.admin import BaseEntityAdmin 213 | 214 | class PatientAdminForm(BaseDynamicEntityForm): 215 | model = Patient 216 | 217 | class PatientAdmin(BaseEntityAdmin): 218 | form = PatientAdminForm 219 | 220 | admin.site.register(Patient, PatientAdmin) 221 | 222 | 223 | Known Issues 224 | ============ 225 | 226 | .. _qobjectissue: 227 | 228 | Q Object Filters 229 | ---------------- 230 | Due to an unexplained Q object / generic relation issue, exclude filters with 231 | EAV Q objects, or EAV Q objects ANDed together may produce inaccurate results. 232 | 233 | Additional Resources 234 | ==================== 235 | 236 | .. toctree:: 237 | :maxdepth: 2 238 | 239 | docstrings 240 | 241 | -------------------------------------------------------------------------------- /eav/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | VERSION = (0, 9, 2) 20 | 21 | def get_version(): 22 | version = "%s.%s" % (VERSION[0], VERSION[1]) 23 | if VERSION[2] != 0: 24 | version = "%s.%s" % (version, VERSION[2]) 25 | return version 26 | 27 | __version__ = get_version() 28 | 29 | def register(model_cls, config_cls=None): 30 | from .registry import Registry 31 | Registry.register(model_cls, config_cls) 32 | 33 | def unregister(model_cls): 34 | from .registry import Registry 35 | Registry.unregister(model_cls) 36 | -------------------------------------------------------------------------------- /eav/admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | 20 | 21 | from django.contrib import admin 22 | from django.contrib.admin.options import ( 23 | ModelAdmin, InlineModelAdmin, StackedInline 24 | ) 25 | from django.forms.models import BaseInlineFormSet 26 | from django.utils.safestring import mark_safe 27 | 28 | from .models import Attribute, Value, EnumValue, EnumGroup 29 | 30 | class BaseEntityAdmin(ModelAdmin): 31 | 32 | def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): 33 | """ 34 | Wrapper for ModelAdmin.render_change_form. Replaces standard static 35 | AdminForm with an EAV-friendly one. The point is that our form generates 36 | fields dynamically and fieldsets must be inferred from a prepared and 37 | validated form instance, not just the form class. Django does not seem 38 | to provide hooks for this purpose, so we simply wrap the view and 39 | substitute some data. 40 | """ 41 | form = context['adminform'].form 42 | 43 | # infer correct data from the form 44 | fieldsets = self.fieldsets or [(None, {'fields': list(form.fields.keys())})] 45 | adminform = admin.helpers.AdminForm(form, fieldsets, 46 | self.prepopulated_fields) 47 | media = mark_safe(self.media + adminform.media) 48 | 49 | context.update(adminform=adminform, media=media) 50 | 51 | super_meth = super(BaseEntityAdmin, self).render_change_form 52 | return super_meth(request, context, add, change, form_url, obj) 53 | 54 | 55 | class BaseEntityInlineFormSet(BaseInlineFormSet): 56 | """ 57 | An inline formset that correctly initializes EAV forms. 58 | """ 59 | def add_fields(self, form, index): 60 | if self.instance: 61 | setattr(form.instance, self.fk.name, self.instance) 62 | form._build_dynamic_fields() 63 | super(BaseEntityInlineFormSet, self).add_fields(form, index) 64 | 65 | 66 | class BaseEntityInline(InlineModelAdmin): 67 | """ 68 | Inline model admin that works correctly with EAV attributes. You should mix 69 | in the standard StackedInline or TabularInline classes in order to define 70 | formset representation, e.g.:: 71 | 72 | class ItemInline(BaseEntityInline, StackedInline): 73 | model = Item 74 | form = forms.ItemForm 75 | 76 | .. warning: TabularInline does *not* work out of the box. There is, 77 | however, a patched template `admin/edit_inline/tabular.html` bundled 78 | with EAV-Django. You can copy or symlink the `admin` directory to your 79 | templates search path (see Django documentation). 80 | 81 | """ 82 | formset = BaseEntityInlineFormSet 83 | 84 | def get_fieldsets(self, request, obj=None): 85 | if self.declared_fieldsets: 86 | return self.declared_fieldsets 87 | 88 | formset = self.get_formset(request) 89 | fk_name = self.fk_name or formset.fk.name 90 | kw = {fk_name: obj} if obj else {} 91 | instance = self.model(**kw) 92 | form = formset.form(request.POST, instance=instance) 93 | 94 | return [(None, {'fields': list(form.fields.keys())})] 95 | 96 | class AttributeAdmin(ModelAdmin): 97 | list_display = ('name', 'content_type', 'slug', 'datatype', 'description', 'site') 98 | list_filter = ['site'] 99 | prepopulated_fields = {'slug': ('name',)} 100 | 101 | admin.site.register(Attribute, AttributeAdmin) 102 | admin.site.register(Value) 103 | admin.site.register(EnumValue) 104 | admin.site.register(EnumGroup) 105 | 106 | -------------------------------------------------------------------------------- /eav/decorators.py: -------------------------------------------------------------------------------- 1 | def register_eav(**kwargs): 2 | """ 3 | Registers the given model(s) classes and wrapped Model class with 4 | django-eav: 5 | 6 | @register_eav 7 | class Author(models.Model): 8 | pass 9 | """ 10 | from . import register 11 | from django.db.models import Model 12 | 13 | def _model_eav_wrapper(model_class): 14 | if not issubclass(model_class, Model): 15 | raise ValueError('Wrapped class must subclass Model.') 16 | register(model_class, **kwargs) 17 | return model_class 18 | 19 | return _model_eav_wrapper -------------------------------------------------------------------------------- /eav/fields.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ****** 21 | fields 22 | ****** 23 | 24 | Contains two custom fields: 25 | 26 | * :class:`EavSlugField` 27 | * :class:`EavDatatypeField` 28 | 29 | Classes 30 | ------- 31 | ''' 32 | 33 | import re 34 | 35 | from django.db import models 36 | from django.utils.translation import ugettext_lazy as _ 37 | from django.core.exceptions import ValidationError 38 | 39 | 40 | class EavSlugField(models.SlugField): 41 | ''' 42 | The slug field used by :class:`~eav.models.Attribute` 43 | ''' 44 | 45 | def validate(self, value, instance): 46 | ''' 47 | Slugs are used to convert the Python attribute name to a database 48 | lookup and vice versa. We need it to be a valid Python identifier. 49 | We don't want it to start with a '_', underscore will be used 50 | var variables we don't want to be saved in db. 51 | ''' 52 | super(EavSlugField, self).validate(value, instance) 53 | slug_regex = r'[a-z][a-z0-9_]*' 54 | if not re.match(slug_regex, value): 55 | raise ValidationError(_(u"Must be all lower case, " \ 56 | u"start with a letter, and contain " \ 57 | u"only letters, numbers, or underscores.")) 58 | 59 | @staticmethod 60 | def create_slug_from_name(name): 61 | ''' 62 | Creates a slug based on the name 63 | ''' 64 | name = name.strip().lower() 65 | 66 | # Change spaces to underscores 67 | name = '_'.join(name.split()) 68 | 69 | # Remove non alphanumeric characters 70 | return re.sub('[^\w]', '', name) 71 | 72 | 73 | class EavDatatypeField(models.CharField): 74 | ''' 75 | The datatype field used by :class:`~eav.models.Attribute` 76 | ''' 77 | 78 | def validate(self, value, instance): 79 | ''' 80 | Raise ``ValidationError`` if they try to change the datatype of an 81 | :class:`~eav.models.Attribute` that is already used by 82 | :class:`~eav.models.Value` objects. 83 | ''' 84 | super(EavDatatypeField, self).validate(value, instance) 85 | if not instance.pk: 86 | return 87 | if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype: 88 | return 89 | if instance.value_set.count(): 90 | raise ValidationError(_(u"You cannot change the datatype of an " 91 | u"attribute that is already in use.")) 92 | -------------------------------------------------------------------------------- /eav/forms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ##### 21 | forms 22 | ##### 23 | 24 | The forms used for admin integration 25 | 26 | Classes 27 | ------- 28 | ''' 29 | from copy import deepcopy 30 | 31 | from django.forms import BooleanField, CharField, DateTimeField, FloatField, \ 32 | IntegerField, ModelForm, ChoiceField, ValidationError 33 | from django.contrib.admin.widgets import AdminSplitDateTime 34 | from django.utils.translation import ugettext_lazy as _ 35 | 36 | 37 | class BaseDynamicEntityForm(ModelForm): 38 | ''' 39 | ModelForm for entity with support for EAV attributes. Form fields are 40 | created on the fly depending on Schema defined for given entity instance. 41 | If no schema is defined (i.e. the entity instance has not been saved yet), 42 | only static fields are used. However, on form validation the schema will be 43 | retrieved and EAV fields dynamically added to the form, so when the 44 | validation is actually done, all EAV fields are present in it (unless 45 | Rubric is not defined). 46 | ''' 47 | 48 | FIELD_CLASSES = { 49 | 'text': CharField, 50 | 'float': FloatField, 51 | 'int': IntegerField, 52 | 'date': DateTimeField, 53 | 'bool': BooleanField, 54 | 'enum': ChoiceField, 55 | } 56 | 57 | def __init__(self, data=None, *args, **kwargs): 58 | super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs) 59 | config_cls = self.instance._eav_config_cls 60 | self.entity = getattr(self.instance, config_cls.eav_attr) 61 | self._build_dynamic_fields() 62 | 63 | def _build_dynamic_fields(self): 64 | # reset form fields 65 | self.fields = deepcopy(self.base_fields) 66 | 67 | for attribute in self.entity.get_all_attributes(): 68 | value = getattr(self.entity, attribute.slug) 69 | 70 | defaults = { 71 | 'label': attribute.name.capitalize(), 72 | 'required': attribute.required, 73 | 'help_text': attribute.help_text, 74 | 'validators': attribute.get_validators(), 75 | } 76 | 77 | datatype = attribute.datatype 78 | if datatype == attribute.TYPE_ENUM: 79 | enums = attribute.get_choices() \ 80 | .values_list('id', 'value') 81 | 82 | choices = [('', '-----')] + list(enums) 83 | 84 | defaults.update({'choices': choices}) 85 | if value: 86 | defaults.update({'initial': value.pk}) 87 | 88 | elif datatype == attribute.TYPE_DATE: 89 | defaults.update({'widget': AdminSplitDateTime}) 90 | elif datatype == attribute.TYPE_OBJECT: 91 | continue 92 | 93 | MappedField = self.FIELD_CLASSES[datatype] 94 | self.fields[attribute.slug] = MappedField(**defaults) 95 | 96 | # fill initial data (if attribute was already defined) 97 | if not value is None and not datatype == attribute.TYPE_ENUM: #enum done above 98 | self.initial[attribute.slug] = value 99 | 100 | def save(self, commit=True): 101 | """ 102 | Saves this ``form``'s cleaned_data into model instance 103 | ``self.instance`` and related EAV attributes. 104 | 105 | Returns ``instance``. 106 | """ 107 | 108 | if self.errors: 109 | raise ValueError(_(u"The %s could not be saved because the data" 110 | u"didn't validate.") % \ 111 | self.instance._meta.object_name) 112 | 113 | # create entity instance, don't save yet 114 | instance = super(BaseDynamicEntityForm, self).save(commit=False) 115 | 116 | # assign attributes 117 | for attribute in self.entity.get_all_attributes(): 118 | value = self.cleaned_data.get(attribute.slug) 119 | if attribute.datatype == attribute.TYPE_ENUM: 120 | if value: 121 | value = attribute.enum_group.enums.get(pk=value) 122 | else: 123 | value = None 124 | 125 | setattr(self.entity, attribute.slug, value) 126 | 127 | # save entity and its attributes 128 | if commit: 129 | instance.save() 130 | 131 | return instance 132 | -------------------------------------------------------------------------------- /eav/managers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ******** 21 | managers 22 | ******** 23 | Contains the custom manager used by entities registered with eav. 24 | 25 | Functions and Classes 26 | --------------------- 27 | ''' 28 | import six 29 | 30 | from functools import wraps 31 | 32 | from django.db import models 33 | 34 | from .models import Attribute, Value 35 | 36 | 37 | def eav_filter(func): 38 | ''' 39 | Decorator used to wrap filter and exlclude methods. Passes args through 40 | expand_q_filters and kwargs through expand_eav_filter. Returns the 41 | called function (filter or exclude) 42 | ''' 43 | 44 | @wraps(func) 45 | def wrapper(self, *args, **kwargs): 46 | new_args = [] 47 | for arg in args: 48 | if isinstance(arg, models.Q): 49 | # modify Q objects (warning: recursion ahead) 50 | arg = expand_q_filters(arg, self.model) 51 | new_args.append(arg) 52 | 53 | new_kwargs = {} 54 | for key, value in list(kwargs.items()): 55 | # modify kwargs (warning: recursion ahead) 56 | new_key, new_value = expand_eav_filter(self.model, key, value) 57 | new_kwargs.update({new_key: new_value}) 58 | 59 | return func(self, *new_args, **new_kwargs) 60 | return wrapper 61 | 62 | 63 | def expand_q_filters(q, root_cls): 64 | ''' 65 | Takes a Q object and a model class. 66 | Recursivley passes each filter / value in the Q object tree leaf nodes 67 | through expand_eav_filter 68 | ''' 69 | new_children = [] 70 | for qi in q.children: 71 | if type(qi) is tuple: 72 | # this child is a leaf node: in Q this is a 2-tuple of: 73 | # (filter parameter, value) 74 | key, value = expand_eav_filter(root_cls, *qi) 75 | new_children.append(models.Q(**{key: value})) 76 | else: 77 | # this child is another Q node: recursify! 78 | new_children.append(expand_q_filters(qi, root_cls)) 79 | q.children = new_children 80 | return q 81 | 82 | 83 | def expand_eav_filter(model_cls, key, value): 84 | ''' 85 | Accepts a model class and a key, value. 86 | Recurisively replaces any eav filter with a subquery. 87 | 88 | For example:: 89 | 90 | key = 'eav__height' 91 | value = 5 92 | 93 | Would return:: 94 | 95 | key = 'eav_values__in' 96 | value = Values.objects.filter(value_int=5, attribute__slug='height') 97 | ''' 98 | fields = key.split('__') 99 | 100 | config_cls = getattr(model_cls, '_eav_config_cls', None) 101 | if len(fields) > 1 and config_cls and \ 102 | fields[0] == config_cls.eav_attr: 103 | slug = fields[1] 104 | gr_name = config_cls.generic_relation_attr 105 | datatype = Attribute.objects.get(slug=slug).datatype 106 | 107 | lookup = '__%s' % fields[2] if len(fields) > 2 else '' 108 | kwargs = {'value_%s%s' % (datatype, lookup): value, 109 | 'attribute__slug': slug} 110 | value = Value.objects.filter(**kwargs) 111 | 112 | return '%s__in' % gr_name, value 113 | 114 | try: 115 | field = model_cls._meta.get_field(fields[0]) 116 | except models.FieldDoesNotExist: 117 | return key, value 118 | 119 | if not field.auto_created or field.concrete: 120 | return key, value 121 | else: 122 | sub_key = '__'.join(fields[1:]) 123 | key, value = expand_eav_filter(field.model, sub_key, value) 124 | return '%s__%s' % (fields[0], key), value 125 | 126 | 127 | class EntityManager(models.Manager): 128 | ''' 129 | Our custom manager, overriding ``models.Manager`` 130 | ''' 131 | 132 | @eav_filter 133 | def filter(self, *args, **kwargs): 134 | ''' 135 | Pass *args* and *kwargs* through :func:`eav_filter`, then pass to 136 | the ``models.Manager`` filter method. 137 | ''' 138 | return super(EntityManager, self).filter(*args, **kwargs).distinct() 139 | 140 | @eav_filter 141 | def exclude(self, *args, **kwargs): 142 | ''' 143 | Pass *args* and *kwargs* through :func:`eav_filter`, then pass to 144 | the ``models.Manager`` exclude method. 145 | ''' 146 | return super(EntityManager, self).exclude(*args, **kwargs).distinct() 147 | 148 | @eav_filter 149 | def get(self, *args, **kwargs): 150 | ''' 151 | Pass *args* and *kwargs* through :func:`eav_filter`, then pass to 152 | the ``models.Manager`` get method. 153 | ''' 154 | return super(EntityManager, self).get(*args, **kwargs) 155 | 156 | def create(self, **kwargs): 157 | ''' 158 | Parse eav attributes out of *kwargs*, then try to create and save 159 | the object, then assign and save it's eav attributes. 160 | ''' 161 | config_cls = getattr(self.model, '_eav_config_cls', None) 162 | 163 | if not config_cls or config_cls.manager_only: 164 | return super(EntityManager, self).create(**kwargs) 165 | 166 | #attributes = config_cls.get_attributes() 167 | prefix = '%s__' % config_cls.eav_attr 168 | 169 | new_kwargs = {} 170 | eav_kwargs = {} 171 | for key, value in six.iteritems(kwargs): 172 | if key.startswith(prefix): 173 | eav_kwargs.update({key[len(prefix):]: value}) 174 | else: 175 | new_kwargs.update({key: value}) 176 | 177 | obj = self.model(**new_kwargs) 178 | obj_eav = getattr(obj, config_cls.eav_attr) 179 | for key, value in six.iteritems(eav_kwargs): 180 | setattr(obj_eav, key, value) 181 | obj.save() 182 | return obj 183 | 184 | def get_or_create(self, **kwargs): 185 | ''' 186 | Reproduces the behavior of get_or_create, eav friendly. 187 | ''' 188 | try: 189 | return self.get(**kwargs), False 190 | except self.model.DoesNotExist: 191 | return self.create(**kwargs), True 192 | -------------------------------------------------------------------------------- /eav/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.10 on 2016-10-13 05:56 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.sites.managers 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.db.models.manager 9 | import django.utils.timezone 10 | import eav.fields 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | initial = True 16 | 17 | dependencies = [ 18 | ('contenttypes', '0002_remove_content_type_name'), 19 | ('sites', '0001_initial'), 20 | ] 21 | 22 | operations = [ 23 | migrations.CreateModel( 24 | name='Attribute', 25 | fields=[ 26 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 27 | ('name', models.CharField(help_text='User-friendly attribute name', max_length=100, verbose_name='name')), 28 | ('slug', eav.fields.EavSlugField(help_text='Short unique attribute label', verbose_name='slug')), 29 | ('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='description')), 30 | ('type', models.CharField(blank=True, max_length=20, null=True, verbose_name='type')), 31 | ('datatype', eav.fields.EavDatatypeField(choices=[(b'text', 'Text'), (b'float', 'Float'), (b'int', 'Integer'), (b'date', 'Date'), (b'bool', 'True / False'), (b'object', 'Django Object'), (b'enum', 'Multiple Choice')], max_length=6, verbose_name='data type')), 32 | ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 33 | ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), 34 | ('required', models.BooleanField(default=False, verbose_name='required')), 35 | ], 36 | options={ 37 | 'ordering': ['name'], 38 | }, 39 | managers=[ 40 | ('objects', django.db.models.manager.Manager()), 41 | ('on_site', django.contrib.sites.managers.CurrentSiteManager()), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name='EnumGroup', 46 | fields=[ 47 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('name', models.CharField(max_length=100, unique=True, verbose_name='name')), 49 | ], 50 | ), 51 | migrations.CreateModel( 52 | name='EnumValue', 53 | fields=[ 54 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 55 | ('value', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='value')), 56 | ], 57 | ), 58 | migrations.CreateModel( 59 | name='Value', 60 | fields=[ 61 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 62 | ('entity_id', models.IntegerField()), 63 | ('value_text', models.TextField(blank=True, null=True)), 64 | ('value_float', models.FloatField(blank=True, null=True)), 65 | ('value_int', models.IntegerField(blank=True, null=True)), 66 | ('value_date', models.DateTimeField(blank=True, null=True)), 67 | ('value_bool', models.NullBooleanField()), 68 | ('generic_value_id', models.IntegerField(blank=True, null=True)), 69 | ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')), 70 | ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), 71 | ('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eav.Attribute', verbose_name='attribute')), 72 | ('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='value_entities', to='contenttypes.ContentType')), 73 | ('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='value_values', to='contenttypes.ContentType')), 74 | ('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='eav_values', to='eav.EnumValue')), 75 | ], 76 | ), 77 | migrations.AddField( 78 | model_name='enumgroup', 79 | name='enums', 80 | field=models.ManyToManyField(to='eav.EnumValue', verbose_name='enum group'), 81 | ), 82 | migrations.AddField( 83 | model_name='attribute', 84 | name='enum_group', 85 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='eav.EnumGroup', verbose_name='choice group'), 86 | ), 87 | migrations.AddField( 88 | model_name='attribute', 89 | name='site', 90 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='sites.Site', verbose_name='site'), 91 | ), 92 | migrations.AlterUniqueTogether( 93 | name='attribute', 94 | unique_together=set([('site', 'slug')]), 95 | ), 96 | ] 97 | -------------------------------------------------------------------------------- /eav/migrations/0002_auto_20161014_0157.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.10 on 2016-10-13 16:57 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 | dependencies = [ 12 | ('contenttypes', '0002_remove_content_type_name'), 13 | ('eav', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name='attribute', 19 | options={'ordering': ['content_type', 'name']}, 20 | ), 21 | migrations.AddField( 22 | model_name='attribute', 23 | name='content_type', 24 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type'), 25 | ), 26 | migrations.AddField( 27 | model_name='attribute', 28 | name='display_order', 29 | field=models.PositiveIntegerField(default=1, verbose_name='display order'), 30 | ), 31 | migrations.AlterUniqueTogether( 32 | name='attribute', 33 | unique_together=set([('site', 'content_type', 'slug')]), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /eav/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvpdev/django-eav/41e99fa366a8712d2efd7006a23bd3b1fdac082a/eav/migrations/__init__.py -------------------------------------------------------------------------------- /eav/models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ****** 21 | models 22 | ****** 23 | This module defines the four concrete, non-abstract models: 24 | 25 | * :class:`Value` 26 | * :class:`Attribute` 27 | * :class:`EnumValue` 28 | * :class:`EnumGroup` 29 | 30 | Along with the :class:`Entity` helper class. 31 | 32 | Classes 33 | ------- 34 | ''' 35 | from __future__ import unicode_literals 36 | from builtins import object 37 | 38 | 39 | from django.utils import timezone 40 | from django.db import models 41 | from django.core.exceptions import ValidationError 42 | from django.utils.translation import ugettext_lazy as _ 43 | from django.contrib.contenttypes.models import ContentType 44 | from django.contrib.contenttypes import fields as generic 45 | from django.contrib.sites.models import Site 46 | from django.contrib.sites.managers import CurrentSiteManager 47 | from django.conf import settings 48 | from django.utils.encoding import python_2_unicode_compatible 49 | 50 | from .validators import * 51 | from .fields import EavSlugField, EavDatatypeField 52 | 53 | 54 | class EnumValue(models.Model): 55 | ''' 56 | *EnumValue* objects are the value 'choices' to multiple choice 57 | *TYPE_ENUM* :class:`Attribute` objects. 58 | 59 | They have only one field, *value*, a``CharField`` that must be unique. 60 | 61 | For example: 62 | 63 | >>> yes = EnumValue.objects.create(value='Yes') # doctest: SKIP 64 | >>> no = EnumValue.objects.create(value='No') 65 | >>> unkown = EnumValue.objects.create(value='Unkown') 66 | 67 | >>> ynu = EnumGroup.objects.create(name='Yes / No / Unkown') 68 | >>> ynu.enums.add(yes, no, unkown) 69 | 70 | >>> Attribute.objects.create(name='Has Fever?', 71 | ... datatype=Attribute.TYPE_ENUM, 72 | ... enum_group=ynu) 73 | 74 | 75 | .. note:: 76 | The same *EnumValue* objects should be reused within multiple 77 | *EnumGroups*. For example, if you have one *EnumGroup* 78 | called: *Yes / No / Unkown* and another called *Yes / No / 79 | Not applicable*, you should only have a total of four *EnumValues* 80 | objects, as you should have used the same *Yes* and *No* *EnumValues* 81 | for both *EnumGroups*. 82 | ''' 83 | value = models.CharField(_(u"value"), db_index=True, 84 | unique=True, max_length=50) 85 | 86 | @python_2_unicode_compatible 87 | def __str__(self): 88 | return self.value 89 | 90 | 91 | class EnumGroup(models.Model): 92 | ''' 93 | *EnumGroup* objects have two fields- a *name* ``CharField`` and *enums*, 94 | a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes 95 | with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*. 96 | 97 | See :class:`EnumValue` for an example. 98 | 99 | ''' 100 | name = models.CharField(_(u"name"), unique=True, max_length=100) 101 | 102 | enums = models.ManyToManyField(EnumValue, verbose_name=_(u"enum group")) 103 | 104 | @python_2_unicode_compatible 105 | def __str__(self): 106 | return self.name 107 | 108 | 109 | class Attribute(models.Model): 110 | ''' 111 | Putting the **A** in *EAV*. This holds the attributes, or concepts. 112 | Examples of possible *Attributes*: color, height, weight, 113 | number of children, number of patients, has fever?, etc... 114 | 115 | Each attribute has a name, and a description, along with a slug that must 116 | be unique. If you don't provide a slug, a default slug (derived from 117 | name), will be created. 118 | 119 | The *required* field is a boolean that indicates whether this EAV attribute 120 | is required for entities to which it applies. It defaults to *False*. 121 | 122 | .. warning:: 123 | Just like a normal model field that is required, you will not be able 124 | to save or create any entity object for which this attribute applies, 125 | without first setting this EAV attribute. 126 | 127 | There are 7 possible values for datatype: 128 | 129 | * int (TYPE_INT) 130 | * float (TYPE_FLOAT) 131 | * text (TYPE_TEXT) 132 | * date (TYPE_DATE) 133 | * bool (TYPE_BOOLEAN) 134 | * object (TYPE_OBJECT) 135 | * enum (TYPE_ENUM) 136 | 137 | Examples: 138 | 139 | >>> Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT) 140 | 141 | 142 | >>> Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT) 143 | 144 | 145 | >>> yes = EnumValue.objects.create(value='yes') 146 | >>> no = EnumValue.objects.create(value='no') 147 | >>> unkown = EnumValue.objects.create(value='unkown') 148 | >>> ynu = EnumGroup.objects.create(name='Yes / No / Unkown') 149 | >>> ynu.enums.add(yes, no, unkown) 150 | >>> Attribute.objects.create(name='Has Fever?', 151 | ... datatype=Attribute.TYPE_ENUM, 152 | ... enum_group=ynu) 153 | 154 | 155 | .. warning:: Once an Attribute has been used by an entity, you can not 156 | change it's datatype. 157 | ''' 158 | 159 | class Meta(object): 160 | ordering = ['content_type', 'name'] 161 | unique_together = ('site', 'content_type', 'slug') 162 | 163 | TYPE_TEXT = 'text' 164 | TYPE_FLOAT = 'float' 165 | TYPE_INT = 'int' 166 | TYPE_DATE = 'date' 167 | TYPE_BOOLEAN = 'bool' 168 | TYPE_OBJECT = 'object' 169 | TYPE_ENUM = 'enum' 170 | 171 | DATATYPE_CHOICES = ( 172 | (TYPE_TEXT, _(u"Text")), 173 | (TYPE_FLOAT, _(u"Float")), 174 | (TYPE_INT, _(u"Integer")), 175 | (TYPE_DATE, _(u"Date")), 176 | (TYPE_BOOLEAN, _(u"True / False")), 177 | (TYPE_OBJECT, _(u"Django Object")), 178 | (TYPE_ENUM, _(u"Multiple Choice")), 179 | ) 180 | 181 | name = models.CharField(_(u"name"), max_length=100, 182 | help_text=_(u"User-friendly attribute name")) 183 | 184 | content_type = models.ForeignKey(ContentType, 185 | blank=True, null=True, 186 | verbose_name=_(u"content type")) 187 | 188 | site = models.ForeignKey(Site, verbose_name=_(u"site"), 189 | default=settings.SITE_ID) 190 | 191 | slug = EavSlugField(_(u"slug"), max_length=50, db_index=True, 192 | help_text=_(u"Short unique attribute label")) 193 | 194 | description = models.CharField(_(u"description"), max_length=256, 195 | blank=True, null=True, 196 | help_text=_(u"Short description")) 197 | 198 | enum_group = models.ForeignKey(EnumGroup, verbose_name=_(u"choice group"), 199 | blank=True, null=True) 200 | 201 | type = models.CharField(_(u"type"), max_length=20, blank=True, null=True) 202 | 203 | @property 204 | def help_text(self): 205 | return self.description 206 | 207 | datatype = EavDatatypeField(_(u"data type"), max_length=6, 208 | choices=DATATYPE_CHOICES) 209 | 210 | created = models.DateTimeField(_(u"created"), default=timezone.now, 211 | editable=False) 212 | 213 | modified = models.DateTimeField(_(u"modified"), auto_now=True) 214 | 215 | required = models.BooleanField(_(u"required"), default=False) 216 | 217 | display_order = models.PositiveIntegerField(_(u"display order"), default=1) 218 | 219 | objects = models.Manager() 220 | on_site = CurrentSiteManager() 221 | 222 | def get_validators(self): 223 | ''' 224 | Returns the appropriate validator function from :mod:`~eav.validators` 225 | as a list (of length one) for the datatype. 226 | 227 | .. note:: 228 | The reason it returns it as a list, is eventually we may want this 229 | method to look elsewhere for additional attribute specific 230 | validators to return as well as the default, built-in one. 231 | ''' 232 | DATATYPE_VALIDATORS = { 233 | 'text': validate_text, 234 | 'float': validate_float, 235 | 'int': validate_int, 236 | 'date': validate_date, 237 | 'bool': validate_bool, 238 | 'object': validate_object, 239 | 'enum': validate_enum, 240 | } 241 | 242 | validation_function = DATATYPE_VALIDATORS[self.datatype] 243 | return [validation_function] 244 | 245 | def validate_value(self, value): 246 | ''' 247 | Check *value* against the validators returned by 248 | :meth:`get_validators` for this attribute. 249 | ''' 250 | for validator in self.get_validators(): 251 | validator(value) 252 | if self.datatype == self.TYPE_ENUM: 253 | if value not in self.enum_group.enums.all(): 254 | raise ValidationError(_(u"%(enum)s is not a valid choice " 255 | u"for %(attr)s") % \ 256 | {'enum': value, 'attr': self}) 257 | 258 | def save(self, *args, **kwargs): 259 | ''' 260 | Saves the Attribute and auto-generates a slug field if one wasn't 261 | provided. 262 | ''' 263 | if not self.slug: 264 | self.slug = EavSlugField.create_slug_from_name(self.name) 265 | self.full_clean() 266 | super(Attribute, self).save(*args, **kwargs) 267 | 268 | def clean(self): 269 | ''' 270 | Validates the attribute. Will raise ``ValidationError`` if 271 | the attribute's datatype is *TYPE_ENUM* and enum_group is not set, 272 | or if the attribute is not *TYPE_ENUM* and the enum group is set. 273 | ''' 274 | if self.datatype == self.TYPE_ENUM and not self.enum_group: 275 | raise ValidationError(_( 276 | u"You must set the choice group for multiple choice" \ 277 | u"attributes")) 278 | 279 | if self.datatype != self.TYPE_ENUM and self.enum_group: 280 | raise ValidationError(_( 281 | u"You can only assign a choice group to multiple choice " \ 282 | u"attributes")) 283 | 284 | def get_choices(self): 285 | ''' 286 | Returns a query set of :class:`EnumValue` objects for this attribute. 287 | Returns None if the datatype of this attribute is not *TYPE_ENUM*. 288 | ''' 289 | if not self.datatype == Attribute.TYPE_ENUM: 290 | return None 291 | return self.enum_group.enums.all() 292 | 293 | def save_value(self, entity, value): 294 | ''' 295 | Called with *entity*, any django object registered with eav, and 296 | *value*, the :class:`Value` this attribute for *entity* should 297 | be set to. 298 | 299 | If a :class:`Value` object for this *entity* and attribute doesn't 300 | exist, one will be created. 301 | 302 | .. note:: 303 | If *value* is None and a :class:`Value` object exists for this 304 | Attribute and *entity*, it will delete that :class:`Value` object. 305 | ''' 306 | ct = ContentType.objects.get_for_model(entity) 307 | try: 308 | value_obj = self.value_set.get(entity_ct=ct, 309 | entity_id=entity.pk, 310 | attribute=self) 311 | except Value.DoesNotExist: 312 | if value == None or value == '': 313 | return 314 | value_obj = Value.objects.create(entity_ct=ct, 315 | entity_id=entity.pk, 316 | attribute=self) 317 | if value == None or value == '': 318 | value_obj.delete() 319 | return 320 | 321 | if value != value_obj.value: 322 | value_obj.value = value 323 | value_obj.save() 324 | 325 | @python_2_unicode_compatible 326 | def __str__(self): 327 | return u"%s.%s (%s)" % (self.content_type, self.name, self.get_datatype_display()) 328 | 329 | 330 | class Value(models.Model): 331 | ''' 332 | Putting the **V** in *EAV*. This model stores the value for one particular 333 | :class:`Attribute` for some entity. 334 | 335 | As with most EAV implementations, most of the columns of this model will 336 | be blank, as onle one *value_* field will be used. 337 | 338 | Example: 339 | 340 | >>> import eav 341 | >>> from django.contrib.auth.models import User 342 | >>> eav.register(User) 343 | >>> u = User.objects.create(username='crazy_dev_user') 344 | >>> a = Attribute.objects.create(name='Favorite Drink', datatype='text', 345 | ... slug='fav_drink') 346 | >>> Value.objects.create(entity=u, attribute=a, value_text='red bull') 347 | 348 | ''' 349 | 350 | entity_ct = models.ForeignKey(ContentType, related_name='value_entities') 351 | entity_id = models.IntegerField() 352 | entity = generic.GenericForeignKey(ct_field='entity_ct', 353 | fk_field='entity_id') 354 | 355 | value_text = models.TextField(blank=True, null=True) 356 | value_float = models.FloatField(blank=True, null=True) 357 | value_int = models.IntegerField(blank=True, null=True) 358 | value_date = models.DateTimeField(blank=True, null=True) 359 | value_bool = models.NullBooleanField(blank=True, null=True) 360 | value_enum = models.ForeignKey(EnumValue, blank=True, null=True, 361 | related_name='eav_values') 362 | 363 | generic_value_id = models.IntegerField(blank=True, null=True) 364 | generic_value_ct = models.ForeignKey(ContentType, blank=True, null=True, 365 | related_name='value_values') 366 | value_object = generic.GenericForeignKey(ct_field='generic_value_ct', 367 | fk_field='generic_value_id') 368 | 369 | created = models.DateTimeField(_(u"created"), default=timezone.now) 370 | modified = models.DateTimeField(_(u"modified"), auto_now=True) 371 | 372 | attribute = models.ForeignKey(Attribute, db_index=True, 373 | verbose_name=_(u"attribute")) 374 | 375 | def save(self, *args, **kwargs): 376 | ''' 377 | Validate and save this value 378 | ''' 379 | self.full_clean() 380 | super(Value, self).save(*args, **kwargs) 381 | 382 | def clean(self): 383 | ''' 384 | Raises ``ValidationError`` if this value's attribute is *TYPE_ENUM* 385 | and value_enum is not a valid choice for this value's attribute. 386 | ''' 387 | if self.attribute.datatype == Attribute.TYPE_ENUM and \ 388 | self.value_enum: 389 | if self.value_enum not in self.attribute.enum_group.enums.all(): 390 | raise ValidationError(_(u"%(choice)s is not a valid " \ 391 | u"choice for %s(attribute)") % \ 392 | {'choice': self.value_enum, 393 | 'attribute': self.attribute}) 394 | 395 | def _get_value(self): 396 | ''' 397 | Return the python object this value is holding 398 | ''' 399 | return getattr(self, 'value_%s' % self.attribute.datatype) 400 | 401 | def _set_value(self, new_value): 402 | ''' 403 | Set the object this value is holding 404 | ''' 405 | setattr(self, 'value_%s' % self.attribute.datatype, new_value) 406 | 407 | value = property(_get_value, _set_value) 408 | 409 | @python_2_unicode_compatible 410 | def __str__(self): 411 | return u"%s - %s: \"%s\"" % (self.entity, self.attribute.name, 412 | self.value) 413 | 414 | 415 | class Entity(object): 416 | ''' 417 | The helper class that will be attached to any entity registered with 418 | eav. 419 | ''' 420 | 421 | def __init__(self, instance): 422 | ''' 423 | Set self.model equal to the instance of the model that we're attached 424 | to. Also, store the content type of that instance. 425 | ''' 426 | self.model = instance 427 | self.ct = ContentType.objects.get_for_model(instance) 428 | 429 | def __getattr__(self, name): 430 | ''' 431 | Tha magic getattr helper. This is called whenevery you do 432 | this_instance. 433 | 434 | Checks if *name* is a valid slug for attributes available to this 435 | instances. If it is, tries to lookup the :class:`Value` with that 436 | attribute slug. If there is one, it returns the value of the 437 | class:`Value` object, otherwise it hasn't been set, so it returns 438 | None. 439 | ''' 440 | if not name.startswith('_'): 441 | try: 442 | attribute = self.get_attribute_by_slug(name) 443 | except Attribute.DoesNotExist: 444 | raise AttributeError(_(u"%(obj)s has no EAV attribute named " \ 445 | u"'%(attr)s'") % \ 446 | {'obj': self.model, 'attr': name}) 447 | try: 448 | return self.get_value_by_attribute(attribute).value 449 | except Value.DoesNotExist: 450 | return None 451 | return getattr(super(Entity, self), name) 452 | 453 | def get_all_attributes(self): 454 | ''' 455 | Return a query set of all :class:`Attribute` objects that can be set 456 | for this entity. 457 | ''' 458 | return self.model._eav_config_cls.get_attributes().filter( 459 | models.Q(content_type__isnull=True) | models.Q(content_type=self.ct)).order_by('display_order') 460 | 461 | def _hasattr(self, attribute_slug): 462 | ''' 463 | Since we override __getattr__ with a backdown to the database, this exists as a way of 464 | checking whether a user has set a real attribute on ourselves, without going to the db if not 465 | ''' 466 | return attribute_slug in self.__dict__ 467 | 468 | def _getattr(self, attribute_slug): 469 | ''' 470 | Since we override __getattr__ with a backdown to the database, this exists as a way of 471 | getting the value a user set for one of our attributes, without going to the db to check 472 | ''' 473 | return self.__dict__[attribute_slug] 474 | 475 | def save(self): 476 | ''' 477 | Saves all the EAV values that have been set on this entity. 478 | ''' 479 | for attribute in self.get_all_attributes(): 480 | if self._hasattr(attribute.slug): 481 | attribute_value = self._getattr(attribute.slug) 482 | attribute.save_value(self.model, attribute_value) 483 | 484 | def validate_attributes(self): 485 | ''' 486 | Called before :meth:`save`, first validate all the entity values to 487 | make sure they can be created / saved cleanly. 488 | 489 | Raise ``ValidationError`` if they can't be. 490 | ''' 491 | values_dict = self.get_values_dict() 492 | 493 | for attribute in self.get_all_attributes(): 494 | value = None 495 | if self._hasattr(attribute.slug): 496 | value = self._getattr(attribute.slug) 497 | else: 498 | value = values_dict.get(attribute.slug, None) 499 | 500 | if value is None: 501 | if attribute.required: 502 | raise ValidationError(_(u"%(attr)s EAV field cannot " \ 503 | u"be blank") % \ 504 | {'attr': attribute.slug}) 505 | else: 506 | try: 507 | attribute.validate_value(value) 508 | except ValidationError as e: 509 | raise ValidationError(_(u"%(attr)s EAV field %(err)s") % \ 510 | {'attr': attribute.slug, 511 | 'err': e}) 512 | 513 | def get_values_dict(self): 514 | values_dict = dict() 515 | for value in self.get_values(): 516 | values_dict[value.attribute.slug] = value.value 517 | 518 | return values_dict 519 | 520 | def get_values(self): 521 | ''' 522 | Get all set :class:`Value` objects for self.model 523 | ''' 524 | return Value.objects.filter(entity_ct=self.ct, 525 | entity_id=self.model.pk).select_related() 526 | 527 | def get_all_attribute_slugs(self): 528 | ''' 529 | Returns a list of slugs for all attributes available to this entity. 530 | ''' 531 | return self.get_all_attributes().values_list('slug', flat=True) 532 | 533 | def get_attribute_by_slug(self, slug): 534 | ''' 535 | Returns a single :class:`Attribute` with *slug* 536 | ''' 537 | return self.get_all_attributes().get(slug=slug) 538 | 539 | def get_value_by_attribute(self, attribute): 540 | ''' 541 | Returns a single :class:`Value` for *attribute* 542 | ''' 543 | return self.get_values().get(attribute=attribute) 544 | 545 | def __iter__(self): 546 | ''' 547 | Iterate over set eav values. 548 | 549 | This would allow you to do: 550 | 551 | >>> for i in m.eav: print i # doctest:SKIP 552 | ''' 553 | return iter(self.get_values()) 554 | 555 | @staticmethod 556 | def post_save_handler(sender, *args, **kwargs): 557 | ''' 558 | Post save handler attached to self.model. Calls :meth:`save` when 559 | the model instance we are attached to is saved. 560 | ''' 561 | instance = kwargs['instance'] 562 | entity = getattr(instance, instance._eav_config_cls.eav_attr) 563 | entity.save() 564 | 565 | @staticmethod 566 | def pre_save_handler(sender, *args, **kwargs): 567 | ''' 568 | Pre save handler attached to self.model. Called before the 569 | model instance we are attached to is saved. This allows us to call 570 | :meth:`validate_attributes` before the entity is saved. 571 | ''' 572 | instance = kwargs['instance'] 573 | entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr) 574 | entity.validate_attributes() 575 | 576 | if 'django_nose' in settings.INSTALLED_APPS: 577 | ''' 578 | The django_nose test runner won't automatically create our Patient model 579 | database table which is required for tests, unless we import it here. 580 | 581 | Please, someone tell me a better way to do this. 582 | ''' 583 | from .tests.models import Patient, Encounter 584 | -------------------------------------------------------------------------------- /eav/registry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ######## 21 | registry 22 | ######## 23 | 24 | This contains the registry classes 25 | 26 | Classes 27 | ------- 28 | ''' 29 | from builtins import object 30 | 31 | from django.db.utils import DatabaseError 32 | from django.db.models.signals import pre_init, post_init, pre_save, post_save 33 | from django.contrib.contenttypes import fields as generic 34 | 35 | from .managers import EntityManager 36 | from .models import Entity, Attribute, Value 37 | 38 | 39 | class EavConfig(object): 40 | ''' 41 | The default EevConfig class used if it is not overriden on registration. 42 | This is where all the default eav attribute names are defined. 43 | ''' 44 | 45 | manager_attr = 'objects' 46 | manager_only = False 47 | eav_attr = 'eav' 48 | generic_relation_attr = 'eav_values' 49 | generic_relation_related_name = None 50 | 51 | @classmethod 52 | def get_attributes(cls): 53 | ''' 54 | By default, all :class:`~eav.models.Attribute` object apply to an 55 | entity, unless you provide a custom EavConfig class overriding this. 56 | ''' 57 | return Attribute.on_site.all() 58 | 59 | 60 | class Registry(object): 61 | ''' 62 | Handles registration through the 63 | :meth:`register` and :meth:`unregister` methods. 64 | ''' 65 | 66 | @staticmethod 67 | def register(model_cls, config_cls=None): 68 | ''' 69 | Registers *model_cls* with eav. You can pass an optional *config_cls* 70 | to override the EavConfig defaults. 71 | 72 | .. note:: 73 | Multiple registrations for the same entity are harmlessly ignored. 74 | ''' 75 | if hasattr(model_cls, '_eav_config_cls'): 76 | return 77 | 78 | if config_cls is EavConfig or config_cls is None: 79 | config_cls = type("%sConfig" % model_cls.__name__, 80 | (EavConfig,), {}) 81 | 82 | # set _eav_config_cls on the model so we can access it there 83 | setattr(model_cls, '_eav_config_cls', config_cls) 84 | 85 | reg = Registry(model_cls) 86 | reg._register_self() 87 | 88 | @staticmethod 89 | def unregister(model_cls): 90 | ''' 91 | Unregisters *model_cls* with eav. 92 | 93 | .. note:: 94 | Unregistering a class not already registered is harmlessly ignored. 95 | ''' 96 | if not getattr(model_cls, '_eav_config_cls', None): 97 | return 98 | reg = Registry(model_cls) 99 | reg._unregister_self() 100 | 101 | delattr(model_cls, '_eav_config_cls') 102 | 103 | @staticmethod 104 | def attach_eav_attr(sender, *args, **kwargs): 105 | ''' 106 | Attache EAV Entity toolkit to an instance after init. 107 | ''' 108 | instance = kwargs['instance'] 109 | config_cls = instance.__class__._eav_config_cls 110 | setattr(instance, config_cls.eav_attr, Entity(instance)) 111 | 112 | def __init__(self, model_cls): 113 | ''' 114 | Set the *model_cls* and its *config_cls* 115 | ''' 116 | self.model_cls = model_cls 117 | self.config_cls = model_cls._eav_config_cls 118 | 119 | def _attach_manager(self): 120 | ''' 121 | Attach the manager to *manager_attr* specified in *config_cls* 122 | ''' 123 | # save the old manager if the attribute name conflict with the new one 124 | if hasattr(self.model_cls, self.config_cls.manager_attr): 125 | mgr = getattr(self.model_cls, self.config_cls.manager_attr) 126 | self.config_cls.old_mgr = mgr 127 | 128 | # attache the new manager to the model 129 | mgr = EntityManager() 130 | mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr) 131 | 132 | def _detach_manager(self): 133 | ''' 134 | Detach the manager, and reatach the previous manager (if there was one) 135 | ''' 136 | delattr(self.model_cls, self.config_cls.manager_attr) 137 | if hasattr(self.config_cls, 'old_mgr'): 138 | self.config_cls.old_mgr \ 139 | .contribute_to_class(self.model_cls, 140 | self.config_cls.manager_attr) 141 | 142 | def _attach_signals(self): 143 | ''' 144 | Attach all signals for eav 145 | ''' 146 | post_init.connect(Registry.attach_eav_attr, sender=self.model_cls) 147 | pre_save.connect(Entity.pre_save_handler, sender=self.model_cls) 148 | post_save.connect(Entity.post_save_handler, sender=self.model_cls) 149 | 150 | def _detach_signals(self): 151 | ''' 152 | Detach all signals for eav 153 | ''' 154 | post_init.disconnect(Registry.attach_eav_attr, sender=self.model_cls) 155 | pre_save.disconnect(Entity.pre_save_handler, sender=self.model_cls) 156 | post_save.disconnect(Entity.post_save_handler, sender=self.model_cls) 157 | 158 | def _attach_generic_relation(self): 159 | ''' 160 | Set up the generic relation for the entity 161 | ''' 162 | rel_name = self.config_cls.generic_relation_related_name or \ 163 | self.model_cls.__name__ 164 | 165 | gr_name = self.config_cls.generic_relation_attr.lower() 166 | generic_relation = \ 167 | generic.GenericRelation(Value, 168 | object_id_field='entity_id', 169 | content_type_field='entity_ct', 170 | related_query_name=rel_name) 171 | generic_relation.contribute_to_class(self.model_cls, gr_name) 172 | 173 | def _detach_generic_relation(self): 174 | ''' 175 | Remove the generic relation from the entity 176 | ''' 177 | gen_rel_field = self.config_cls.generic_relation_attr.lower() 178 | for field in self.model_cls._meta.local_many_to_many: 179 | if field.name == gen_rel_field: 180 | self.model_cls._meta.local_many_to_many.remove(field) 181 | break 182 | 183 | delattr(self.model_cls, gen_rel_field) 184 | 185 | def _register_self(self): 186 | ''' 187 | Call the necessary registration methods 188 | ''' 189 | self._attach_manager() 190 | 191 | if not self.config_cls.manager_only: 192 | self._attach_signals() 193 | self._attach_generic_relation() 194 | 195 | def _unregister_self(self): 196 | ''' 197 | Call the necessary unregistration methods 198 | ''' 199 | self._detach_manager() 200 | 201 | if not self.config_cls.manager_only: 202 | self._detach_signals() 203 | self._detach_generic_relation() 204 | -------------------------------------------------------------------------------- /eav/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .registry import * 2 | from .limiting_attributes import * 3 | from .data_validation import * 4 | from .misc_models import * 5 | from .queries import * 6 | -------------------------------------------------------------------------------- /eav/tests/data_validation.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from django.test import TestCase 4 | from django.core.exceptions import ValidationError 5 | from django.contrib.auth.models import User 6 | 7 | import eav 8 | from ..registry import EavConfig 9 | from ..models import Attribute, Value, EnumValue, EnumGroup 10 | 11 | from .models import Patient, Encounter 12 | 13 | 14 | class DataValidation(TestCase): 15 | 16 | def setUp(self): 17 | eav.register(Patient) 18 | 19 | Attribute.objects.create(name='Age', datatype=Attribute.TYPE_INT) 20 | Attribute.objects.create(name='DoB', datatype=Attribute.TYPE_DATE) 21 | Attribute.objects.create(name='Height', datatype=Attribute.TYPE_FLOAT) 22 | Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT) 23 | Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN) 24 | Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT) 25 | 26 | def tearDown(self): 27 | eav.unregister(Patient) 28 | 29 | def test_required_field(self): 30 | p = Patient(name='Bob') 31 | p.eav.age = 5 32 | p.save() 33 | 34 | Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True) 35 | p.eav.age = 6 36 | self.assertRaises(ValidationError, p.save) 37 | p = Patient.objects.get(name='Bob') 38 | self.assertEqual(p.eav.age, 5) 39 | p.eav.weight = 23 40 | p.save() 41 | p = Patient.objects.get(name='Bob') 42 | self.assertEqual(p.eav.weight, 23) 43 | 44 | def test_create_required_field(self): 45 | Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True) 46 | self.assertRaises(ValidationError, 47 | Patient.objects.create, 48 | name='Joe', eav__age=5) 49 | self.assertEqual(Patient.objects.count(), 0) 50 | self.assertEqual(Value.objects.count(), 0) 51 | 52 | p = Patient.objects.create(name='Joe', eav__weight=2, eav__age=5) 53 | self.assertEqual(Patient.objects.count(), 1) 54 | self.assertEqual(Value.objects.count(), 2) 55 | 56 | def test_validation_error_create(self): 57 | self.assertRaises(ValidationError, 58 | Patient.objects.create, 59 | name='Joe', eav__age='df') 60 | self.assertEqual(Patient.objects.count(), 0) 61 | self.assertEqual(Value.objects.count(), 0) 62 | 63 | def test_bad_slug(self): 64 | a = Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) 65 | a.slug = 'Color' 66 | self.assertRaises(ValidationError, a.save) 67 | a.slug = '1st' 68 | self.assertRaises(ValidationError, a.save) 69 | a.slug = '_st' 70 | self.assertRaises(ValidationError, a.save) 71 | 72 | def test_changing_datatypes(self): 73 | a = Attribute.objects.create(name='Color', datatype=Attribute.TYPE_INT) 74 | a.datatype = Attribute.TYPE_TEXT 75 | a.save() 76 | Patient.objects.create(name='Bob', eav__color='brown') 77 | a.datatype = Attribute.TYPE_INT 78 | self.assertRaises(ValidationError, a.save) 79 | 80 | def test_int_validation(self): 81 | p = Patient.objects.create(name='Joe') 82 | p.eav.age = 'bad' 83 | self.assertRaises(ValidationError, p.save) 84 | p.eav.age = 15 85 | p.save() 86 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15) 87 | 88 | def test_date_validation(self): 89 | p = Patient.objects.create(name='Joe') 90 | p.eav.dob = 'bad' 91 | self.assertRaises(ValidationError, p.save) 92 | p.eav.dob = 15 93 | self.assertRaises(ValidationError, p.save) 94 | now = timezone.now() 95 | now = timezone.datetime(year=now.year, month=now.month, day=now.day, 96 | hour=now.hour, minute=now.minute, second=now.second) 97 | p.eav.dob = now 98 | p.save() 99 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob, now) 100 | today = timezone.today() 101 | p.eav.dob = today 102 | p.save() 103 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today) 104 | 105 | def test_float_validation(self): 106 | p = Patient.objects.create(name='Joe') 107 | p.eav.height = 'bad' 108 | self.assertRaises(ValidationError, p.save) 109 | p.eav.height = 15 110 | p.save() 111 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15) 112 | p.eav.height='2.3' 113 | p.save() 114 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3) 115 | 116 | def test_text_validation(self): 117 | p = Patient.objects.create(name='Joe') 118 | p.eav.city = 5 119 | self.assertRaises(ValidationError, p.save) 120 | p.eav.city = 'El Dorado' 121 | p.save() 122 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, 'El Dorado') 123 | 124 | def test_bool_validation(self): 125 | p = Patient.objects.create(name='Joe') 126 | p.eav.pregnant = 5 127 | self.assertRaises(ValidationError, p.save) 128 | p.eav.pregnant = True 129 | p.save() 130 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True) 131 | 132 | def test_object_validation(self): 133 | p = Patient.objects.create(name='Joe') 134 | p.eav.user = 5 135 | self.assertRaises(ValidationError, p.save) 136 | p.eav.user = object 137 | self.assertRaises(ValidationError, p.save) 138 | p.eav.user = User(username='joe') 139 | self.assertRaises(ValidationError, p.save) 140 | u = User.objects.create(username='joe') 141 | p.eav.user = u 142 | p.save() 143 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u) 144 | 145 | def test_enum_validation(self): 146 | yes = EnumValue.objects.create(value='yes') 147 | no = EnumValue.objects.create(value='no') 148 | unkown = EnumValue.objects.create(value='unkown') 149 | green = EnumValue.objects.create(value='green') 150 | ynu = EnumGroup.objects.create(name='Yes / No / Unknown') 151 | ynu.enums.add(yes) 152 | ynu.enums.add(no) 153 | ynu.enums.add(unkown) 154 | Attribute.objects.create(name='Fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu) 155 | 156 | p = Patient.objects.create(name='Joe') 157 | p.eav.fever = 5 158 | self.assertRaises(ValidationError, p.save) 159 | p.eav.fever = object 160 | self.assertRaises(ValidationError, p.save) 161 | p.eav.fever = 'yes' 162 | self.assertRaises(ValidationError, p.save) 163 | p.eav.fever = green 164 | self.assertRaises(ValidationError, p.save) 165 | p.eav.fever = EnumValue(value='yes') 166 | self.assertRaises(ValidationError, p.save) 167 | p.eav.fever = no 168 | p.save() 169 | self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no) 170 | 171 | def test_enum_datatype_without_enum_group(self): 172 | a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM) 173 | self.assertRaises(ValidationError, a.save) 174 | yes = EnumValue.objects.create(value='yes') 175 | no = EnumValue.objects.create(value='no') 176 | unkown = EnumValue.objects.create(value='unkown') 177 | ynu = EnumGroup.objects.create(name='Yes / No / Unknown') 178 | ynu.enums.add(yes) 179 | ynu.enums.add(no) 180 | ynu.enums.add(unkown) 181 | a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM, enum_group=ynu) 182 | a.save() 183 | 184 | def test_enum_group_on_other_datatype(self): 185 | yes = EnumValue.objects.create(value='yes') 186 | no = EnumValue.objects.create(value='no') 187 | unkown = EnumValue.objects.create(value='unkown') 188 | ynu = EnumGroup.objects.create(name='Yes / No / Unknown') 189 | ynu.enums.add(yes) 190 | ynu.enums.add(no) 191 | ynu.enums.add(unkown) 192 | a = Attribute(name='color', datatype=Attribute.TYPE_TEXT, enum_group=ynu) 193 | self.assertRaises(ValidationError, a.save) 194 | -------------------------------------------------------------------------------- /eav/tests/limiting_attributes.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | import eav 4 | from ..registry import EavConfig 5 | from ..models import Attribute, Value 6 | 7 | from .models import Patient, Encounter 8 | 9 | 10 | class LimittingAttributes(TestCase): 11 | 12 | def setUp(self): 13 | class EncounterEavConfig(EavConfig): 14 | manager_attr = 'eav_objects' 15 | eav_attr = 'eav_field' 16 | generic_relation_attr = 'encounter_eav_values' 17 | generic_relation_related_name = 'encounters' 18 | 19 | @classmethod 20 | def get_attributes(cls): 21 | return Attribute.objects.filter(slug__contains='a') 22 | 23 | eav.register(Encounter, EncounterEavConfig) 24 | eav.register(Patient) 25 | 26 | Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) 27 | Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) 28 | Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) 29 | 30 | def tearDown(self): 31 | eav.unregister(Encounter) 32 | eav.unregister(Patient) 33 | 34 | def test_get_attribute_querysets(self): 35 | self.assertEqual(Patient._eav_config_cls \ 36 | .get_attributes().count(), 3) 37 | self.assertEqual(Encounter._eav_config_cls \ 38 | .get_attributes().count(), 1) 39 | 40 | def test_setting_attributes(self): 41 | p = Patient.objects.create(name='Jon') 42 | e = Encounter.objects.create(patient=p, num=1) 43 | p.eav.age = 3 44 | p.eav.height = 2.3 45 | p.save() 46 | e.eav_field.age = 4 47 | e.eav_field.height = 4.5 48 | e.save() 49 | self.assertEqual(Value.objects.count(), 3) 50 | p = Patient.objects.get(name='Jon') 51 | self.assertEqual(p.eav.age, 3) 52 | self.assertEqual(p.eav.height, 2.3) 53 | e = Encounter.objects.get(num=1) 54 | self.assertEqual(e.eav_field.age, 4) 55 | self.assertFalse(hasattr(e.eav_field, 'height')) 56 | -------------------------------------------------------------------------------- /eav/tests/misc_models.py: -------------------------------------------------------------------------------- 1 | from builtins import str 2 | from django.test import TestCase 3 | 4 | from ..models import EnumGroup, Attribute, Value 5 | 6 | import eav 7 | from .models import Patient 8 | 9 | 10 | class MiscModels(TestCase): 11 | 12 | def test_enumgroup_unicode(self): 13 | name = 'Yes / No' 14 | e = EnumGroup.objects.create(name=name) 15 | self.assertEqual(str(e), name) 16 | 17 | def test_attribute_help_text(self): 18 | desc = 'Patient Age' 19 | a = Attribute.objects.create(name='age', description=desc, datatype=Attribute.TYPE_INT) 20 | self.assertEqual(a.help_text, desc) 21 | 22 | def test_setting_to_none_deletes_value(self): 23 | eav.register(Patient) 24 | Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) 25 | p = Patient.objects.create(name='Bob', eav__age=5) 26 | self.assertEqual(Value.objects.count(), 1) 27 | p.eav.age = None 28 | p.save() 29 | self.assertEqual(Value.objects.count(), 0) 30 | -------------------------------------------------------------------------------- /eav/tests/models.py: -------------------------------------------------------------------------------- 1 | from builtins import object 2 | from django.db import models 3 | from ..decorators import register_eav 4 | 5 | class Patient(models.Model): 6 | class Meta(object): 7 | app_label = 'eav' 8 | 9 | name = models.CharField(max_length=12) 10 | 11 | def __unicode__(self): 12 | return self.name 13 | 14 | class Encounter(models.Model): 15 | class Meta(object): 16 | app_label = 'eav' 17 | 18 | num = models.PositiveSmallIntegerField() 19 | patient = models.ForeignKey(Patient) 20 | 21 | def __unicode__(self): 22 | return '%s: encounter num %d' % (self.patient, self.num) 23 | 24 | @register_eav() 25 | class ExampleModel(models.Model): 26 | class Meta(object): 27 | app_label = 'eav' 28 | 29 | name = models.CharField(max_length=12) 30 | 31 | def __unicode__(self): 32 | return self.name 33 | -------------------------------------------------------------------------------- /eav/tests/queries.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.db.models import Q 3 | from django.contrib.auth.models import User 4 | 5 | from ..registry import EavConfig 6 | from ..models import EnumValue, EnumGroup, Attribute, Value 7 | 8 | import eav 9 | from .models import Patient, Encounter 10 | 11 | 12 | class Queries(TestCase): 13 | 14 | def setUp(self): 15 | eav.register(Encounter) 16 | eav.register(Patient) 17 | 18 | Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) 19 | Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) 20 | Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) 21 | Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT) 22 | Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT) 23 | 24 | self.yes = EnumValue.objects.create(value='yes') 25 | self.no = EnumValue.objects.create(value='no') 26 | self.unkown = EnumValue.objects.create(value='unkown') 27 | ynu = EnumGroup.objects.create(name='Yes / No / Unknown') 28 | ynu.enums.add(self.yes) 29 | ynu.enums.add(self.no) 30 | ynu.enums.add(self.unkown) 31 | Attribute.objects.create(name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu) 32 | 33 | def tearDown(self): 34 | eav.unregister(Encounter) 35 | eav.unregister(Patient) 36 | 37 | def test_get_or_create_with_eav(self): 38 | p = Patient.objects.get_or_create(name='Bob', eav__age=5) 39 | self.assertEqual(Patient.objects.count(), 1) 40 | self.assertEqual(Value.objects.count(), 1) 41 | p = Patient.objects.get_or_create(name='Bob', eav__age=5) 42 | self.assertEqual(Patient.objects.count(), 1) 43 | self.assertEqual(Value.objects.count(), 1) 44 | p = Patient.objects.get_or_create(name='Bob', eav__age=6) 45 | self.assertEqual(Patient.objects.count(), 2) 46 | self.assertEqual(Value.objects.count(), 2) 47 | 48 | def test_get_with_eav(self): 49 | p1 = Patient.objects.get_or_create(name='Bob', eav__age=6) 50 | self.assertEqual(Patient.objects.get(eav__age=6), p1) 51 | p2 = Patient.objects.get_or_create(name='Fred', eav__age=6) 52 | self.assertRaises(Patient.MultipleObjectsReturned, 53 | Patient.objects.get, eav__age=6) 54 | 55 | def test_filtering_on_normal_and_eav_fields(self): 56 | yes = self.yes 57 | no = self.no 58 | data = [ 59 | # Name Age Fever City Country 60 | [ 'Bob', 12, no, 'New York', 'USA' ], 61 | [ 'Fred', 15, no, 'Bamako', 'Mali' ], 62 | [ 'Jose', 15, yes, 'Kisumu', 'Kenya' ], 63 | [ 'Joe', 2, no, 'Nice', 'France'], 64 | [ 'Beth', 21, yes, 'France', 'Nice' ] 65 | ] 66 | for row in data: 67 | Patient.objects.create(name=row[0], eav__age=row[1], 68 | eav__fever=row[2], eav__city=row[3], 69 | eav__country=row[4]) 70 | 71 | self.assertEqual(Patient.objects.count(), 5) 72 | self.assertEqual(Value.objects.count(), 20) 73 | 74 | self.assertEqual(Patient.objects.filter(eav__city__contains='Y').count(), 1) 75 | self.assertEqual(Patient.objects.exclude(eav__city__contains='Y').count(), 4) 76 | 77 | # Bob 78 | self.assertEqual(Patient.objects.filter(Q(eav__city__contains='Y')).count(), 1) 79 | 80 | # Everyone except Bob 81 | #self.assertEqual(Patient.objects.exclude(Q(eav__city__contains='Y')).count(), 4) 82 | 83 | 84 | # Bob, Fred, Joe 85 | q1 = Q(eav__city__contains='Y') | Q(eav__fever=no) 86 | self.assertEqual(Patient.objects.filter(q1).count(), 3) 87 | 88 | # Joe 89 | q2 = Q(eav__age=2) 90 | self.assertEqual(Patient.objects.filter(q2).count(), 1) 91 | 92 | # Joe 93 | #self.assertEqual(Patient.objects.filter(q1 & q2).count(), 1) 94 | 95 | # Jose 96 | self.assertEqual(Patient.objects.filter(name__contains='J', eav__fever=yes).count(), 1) 97 | 98 | def test_eav_through_foreign_key(self): 99 | Patient.objects.create(name='Fred', eav__age=15) 100 | p = Patient.objects.create(name='Jon', eav__age=15) 101 | e = Encounter.objects.create(num=1, patient=p, eav__fever=self.yes) 102 | 103 | self.assertEqual(Patient.objects.filter(eav__age=15, encounter__eav__fever=self.yes).count(), 1) 104 | 105 | 106 | def test_manager_only_create(self): 107 | class UserEavConfig(EavConfig): 108 | manager_only = True 109 | 110 | eav.register(User, UserEavConfig) 111 | 112 | c = User.objects.create(username='joe') 113 | -------------------------------------------------------------------------------- /eav/tests/registry.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | import eav 4 | from ..registry import Registry, EavConfig 5 | from ..managers import EntityManager 6 | from ..models import Attribute 7 | 8 | from .models import Patient, Encounter, ExampleModel 9 | 10 | 11 | class RegistryTests(TestCase): 12 | 13 | def setUp(self): 14 | pass 15 | 16 | def tearDown(self): 17 | pass 18 | 19 | def register_encounter(self): 20 | class EncounterEav(EavConfig): 21 | manager_attr = 'eav_objects' 22 | eav_attr = 'eav_field' 23 | generic_relation_attr = 'encounter_eav_values' 24 | generic_relation_related_name = 'encounters' 25 | 26 | @classmethod 27 | def get_attributes(cls): 28 | return 'testing' 29 | 30 | eav.register(Encounter, EncounterEav) 31 | 32 | 33 | def test_registering_with_defaults(self): 34 | eav.register(Patient) 35 | self.assertTrue(hasattr(Patient, '_eav_config_cls')) 36 | self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') 37 | self.assertFalse(Patient._eav_config_cls.manager_only) 38 | self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') 39 | self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 40 | 'eav_values') 41 | self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, 42 | None) 43 | eav.unregister(Patient) 44 | 45 | def test_registering_overriding_defaults(self): 46 | eav.register(Patient) 47 | self.register_encounter() 48 | self.assertTrue(hasattr(Patient, '_eav_config_cls')) 49 | self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') 50 | self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') 51 | 52 | self.assertTrue(hasattr(Encounter, '_eav_config_cls')) 53 | self.assertEqual(Encounter._eav_config_cls.get_attributes(), 'testing') 54 | self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects') 55 | self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field') 56 | eav.unregister(Patient) 57 | eav.unregister(Encounter) 58 | 59 | def test_registering_via_decorator_with_defaults(self): 60 | self.assertTrue(hasattr(ExampleModel, '_eav_config_cls')) 61 | self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects') 62 | self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav') 63 | 64 | def test_unregistering(self): 65 | old_mgr = Patient.objects 66 | eav.register(Patient) 67 | self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager') 68 | eav.unregister(Patient) 69 | self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager') 70 | self.assertEqual(Patient.objects, old_mgr) 71 | self.assertFalse(hasattr(Patient, '_eav_config_cls')) 72 | 73 | def test_unregistering_via_decorator(self): 74 | self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager') 75 | eav.unregister(ExampleModel) 76 | e = ExampleModel() 77 | self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager') 78 | 79 | def test_unregistering_unregistered_model_proceeds_silently(self): 80 | eav.unregister(Patient) 81 | 82 | def test_double_registering_model_is_harmless(self): 83 | eav.register(Patient) 84 | eav.register(Patient) 85 | -------------------------------------------------------------------------------- /eav/tests/set_and_get.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | import eav 4 | from ..registry import Registry, EavConfig 5 | from ..managers import EntityManager 6 | 7 | from .models import Patient, Encounter 8 | 9 | 10 | class RegistryTests(TestCase): 11 | 12 | def setUp(self): 13 | pass 14 | 15 | def tearDown(self): 16 | pass 17 | 18 | def register_encounter(self): 19 | class EncounterEav(EavConfig): 20 | manager_attr = 'eav_objects' 21 | eav_attr = 'eav_field' 22 | generic_relation_attr = 'encounter_eav_values' 23 | generic_relation_related_name = 'encounters' 24 | eav.register(Encounter, EncounterEav) 25 | 26 | 27 | def test_registering_with_defaults(self): 28 | eav.register(Patient) 29 | self.assertTrue(hasattr(Patient, '_eav_config_cls')) 30 | self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') 31 | self.assertFalse(Patient._eav_config_cls.manager_only) 32 | self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') 33 | self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 34 | 'eav_values') 35 | self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, 36 | None) 37 | eav.unregister(Patient) 38 | 39 | def test_registering_overriding_defaults(self): 40 | eav.register(Patient) 41 | self.register_encounter() 42 | self.assertTrue(hasattr(Patient, '_eav_config_cls')) 43 | self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') 44 | self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') 45 | 46 | self.assertTrue(hasattr(Encounter, '_eav_config_cls')) 47 | self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects') 48 | self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field') 49 | eav.unregister(Patient) 50 | eav.unregister(Encounter) 51 | 52 | def test_unregistering(self): 53 | old_mgr = Patient.objects 54 | eav.register(Patient) 55 | self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager') 56 | eav.unregister(Patient) 57 | self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager') 58 | self.assertEqual(Patient.objects, old_mgr) 59 | self.assertFalse(hasattr(Patient, '_eav_config_cls')) 60 | 61 | def test_unregistering_unregistered_model_proceeds_silently(self): 62 | eav.unregister(Patient) 63 | 64 | def test_double_registering_model_is_harmless(self): 65 | eav.register(Patient) 66 | eav.register(Patient) 67 | -------------------------------------------------------------------------------- /eav/validators.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | ''' 20 | ********** 21 | validators 22 | ********** 23 | This module contains a validator for each Attribute datatype. 24 | 25 | A validator is a callable that takes a value and raises a ``ValidationError`` 26 | if it doesn’t meet some criteria. (see 27 | `django validators `_) 28 | 29 | These validators are called by the 30 | :meth:`~eav.models.Attribute.validate_value` method in the 31 | :class:`~eav.models.Attribute` model. 32 | 33 | Functions 34 | --------- 35 | ''' 36 | 37 | from django.utils import timezone 38 | from django.db import models 39 | from django.utils.translation import ugettext_lazy as _ 40 | from django.core.exceptions import ValidationError 41 | 42 | 43 | def validate_text(value): 44 | ''' 45 | Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode`` 46 | ''' 47 | if not (type(value) == str or type(value) == str): 48 | raise ValidationError(_(u"Must be str or unicode")) 49 | 50 | 51 | def validate_float(value): 52 | ''' 53 | Raises ``ValidationError`` unless *value* can be cast as a ``float`` 54 | ''' 55 | try: 56 | float(value) 57 | except ValueError: 58 | raise ValidationError(_(u"Must be a float")) 59 | 60 | 61 | def validate_int(value): 62 | ''' 63 | Raises ``ValidationError`` unless *value* can be cast as an ``int`` 64 | ''' 65 | try: 66 | int(value) 67 | except ValueError: 68 | raise ValidationError(_(u"Must be an integer")) 69 | 70 | 71 | def validate_date(value): 72 | ''' 73 | Raises ``ValidationError`` unless *value* is an instance of ``datetime`` 74 | or ``date`` 75 | ''' 76 | if not (isinstance(value, timezone.datetime) or isinstance(value, timezone.datetime.date)): 77 | raise ValidationError(_(u"Must be a date or datetime")) 78 | 79 | 80 | def validate_bool(value): 81 | ''' 82 | Raises ``ValidationError`` unless *value* type is ``bool`` 83 | ''' 84 | if not type(value) == bool: 85 | raise ValidationError(_(u"Must be a boolean")) 86 | 87 | 88 | def validate_object(value): 89 | ''' 90 | Raises ``ValidationError`` unless *value* is a saved 91 | django model instance. 92 | ''' 93 | if not isinstance(value, models.Model): 94 | raise ValidationError(_(u"Must be a django model object instance")) 95 | if not value.pk: 96 | raise ValidationError(_(u"Model has not been saved yet")) 97 | 98 | 99 | def validate_enum(value): 100 | ''' 101 | Raises ``ValidationError`` unless *value* is a saved 102 | :class:`~eav.models.EnumValue` model instance. 103 | ''' 104 | pass 105 | """ 106 | # This never passes, value is a str 107 | from .models import EnumValue 108 | if not isinstance(value, EnumValue): 109 | raise ValidationError(_(u"Must be an EnumValue model object instance")) 110 | if not value.pk: 111 | raise ValidationError(_(u"EnumValue has not been saved yet")) 112 | """ 113 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 coding=utf-8 3 | # 4 | # This software is derived from EAV-Django originally written and 5 | # copyrighted by Andrey Mikhaylenko 6 | # 7 | # This is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Lesser General Public License as published 9 | # by the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This software is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Lesser General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Lesser General Public License 18 | # along with EAV-Django. If not, see . 19 | from distutils.core import setup 20 | 21 | setup( 22 | name='django-eav', 23 | version=__import__('eav').__version__, 24 | license = 'GNU Lesser General Public License (LGPL), Version 3', 25 | 26 | requires = ['python (>= 2.5)', 'django (>= 1.2)', 'six'], 27 | provides = ['eav'], 28 | 29 | description='Entity-attribute-value model implementation as a reusable' 30 | 'Django app.', 31 | long_description=open('README.rst').read(), 32 | 33 | url='http://github.com/mvpdev/django-eav', 34 | 35 | packages=['eav', 'eav.tests'], 36 | 37 | classifiers = [ 38 | 'Development Status :: 4 - Beta', 39 | 'Environment :: Web Environment', 40 | 'Framework :: Django', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)', 43 | 'Programming Language :: Python', 44 | 'Topic :: Database', 45 | 'Topic :: Software Development :: Libraries :: Python Modules', 46 | ], 47 | ) 48 | --------------------------------------------------------------------------------