├── .dockerignore ├── .flake8 ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pypirc ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── drf_extra_fields ├── __init__.py ├── compat.py ├── fields.py ├── geo_fields.py ├── relations.py └── runtests │ ├── __init__.py │ └── settings.py ├── pytest.ini ├── requirements.txt ├── requirements_dev.txt ├── setup.py ├── tests ├── __init__.py ├── test_fields.py ├── test_model_serializer.py ├── test_relations.py └── utils.py ├── tools └── run_development.sh └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .tox/ 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tox: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox tox-gh-actions 25 | sudo apt update 26 | sudo apt install gdal-bin 27 | - name: Run tests with tox 28 | run: tox 29 | - name: Upload coverage to Codecov 30 | uses: codecov/codecov-action@v2.1.0 31 | with: 32 | fail_ci_if_error: true 33 | verbose: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | !.gitignore 3 | dist/ 4 | build/ 5 | env/ 6 | dist/ 7 | *.egg-info 8 | .idea 9 | .pypirc 10 | .tox/ 11 | .cache/ 12 | venv/ 13 | .venv 14 | .vscode/ 15 | .coverage 16 | coverage.xml 17 | -------------------------------------------------------------------------------- /.pypirc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hipo/drf-extra-fields/8c18a7542c8a38fe3dccd1874a74a38410aa3a7f/.pypirc -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | # Needed to be able to install python versions. 4 | RUN apt-get update && apt-get install -y software-properties-common 5 | RUN add-apt-repository ppa:deadsnakes/ppa 6 | 7 | ARG DEBIAN_FRONTEND=noninteractive # to prevent tzdata from asking for input and hanging #193 8 | 9 | RUN apt-get update && apt-get install -y \ 10 | python3.7 python3.7-distutils \ 11 | python3.8 python3.8-distutils \ 12 | python3.9 python3.9-distutils \ 13 | python3.10 python3.10-distutils \ 14 | python3.11 python3.11-distutils \ 15 | gdal-bin \ 16 | python3-pip 17 | 18 | WORKDIR /app 19 | 20 | RUN pip3 install --upgrade pip 21 | RUN pip3 install tox 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DRF-EXTRA-FIELDS 2 | ================ 3 | 4 | Extra Fields for Django Rest Framework 5 | 6 | [![Build Status](https://github.com/Hipo/drf-extra-fields/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/Hipo/drf-extra-fields/actions) 7 | [![codecov](https://codecov.io/gh/Hipo/drf-extra-fields/branch/master/graph/badge.svg)](https://codecov.io/gh/Hipo/drf-extra-fields) 8 | [![PyPI Version](https://img.shields.io/pypi/v/drf-extra-fields.svg)](https://pypi.org/project/drf-extra-fields) 9 | [![Python Versions](https://img.shields.io/pypi/pyversions/drf-extra-fields.svg)](https://pypi.org/project/drf-extra-fields) 10 | 11 | Latest Changes 12 | ============== 13 | - **v3.7.0** 14 | - `psycopg` (psycopg 3) is now supported and it's used automatically instead of `psycopg2` if available. 15 | - **v3.6.0** 16 | - File objects without an actual file-system path can now be used in `Base64ImageField`, `Base64FileField` and `HybridImageField` 17 | - **v3.5.0** 18 | - Development environment fixes & improvements. 19 | - Since `Python 3.6` support is ended, the codebase is refactored/modernized for `Python 3.7`. 20 | - `WebP` is added to default `ALLOWED_TYPES` of the `Base64ImageField`. 21 | - Deprecated `imghdr` library is replaced with `filetype`. 22 | - Unintended `Pillow` dependency is removed. 23 | - **v3.4.0** 24 | - :warning: **BACKWARD INCOMPATIBLE** :warning: 25 | - Support for `Django 3.0` and `Django 3.1` is ended. 26 | - `Django 4.0` is now supported. 27 | - **v3.3.0** 28 | - :warning: **BACKWARD INCOMPATIBLE** :warning: 29 | - Support for `Python 3.6` is ended. 30 | - **v3.2.1** 31 | - A typo in the `python_requires` argument of `setup.py` that prevents installation for `Python 3.6` is fixed. 32 | - **v3.2.0** 33 | - :warning: **BACKWARD INCOMPATIBLE** :warning: 34 | - Support for `Python 3.5` is ended. 35 | - `Python 3.9` and `Python 3.10` are now supported. 36 | - `Django 3.2` is now supported. 37 | - **v3.1.1** 38 | - `psycopg2` dependency is made optional. 39 | - **v3.1.0** 40 | - **Possible Breaking Change**: 41 | - In this version we have changed file class used in `Base64FileField` from `ContentFile` to `SimpleUploadedFile` (you may see the change [here](https://github.com/Hipo/drf-extra-fields/pull/149/files#diff-5f77bcb61083cd9c026f6dfb3b77bf8fa824c45e620cdb7826ad713bde7b65f8L72-R85)). 42 | - `child_attrs` property is added to [RangeFields](https://github.com/Hipo/drf-extra-fields#rangefield). 43 | 44 | Usage 45 | ================ 46 | 47 | Install the package 48 | 49 | ```bash 50 | pip install drf-extra-fields 51 | ``` 52 | 53 | **Note:** 54 | - **This package renamed as "drf-extra-fields", earlier it was named as django-extra-fields.** 55 | - Install version 0.1 for Django Rest Framework 2.* 56 | - Install version 0.3 or greater for Django Rest Framework 3.* 57 | 58 | Fields: 59 | ---------------- 60 | 61 | 62 | ## Base64ImageField 63 | 64 | An image representation for Base64ImageField 65 | 66 | Inherited from `ImageField` 67 | 68 | 69 | **Signature:** `Base64ImageField()` 70 | 71 | - It takes a base64 image as a string. 72 | - A base64 image: `data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7` 73 | - Base64ImageField accepts the entire string or just the part after base64, `R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7` 74 | - It takes the optional parameter `represent_in_base64` (`False` by default), if set to `True` it will allow for base64-encoded downloads of an `ImageField`. 75 | - You can inherit the `Base64ImageField` class and set allowed extensions (`ALLOWED_TYPES` list), or customize the validation messages (`INVALID_FILE_MESSAGE`, `INVALID_TYPE_MESSAGE`) 76 | 77 | 78 | **Example:** 79 | 80 | ```python 81 | # serializer 82 | 83 | from drf_extra_fields.fields import Base64ImageField 84 | 85 | class UploadedBase64ImageSerializer(serializers.Serializer): 86 | file = Base64ImageField(required=False) 87 | created = serializers.DateTimeField() 88 | 89 | # use the serializer 90 | file = 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 91 | serializer = UploadedBase64ImageSerializer(data={'created': now, 'file': file}) 92 | ``` 93 | 94 | 95 | ## Base64FileField 96 | 97 | A file representation for Base64FileField 98 | 99 | Inherited from `FileField` 100 | 101 | 102 | **Signature:** `Base64FileField()` 103 | 104 | - It takes a base64 file as a string. 105 | - Other options like for `Base64ImageField` 106 | - You have to provide your own full implementation of this class. You have to implement file validation in `get_file_extension` method and set `ALLOWED_TYPES` list. 107 | 108 | 109 | **Example:** 110 | 111 | ```python 112 | class PDFBase64File(Base64FileField): 113 | ALLOWED_TYPES = ['pdf'] 114 | 115 | def get_file_extension(self, filename, decoded_file): 116 | try: 117 | PyPDF2.PdfFileReader(io.BytesIO(decoded_file)) 118 | except PyPDF2.utils.PdfReadError as e: 119 | logger.warning(e) 120 | else: 121 | return 'pdf' 122 | ``` 123 | 124 | 125 | ## PointField 126 | 127 | Point field for GeoDjango 128 | 129 | 130 | **Signature:** `PointField()` 131 | 132 | - It takes a dictionary contains latitude and longitude keys like below 133 | 134 | { 135 | "latitude": 49.8782482189424, 136 | "longitude": 24.452545489 137 | } 138 | - It takes the optional parameter `str_points` (False by default), if set to True it serializes the longitude/latitude 139 | values as strings 140 | - It takes the optional parameter `srid` (None by default), if set the Point created object will have its srid attribute set to the same value. 141 | 142 | **Example:** 143 | 144 | ```python 145 | # serializer 146 | 147 | from drf_extra_fields.geo_fields import PointField 148 | 149 | class PointFieldSerializer(serializers.Serializer): 150 | point = PointField(required=False) 151 | created = serializers.DateTimeField() 152 | 153 | # use the serializer 154 | point = { 155 | "latitude": 49.8782482189424, 156 | "longitude": 24.452545489 157 | } 158 | serializer = PointFieldSerializer(data={'created': now, 'point': point}) 159 | ``` 160 | 161 | 162 | # RangeField 163 | 164 | The Range Fields map to Django's PostgreSQL specific [Range Fields](https://docs.djangoproject.com/en/stable/ref/contrib/postgres/fields/#range-fields). 165 | 166 | Each accepts an optional parameter `child_attrs`, which allows passing parameters to the child field. 167 | 168 | For example, calling `IntegerRangeField(child_attrs={"allow_null": True})` allows deserializing data with a null value for `lower` and/or `upper`: 169 | 170 | ```python 171 | from rest_framework import serializers 172 | from drf_extra_fields.fields import IntegerRangeField 173 | 174 | 175 | class RangeSerializer(serializers.Serializer): 176 | ranges = IntegerRangeField(child_attrs={"allow_null": True}) 177 | 178 | 179 | serializer = RangeSerializer(data={'ranges': {'lower': 0, 'upper': None}}) 180 | 181 | ``` 182 | 183 | ## IntegerRangeField 184 | 185 | ```python 186 | from rest_framework import serializers 187 | from drf_extra_fields.fields import IntegerRangeField 188 | 189 | 190 | class RangeSerializer(serializers.Serializer): 191 | ranges = IntegerRangeField() 192 | 193 | 194 | serializer = RangeSerializer(data={'ranges': {'lower': 0, 'upper': 1}}) 195 | 196 | ``` 197 | 198 | ## FloatRangeField 199 | 200 | ```python 201 | from rest_framework import serializers 202 | from drf_extra_fields.fields import FloatRangeField 203 | 204 | 205 | class RangeSerializer(serializers.Serializer): 206 | ranges = FloatRangeField() 207 | 208 | 209 | serializer = RangeSerializer(data={'ranges': {'lower': 0., 'upper': 1.}}) 210 | 211 | ``` 212 | 213 | ## DecimalRangeField 214 | 215 | ```python 216 | from rest_framework import serializers 217 | from drf_extra_fields.fields import DecimalRangeField 218 | 219 | 220 | class RangeSerializer(serializers.Serializer): 221 | ranges = DecimalRangeField() 222 | 223 | 224 | serializer = RangeSerializer(data={'ranges': {'lower': 0., 'upper': 1.}}, ) 225 | 226 | ``` 227 | 228 | ## DateRangeField 229 | 230 | ```python 231 | import datetime 232 | 233 | from rest_framework import serializers 234 | from drf_extra_fields.fields import DateRangeField 235 | 236 | 237 | class RangeSerializer(serializers.Serializer): 238 | ranges = DateRangeField() 239 | 240 | 241 | serializer = RangeSerializer(data={'ranges': {'lower': datetime.date(2015, 1, 1), 'upper': datetime.date(2015, 2, 1)}}) 242 | 243 | ``` 244 | 245 | ## DateTimeRangeField 246 | 247 | ```python 248 | import datetime 249 | 250 | from rest_framework import serializers 251 | from drf_extra_fields.fields import DateTimeRangeField 252 | 253 | 254 | class RangeSerializer(serializers.Serializer): 255 | ranges = DateTimeRangeField() 256 | 257 | 258 | serializer = RangeSerializer(data={'ranges': {'lower': datetime.datetime(2015, 1, 1, 0), 'upper': datetime.datetime(2015, 2, 1, 0)}}) 259 | 260 | ``` 261 | 262 | ## PresentablePrimaryKeyRelatedField 263 | 264 | Represents related object with a serializer. 265 | 266 | `presentation_serializer` could also be a string that represents a dotted path of a serializer, this is useful when you want to represent a related field with the same serializer. 267 | 268 | ```python 269 | from drf_extra_fields.relations import PresentablePrimaryKeyRelatedField 270 | 271 | class UserSerializer(serializers.ModelSerializer): 272 | class Meta: 273 | model = User 274 | fields = ( 275 | 'id', 276 | "username", 277 | ) 278 | 279 | class PostSerializer(serializers.ModelSerializer): 280 | user = PresentablePrimaryKeyRelatedField( 281 | queryset=User.objects.all(), 282 | presentation_serializer=UserSerializer, 283 | presentation_serializer_kwargs={ 284 | 'example': [ 285 | 'of', 286 | 'passing', 287 | 'kwargs', 288 | 'to', 289 | 'serializer', 290 | ] 291 | }, 292 | read_source=None 293 | ) 294 | class Meta: 295 | model = Post 296 | fields = ( 297 | "id", 298 | "title", 299 | "user", 300 | ) 301 | ``` 302 | 303 | **Serializer data:** 304 | ``` 305 | { 306 | "user": 1, 307 | "title": "test" 308 | } 309 | ``` 310 | 311 | **Serialized data with PrimaryKeyRelatedField:** 312 | ``` 313 | { 314 | "id":1, 315 | "user": 1, 316 | "title": "test" 317 | } 318 | ``` 319 | 320 | **Serialized data with PresentablePrimaryKeyRelatedField:** 321 | ``` 322 | { 323 | "id":1, 324 | "user": { 325 | "id": 1, 326 | "username": "test" 327 | }, 328 | "title": "test" 329 | } 330 | ``` 331 | 332 | 333 | ## PresentableSlugRelatedField 334 | 335 | Represents related object retrieved using slug with a serializer. 336 | 337 | ```python 338 | from drf_extra_fields.relations import PresentableSlugRelatedField 339 | 340 | class CategorySerializer(serializers.ModelSerializer): 341 | class Meta: 342 | model = Category 343 | fields = ( 344 | "id", 345 | "slug", 346 | "name" 347 | ) 348 | 349 | class ProductSerializer(serializers.ModelSerializer): 350 | category = PresentableSlugRelatedField( 351 | slug_field="slug", 352 | queryset=Category.objects.all(), 353 | presentation_serializer=CategorySerializer, 354 | presentation_serializer_kwargs={ 355 | 'example': [ 356 | 'of', 357 | 'passing', 358 | 'kwargs', 359 | 'to', 360 | 'serializer', 361 | ] 362 | }, 363 | read_source=None 364 | ) 365 | class Meta: 366 | model = Product 367 | fields = ( 368 | "id", 369 | "name", 370 | "category", 371 | ) 372 | ``` 373 | 374 | **Serializer data:** 375 | ``` 376 | { 377 | "category": "vegetables", 378 | "name": "Tomato" 379 | } 380 | ``` 381 | 382 | **Serialized data with SlugRelatedField:** 383 | ``` 384 | { 385 | "id": 1, 386 | "name": "Tomato", 387 | "category": "vegetables" 388 | } 389 | ``` 390 | 391 | **Serialized data with PresentableSlugRelatedField:** 392 | ``` 393 | { 394 | "id": 1, 395 | "name": "Tomato", 396 | "category": { 397 | "id": 1, 398 | "slug": "vegetables", 399 | "name": "Vegetables" 400 | } 401 | } 402 | ``` 403 | 404 | ### read_source parameter 405 | This parameter allows you to use different `source` for read operations and doesn't change field name for write operations. This is only used while representing the data. 406 | 407 | ## HybridImageField 408 | A django-rest-framework field for handling image-uploads through raw post data, with a fallback to multipart form data. 409 | 410 | It first tries Base64ImageField. if it fails then tries ImageField. 411 | 412 | ```python 413 | from rest_framework import serializers 414 | from drf_extra_fields.fields import HybridImageField 415 | 416 | 417 | class HybridImageSerializer(serializers.Serializer): 418 | image = HybridImageField() 419 | ``` 420 | 421 | drf-yasg fix for BASE64 Fields: 422 | ---------------- 423 | The [drf-yasg](https://github.com/axnsan12/drf-yasg) project seems to generate wrong documentation on Base64ImageField or Base64FileField. It marks those fields as readonly. Here is the workaround code for correct the generated document. (More detail on issue [#66](https://github.com/Hipo/drf-extra-fields/issues/66)) 424 | 425 | ```python 426 | class PDFBase64FileField(Base64FileField): 427 | ALLOWED_TYPES = ['pdf'] 428 | 429 | class Meta: 430 | swagger_schema_fields = { 431 | 'type': 'string', 432 | 'title': 'File Content', 433 | 'description': 'Content of the file base64 encoded', 434 | 'read_only': False # <-- FIX 435 | } 436 | 437 | def get_file_extension(self, filename, decoded_file): 438 | try: 439 | PyPDF2.PdfFileReader(io.BytesIO(decoded_file)) 440 | except PyPDF2.utils.PdfReadError as e: 441 | logger.warning(e) 442 | else: 443 | return 'pdf' 444 | ``` 445 | 446 | 447 | ## LowercaseEmailField 448 | An enhancement over django-rest-framework's EmailField to allow case-insensitive serialization and deserialization of e-mail addresses. 449 | 450 | ```python 451 | from rest_framework import serializers 452 | from drf_extra_fields.fields import LowercaseEmailField 453 | 454 | 455 | class EmailSerializer(serializers.Serializer): 456 | email = LowercaseEmailField() 457 | 458 | ``` 459 | 460 | CONTRIBUTION 461 | ================= 462 | 463 | **TESTS** 464 | - Make sure that you add the test for contributed field to test/test_fields.py 465 | and run with command before sending a pull request: 466 | 467 | ```bash 468 | $ pip install tox # if not already installed 469 | $ tox 470 | ``` 471 | 472 | Or, if you prefer using Docker (recommended): 473 | 474 | ```bash 475 | tools/run_development.sh 476 | tox 477 | ``` 478 | 479 | **README** 480 | - Make sure that you add the documentation for the field added to README.md 481 | 482 | 483 | LICENSE 484 | ==================== 485 | 486 | Copyright DRF EXTRA FIELDS HIPO 487 | 488 | Licensed under the Apache License, Version 2.0 (the "License"); 489 | you may not use this file except in compliance with the License. 490 | You may obtain a copy of the License at 491 | 492 | http://www.apache.org/licenses/LICENSE-2.0 493 | 494 | Unless required by applicable law or agreed to in writing, software 495 | distributed under the License is distributed on an "AS IS" BASIS, 496 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 497 | See the License for the specific language governing permissions and 498 | limitations under the License. 499 | -------------------------------------------------------------------------------- /drf_extra_fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hipo/drf-extra-fields/8c18a7542c8a38fe3dccd1874a74a38410aa3a7f/drf_extra_fields/__init__.py -------------------------------------------------------------------------------- /drf_extra_fields/compat.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | try: 4 | from django.contrib.postgres import fields as postgres_fields 5 | 6 | if django.VERSION >= (4, 2): 7 | try: 8 | from psycopg.types.range import DateRange, NumericRange 9 | from psycopg.types.range import TimestamptzRange as DateTimeTZRange 10 | except ImportError: 11 | from psycopg2.extras import DateRange, DateTimeTZRange, NumericRange 12 | else: 13 | from psycopg2.extras import DateRange, DateTimeTZRange, NumericRange 14 | except ImportError: 15 | postgres_fields = None 16 | DateRange = None 17 | DateTimeTZRange = None 18 | NumericRange = None 19 | -------------------------------------------------------------------------------- /drf_extra_fields/fields.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import io 4 | import uuid 5 | 6 | import filetype 7 | from django.core.exceptions import ValidationError 8 | from django.core.files.uploadedfile import SimpleUploadedFile 9 | from django.utils.translation import gettext_lazy as _ 10 | from rest_framework.fields import ( 11 | DateField, 12 | DateTimeField, 13 | DecimalField, 14 | DictField, 15 | EmailField, 16 | FileField, 17 | FloatField, 18 | ImageField, 19 | IntegerField, 20 | ) 21 | from rest_framework.serializers import ModelSerializer 22 | from rest_framework.utils import html 23 | 24 | from drf_extra_fields import compat 25 | from drf_extra_fields.compat import DateRange, DateTimeTZRange, NumericRange 26 | 27 | DEFAULT_CONTENT_TYPE = "application/octet-stream" 28 | 29 | 30 | class Base64FieldMixin: 31 | EMPTY_VALUES = (None, "", [], (), {}) 32 | 33 | @property 34 | def ALLOWED_TYPES(self): 35 | raise NotImplementedError 36 | 37 | @property 38 | def INVALID_FILE_MESSAGE(self): 39 | raise NotImplementedError 40 | 41 | @property 42 | def INVALID_TYPE_MESSAGE(self): 43 | raise NotImplementedError 44 | 45 | def __init__(self, *args, **kwargs): 46 | self.trust_provided_content_type = kwargs.pop("trust_provided_content_type", False) 47 | self.represent_in_base64 = kwargs.pop("represent_in_base64", False) 48 | super().__init__(*args, **kwargs) 49 | 50 | def to_internal_value(self, base64_data): 51 | # Check if this is a base64 string 52 | if base64_data in self.EMPTY_VALUES: 53 | return None 54 | 55 | if isinstance(base64_data, str): 56 | file_mime_type = None 57 | 58 | # Strip base64 header, get mime_type from base64 header. 59 | if ";base64," in base64_data: 60 | header, base64_data = base64_data.split(";base64,") 61 | if self.trust_provided_content_type: 62 | file_mime_type = header.replace("data:", "") 63 | 64 | # Try to decode the file. Return validation error if it fails. 65 | try: 66 | decoded_file = base64.b64decode(base64_data) 67 | except (TypeError, binascii.Error, ValueError): 68 | raise ValidationError(self.INVALID_FILE_MESSAGE) 69 | 70 | # Generate file name: 71 | file_name = self.get_file_name(decoded_file) 72 | 73 | # Get the file name extension: 74 | file_extension = self.get_file_extension(file_name, decoded_file) 75 | 76 | if file_extension not in self.ALLOWED_TYPES: 77 | raise ValidationError(self.INVALID_TYPE_MESSAGE) 78 | 79 | complete_file_name = file_name + "." + file_extension 80 | data = SimpleUploadedFile( 81 | name=complete_file_name, 82 | content=decoded_file, 83 | content_type=file_mime_type 84 | ) 85 | 86 | return super().to_internal_value(data) 87 | 88 | raise ValidationError(_(f"Invalid type. This is not an base64 string: {type(base64_data)}")) 89 | 90 | def get_file_extension(self, filename, decoded_file): 91 | raise NotImplementedError 92 | 93 | def get_file_name(self, decoded_file): 94 | return str(uuid.uuid4()) 95 | 96 | def to_representation(self, file): 97 | if self.represent_in_base64: 98 | # If the underlying ImageField is blank, a ValueError would be 99 | # raised on `open`. When representing as base64, simply return an 100 | # empty base64 str rather than let the exception propagate unhandled 101 | # up into serializers. 102 | if not file: 103 | return "" 104 | 105 | try: 106 | with file.open() as f: 107 | return base64.b64encode(f.read()).decode() 108 | except Exception: 109 | raise OSError("Error encoding file") 110 | else: 111 | return super().to_representation(file) 112 | 113 | 114 | class Base64ImageField(Base64FieldMixin, ImageField): 115 | """ 116 | A django-rest-framework field for handling image-uploads through raw post data. 117 | It uses base64 for en-/decoding the contents of the file. 118 | """ 119 | ALLOWED_TYPES = ( 120 | "jpeg", 121 | "jpg", 122 | "png", 123 | "gif", 124 | "webp" 125 | ) 126 | INVALID_FILE_MESSAGE = _("Please upload a valid image.") 127 | INVALID_TYPE_MESSAGE = _("The type of the image couldn't be determined.") 128 | 129 | def get_file_extension(self, filename, decoded_file): 130 | extension = filetype.guess_extension(decoded_file) 131 | if extension is None: 132 | try: 133 | # Try with PIL as fallback if format not detected 134 | # with `filetype` module 135 | from PIL import Image 136 | image = Image.open(io.BytesIO(decoded_file)) 137 | except (ImportError, OSError): 138 | raise ValidationError(self.INVALID_FILE_MESSAGE) 139 | else: 140 | extension = image.format.lower() 141 | 142 | return "jpg" if extension == "jpeg" else extension 143 | 144 | 145 | class HybridImageField(Base64ImageField): 146 | """ 147 | A django-rest-framework field for handling image-uploads through 148 | raw post data, with a fallback to multipart form data. 149 | """ 150 | 151 | def to_internal_value(self, data): 152 | """ 153 | Try Base64Field first, and then try the ImageField 154 | ``to_internal_value``, MRO doesn't work here because 155 | Base64FieldMixin throws before ImageField can run. 156 | """ 157 | try: 158 | return Base64FieldMixin.to_internal_value(self, data) 159 | except ValidationError: 160 | return ImageField.to_internal_value(self, data) 161 | 162 | 163 | class Base64FileField(Base64FieldMixin, FileField): 164 | """ 165 | A django-rest-framework field for handling file-uploads through raw post data. 166 | It uses base64 for en-/decoding the contents of the file. 167 | """ 168 | 169 | @property 170 | def ALLOWED_TYPES(self): 171 | raise NotImplementedError('List allowed file extensions') 172 | 173 | INVALID_FILE_MESSAGE = _("Please upload a valid file.") 174 | INVALID_TYPE_MESSAGE = _("The type of the file couldn't be determined.") 175 | 176 | def get_file_extension(self, filename, decoded_file): 177 | raise NotImplementedError('Implement file validation and return matching extension.') 178 | 179 | 180 | class RangeField(DictField): 181 | range_type = None 182 | 183 | default_error_messages = dict(DictField.default_error_messages) 184 | default_error_messages.update({ 185 | 'too_much_content': _('Extra content not allowed "{extra}".'), 186 | 'bound_ordering': _('The start of the range must not exceed the end of the range.'), 187 | }) 188 | 189 | def __init__(self, **kwargs): 190 | if compat.postgres_fields is None: 191 | assert False, "'psycopg' is required to use {name}. Please install the 'psycopg2' (or 'psycopg' if you are using Django>=4.2) library from 'pip'".format( 192 | name=self.__class__.__name__ 193 | ) 194 | 195 | self.child_attrs = kwargs.pop("child_attrs", {}) 196 | self.child = self.child_class(**self.default_child_attrs, **self.child_attrs) 197 | super().__init__(**kwargs) 198 | 199 | def to_internal_value(self, data): 200 | """ 201 | Range instances <- Dicts of primitive datatypes. 202 | """ 203 | if html.is_html_input(data): 204 | data = html.parse_html_dict(data) 205 | if not isinstance(data, dict): 206 | self.fail('not_a_dict', input_type=type(data).__name__) 207 | 208 | # allow_empty is added to DictField in DRF Version 3.9.3 209 | if hasattr(self, "allow_empty") and not self.allow_empty and len(data) == 0: 210 | self.fail('empty') 211 | 212 | extra_content = list(set(data) - {"lower", "upper", "bounds", "empty"}) 213 | if extra_content: 214 | self.fail('too_much_content', extra=', '.join(map(str, extra_content))) 215 | 216 | validated_dict = {} 217 | for key in ('lower', 'upper'): 218 | try: 219 | value = data[key] 220 | except KeyError: 221 | continue 222 | 223 | validated_dict[str(key)] = self.child.run_validation(value) 224 | 225 | lower, upper = validated_dict.get('lower'), validated_dict.get('upper') 226 | if lower is not None and upper is not None and lower > upper: 227 | self.fail('bound_ordering') 228 | 229 | for key in ('bounds', 'empty'): 230 | try: 231 | value = data[key] 232 | except KeyError: 233 | continue 234 | 235 | validated_dict[str(key)] = value 236 | 237 | return self.range_type(**validated_dict) 238 | 239 | def to_representation(self, value): 240 | """ 241 | Range instances -> dicts of primitive datatypes. 242 | """ 243 | if isinstance(value, dict): 244 | if not value: 245 | return value 246 | 247 | lower = value.get("lower") 248 | upper = value.get("upper") 249 | bounds = value.get("bounds") 250 | else: 251 | if value.isempty: 252 | return {'empty': True} 253 | lower = value.lower 254 | upper = value.upper 255 | bounds = value._bounds 256 | 257 | return {'lower': self.child.to_representation(lower) if lower is not None else None, 258 | 'upper': self.child.to_representation(upper) if upper is not None else None, 259 | 'bounds': bounds} 260 | 261 | def get_initial(self): 262 | initial = super().get_initial() 263 | return self.to_representation(initial) 264 | 265 | 266 | class IntegerRangeField(RangeField): 267 | child_class = IntegerField 268 | default_child_attrs = {} 269 | range_type = NumericRange 270 | 271 | 272 | class FloatRangeField(RangeField): 273 | child_class = FloatField 274 | default_child_attrs = {} 275 | range_type = NumericRange 276 | 277 | 278 | class DecimalRangeField(RangeField): 279 | child_class = DecimalField 280 | default_child_attrs = {"max_digits": None, "decimal_places": None} 281 | range_type = NumericRange 282 | 283 | 284 | class DateTimeRangeField(RangeField): 285 | child_class = DateTimeField 286 | default_child_attrs = {} 287 | range_type = DateTimeTZRange 288 | 289 | 290 | class DateRangeField(RangeField): 291 | child_class = DateField 292 | default_child_attrs = {} 293 | range_type = DateRange 294 | 295 | 296 | if compat.postgres_fields: 297 | # monkey patch modelserializer to map Native django Range fields to 298 | # drf_extra_fiels's Range fields. 299 | ModelSerializer.serializer_field_mapping[compat.postgres_fields.DateTimeRangeField] = DateTimeRangeField 300 | ModelSerializer.serializer_field_mapping[compat.postgres_fields.DateRangeField] = DateRangeField 301 | ModelSerializer.serializer_field_mapping[compat.postgres_fields.IntegerRangeField] = IntegerRangeField 302 | ModelSerializer.serializer_field_mapping[compat.postgres_fields.DecimalRangeField] = DecimalRangeField 303 | if hasattr(compat.postgres_fields, "FloatRangeField"): 304 | ModelSerializer.serializer_field_mapping[compat.postgres_fields.FloatRangeField] = FloatRangeField 305 | 306 | 307 | class LowercaseEmailField(EmailField): 308 | """ 309 | An enhancement over django-rest-framework's EmailField to allow 310 | case-insensitive serialization and deserialization of e-mail addresses. 311 | """ 312 | def to_internal_value(self, data): 313 | data = super().to_internal_value(data) 314 | return data.lower() 315 | 316 | def to_representation(self, value): 317 | value = super().to_representation(value) 318 | return value.lower() 319 | -------------------------------------------------------------------------------- /drf_extra_fields/geo_fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.contrib.gis.geos import GEOSGeometry 3 | from django.contrib.gis.geos.error import GEOSException 4 | from django.utils.encoding import smart_str 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from rest_framework import serializers 8 | 9 | EMPTY_VALUES = (None, '', [], (), {}) 10 | 11 | 12 | class PointField(serializers.Field): 13 | """ 14 | A field for handling GeoDjango Point fields as a json format. 15 | Expected input format: 16 | { 17 | "latitude": 49.8782482189424, 18 | "longitude": 24.452545489 19 | } 20 | 21 | """ 22 | type_name = 'PointField' 23 | type_label = 'point' 24 | 25 | default_error_messages = { 26 | 'invalid': _('Enter a valid location.'), 27 | } 28 | 29 | def __init__(self, *args, **kwargs): 30 | self.str_points = kwargs.pop('str_points', False) 31 | self.srid = kwargs.pop('srid', None) 32 | super().__init__(*args, **kwargs) 33 | 34 | def to_internal_value(self, value): 35 | """ 36 | Parse json data and return a point object 37 | """ 38 | if value in EMPTY_VALUES and not self.required: 39 | return None 40 | 41 | if isinstance(value, str): 42 | try: 43 | value = value.replace("'", '"') 44 | value = json.loads(value) 45 | except ValueError: 46 | self.fail('invalid') 47 | 48 | if value and isinstance(value, dict): 49 | try: 50 | latitude = value.get("latitude") 51 | longitude = value.get("longitude") 52 | 53 | return GEOSGeometry(f"POINT({longitude} {latitude})", srid=self.srid) 54 | 55 | except (GEOSException, ValueError): 56 | self.fail('invalid') 57 | self.fail('invalid') 58 | 59 | def to_representation(self, value): 60 | """ 61 | Transform POINT object to json. 62 | """ 63 | if value is None: 64 | return value 65 | 66 | if isinstance(value, GEOSGeometry): 67 | value = { 68 | "latitude": value.y, 69 | "longitude": value.x 70 | } 71 | 72 | if self.str_points: 73 | value['longitude'] = smart_str(value.pop('longitude')) 74 | value['latitude'] = smart_str(value.pop('latitude')) 75 | 76 | return value 77 | -------------------------------------------------------------------------------- /drf_extra_fields/relations.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.utils.module_loading import import_string 4 | from rest_framework.relations import ( 5 | PrimaryKeyRelatedField, SlugRelatedField, MANY_RELATION_KWARGS, 6 | ManyRelatedField as DRFManyRelatedField 7 | ) 8 | 9 | 10 | class ReadSourceMixin: 11 | """ 12 | This mixin override get_attribute method and set read_source attribute 13 | to source attribute if read_source attribute setted. For the purpose of 14 | not want to effect of write operation, we don't override bind method. 15 | """ 16 | 17 | class ManyRelatedField(DRFManyRelatedField): 18 | def get_attribute(self, instance): 19 | if self.child_relation.read_source: 20 | self.source = self.child_relation.read_source 21 | self.bind(self.field_name, self.parent) 22 | 23 | return super().get_attribute(instance) 24 | 25 | def __init__(self, **kwargs): 26 | self.read_source = kwargs.pop("read_source", None) 27 | super().__init__(**kwargs) 28 | 29 | @classmethod 30 | def many_init(cls, *args, **kwargs): 31 | if not kwargs.get("read_source", None): 32 | return super().many_init(*args, **kwargs) 33 | 34 | list_kwargs = {'child_relation': cls(*args, **kwargs)} 35 | for key in kwargs: 36 | if key in MANY_RELATION_KWARGS: 37 | list_kwargs[key] = kwargs[key] 38 | 39 | return cls.ManyRelatedField(**list_kwargs) 40 | 41 | def get_attribute(self, instance): 42 | if self.read_source: 43 | self.source = self.read_source 44 | self.bind(self.field_name, self.parent) 45 | 46 | return super().get_attribute(instance) 47 | 48 | 49 | class PresentableRelatedFieldMixin(ReadSourceMixin): 50 | def __init__(self, **kwargs): 51 | self.presentation_serializer = kwargs.pop("presentation_serializer", None) 52 | self.presentation_serializer_kwargs = kwargs.pop( 53 | "presentation_serializer_kwargs", dict() 54 | ) 55 | assert self.presentation_serializer is not None, ( 56 | self.__class__.__name__ 57 | + " must provide a `presentation_serializer` argument" 58 | ) 59 | super().__init__(**kwargs) 60 | 61 | def use_pk_only_optimization(self): 62 | 63 | """ 64 | Instead of sending pk only object, return full object. The object already retrieved from db by drf. 65 | This doesn't cause an extra query. 66 | It even might save from making an extra query on serializer.to_representation method. 67 | Related source codes: 68 | - https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/relations.py#L41 69 | - https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/relations.py#L132 70 | """ 71 | return False 72 | 73 | def get_choices(self, cutoff=None): 74 | queryset = self.get_queryset() 75 | if queryset is None: 76 | # Ensure that field.choices returns something sensible 77 | # even when accessed with a read-only field. 78 | return {} 79 | 80 | if cutoff is not None: 81 | queryset = queryset[:cutoff] 82 | 83 | return OrderedDict([(item.pk, self.display_value(item)) for item in queryset]) 84 | 85 | def to_representation(self, data): 86 | if isinstance(self.presentation_serializer, str): 87 | self.presentation_serializer = import_string(self.presentation_serializer) 88 | 89 | return self.presentation_serializer( 90 | data, context=self.context, **self.presentation_serializer_kwargs 91 | ).data 92 | 93 | 94 | class PresentablePrimaryKeyRelatedField( 95 | PresentableRelatedFieldMixin, PrimaryKeyRelatedField 96 | ): 97 | """ 98 | Override PrimaryKeyRelatedField to represent serializer data instead of a pk field of the object. 99 | """ 100 | 101 | pass 102 | 103 | 104 | class PresentableSlugRelatedField(PresentableRelatedFieldMixin, SlugRelatedField): 105 | """ 106 | Override SlugRelatedField to represent serializer data instead of a slug field of the object. 107 | """ 108 | 109 | pass 110 | -------------------------------------------------------------------------------- /drf_extra_fields/runtests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hipo/drf-extra-fields/8c18a7542c8a38fe3dccd1874a74a38410aa3a7f/drf_extra_fields/runtests/__init__.py -------------------------------------------------------------------------------- /drf_extra_fields/runtests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Django settings for testproject project. 4 | 5 | DEBUG = True 6 | TEMPLATE_DEBUG = DEBUG 7 | DEBUG_PROPAGATE_EXCEPTIONS = True 8 | 9 | ALLOWED_HOSTS = ['*'] 10 | 11 | ADMINS = ( 12 | # ('Your Name', 'your_email@domain.com'), 13 | ) 14 | 15 | MANAGERS = ADMINS 16 | 17 | DATABASES = { 18 | 'default': { 19 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 20 | 'NAME': 'sqlite.db', # Or path to database file if using sqlite3. 21 | 'USER': '', # Not used with sqlite3. 22 | 'PASSWORD': '', # Not used with sqlite3. 23 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 24 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 25 | } 26 | } 27 | 28 | CACHES = { 29 | 'default': { 30 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 31 | } 32 | } 33 | 34 | # Local time zone for this installation. Choices can be found here: 35 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 36 | # although not all choices may be available on all operating systems. 37 | # On Unix systems, a value of None will cause Django to use the same 38 | # timezone as the operating system. 39 | # If running in a Windows environment this must be set to the same as your 40 | # system time zone. 41 | TIME_ZONE = 'Europe/London' 42 | 43 | # Language code for this installation. All choices can be found here: 44 | # http://www.i18nguy.com/unicode/language-identifiers.html 45 | LANGUAGE_CODE = 'en-uk' 46 | 47 | SITE_ID = 1 48 | 49 | # If you set this to False, Django will make some optimizations so as not 50 | # to load the internationalization machinery. 51 | USE_I18N = True 52 | 53 | # If you set this to False, Django will not format dates, numbers and 54 | # calendars according to the current locale 55 | USE_L10N = True 56 | 57 | # Absolute filesystem path to the directory that will hold user-uploaded files. 58 | # Example: "/home/media/media.lawrence.com/" 59 | MEDIA_ROOT = '' 60 | 61 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 62 | # trailing slash if there is a path component (optional in other cases). 63 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 64 | MEDIA_URL = '' 65 | 66 | # Make this unique, and don't share it with anybody. 67 | SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy' 68 | 69 | # List of callables that know how to import templates from various sources. 70 | TEMPLATE_LOADERS = ( 71 | 'django.template.loaders.filesystem.Loader', 72 | 'django.template.loaders.app_directories.Loader', 73 | # 'django.template.loaders.eggs.Loader', 74 | ) 75 | 76 | MIDDLEWARE_CLASSES = ( 77 | 'django.middleware.common.CommonMiddleware', 78 | 'django.contrib.sessions.middleware.SessionMiddleware', 79 | 'django.middleware.csrf.CsrfViewMiddleware', 80 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 81 | 'django.contrib.messages.middleware.MessageMiddleware', 82 | ) 83 | 84 | ROOT_URLCONF = 'urls' 85 | 86 | TEMPLATE_DIRS = ( 87 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 88 | # Always use forward slashes, even on Windows. 89 | # Don't forget to use absolute paths, not relative paths. 90 | ) 91 | 92 | INSTALLED_APPS = ( 93 | 'django.contrib.auth', 94 | 'django.contrib.contenttypes', 95 | 'django.contrib.sessions', 96 | 'django.contrib.sites', 97 | 'django.contrib.messages', 98 | # Uncomment the next line to enable the admin: 99 | # 'django.contrib.admin', 100 | # Uncomment the next line to enable admin documentation: 101 | # 'django.contrib.admindocs', 102 | 'rest_framework', 103 | 'rest_framework.authtoken', 104 | # 'rest_framework.tests', 105 | # 'rest_framework.tests.accounts', 106 | # 'rest_framework.tests.records', 107 | # 'rest_framework.tests.users', 108 | ) 109 | 110 | STATIC_URL = '/static/' 111 | 112 | PASSWORD_HASHERS = ( 113 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 114 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 115 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 116 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 117 | 'django.contrib.auth.hashers.MD5PasswordHasher', 118 | 'django.contrib.auth.hashers.CryptPasswordHasher', 119 | ) 120 | 121 | AUTH_USER_MODEL = 'auth.User' 122 | 123 | # If we're running on the Jenkins server we want to archive the coverage reports as XML. 124 | if os.environ.get('HUDSON_URL', None): 125 | TEST_RUNNER = 'xmlrunner.extra.djangotestrunner.XMLTestRunner' 126 | TEST_OUTPUT_VERBOSE = True 127 | TEST_OUTPUT_DESCRIPTIONS = True 128 | TEST_OUTPUT_DIR = 'xmlrunner' 129 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = drf_extra_fields.runtests.settings 3 | testpaths = tests 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django >= 2.2 2 | djangorestframework >= 3.9.2 3 | filetype >= 1.2.0 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | Pillow >= 6.2.1 2 | pytest-django 3 | pytest-cov 4 | flake8 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: 5 | README = readme.read() 6 | 7 | with open(os.path.join(os.path.dirname(__file__), 'requirements.txt')) as requirements_txt: 8 | requirements = requirements_txt.read().strip().splitlines() 9 | 10 | # allow setup.py to be run from any path 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | setup( 14 | name='drf-extra-fields', 15 | version='3.7.0', 16 | packages=['drf_extra_fields', 17 | 'drf_extra_fields.runtests'], 18 | include_package_data=True, 19 | extras_require={ 20 | "Base64ImageField": ["Pillow >= 6.2.1"], 21 | }, 22 | license='Apache-2.0', 23 | license_files=['LICENSE'], 24 | description='Additional fields for Django Rest Framework.', 25 | long_description=README, 26 | long_description_content_type="text/markdown", 27 | author='hipo', 28 | author_email='pypi@hipolabs.com', 29 | url='https://github.com/Hipo/drf-extra-fields', 30 | python_requires=">=3.7", 31 | install_requires=requirements, 32 | classifiers=[ 33 | 'Environment :: Web Environment', 34 | 'Framework :: Django', 35 | 'Intended Audience :: Developers', 36 | 'Operating System :: OS Independent', 37 | 'License :: OSI Approved :: BSD License', 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 3', 40 | 'Programming Language :: Python :: 3.7', 41 | 'Programming Language :: Python :: 3.8', 42 | 'Programming Language :: Python :: 3.9', 43 | 'Programming Language :: Python :: 3.10', 44 | 'Programming Language :: Python :: 3.11', 45 | 'Topic :: Internet :: WWW/HTTP', 46 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hipo/drf-extra-fields/8c18a7542c8a38fe3dccd1874a74a38410aa3a7f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import copy 3 | import datetime 4 | import os 5 | from decimal import Decimal 6 | from unittest.mock import patch 7 | 8 | import django 9 | import pytest 10 | import pytz 11 | from django.core.exceptions import ValidationError 12 | from django.test import TestCase, override_settings 13 | from rest_framework import serializers 14 | from rest_framework.fields import DecimalField 15 | 16 | from drf_extra_fields import compat 17 | from drf_extra_fields.compat import DateRange, DateTimeTZRange, NumericRange 18 | from drf_extra_fields.fields import ( 19 | Base64FileField, 20 | Base64ImageField, 21 | DateRangeField, 22 | DateTimeRangeField, 23 | DecimalRangeField, 24 | FloatRangeField, 25 | HybridImageField, 26 | IntegerRangeField, 27 | LowercaseEmailField, 28 | ) 29 | from drf_extra_fields.geo_fields import PointField 30 | 31 | 32 | class UploadedBase64Image: 33 | def __init__(self, file=None, created=None): 34 | self.file = file 35 | self.created = created or datetime.datetime.now() 36 | 37 | 38 | class UploadedBase64File(UploadedBase64Image): 39 | pass 40 | 41 | 42 | class DownloadableBase64Image: 43 | class ImageFieldFile: 44 | def __init__(self, path): 45 | self.path = path 46 | 47 | def open(self): 48 | return open(self.path, "rb") 49 | 50 | def __init__(self, image_path): 51 | self.image = self.ImageFieldFile(path=image_path) 52 | 53 | 54 | class DownloadableBase64File: 55 | class FieldFile: 56 | def __init__(self, path): 57 | self.path = path 58 | 59 | def open(self): 60 | return open(self.path, "rb") 61 | 62 | def __init__(self, file_path): 63 | self.file = self.FieldFile(path=file_path) 64 | 65 | 66 | class UploadedBase64ImageSerializer(serializers.Serializer): 67 | file = Base64ImageField(required=False) 68 | created = serializers.DateTimeField() 69 | 70 | def update(self, instance, validated_data): 71 | instance.file = validated_data['file'] 72 | return instance 73 | 74 | def create(self, validated_data): 75 | return UploadedBase64Image(**validated_data) 76 | 77 | 78 | class DownloadableBase64ImageSerializer(serializers.Serializer): 79 | image = Base64ImageField(represent_in_base64=True) 80 | 81 | 82 | class Base64ImageSerializerTests(TestCase): 83 | 84 | def test_create(self): 85 | """ 86 | Test for creating Base64 image in the server side 87 | """ 88 | now = datetime.datetime.now() 89 | file = 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 90 | serializer = UploadedBase64ImageSerializer(data={'created': now, 'file': file}) 91 | uploaded_image = UploadedBase64Image(file=file, created=now) 92 | self.assertTrue(serializer.is_valid()) 93 | self.assertEqual(serializer.validated_data['created'], uploaded_image.created) 94 | self.assertFalse(serializer.validated_data is uploaded_image) 95 | 96 | def test_create_with_base64_prefix(self): 97 | """ 98 | Test for creating Base64 image in the server side 99 | """ 100 | now = datetime.datetime.now() 101 | file = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 102 | serializer = UploadedBase64ImageSerializer(data={'created': now, 'file': file}) 103 | uploaded_image = UploadedBase64Image(file=file, created=now) 104 | self.assertTrue(serializer.is_valid()) 105 | self.assertEqual(serializer.validated_data['created'], uploaded_image.created) 106 | self.assertFalse(serializer.validated_data is uploaded_image) 107 | 108 | def test_create_with_invalid_base64(self): 109 | """ 110 | Test for creating Base64 image with an invalid Base64 string in the server side 111 | """ 112 | now = datetime.datetime.now() 113 | file = 'this_is_not_a_base64' 114 | serializer = UploadedBase64ImageSerializer(data={'created': now, 'file': file}) 115 | 116 | self.assertFalse(serializer.is_valid()) 117 | self.assertEqual(serializer.errors, {'file': [Base64ImageField.INVALID_FILE_MESSAGE]}) 118 | 119 | def test_validation_error_with_non_file(self): 120 | """ 121 | Passing non-base64 should raise a validation error. 122 | """ 123 | now = datetime.datetime.now() 124 | serializer = UploadedBase64ImageSerializer(data={'created': now, 125 | 'file': 'abc'}) 126 | self.assertFalse(serializer.is_valid()) 127 | self.assertEqual(serializer.errors, {'file': [Base64ImageField.INVALID_FILE_MESSAGE]}) 128 | 129 | def test_remove_with_empty_string(self): 130 | """ 131 | Passing empty string as data should cause image to be removed 132 | """ 133 | now = datetime.datetime.now() 134 | file = 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 135 | uploaded_image = UploadedBase64Image(file=file, created=now) 136 | serializer = UploadedBase64ImageSerializer(instance=uploaded_image, data={'created': now, 'file': ''}) 137 | self.assertTrue(serializer.is_valid()) 138 | self.assertEqual(serializer.validated_data['created'], uploaded_image.created) 139 | self.assertIsNone(serializer.validated_data['file']) 140 | 141 | def test_download(self): 142 | encoded_source = 'R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=' 143 | 144 | with open('im.jpg', 'wb') as im_file: 145 | im_file.write(base64.b64decode(encoded_source)) 146 | image = DownloadableBase64Image(os.path.abspath('im.jpg')) 147 | serializer = DownloadableBase64ImageSerializer(image) 148 | 149 | try: 150 | self.assertEqual(serializer.data['image'], encoded_source) 151 | finally: 152 | os.remove('im.jpg') 153 | 154 | def test_hybrid_image_field(self): 155 | field = HybridImageField() 156 | with patch('drf_extra_fields.fields.Base64FieldMixin') as mixin_patch: 157 | field.to_internal_value({}) 158 | self.assertTrue(mixin_patch.to_internal_value.called) 159 | 160 | with patch('drf_extra_fields.fields.Base64FieldMixin') as mixin_patch: 161 | mixin_patch.to_internal_value.side_effect = ValidationError('foobar') 162 | with patch('drf_extra_fields.fields.ImageField') as image_patch: 163 | field.to_internal_value({}) 164 | self.assertTrue(mixin_patch.to_internal_value.called) 165 | self.assertTrue(image_patch.to_internal_value.called) 166 | 167 | def test_create_with_webp_image(self): 168 | """ 169 | Test for creating Base64 image with webp format in the server side 170 | """ 171 | now = datetime.datetime.now() 172 | 173 | file = "data:image/webp;base64,UklGRkAAAABXRUJQVlA4IDQAAADwAQCdASoBAAEAAQAc" \ 174 | "JaACdLoB+AAETAAA/vW4f/6aR40jxpHxcP/ugT90CfugT/3NoAAA" 175 | serializer = UploadedBase64ImageSerializer(data={'created': now, 'file': file}) 176 | uploaded_image = UploadedBase64Image(file=file, created=now) 177 | self.assertTrue(serializer.is_valid()) 178 | self.assertEqual(serializer.validated_data['created'], uploaded_image.created) 179 | self.assertFalse(serializer.validated_data is uploaded_image) 180 | 181 | 182 | class PDFBase64FileField(Base64FileField): 183 | ALLOWED_TYPES = ('pdf',) 184 | 185 | def get_file_extension(self, filename, decoded_file): 186 | return 'pdf' 187 | 188 | 189 | class UploadedBase64FileSerializer(serializers.Serializer): 190 | file = PDFBase64FileField(required=False) 191 | created = serializers.DateTimeField() 192 | 193 | def update(self, instance, validated_data): 194 | instance.file = validated_data['file'] 195 | return instance 196 | 197 | def create(self, validated_data): 198 | return UploadedBase64File(**validated_data) 199 | 200 | 201 | class DownloadableBase64FileSerializer(serializers.Serializer): 202 | file = PDFBase64FileField(represent_in_base64=True) 203 | 204 | 205 | class Base64FileSerializerTests(TestCase): 206 | def test_create(self): 207 | """ 208 | Test for creating Base64 file in the server side 209 | """ 210 | now = datetime.datetime.now() 211 | file = 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 212 | serializer = UploadedBase64FileSerializer(data={'created': now, 'file': file}) 213 | uploaded_file = UploadedBase64File(file=file, created=now) 214 | serializer.is_valid() 215 | self.assertTrue(serializer.is_valid()) 216 | self.assertEqual(serializer.validated_data['created'], uploaded_file.created) 217 | self.assertFalse(serializer.validated_data is uploaded_file) 218 | 219 | def test_create_with_base64_prefix(self): 220 | """ 221 | Test for creating Base64 file in the server side 222 | """ 223 | now = datetime.datetime.now() 224 | file = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 225 | serializer = UploadedBase64FileSerializer(data={'created': now, 'file': file}) 226 | uploaded_file = UploadedBase64File(file=file, created=now) 227 | self.assertTrue(serializer.is_valid()) 228 | self.assertEqual(serializer.validated_data['created'], uploaded_file.created) 229 | self.assertFalse(serializer.validated_data is uploaded_file) 230 | 231 | def test_validation_error_with_non_file(self): 232 | """ 233 | Passing non-base64 should raise a validation error. 234 | """ 235 | now = datetime.datetime.now() 236 | serializer = UploadedBase64FileSerializer(data={'created': now, 'file': 'abc'}) 237 | self.assertFalse(serializer.is_valid()) 238 | self.assertEqual(serializer.errors, {'file': [Base64FileField.INVALID_FILE_MESSAGE]}) 239 | 240 | def test_remove_with_empty_string(self): 241 | """ 242 | Passing empty string as data should cause file to be removed 243 | """ 244 | now = datetime.datetime.now() 245 | file = 'R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' 246 | uploaded_file = UploadedBase64File(file=file, created=now) 247 | serializer = UploadedBase64FileSerializer(instance=uploaded_file, data={'created': now, 'file': ''}) 248 | self.assertTrue(serializer.is_valid()) 249 | self.assertEqual(serializer.validated_data['created'], uploaded_file.created) 250 | self.assertIsNone(serializer.validated_data['file']) 251 | 252 | def test_download(self): 253 | encoded_source = 'R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=' 254 | 255 | with open('im.jpg', 'wb') as im_file: 256 | im_file.write(base64.b64decode(encoded_source)) 257 | file = DownloadableBase64File(os.path.abspath('im.jpg')) 258 | serializer = DownloadableBase64FileSerializer(file) 259 | 260 | try: 261 | self.assertEqual(serializer.data['file'], encoded_source) 262 | finally: 263 | os.remove('im.jpg') 264 | 265 | 266 | class SavePoint: 267 | def __init__(self, point=None, created=None): 268 | self.point = point 269 | self.created = created or datetime.datetime.now() 270 | 271 | 272 | class PointSerializer(serializers.Serializer): 273 | point = PointField(required=False) 274 | created = serializers.DateTimeField() 275 | 276 | def update(self, instance, validated_data): 277 | instance.point = validated_data['point'] 278 | return instance 279 | 280 | def create(self, validated_data): 281 | return SavePoint(**validated_data) 282 | 283 | 284 | class StringPointSerializer(PointSerializer): 285 | point = PointField(required=False, str_points=True) 286 | 287 | 288 | class SridPointSerializer(PointSerializer): 289 | point = PointField(required=False, srid=4326) 290 | 291 | 292 | class PointSerializerTest(TestCase): 293 | def test_create(self): 294 | """ 295 | Test for creating Point field in the server side 296 | """ 297 | now = datetime.datetime.now() 298 | point = { 299 | "latitude": 49.8782482189424, 300 | "longitude": 24.452545489 301 | } 302 | serializer = PointSerializer(data={'created': now, 'point': point}) 303 | saved_point = SavePoint(point=point, created=now) 304 | self.assertTrue(serializer.is_valid()) 305 | self.assertEqual(serializer.validated_data['created'], saved_point.created) 306 | self.assertFalse(serializer.validated_data is saved_point) 307 | self.assertIsNone(serializer.validated_data['point'].srid) 308 | 309 | def test_validation_error_with_non_file(self): 310 | """ 311 | Passing non-dict contains latitude and longitude should raise a validation error. 312 | """ 313 | now = datetime.datetime.now() 314 | serializer = PointSerializer(data={'created': now, 'point': '123'}) 315 | self.assertFalse(serializer.is_valid()) 316 | 317 | def test_remove_with_empty_string(self): 318 | """ 319 | Passing empty string as data should cause point to be removed 320 | """ 321 | now = datetime.datetime.now() 322 | point = { 323 | "latitude": 49.8782482189424, 324 | "longitude": 24.452545489 325 | } 326 | saved_point = SavePoint(point=point, created=now) 327 | serializer = PointSerializer(data={'created': now, 'point': ''}) 328 | self.assertTrue(serializer.is_valid()) 329 | self.assertEqual(serializer.validated_data['created'], saved_point.created) 330 | self.assertIsNone(serializer.validated_data['point']) 331 | 332 | def test_empty_latitude(self): 333 | now = datetime.datetime.now() 334 | point = { 335 | "latitude": 49.8782482189424, 336 | "longitude": "" 337 | } 338 | serializer = PointSerializer(data={'created': now, 'point': point}) 339 | self.assertFalse(serializer.is_valid()) 340 | 341 | def test_invalid_latitude(self): 342 | now = datetime.datetime.now() 343 | point = { 344 | "latitude": 49.8782482189424, 345 | "longitude": "fdff" 346 | } 347 | serializer = PointSerializer(data={'created': now, 'point': point}) 348 | self.assertFalse(serializer.is_valid()) 349 | 350 | def test_serialization(self): 351 | """ 352 | Regular JSON serialization should output float values 353 | """ 354 | from django.contrib.gis.geos import Point 355 | now = datetime.datetime.now() 356 | point = Point(24.452545489, 49.8782482189424) 357 | saved_point = SavePoint(point=point, created=now) 358 | serializer = PointSerializer(saved_point) 359 | self.assertEqual(serializer.data['point'], {'latitude': 49.8782482189424, 'longitude': 24.452545489}) 360 | 361 | def test_str_points_serialization(self): 362 | """ 363 | PointField with str_points=True should output string values 364 | """ 365 | from django.contrib.gis.geos import Point 366 | now = datetime.datetime.now() 367 | # test input is shortened due to string conversion rounding 368 | # gps has at max 8 decimals, so it doesn't make a difference 369 | point = Point(24.452545489, 49.8782482189) 370 | saved_point = SavePoint(point=point, created=now) 371 | serializer = StringPointSerializer(saved_point) 372 | self.assertEqual(serializer.data['point'], {'latitude': '49.8782482189', 'longitude': '24.452545489'}) 373 | 374 | def test_srid_point(self): 375 | """ 376 | PointField with srid should should result in a Point object with srid set 377 | """ 378 | now = datetime.datetime.now() 379 | point = { 380 | "latitude": 49.8782482189424, 381 | "longitude": 24.452545489 382 | } 383 | serializer = SridPointSerializer(data={'created': now, 'point': point}) 384 | self.assertTrue(serializer.is_valid()) 385 | self.assertEqual(serializer.validated_data['point'].srid, 4326) 386 | 387 | 388 | # Backported from django_rest_framework/tests/test_fields.py 389 | def get_items(mapping_or_list_of_two_tuples): 390 | # Tests accept either lists of two tuples, or dictionaries. 391 | if isinstance(mapping_or_list_of_two_tuples, dict): 392 | # {value: expected} 393 | return mapping_or_list_of_two_tuples.items() 394 | # [(value, expected), ...] 395 | return mapping_or_list_of_two_tuples 396 | 397 | 398 | class IntegerRangeSerializer(serializers.Serializer): 399 | 400 | range = IntegerRangeField() 401 | 402 | 403 | class IntegerRangeChildAllowNullSerializer(serializers.Serializer): 404 | 405 | range = IntegerRangeField(child_attrs={"allow_null": True}) 406 | 407 | 408 | class FloatRangeSerializer(serializers.Serializer): 409 | 410 | range = FloatRangeField() 411 | 412 | 413 | class DateTimeRangeSerializer(serializers.Serializer): 414 | 415 | range = DateTimeRangeField() 416 | 417 | 418 | class DateRangeSerializer(serializers.Serializer): 419 | 420 | range = DateRangeField(initial=DateRange(None, None, '()')) 421 | 422 | 423 | class DateRangeWithAllowEmptyFalseSerializer(serializers.Serializer): 424 | 425 | range = DateRangeField(allow_empty=False) 426 | 427 | 428 | class DateRangeWithAllowEmptyTrueSerializer(serializers.Serializer): 429 | 430 | range = DateRangeField(allow_empty=True) 431 | 432 | 433 | class DecimalRangeSerializer(serializers.Serializer): 434 | 435 | range = DecimalRangeField() 436 | 437 | 438 | class DecimalRangeSerializerWithChildAttribute(serializers.Serializer): 439 | 440 | range = DecimalRangeField(child=DecimalField(max_digits=5, decimal_places=2)) 441 | 442 | 443 | class FieldValues: 444 | """ 445 | Base class for testing valid and invalid input values. 446 | """ 447 | 448 | def test_valid_inputs(self): 449 | """ 450 | Ensure that valid values return the expected validated data. 451 | """ 452 | for input_value, expected_output in get_items(self.valid_inputs): 453 | initial_input_value = copy.deepcopy(input_value) 454 | 455 | serializer = self.serializer_class(data=input_value) 456 | serializer.is_valid() 457 | 458 | assert serializer.initial_data == initial_input_value 459 | assert self.field.run_validation(initial_input_value) == expected_output 460 | 461 | def test_invalid_inputs(self): 462 | """ 463 | Ensure that invalid values raise the expected validation error. 464 | """ 465 | for input_value, expected_failure in get_items(self.invalid_inputs): 466 | with pytest.raises(serializers.ValidationError) as exc_info: 467 | self.field.run_validation(input_value) 468 | assert exc_info.value.detail == expected_failure 469 | 470 | def test_outputs(self): 471 | for output_value, expected_output in get_items(self.outputs): 472 | assert self.field.to_representation(output_value) == expected_output 473 | 474 | # end of backport 475 | 476 | 477 | class TestIntegerRangeField(FieldValues): 478 | """ 479 | Values for `ListField` with CharField as child. 480 | """ 481 | serializer_class = IntegerRangeSerializer 482 | valid_inputs = [ 483 | ({'lower': '1', 'upper': 2, 'bounds': '[)'}, 484 | NumericRange(**{'lower': 1, 'upper': 2, 'bounds': '[)'})), 485 | ({'lower': 1, 'upper': 2}, 486 | NumericRange(**{'lower': 1, 'upper': 2})), 487 | ({'lower': 1}, 488 | NumericRange(**{'lower': 1})), 489 | ({'upper': 1}, 490 | NumericRange(**{'upper': 1})), 491 | ({'empty': True}, 492 | NumericRange(**{'empty': True})), 493 | ({}, NumericRange()), 494 | ] 495 | invalid_inputs = [ 496 | ({'lower': 'a'}, ['A valid integer is required.']), 497 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 498 | ({'foo': 'bar'}, ['Extra content not allowed "foo".']), 499 | ({'lower': 2, 'upper': 1}, ['The start of the range must not exceed the end of the range.']), 500 | ({'lower': 1, 'upper': None, 'bounds': '[)'}, ['This field may not be null.']), 501 | ({'lower': None, 'upper': 1, 'bounds': '[)'}, ['This field may not be null.']), 502 | ] 503 | outputs = [ 504 | (NumericRange(**{'lower': '1', 'upper': '2'}), 505 | {'lower': 1, 'upper': 2, 'bounds': '[)'}), 506 | (NumericRange(**{'empty': True}), {'empty': True}), 507 | (NumericRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 508 | ({'lower': '1', 'upper': 2, 'bounds': '[)'}, 509 | {'lower': 1, 'upper': 2, 'bounds': '[)'}), 510 | ({'lower': 1, 'upper': 2}, 511 | {'lower': 1, 'upper': 2, 'bounds': None}), 512 | ({'lower': 1}, 513 | {'lower': 1, 'upper': None, 'bounds': None}), 514 | ({'upper': 1}, 515 | {'lower': None, 'upper': 1, 'bounds': None}), 516 | ({}, {}), 517 | ] 518 | field = IntegerRangeField() 519 | 520 | def test_no_source_on_child(self): 521 | with pytest.raises(AssertionError) as exc_info: 522 | IntegerRangeField(child=serializers.IntegerField(source='other')) 523 | 524 | assert str(exc_info.value) == ( 525 | "The `source` argument is not meaningful when applied to a `child=` field. " 526 | "Remove `source=` from the field declaration." 527 | ) 528 | 529 | 530 | class TestIntegerRangeChildAllowNullField(FieldValues): 531 | serializer_class = IntegerRangeChildAllowNullSerializer 532 | 533 | valid_inputs = [ 534 | ({'lower': '1', 'upper': 2, 'bounds': '[)'}, 535 | NumericRange(**{'lower': 1, 'upper': 2, 'bounds': '[)'})), 536 | ({'lower': 1, 'upper': 2}, 537 | NumericRange(**{'lower': 1, 'upper': 2})), 538 | ({'lower': 1}, 539 | NumericRange(**{'lower': 1})), 540 | ({'upper': 1}, 541 | NumericRange(**{'upper': 1})), 542 | ({'empty': True}, 543 | NumericRange(**{'empty': True})), 544 | ({}, NumericRange()), 545 | ({'lower': 1, 'upper': None, 'bounds': '[)'}, 546 | NumericRange(**{'lower': 1, 'upper': None, 'bounds': '[)'})), 547 | ({'lower': None, 'upper': 1, 'bounds': '[)'}, 548 | NumericRange(**{'lower': None, 'upper': 1, 'bounds': '[)'})), 549 | ] 550 | invalid_inputs = [ 551 | ({'lower': 'a'}, ['A valid integer is required.']), 552 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 553 | ({'foo': 'bar'}, ['Extra content not allowed "foo".']), 554 | ({'lower': 2, 'upper': 1}, ['The start of the range must not exceed the end of the range.']), 555 | ] 556 | outputs = [ 557 | (NumericRange(**{'lower': '1', 'upper': '2'}), 558 | {'lower': 1, 'upper': 2, 'bounds': '[)'}), 559 | (NumericRange(**{'empty': True}), {'empty': True}), 560 | (NumericRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 561 | ({'lower': '1', 'upper': 2, 'bounds': '[)'}, 562 | {'lower': 1, 'upper': 2, 'bounds': '[)'}), 563 | ({'lower': 1, 'upper': 2}, 564 | {'lower': 1, 'upper': 2, 'bounds': None}), 565 | ({'lower': 1}, 566 | {'lower': 1, 'upper': None, 'bounds': None}), 567 | ({'upper': 1}, 568 | {'lower': None, 'upper': 1, 'bounds': None}), 569 | ({}, {}), 570 | ] 571 | field = IntegerRangeField(child_attrs={"allow_null": True}) 572 | 573 | 574 | class TestDecimalRangeField(FieldValues): 575 | serializer_class = DecimalRangeSerializer 576 | 577 | valid_inputs = [ 578 | ({'lower': '1', 'upper': 2., 'bounds': '[)'}, 579 | NumericRange(**{'lower': 1., 'upper': 2., 'bounds': '[)'})), 580 | ({'lower': 1., 'upper': 2.}, 581 | NumericRange(**{'lower': 1, 'upper': 2})), 582 | ({'lower': 1}, 583 | NumericRange(**{'lower': 1})), 584 | ({'upper': 1}, 585 | NumericRange(**{'upper': 1})), 586 | ({'empty': True}, 587 | NumericRange(**{'empty': True})), 588 | ({}, NumericRange()), 589 | ] 590 | invalid_inputs = [ 591 | ({'lower': 'a'}, ['A valid number is required.']), 592 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 593 | ({'lower': 2., 'upper': 1.}, ['The start of the range must not exceed the end of the range.']), 594 | ] 595 | outputs = [ 596 | (NumericRange(**{'lower': '1.1', 'upper': '2'}), 597 | {'lower': '1.1', 'upper': '2', 'bounds': '[)'}), 598 | (NumericRange(**{'empty': True}), {'empty': True}), 599 | (NumericRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 600 | ({'lower': Decimal('1.1'), 'upper': "2.3", 'bounds': '[)'}, 601 | {'lower': "1.1", 'upper': "2.3", 'bounds': '[)'}), 602 | ({'lower': Decimal('1.1'), 'upper': "2.3"}, 603 | {'lower': "1.1", 'upper': "2.3", 'bounds': None}), 604 | ({'lower': 1}, 605 | {'lower': "1", 'upper': None, 'bounds': None}), 606 | ({'upper': 1}, 607 | {'lower': None, 'upper': "1", 'bounds': None}), 608 | ({}, {}), 609 | ] 610 | field = DecimalRangeField() 611 | 612 | def test_no_source_on_child(self): 613 | with pytest.raises(AssertionError) as exc_info: 614 | DecimalRangeField(child=serializers.DecimalField(source='other', max_digits=None, decimal_places=None)) 615 | 616 | assert str(exc_info.value) == ( 617 | "The `source` argument is not meaningful when applied to a `child=` field. " 618 | "Remove `source=` from the field declaration." 619 | ) 620 | 621 | 622 | class TestDecimalRangeFieldWithChildAttribute(FieldValues): 623 | serializer_class = DecimalRangeSerializerWithChildAttribute 624 | field = DecimalRangeField(child=DecimalField(max_digits=5, decimal_places=2)) 625 | 626 | valid_inputs = [ 627 | ({'lower': '1', 'upper': 2., 'bounds': '[)'}, 628 | NumericRange(**{'lower': 1., 'upper': 2., 'bounds': '[)'})), 629 | ({'lower': 1., 'upper': 2.}, 630 | NumericRange(**{'lower': 1, 'upper': 2})), 631 | ({'lower': 1}, 632 | NumericRange(**{'lower': 1})), 633 | ({'upper': 1}, 634 | NumericRange(**{'upper': 1})), 635 | ({'empty': True}, 636 | NumericRange(**{'empty': True})), 637 | ({}, NumericRange()), 638 | ] 639 | invalid_inputs = [ 640 | ({'lower': 'a'}, ['A valid number is required.']), 641 | ({'upper': '123456'}, ['Ensure that there are no more than 5 digits in total.']), 642 | ({'lower': '9.123'}, ['Ensure that there are no more than 2 decimal places.']), 643 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 644 | ({'lower': 2., 'upper': 1.}, ['The start of the range must not exceed the end of the range.']), 645 | ] 646 | outputs = [ 647 | (NumericRange(**{'lower': '1.1', 'upper': '2'}), 648 | {'lower': '1.10', 'upper': '2.00', 'bounds': '[)'}), 649 | (NumericRange(**{'empty': True}), {'empty': True}), 650 | (NumericRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 651 | ({'lower': Decimal('1.1'), 'upper': "2.3", 'bounds': '[)'}, 652 | {'lower': "1.10", 'upper': "2.30", 'bounds': '[)'}), 653 | ({'lower': Decimal('1.1'), 'upper': "2.3"}, 654 | {'lower': "1.10", 'upper': "2.30", 'bounds': None}), 655 | ({'lower': 1}, 656 | {'lower': "1.00", 'upper': None, 'bounds': None}), 657 | ({'upper': 1}, 658 | {'lower': None, 'upper': "1.00", 'bounds': None}), 659 | ({}, {}), 660 | ] 661 | 662 | 663 | @pytest.mark.skipif(django.VERSION >= (3, 1) or not hasattr(compat.postgres_fields, "FloatRangeField"), 664 | reason='FloatRangeField deprecated on django 3.1 ') 665 | class TestFloatRangeField(FieldValues): 666 | """ 667 | Values for `ListField` with CharField as child. 668 | """ 669 | serializer_class = FloatRangeSerializer 670 | 671 | valid_inputs = [ 672 | ({'lower': '1', 'upper': 2., 'bounds': '[)'}, 673 | NumericRange(**{'lower': 1., 'upper': 2., 'bounds': '[)'})), 674 | ({'lower': 1., 'upper': 2.}, 675 | NumericRange(**{'lower': 1, 'upper': 2})), 676 | ({'lower': 1}, 677 | NumericRange(**{'lower': 1})), 678 | ({'upper': 1}, 679 | NumericRange(**{'upper': 1})), 680 | ({'empty': True}, 681 | NumericRange(**{'empty': True})), 682 | ({}, NumericRange()), 683 | ] 684 | invalid_inputs = [ 685 | ({'lower': 'a'}, ['A valid number is required.']), 686 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 687 | ({'lower': 2., 'upper': 1.}, ['The start of the range must not exceed the end of the range.']), 688 | ] 689 | outputs = [ 690 | (NumericRange(**{'lower': '1.1', 'upper': '2'}), 691 | {'lower': 1.1, 'upper': 2, 'bounds': '[)'}), 692 | (NumericRange(**{'empty': True}), {'empty': True}), 693 | (NumericRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 694 | ({'lower': '1', 'upper': 2., 'bounds': '[)'}, 695 | {'lower': 1., 'upper': 2., 'bounds': '[)'}), 696 | ({'lower': 1., 'upper': 2.}, 697 | {'lower': 1, 'upper': 2, 'bounds': None}), 698 | ({'lower': 1}, 699 | {'lower': 1, 'upper': None, 'bounds': None}), 700 | ({'upper': 1}, 701 | {'lower': None, 'upper': 1, 'bounds': None}), 702 | ({}, {}), 703 | ] 704 | field = FloatRangeField() 705 | 706 | def test_no_source_on_child(self): 707 | with pytest.raises(AssertionError) as exc_info: 708 | FloatRangeField(child=serializers.IntegerField(source='other')) 709 | 710 | assert str(exc_info.value) == ( 711 | "The `source` argument is not meaningful when applied to a `child=` field. " 712 | "Remove `source=` from the field declaration." 713 | ) 714 | 715 | 716 | @override_settings(USE_TZ=True) 717 | class TestDateTimeRangeField(TestCase, FieldValues): 718 | """ 719 | Values for `ListField` with CharField as child. 720 | """ 721 | serializer_class = DateTimeRangeSerializer 722 | 723 | valid_inputs = [ 724 | ({'lower': '2001-01-01T13:00:00Z', 725 | 'upper': '2001-02-02T13:00:00Z', 726 | 'bounds': '[)'}, 727 | DateTimeTZRange( 728 | **{'lower': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=pytz.utc), 729 | 'upper': datetime.datetime(2001, 2, 2, 13, 00, tzinfo=pytz.utc), 730 | 'bounds': '[)'})), 731 | ({'upper': '2001-02-02T13:00:00Z', 732 | 'bounds': '[)'}, 733 | DateTimeTZRange( 734 | **{'upper': datetime.datetime(2001, 2, 2, 13, 00, tzinfo=pytz.utc), 735 | 'bounds': '[)'})), 736 | ({'lower': '2001-01-01T13:00:00Z', 737 | 'bounds': '[)'}, 738 | DateTimeTZRange( 739 | **{'lower': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=pytz.utc), 740 | 'bounds': '[)'})), 741 | ({'empty': True}, 742 | DateTimeTZRange(**{'empty': True})), 743 | ({}, DateTimeTZRange()), 744 | ] 745 | invalid_inputs = [ 746 | ({'lower': 'a'}, ['Datetime has wrong format. Use one of these' 747 | ' formats instead: ' 748 | 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']), 749 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 750 | ({'lower': '2001-02-02T13:00:00Z', 751 | 'upper': '2001-01-01T13:00:00Z'}, 752 | ['The start of the range must not exceed the end of the range.']), 753 | ] 754 | outputs = [ 755 | (DateTimeTZRange( 756 | **{'lower': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=pytz.utc), 757 | 'upper': datetime.datetime(2001, 2, 2, 13, 00, tzinfo=pytz.utc)}), 758 | {'lower': '2001-01-01T13:00:00Z', 759 | 'upper': '2001-02-02T13:00:00Z', 760 | 'bounds': '[)'}), 761 | (DateTimeTZRange(**{'empty': True}), 762 | {'empty': True}), 763 | (DateTimeTZRange(bounds='()'), 764 | {'bounds': '()', 'lower': None, 'upper': None}), 765 | ({'lower': '2001-01-01T13:00:00Z', 766 | 'upper': '2001-02-02T13:00:00Z', 767 | 'bounds': '[)'}, 768 | {'lower': '2001-01-01T13:00:00Z', 769 | 'upper': '2001-02-02T13:00:00Z', 770 | 'bounds': '[)'}), 771 | ({'lower': datetime.datetime(2001, 1, 1, 13, 00, tzinfo=pytz.utc), 772 | 'upper': datetime.datetime(2001, 2, 2, 13, 00, tzinfo=pytz.utc), 773 | 'bounds': '[)'}, 774 | {'lower': '2001-01-01T13:00:00Z', 775 | 'upper': '2001-02-02T13:00:00Z', 776 | 'bounds': '[)'}), 777 | ({'upper': '2001-02-02T13:00:00Z', 'bounds': '[)'}, 778 | {'lower': None, 'upper': '2001-02-02T13:00:00Z', 'bounds': '[)'}), 779 | ({'lower': '2001-01-01T13:00:00Z', 'bounds': '[)'}, 780 | {'lower': '2001-01-01T13:00:00Z', 'upper': None, 'bounds': '[)'}), 781 | ({}, {}), 782 | ] 783 | field = DateTimeRangeField() 784 | 785 | def test_no_source_on_child(self): 786 | with pytest.raises(AssertionError) as exc_info: 787 | DateTimeRangeField(child=serializers.IntegerField(source='other')) 788 | 789 | assert str(exc_info.value) == ( 790 | "The `source` argument is not meaningful when applied to a `child=` field. " 791 | "Remove `source=` from the field declaration." 792 | ) 793 | 794 | 795 | class TestDateRangeField(FieldValues): 796 | """ 797 | Values for `ListField` with CharField as child. 798 | """ 799 | serializer_class = DateRangeSerializer 800 | 801 | valid_inputs = [ 802 | ({'lower': '2001-01-01', 803 | 'upper': '2001-02-02', 804 | 'bounds': '[)'}, 805 | DateRange( 806 | **{'lower': datetime.date(2001, 1, 1), 807 | 'upper': datetime.date(2001, 2, 2), 808 | 'bounds': '[)'})), 809 | ({'upper': '2001-02-02', 810 | 'bounds': '[)'}, 811 | DateRange( 812 | **{'upper': datetime.date(2001, 2, 2), 813 | 'bounds': '[)'})), 814 | ({'lower': '2001-01-01', 815 | 'bounds': '[)'}, 816 | DateRange( 817 | **{'lower': datetime.date(2001, 1, 1), 818 | 'bounds': '[)'})), 819 | ({'empty': True}, 820 | DateRange(**{'empty': True})), 821 | ({}, DateRange()), 822 | ] 823 | invalid_inputs = [ 824 | ({'lower': 'a'}, ['Date has wrong format. Use one of these' 825 | ' formats instead: ' 826 | 'YYYY-MM-DD.']), 827 | ('not a dict', ['Expected a dictionary of items but got type "str".']), 828 | ({'lower': '2001-02-02', 829 | 'upper': '2001-01-01'}, 830 | ['The start of the range must not exceed the end of the range.']), 831 | ] 832 | outputs = [ 833 | (DateRange( 834 | **{'lower': datetime.date(2001, 1, 1), 835 | 'upper': datetime.date(2001, 2, 2)}), 836 | {'lower': '2001-01-01', 837 | 'upper': '2001-02-02', 838 | 'bounds': '[)'}), 839 | (DateRange(**{'empty': True}), 840 | {'empty': True}), 841 | (DateRange(bounds='()'), {'bounds': '()', 'lower': None, 'upper': None}), 842 | ({'lower': '2001-01-01', 843 | 'upper': '2001-02-02', 844 | 'bounds': '[)'}, 845 | {'lower': '2001-01-01', 846 | 'upper': '2001-02-02', 847 | 'bounds': '[)'}), 848 | ({'lower': datetime.date(2001, 1, 1), 849 | 'upper': datetime.date(2001, 2, 2), 850 | 'bounds': '[)'}, 851 | {'lower': '2001-01-01', 852 | 'upper': '2001-02-02', 853 | 'bounds': '[)'}), 854 | ({'upper': '2001-02-02', 'bounds': '[)'}, 855 | {'lower': None, 'upper': '2001-02-02', 'bounds': '[)'}), 856 | ({'lower': '2001-01-01', 'bounds': '[)'}, 857 | {'lower': '2001-01-01', 'upper': None, 'bounds': '[)'}), 858 | ({}, {}), 859 | ] 860 | field = DateRangeField() 861 | 862 | def test_no_source_on_child(self): 863 | with pytest.raises(AssertionError) as exc_info: 864 | DateRangeField(child=serializers.IntegerField(source='other')) 865 | 866 | assert str(exc_info.value) == ( 867 | "The `source` argument is not meaningful when applied to a `child=` field. " 868 | "Remove `source=` from the field declaration." 869 | ) 870 | 871 | def test_initial_value_of_field(self): 872 | serializer = DateRangeSerializer() 873 | assert serializer.data['range'] == {'lower': None, 'upper': None, 'bounds': '()'} 874 | 875 | def test_allow_empty(self): 876 | serializer = DateRangeWithAllowEmptyFalseSerializer(data={"range": {}}) 877 | with pytest.raises(serializers.ValidationError) as exc_info: 878 | serializer.is_valid(raise_exception=True) 879 | assert exc_info.value.detail == ["This dictionary may not be empty."] 880 | 881 | serializer = DateRangeWithAllowEmptyTrueSerializer(data={"range": {}}) 882 | assert serializer.is_valid() 883 | 884 | 885 | class EmailSerializer(serializers.Serializer): 886 | email = LowercaseEmailField() 887 | 888 | 889 | class LowercaseEmailFieldTest(TestCase): 890 | 891 | def test_serialization(self): 892 | email = 'ALL_CAPS@example.com' 893 | serializer = EmailSerializer(data={'email': email}) 894 | serializer.is_valid() 895 | self.assertTrue(serializer.is_valid()) 896 | self.assertEqual(serializer.validated_data['email'], email.lower()) 897 | -------------------------------------------------------------------------------- /tests/test_model_serializer.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.contrib.postgres.fields import ( 3 | DateRangeField, 4 | DateTimeRangeField, 5 | IntegerRangeField, 6 | DecimalRangeField 7 | ) 8 | from django.db import models 9 | from django.test import TestCase 10 | from rest_framework import serializers 11 | import pytest 12 | 13 | from drf_extra_fields import compat 14 | 15 | 16 | def dedent(blocktext): 17 | return '\n'.join([line[12:] for line in blocktext.splitlines()[1:-1]]) 18 | 19 | 20 | class PostgreFieldsModel(models.Model): 21 | date_range_field = DateRangeField() 22 | datetime_range_field = DateTimeRangeField() 23 | integer_range_field = IntegerRangeField() 24 | decimal_range_field = DecimalRangeField() 25 | 26 | class Meta: 27 | app_label = 'tests' 28 | 29 | 30 | @pytest.mark.skipif(django.VERSION >= (3, 1) or not hasattr(compat.postgres_fields, "FloatRangeField"), 31 | reason='FloatRangeField deprecated on django 3.1 ') 32 | class TestFloatRangeFieldMapping(TestCase): 33 | 34 | def test_float_range_field(self): 35 | class FloatRangeFieldModel(models.Model): 36 | float_range_field = compat.postgres_fields.FloatRangeField() 37 | 38 | class Meta: 39 | app_label = 'tests' 40 | 41 | class TestSerializer(serializers.ModelSerializer): 42 | class Meta: 43 | model = FloatRangeFieldModel 44 | fields = ("float_range_field",) 45 | 46 | expected = dedent(""" 47 | TestSerializer(): 48 | float_range_field = FloatRangeField() 49 | """) 50 | self.assertEqual(repr(TestSerializer()), expected) 51 | 52 | 53 | class TestPosgreFieldMappings(TestCase): 54 | def test_regular_fields(self): 55 | """ 56 | Model fields should map to their equivalent serializer fields. 57 | """ 58 | class TestSerializer(serializers.ModelSerializer): 59 | class Meta: 60 | model = PostgreFieldsModel 61 | fields = ("date_range_field", "datetime_range_field", 62 | "integer_range_field", "decimal_range_field") 63 | 64 | expected = dedent(""" 65 | TestSerializer(): 66 | date_range_field = DateRangeField() 67 | datetime_range_field = DateTimeRangeField() 68 | integer_range_field = IntegerRangeField() 69 | decimal_range_field = DecimalRangeField() 70 | """) 71 | 72 | self.assertEqual(repr(TestSerializer()), expected) 73 | -------------------------------------------------------------------------------- /tests/test_relations.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.test import APISimpleTestCase 3 | 4 | from drf_extra_fields.relations import ( 5 | PresentablePrimaryKeyRelatedField, 6 | PresentableSlugRelatedField, 7 | ) 8 | from .utils import MockObject, MockQueryset 9 | 10 | 11 | class PresentationSerializer(serializers.Serializer): 12 | def to_representation(self, instance): 13 | return {"pk": instance.pk, "name": instance.name} 14 | 15 | 16 | class RecursiveSerializer(serializers.Serializer): 17 | pk = serializers.CharField() 18 | recursive_field = PresentablePrimaryKeyRelatedField( 19 | queryset=MockQueryset([]), 20 | presentation_serializer="tests.test_relations.RecursiveSerializer", 21 | ) 22 | recursive_fields = PresentablePrimaryKeyRelatedField( 23 | queryset=MockQueryset([]), 24 | presentation_serializer="tests.test_relations.RecursiveSerializer", 25 | many=True 26 | ) 27 | 28 | 29 | class SerializerWithPresentable(serializers.Serializer): 30 | test_many_field = PresentablePrimaryKeyRelatedField( 31 | queryset=MockQueryset([]), 32 | presentation_serializer=PresentationSerializer, 33 | read_source="foo_property", many=True 34 | ) 35 | test_function_field = PresentablePrimaryKeyRelatedField( 36 | queryset=MockQueryset([]), 37 | presentation_serializer=PresentationSerializer, 38 | read_source="foo_function", many=True 39 | ) 40 | test_field = PresentablePrimaryKeyRelatedField( 41 | queryset=MockQueryset([MockObject(pk=1, name="foo")]), 42 | presentation_serializer=PresentationSerializer, 43 | read_source="bar_property", many=False 44 | ) 45 | 46 | 47 | class TestPresentablePrimaryKeyRelatedField(APISimpleTestCase): 48 | def setUp(self): 49 | self.queryset = MockQueryset( 50 | [ 51 | MockObject(pk=1, name="foo"), 52 | MockObject(pk=2, name="bar"), 53 | MockObject(pk=3, name="baz"), 54 | ] 55 | ) 56 | self.instance = self.queryset.items[2] 57 | self.field = PresentablePrimaryKeyRelatedField( 58 | queryset=self.queryset, presentation_serializer=PresentationSerializer 59 | ) 60 | 61 | def test_representation(self): 62 | representation = self.field.to_representation(self.instance) 63 | expected_representation = PresentationSerializer(self.instance).data 64 | assert representation == expected_representation 65 | 66 | def test_read_source_with_context(self): 67 | representation = SerializerWithPresentable(self.instance) 68 | expected_representation = [PresentationSerializer(x).data for x in MockObject().foo_property] 69 | assert representation.data['test_many_field'] == expected_representation 70 | assert representation.data['test_function_field'] == expected_representation 71 | 72 | expected_representation = PresentationSerializer(MockObject().bar_property).data 73 | assert representation.data['test_field'] == expected_representation 74 | 75 | 76 | class TestPresentableSlugRelatedField(APISimpleTestCase): 77 | def setUp(self): 78 | self.queryset = MockQueryset( 79 | [ 80 | MockObject(pk=1, name="foo"), 81 | MockObject(pk=2, name="bar"), 82 | MockObject(pk=3, name="baz"), 83 | ] 84 | ) 85 | self.instance = self.queryset.items[2] 86 | self.field = PresentableSlugRelatedField( 87 | slug_field="name", 88 | queryset=self.queryset, 89 | presentation_serializer=PresentationSerializer, 90 | ) 91 | 92 | def test_representation(self): 93 | representation = self.field.to_representation(self.instance) 94 | expected_representation = PresentationSerializer(self.instance).data 95 | assert representation == expected_representation 96 | 97 | 98 | class TestRecursivePresentablePrimaryKeyRelatedField(APISimpleTestCase): 99 | def setUp(self): 100 | self.related_object = MockObject( 101 | pk=3, 102 | name="baz", 103 | recursive_fields=[ 104 | MockObject(pk=6, name="foo", recursive_fields=[], recursive_field=None), 105 | MockObject(pk=7, name="baz", recursive_fields=[], recursive_field=None) 106 | ], 107 | recursive_field=MockObject( 108 | pk=4, 109 | name="foobar", 110 | recursive_fields=[], 111 | recursive_field=MockObject( 112 | pk=5, 113 | name="barbaz", 114 | recursive_fields=[], 115 | recursive_field=None 116 | ) 117 | ), 118 | ) 119 | 120 | def test_recursive(self): 121 | serializer = RecursiveSerializer(self.related_object) 122 | assert serializer.data == { 123 | 'pk': '3', 124 | 'recursive_field': { 125 | 'pk': '4', 126 | 'recursive_field': { 127 | 'pk': '5', 128 | 'recursive_field': None, 129 | 'recursive_fields': [] 130 | }, 131 | 'recursive_fields': [] 132 | }, 133 | 'recursive_fields': [ 134 | { 135 | 'pk': '6', 136 | 'recursive_field': None, 137 | 'recursive_fields': [] 138 | }, 139 | { 140 | 'pk': '7', 141 | 'recursive_field': None, 142 | 'recursive_fields': [] 143 | } 144 | ] 145 | } 146 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # Copied from django-rest-framework/tests/utils.py 2 | from django.core.exceptions import ObjectDoesNotExist 3 | 4 | 5 | class MockObject: 6 | def __init__(self, **kwargs): 7 | self._kwargs = kwargs 8 | for key, val in kwargs.items(): 9 | setattr(self, key, val) 10 | 11 | def __str__(self): 12 | kwargs_str = ', '.join([ 13 | f'{key}={value}' 14 | for key, value in sorted(self._kwargs.items()) 15 | ]) 16 | return '' % kwargs_str 17 | 18 | @property 19 | def foo_property(self): 20 | return MockQueryset( 21 | [ 22 | MockObject(pk=3, name="foo"), 23 | MockObject(pk=1, name="bar"), 24 | MockObject(pk=2, name="baz"), 25 | ] 26 | ) 27 | 28 | def foo_function(self): 29 | return self.foo_property 30 | 31 | @property 32 | def bar_property(self): 33 | return MockObject(pk=3, name="foo") 34 | 35 | 36 | class MockQueryset: 37 | def __init__(self, iterable): 38 | self.items = iterable 39 | 40 | def get(self, **lookup): 41 | for item in self.items: 42 | if all([ 43 | getattr(item, key, None) == value 44 | for key, value in lookup.items() 45 | ]): 46 | return item 47 | raise ObjectDoesNotExist() 48 | 49 | def __iter__(self): 50 | return MockIterator(self.items) 51 | 52 | 53 | class MockIterator: 54 | def __init__(self, items): 55 | self.items = items 56 | self.index = 0 57 | 58 | def __next__(self): 59 | if self.index >= len(self.items): 60 | raise StopIteration 61 | 62 | self.index += 1 63 | return self.items[self.index - 1] 64 | -------------------------------------------------------------------------------- /tools/run_development.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker build -t drf_extra_fields . 4 | docker run -v $(pwd):/app -it drf_extra_fields /bin/bash 5 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.7: py37 4 | 3.8: py38 5 | 3.9: py39 6 | 3.10: py310 7 | 3.11: py311 8 | 9 | [tox] 10 | envlist = 11 | flake8, 12 | py{37,38,39,310}-drf3-django{22,32}-psycopg2 13 | py{38,39,310}-drf3-django40-psycopg2 14 | py{38,39,310,311}-drf3-django{41,42}-psycopg2 15 | py{38,39,310,311}-drf3-django42-psycopg3 16 | 17 | [testenv] 18 | deps = 19 | django22: Django>=2.2,<2.3 20 | django32: Django>=3.2,<3.3 21 | django40: Django>=4.0,<4.1 22 | django41: Django>=4.1,<4.2 23 | django42: Django>=4.2,<4.3 24 | drf3: djangorestframework>=3 25 | psycopg2: psycopg2-binary 26 | psycopg3: psycopg[binary] 27 | -r requirements_dev.txt 28 | commands = 29 | py.test {posargs} --cov-report=xml --cov 30 | 31 | [testenv:flake8] 32 | deps = flake8 33 | commands = 34 | flake8 35 | --------------------------------------------------------------------------------