├── .gitignore ├── .travis.yml ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── conftest.py ├── example ├── example │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py └── person │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── setup.cfg ├── setup.py ├── src └── computed_field │ ├── __init__.py │ └── fields.py ├── tests ├── __init__.py ├── admin.py ├── models.py ├── settings.py ├── tests │ ├── __init__.py │ ├── test_joined_models.py │ ├── test_lookup_expression.py │ └── test_simplest_expression.py └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | dist: xenial 3 | language: python 4 | python: 5 | - "2.7" 6 | - "3.6" 7 | - "3.7" 8 | install: pip install tox-travis 9 | script: tox --parallel auto 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Matthew Schinckel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "*" 8 | django-computed-field = {editable = true, path = "."} 9 | dj-database-url = "*" 10 | pytest-django = "*" 11 | pytest-cov = "*" 12 | pytest = "*" 13 | 14 | [dev-packages] 15 | 16 | [requires] 17 | python_version = "3.6" 18 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "2b691426920d634b995de7a9a62fbac6d9c4ef2a471dc69e9e5b3ffa9407480c" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "atomicwrites": { 20 | "hashes": [ 21 | "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", 22 | "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" 23 | ], 24 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 25 | "version": "==1.4.0" 26 | }, 27 | "attrs": { 28 | "hashes": [ 29 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 30 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 31 | ], 32 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 33 | "version": "==21.4.0" 34 | }, 35 | "coverage": { 36 | "hashes": [ 37 | "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0", 38 | "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd", 39 | "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884", 40 | "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48", 41 | "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76", 42 | "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0", 43 | "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64", 44 | "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685", 45 | "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47", 46 | "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d", 47 | "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840", 48 | "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f", 49 | "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971", 50 | "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c", 51 | "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a", 52 | "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de", 53 | "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17", 54 | "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4", 55 | "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521", 56 | "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57", 57 | "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b", 58 | "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282", 59 | "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644", 60 | "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475", 61 | "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d", 62 | "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da", 63 | "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953", 64 | "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2", 65 | "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e", 66 | "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c", 67 | "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc", 68 | "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64", 69 | "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74", 70 | "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617", 71 | "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3", 72 | "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d", 73 | "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa", 74 | "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739", 75 | "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8", 76 | "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8", 77 | "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781", 78 | "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58", 79 | "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9", 80 | "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c", 81 | "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd", 82 | "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e", 83 | "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49" 84 | ], 85 | "markers": "python_version >= '3.6'", 86 | "version": "==6.2" 87 | }, 88 | "dj-database-url": { 89 | "hashes": [ 90 | "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", 91 | "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" 92 | ], 93 | "index": "pypi", 94 | "version": "==0.5.0" 95 | }, 96 | "django": { 97 | "hashes": [ 98 | "sha256:0200b657afbf1bc08003845ddda053c7641b9b24951e52acd51f6abda33a7413", 99 | "sha256:365429d07c1336eb42ba15aa79f45e1c13a0b04d5c21569e7d596696418a6a45" 100 | ], 101 | "index": "pypi", 102 | "version": "==2.2.28" 103 | }, 104 | "django-computed-field": { 105 | "editable": true, 106 | "path": "." 107 | }, 108 | "importlib-metadata": { 109 | "hashes": [ 110 | "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e", 111 | "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668" 112 | ], 113 | "markers": "python_version < '3.8'", 114 | "version": "==4.8.3" 115 | }, 116 | "more-itertools": { 117 | "hashes": [ 118 | "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b", 119 | "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064" 120 | ], 121 | "markers": "python_version >= '3.5'", 122 | "version": "==8.12.0" 123 | }, 124 | "pluggy": { 125 | "hashes": [ 126 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 127 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 128 | ], 129 | "markers": "python_version >= '3.6'", 130 | "version": "==1.0.0" 131 | }, 132 | "py": { 133 | "hashes": [ 134 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 135 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 136 | ], 137 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 138 | "version": "==1.11.0" 139 | }, 140 | "pytest": { 141 | "hashes": [ 142 | "sha256:41568ea7ecb4a68d7f63837cf65b92ce8d0105e43196ff2b26622995bb3dc4b2", 143 | "sha256:c3c573a29d7c9547fb90217ece8a8843aa0c1328a797e200290dc3d0b4b823be" 144 | ], 145 | "index": "pypi", 146 | "version": "==4.1.1" 147 | }, 148 | "pytest-cov": { 149 | "hashes": [ 150 | "sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33", 151 | "sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f" 152 | ], 153 | "index": "pypi", 154 | "version": "==2.6.1" 155 | }, 156 | "pytest-django": { 157 | "hashes": [ 158 | "sha256:1a5d33be930e3172fa238643a380414dc369fe8fa4b3c3de25e59ed142950736", 159 | "sha256:e88e471d3d0f9acfb6293bb03d0ee8a33ed978734e92ea6b5312163a6c9e87cc" 160 | ], 161 | "index": "pypi", 162 | "version": "==3.4.5" 163 | }, 164 | "pytz": { 165 | "hashes": [ 166 | "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7", 167 | "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c" 168 | ], 169 | "version": "==2022.1" 170 | }, 171 | "sanitized-package": { 172 | "editable": true, 173 | "path": "." 174 | }, 175 | "setuptools": { 176 | "hashes": [ 177 | "sha256:22c7348c6d2976a52632c67f7ab0cdf40147db7789f9aed18734643fe9cf3373", 178 | "sha256:4ce92f1e1f8f01233ee9952c04f6b81d1e02939d6e1b488428154974a4d0783e" 179 | ], 180 | "markers": "python_version >= '3.6'", 181 | "version": "==59.6.0" 182 | }, 183 | "six": { 184 | "hashes": [ 185 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 186 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 187 | ], 188 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 189 | "version": "==1.16.0" 190 | }, 191 | "sqlparse": { 192 | "hashes": [ 193 | "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", 194 | "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" 195 | ], 196 | "markers": "python_version >= '3.5'", 197 | "version": "==0.4.2" 198 | }, 199 | "typing-extensions": { 200 | "hashes": [ 201 | "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42", 202 | "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2" 203 | ], 204 | "markers": "python_version < '3.8'", 205 | "version": "==4.1.1" 206 | }, 207 | "zipp": { 208 | "hashes": [ 209 | "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832", 210 | "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc" 211 | ], 212 | "markers": "python_version >= '3.6'", 213 | "version": "==3.6.0" 214 | } 215 | }, 216 | "develop": {} 217 | } 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-computed-field 2 | 3 | ComputedField() for django 4 | 5 | 6 | A very common pattern, at least in code I've written and seen, is to use `.annotate()` to add on a field that is based upon one or more real fields, that in many cases is used as frequently as the real fields. A toy example might be: 7 | 8 | ```python 9 | class PersonQuerySet(models.query.QuerySet): 10 | def with_name(self): 11 | return self.annotate( 12 | name=Concat( 13 | models.F('first_name'), 14 | Value(' '), 15 | models.F('last_name'), 16 | output_field=models.TextField(), 17 | ), 18 | ) 19 | 20 | 21 | class Person(models.Model): 22 | first_name = models.TextField() 23 | last_name = models.TextField() 24 | 25 | objects = PersonQuerySet.as_manager() 26 | ``` 27 | (Yes, I'm aware of [falsehoods programmers believe about names](https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/), this is just a really neat example). 28 | 29 | In order to get access to the name "field", we must ensure we use the ``Person.objects.with_name()`` queryset method each time we need it; alternatively we could look at overriding a manager's `get_queryset()` to always apply this. 30 | 31 | However, it would be neat if it we could define this field directly on the model, and it would always be fetched, never be written, and follow django's normal rules with respect to defer/only. 32 | 33 | ```python 34 | class Person(models.Model): 35 | first_name = models.TextField() 36 | last_name = models.TextField() 37 | 38 | name = ComputedField( 39 | Concat( 40 | models.F('first_name'), 41 | Value(' '), 42 | models.F('last_name'), 43 | output_field=models.TextField(), 44 | ), 45 | ) 46 | 47 | group = ComputedField( 48 | ExpressionWrapper(models.F('user__group__name')), 49 | output_field=models.TextField() 50 | ) 51 | ``` 52 | 53 | We can also, as shown in the `group` field, refer to foreign keys. This does require us to use an ExpressionWrapper, and provide the correct output_field. 54 | 55 | This is still a proof of concept: whilst it works in the tests I have written so far, it may not work in all cases (and actually still contains a `pdb` breakpoint, sort-of deliberately). 56 | 57 | It's also missing major functionality that I think is required before it can be used: the ability to declare that a ComputedField should have an index, and the migrations framework detecting that, and creating one. This currently cannot be done due to the mechanism I have used for avoiding other things happening (like the value being sent to the database when we save) also prevents the field from being used in indices. 58 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def enable_db_access_for_all_tests(db): 6 | pass 7 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.1/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'x23d+ztwbd^sg(q-z-e$3$j89d5fpkglrdm3bosa^(e1==&1=s' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'person', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'example.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'example.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.sqlite3', 80 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/2.1/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /example/person/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/example/person/__init__.py -------------------------------------------------------------------------------- /example/person/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Person 4 | 5 | 6 | @admin.register(Person) 7 | class PersonAdmin(admin.ModelAdmin): 8 | list_display = ( 9 | 'name', 10 | 'active', 11 | 'first_name', 'last_name', 12 | ) 13 | 14 | search_fields = ('name',) 15 | readonly_fields = ('name',) 16 | list_filter = ('active', 'name') 17 | -------------------------------------------------------------------------------- /example/person/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PersonConfig(AppConfig): 5 | name = 'person' 6 | -------------------------------------------------------------------------------- /example/person/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/example/person/migrations/__init__.py -------------------------------------------------------------------------------- /example/person/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.expressions import Value 3 | from django.db.models.functions import Concat, Lower 4 | 5 | from computed_field.fields import ComputedField 6 | 7 | 8 | class ComputedIndex(models.Index): 9 | def __init__(self, computed_field, name=None, db_tablespace=None): 10 | self.expression = computed_field.expression 11 | self.name = name or '' 12 | if self.name: 13 | errors = self.check_name() 14 | if len(self.name) > self.max_name_length: 15 | errors.append('Index names cannot be longer than %s characters', self.max_name_length) 16 | if errors: 17 | raise ValueError(errors) 18 | self.db_tablespace = db_tablespace 19 | 20 | 21 | class Person(models.Model): 22 | first_name = models.TextField() 23 | last_name = models.TextField() 24 | 25 | active = models.BooleanField() 26 | 27 | name = ComputedField(Concat( 28 | models.F('first_name'), Value(' '), models.F('last_name'), 29 | output_field=models.TextField() 30 | )) 31 | 32 | lower_name = ComputedField(Lower(models.F('name'))) 33 | 34 | class Meta: 35 | verbose_name_plural = 'people' 36 | # indexes = [ 37 | # models.Index(name='lower_name', fields=['lower_name']), 38 | # ] 39 | 40 | def __str__(self): 41 | return self.name 42 | -------------------------------------------------------------------------------- /example/person/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /example/person/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = E501,E128 6 | max-line-length=119 7 | 8 | [isort] 9 | combine_as_imports= true 10 | forced_to_top= occupation,django 11 | include_trailing_comma= true 12 | known_first_party= computed_field 13 | known_third_party= django 14 | line_length= 79 15 | multi_line_output= 5 16 | not_skip= __init__.py 17 | 18 | [aliases] 19 | test=pytest -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name='django-computed-field', 5 | version='0.8.0', 6 | description='ComputedField(): automatically annotate expressions', 7 | url='https://bitbucket.org/schinckel/django-computed-field', 8 | author='Matthew Schinckel', 9 | author_email='matt@schinckel.net', 10 | packages=find_packages('src', exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), 11 | package_dir={'': 'src'}, 12 | include_package_data=True, 13 | package_data={}, 14 | python_requires='>=2.7', 15 | install_requires=[ 16 | 'django', 17 | ], 18 | setup_requires=["pytest-runner", ], 19 | tests_require=["pytest", "pytest-django", "pytest-cov", "dj-database-url"], 20 | classifiers=[ 21 | 'Development Status :: 1 - Alpha', 22 | 'Framework :: Django :: 2.0', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python :: 3.6', 27 | 'Programming Language :: Python :: Implementation :: CPython', 28 | 'Topic :: Software Development :: Libraries :: Python Modules', 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /src/computed_field/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/src/computed_field/__init__.py -------------------------------------------------------------------------------- /src/computed_field/fields.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import django 4 | from django.db import models 5 | from django.db.models.expressions import Col 6 | 7 | 8 | class ComputedField(models.Field): 9 | def __init__(self, expression, *args, **kwargs): 10 | # We want to trigger the read-only behaviour in the admin. 11 | kwargs.update(editable=False) 12 | # If our expression has a "copy" attribute, then we want to make a copy of it. 13 | # It's possible the reference could be re-used elsewhere, so we need to make 14 | # sure that we have a copy of what it looks like now, rather than if someone 15 | # mutates is. If our expression is an F() expression, for instance, we will 16 | # not have a copy() method, and that's fine. 17 | if hasattr(expression, 'copy'): 18 | self.expression = expression.copy() 19 | else: 20 | self.expression = expression 21 | super(ComputedField, self).__init__(*args, **kwargs) 22 | # Can we prevent this field from being used in a form? 23 | 24 | def db_type(self, connection): 25 | # We can easily prevent creation of a field in a migration by returning None here. 26 | return None 27 | 28 | def from_db_value(self, value, expression, connection): 29 | # This will always be delegated to the output field of the expression. 30 | return self.expression.output_field.from_db_value(value, expression, connection) 31 | 32 | def deconstruct(self): 33 | name, path, args, kwargs = super(ComputedField, self).deconstruct() 34 | return name, path, [self.expression] + args, kwargs 35 | 36 | def get_col(self, alias, output_field=None): 37 | def resolve_f(expression, query): 38 | # Because of the fact that F() expressions refer to the "local" table, 39 | # we need to resolve these to a Col() expression that uses the table 40 | # alias (supplied to us), and the field that the F() expression refers 41 | # to. This is then complicated by the fact that we could have an F() 42 | # expression that refers to a ComputedField - which we just want to 43 | # resolve the inner F expressions in right now. We'll rely on the 44 | # fact that resolve_expression() will keep resolving source expressions 45 | # to handle everything else: this is just a precursor to that. 46 | if hasattr(expression, 'get_source_expressions'): 47 | # We want a copy here, because otherwise we'd be mutating objects we 48 | # really shouldn't, which could affect places where the same expression 49 | # was shared between different things. 50 | expression = expression.copy() 51 | expression.set_source_expressions([ 52 | resolve_f(expr, query) for expr in expression.get_source_expressions() 53 | ]) 54 | if isinstance(expression, models.F): 55 | # If we are dealing with an F() expression, we want to try to resolve 56 | # it to a Col(alias, field) for the relevant field on our model. 57 | # If that is a ComputedField(), then just resolve the F() expressions 58 | # inside that. 59 | 60 | # This uses the same code as django.db.models.sql.Query.resolve_ref(), except 61 | # it uses the current ComputedField to determine the table to which the join 62 | # needs to refer. 63 | field_parts = expression.name.split('__') 64 | current_alias, ref = query.table_alias(self.model._meta.db_table) 65 | join_info = query.setup_joins(field_parts, self.model._meta, current_alias) 66 | 67 | if django.VERSION < (2, 0): 68 | targets, final_alias, join_list = query.trim_joins( 69 | join_info[1], # join_info.targets, 70 | join_info[3], # join_info.joins, 71 | join_info[4], # join_info.path, 72 | ) 73 | field = join_info[1][0] 74 | else: 75 | targets, final_alias, join_list = query.trim_joins( 76 | join_info.targets, 77 | join_info.joins, 78 | join_info.path, 79 | ) 80 | join_info.transform_function(targets[0], final_alias) 81 | field = join_info.targets[0] 82 | if hasattr(field, 'expression'): 83 | return resolve_f(field.expression, query) 84 | return Col(join_list[-1], field) 85 | 86 | return expression 87 | 88 | # I'd love some way to get the query object without having to peek up the stack... 89 | query = None 90 | for frame in inspect.stack(): 91 | # Python 2 and 3 have a different structure here. 92 | function = getattr(frame, 'function', None) or frame[3] 93 | frame = getattr(frame, 'frame', None) or frame[0] 94 | 95 | if function in ['get_default_columns', 'get_order_by']: 96 | query = frame.f_locals['self'].query 97 | break 98 | if function in ['add_fields', 'build_filter']: 99 | query = frame.f_locals['self'] 100 | break 101 | else: 102 | import pdb; pdb.set_trace() # NOQA 103 | 104 | col = resolve_f(self.expression, query).resolve_expression(query=query) 105 | col.target = getattr(col, 'target', self) 106 | return col 107 | 108 | def contribute_to_class(self, cls, name, private_only=False): 109 | # We use a private field, because that then means it won't be added to the 110 | # list of local/concrete fields (which would mean we can change how and when 111 | # it is included in the query). I think this is the mechanism that is used 112 | # by inherited fields. Seems to work okay, unless we try to use this field 113 | # in an index. 114 | super(ComputedField, self).contribute_to_class(cls, name, True) 115 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/tests/admin.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.expressions import Value 3 | from django.db.models.functions import Concat, Lower 4 | 5 | from computed_field.fields import ComputedField 6 | 7 | 8 | class Group(models.Model): 9 | name = models.TextField() 10 | 11 | 12 | class User(models.Model): 13 | username = models.TextField() 14 | group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL) 15 | 16 | 17 | class Person(models.Model): 18 | user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE) 19 | first_name = models.TextField() 20 | last_name = models.TextField() 21 | 22 | name = ComputedField(Concat( 23 | models.F('first_name'), Value(' '), models.F('last_name'), 24 | output_field=models.TextField() 25 | )) 26 | 27 | lower_name = ComputedField(Lower(models.F('name')), db_index=True) 28 | 29 | username = ComputedField(models.F('user__username')) 30 | group = ComputedField(models.F('user__group__name')) 31 | 32 | class Meta: 33 | indexes = [ 34 | # models.Index(fields=['lower_name']), 35 | ] 36 | 37 | 38 | class Address(models.Model): 39 | person = models.OneToOneField(Person, related_name='address', primary_key=True, on_delete=models.CASCADE) 40 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import dj_database_url 2 | 3 | USE_TZ = True 4 | 5 | INSTALLED_APPS = [ 6 | 'django.contrib.auth', 7 | 'django.contrib.contenttypes', 8 | 'django.contrib.sessions', 9 | 'django.contrib.messages', 10 | 'django.contrib.admin', 11 | 'tests', 12 | ] 13 | 14 | 15 | DATABASES = { 16 | "default": dj_database_url.config(conn_max_age=600) 17 | } 18 | 19 | ROOT_URLCONF = 'tests.urls' 20 | STATIC_URL = '/static/' 21 | 22 | MIDDLEWARE = ( 23 | 'django.contrib.sessions.middleware.SessionMiddleware', 24 | 'django.middleware.common.CommonMiddleware', 25 | 'django.middleware.csrf.CsrfViewMiddleware', 26 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 27 | 'django.contrib.messages.middleware.MessageMiddleware', 28 | 29 | ) 30 | 31 | PASSWORD_HASHERS = ( 32 | 'django.contrib.auth.hashers.MD5PasswordHasher', 33 | ) 34 | AUTH_USER_MODEL = 'auth.User' 35 | SECRET_KEY = 'django-computed-field-sekret-keye' 36 | 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'APP_DIRS': True, 42 | 'DIRS': [], 43 | 'OPTIONS': { 44 | 'debug': True, 45 | 'context_processors': [ 46 | 'django.contrib.auth.context_processors.auth', 47 | 'django.template.context_processors.debug', 48 | 'django.template.context_processors.i18n', 49 | 'django.template.context_processors.media', 50 | 'django.template.context_processors.static', 51 | 'django.template.context_processors.tz', 52 | 'django.contrib.messages.context_processors.messages', 53 | ] 54 | } 55 | }, 56 | ] 57 | 58 | SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' 59 | 60 | ALLOWED_HOSTS = ['localhost'] 61 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schinckel/django-computed-field/3e238a72533831e55da2d20fcc4b74e0af832139/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/test_joined_models.py: -------------------------------------------------------------------------------- 1 | from ..models import Address, Person 2 | 3 | 4 | def test_joined_lookup(): 5 | assert len(Address.objects.filter(person__name='foo')) == 0 6 | assert len(Address.objects.filter(person__name__icontains='foo')) == 0 7 | 8 | 9 | def test_select_related(): 10 | person = Person.objects.create(first_name='Foo', last_name='Bar') 11 | Address.objects.create(person=person) 12 | 13 | assert Address.objects.select_related('person').get().person.name == 'Foo Bar' 14 | -------------------------------------------------------------------------------- /tests/tests/test_lookup_expression.py: -------------------------------------------------------------------------------- 1 | from ..models import Person, User, Group 2 | 3 | 4 | def test_empty_lookup(): 5 | assert not Person.objects.filter(username=None).exists() 6 | 7 | 8 | def test_null_lookup(): 9 | Person.objects.create(first_name='foo', last_name='bar') 10 | assert Person.objects.get().username is None 11 | assert Person.objects.filter(username=None).exists() 12 | 13 | 14 | def test_value_lookup(): 15 | Person.objects.create( 16 | first_name='foo', 17 | last_name='bar', 18 | user=User.objects.create(username='baz') 19 | ) 20 | assert Person.objects.get().username == 'baz' 21 | assert Person.objects.filter(username='baz').exists() 22 | assert not Person.objects.filter(username=None).exists() 23 | 24 | 25 | def test_multiple_lookups(): 26 | Person.objects.create( 27 | first_name='foo', 28 | last_name='bar', 29 | user=User.objects.create(username='baz', group=Group.objects.create(name='qux')) 30 | ) 31 | 32 | assert Person.objects.get().group == 'qux' 33 | assert Person.objects.filter(group='qux').exists() 34 | assert not Person.objects.filter(group=None).exists() 35 | -------------------------------------------------------------------------------- /tests/tests/test_simplest_expression.py: -------------------------------------------------------------------------------- 1 | from ..models import Person 2 | 3 | 4 | def test_computed_field_exists_and_can_be_queried(): 5 | assert not Person.objects.filter(name='foo bar').exists() 6 | 7 | 8 | def test_values_query_result_includes_column(): 9 | Person.objects.create(first_name='Foo', last_name='Bar') 10 | assert 'name' in Person.objects.values()[0] 11 | 12 | 13 | def test_create_works(): 14 | Person.objects.create(first_name='Foo', last_name='Bar') 15 | 16 | 17 | def test_computed_field_is_set_on_object(): 18 | Person.objects.create(first_name='Foo', last_name='Bar') 19 | assert Person.objects.get().name == 'Foo Bar' 20 | 21 | 22 | def test_computed_field_is_set_on_values(): 23 | Person.objects.create(first_name='Foo', last_name='Bar') 24 | assert Person.objects.values('name')[0] == {'name': 'Foo Bar'} 25 | 26 | 27 | def test_filter_on_computed_field(): 28 | assert not Person.objects.filter(name='Foo Bar').exists() 29 | Person.objects.create(first_name='Foo', last_name='Bar') 30 | assert Person.objects.filter(name='Foo Bar').exists() 31 | 32 | 33 | def test_filter_transform_on_computed_field(): 34 | Person.objects.create(first_name='Foo', last_name='Bar') 35 | assert Person.objects.filter(name__icontains='foo').exists() 36 | 37 | 38 | def test_cascading_field(): 39 | Person.objects.create(first_name='Foo', last_name='Bar') 40 | assert Person.objects.filter(lower_name='foo bar').exists() 41 | 42 | 43 | def test_ordering(): 44 | assert len(Person.objects.order_by('name')) == 0 45 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | flake8, 4 | isort, 5 | bandit, 6 | py{36,37}-django{20,21,22,dev}-{base,flake8,prospector}, 7 | py{27,36,37}-django11-{base,flake8,prospector} 8 | skip_missing_interpreters=true 9 | 10 | [travis] 11 | python= 12 | 2.7: py27 13 | 3.6: py36 14 | 3.7: py37 15 | 16 | [pytest] 17 | DJANGO_SETTINGS_MODULE = tests.settings 18 | # -- recommended but optional: 19 | python_files = tests.py test_*.py *_tests.py 20 | 21 | [flake8] 22 | ignore=E305 23 | max_line_length=119 24 | 25 | [coverage] 26 | deps= 27 | coverage 28 | 29 | [django] 30 | deps= 31 | django11: Django>=1.11,<2.0 32 | django20: Django>=2.0,<2.1 33 | django21: Django>=2.1.11,<2.2 34 | django22: Django>=2.2 35 | djangodev: https://github.com/django/django/archive/master.tar.gz 36 | 37 | [testenv] 38 | setenv= 39 | DJANGO_SETTINGS_MODULE=tests.settings 40 | DATABASE_URL=sqlite:memory: 41 | PIPENV_VERBOSITY=-1 42 | PYTHONPATH=. 43 | deps= 44 | {[coverage]deps} 45 | {[django]deps} 46 | flake8: flake8 47 | base: pytest 48 | base: pytest-django 49 | base: pytest-cov 50 | base: dj-database-url 51 | commands= 52 | base: pytest --cov=src/computed_field 53 | flake8: flake8 src/computed_field 54 | 55 | [testenv:flake8] 56 | deps= 57 | flake8 58 | commands= 59 | flake8 src/ tests/ 60 | 61 | [testenv:isort] 62 | # I can't seem to get this to reliably pick up the correct settings. 63 | deps= 64 | isort 65 | commands= 66 | isort -rc -c src/computed_field 67 | 68 | [testenv:prospector] 69 | deps= 70 | py27: prospector==0.11.1 71 | py36: prospector 72 | py37: prospector 73 | pylint-django 74 | {[django]deps} 75 | commands= 76 | prospector src/computed_field --uses django 77 | --------------------------------------------------------------------------------