diff --git a/README.rst b/README.rst index bb23c7c..32a277c 100644 --- a/README.rst +++ b/README.rst @@ -54,13 +54,13 @@ Requirements This database wrapper work with -- python 3.6, 3.7 -- django 2.0, 2.1, 2.2, 3.0, 3.1, 3.2 +- python 3.9, 3.10, 3.11, 3.12 +- django 4.2, 5.0, 5.1 On the api, this is tested against -- django-rest-framework 3.11, 3.12, 3.13 -- dynamic-rest 2.1 +- django-rest-framework 3.14, 3.15 +- dynamic-rest-bse 2.4 (It's a fork because dynamic-rest is not compatible with django 4.2 at this day) Examples @@ -259,6 +259,31 @@ This database api support : Support for ForeignKey is only available with models on the same database (api<->api) or (default<->default). It's not possible to add a ForeignKey/ManyToMany field on a local model related to a remote model (with ApiMeta) +OAuthToken auth backend +----------------------- + +grant_type is provided to the API get_token view by a GET parameter. + +Recent framework updates like Django OAuth Toolkit enforce that no GET parameters are used. + +Use ENFORCE_POST setting in OPTIONS of api's DATABASE : + +.. code-block:: python + + DATABASES = { + 'default': { + ... + }, + 'api': { + ... + 'OPTIONS': { + 'OAUTH_URL': '/oauth2/token/', + 'ENFORCE_POST': True, + } + }, + } + + Documentation ------------- @@ -268,8 +293,8 @@ The full documentation is at http://django-rest-models.readthedocs.org/en/latest Requirements ------------ -- Python 2.7, 3.5 -- Django >= 1.8 +- Python 3.9, 3.10, 3.11, 3.12 +- Django >= 4.2 Contributions and pull requests are welcome. diff --git a/requirements.txt b/requirements.txt index a229266..f3c8dba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -Django<3.3 +Django<5.2 wheel sphinx tox isort flake8 requests -djangorestframework<3.14 -dynamic-rest<2.2 +djangorestframework<3.16 +dynamic-rest-bse<2.5 -r test_requirements.txt twine psycopg2-binary -Pillow<11 +Pillow<12 unidecode diff --git a/rest_models/__init__.py b/rest_models/__init__.py index b10aa72..0cdc27e 100644 --- a/rest_models/__init__.py +++ b/rest_models/__init__.py @@ -1,4 +1,4 @@ -__VERSION__ = '2.1.1' +__VERSION__ = '3.0.0' try: from rest_models.checks import register_checks diff --git a/rest_models/backend/auth.py b/rest_models/backend/auth.py index 8ad3fd7..ac725fc 100644 --- a/rest_models/backend/auth.py +++ b/rest_models/backend/auth.py @@ -80,15 +80,23 @@ def get_token(self): :return: the token from the api :rtype: Token """ - conn = self.databasewrapper.cursor() - params = {'grant_type': 'client_credentials'} + if self.settings_dict.get('OPTIONS', {}).get('ENFORCE_POST', False): + kw = 'data' + else: + logger.warning("Get oauth token: Using deprecated GET parameter, will be removed in future releases." + "use ENFORCE_POST to fix this (cf. djang-rest-models README)") + kw = 'params' + _kwargs = { + 'auth': (self.settings_dict['USER'], self.settings_dict['PASSWORD']), + 'stream': False, + kw: {'grant_type': 'client_credentials'} + } # Get client credentials params + conn = self.databasewrapper.cursor() response = conn.session.request( 'POST', self.url_token, - params=params, - auth=(self.settings_dict['USER'], self.settings_dict['PASSWORD']), - stream=False + **_kwargs ) if response.status_code != 200: raise ProgrammingError("unable to retrive the oauth token from %s: %s" % diff --git a/rest_models/backend/compiler.py b/rest_models/backend/compiler.py index b027af4..a840bb4 100644 --- a/rest_models/backend/compiler.py +++ b/rest_models/backend/compiler.py @@ -14,7 +14,7 @@ from django.db.models import FileField, Transform from django.db.models.aggregates import Count from django.db.models.base import ModelBase -from django.db.models.expressions import Col, RawSQL +from django.db.models.expressions import Col, RawSQL, Value from django.db.models.fields.related_lookups import RelatedExact, RelatedIn from django.db.models.lookups import Exact, In, IsNull, Lookup, Range from django.db.models.sql.compiler import SQLCompiler as BaseSQLCompiler @@ -60,8 +60,8 @@ def extract_exact_pk_value(where): exact, isnull = where.children if ( - isinstance(exact, Exact) and isinstance(isnull, IsNull) and - exact.lhs.target == isnull.lhs.target + isinstance(exact, Exact) and isinstance(isnull, IsNull) and + exact.lhs.target == isnull.lhs.target ): return exact return None @@ -379,7 +379,12 @@ def get_resources_for_cols(self, cols): :return: 2 sets, the first if the alias useds, the 2nd is the set of the full path of the resources, with the attributes """ - resolved = [self.resolve_path(col) for col in cols if not isinstance(col, RawSQL) or col.sql != '1'] + resolved = [ + self.resolve_path(col) + for col in cols + if (not isinstance(col, RawSQL) or col.sql != '1') + and (not isinstance(col, Value) or col.value != 1) # skip special cases with exists() + ] return ( set(r[0] for r in resolved), # set of tuple of Alias successives @@ -646,7 +651,7 @@ class SQLCompiler(BaseSQLCompiler): META_NAME = 'meta' - def __init__(self, query, connection, using): + def __init__(self, query, connection, using, elide_empty=True): """ :param django.db.models.sql.query.Query query: the query :param rest_models.backend.base.DatabaseWrapper connection: the connection @@ -654,6 +659,7 @@ def __init__(self, query, connection, using): """ self.query = query self.connection = connection + self.elide_empty = elide_empty self.using = using self.quote_cache = {'*': '*'} # The select, klass_info, and annotations are needed by QuerySet.iterator() @@ -948,7 +954,8 @@ def response_to_table(self, responsereader, item): resolved = [ self.query_parser.resolve_path(col) for col, _, _ in self.select - if not isinstance(col, RawSQL) or col.sql != '1' # skip special case with exists() + if (not isinstance(col, RawSQL) or col.sql != '1') + and (not isinstance(col, Value) or col.value != 1) # skip special cases with exists() ] if not resolved: # nothing in select. special case in exists() @@ -1226,7 +1233,7 @@ def execute_sql(self, return_id=False, chunk_size=None): result = [result_json[get_resource_name(query.model, many=False)][opts.pk.column]] elif django.VERSION < (3, 0): return result - return (result, ) + return (result,) class FakeCursor(object): diff --git a/rest_models/backend/operations.py b/rest_models/backend/operations.py index aecc263..535a9bc 100644 --- a/rest_models/backend/operations.py +++ b/rest_models/backend/operations.py @@ -10,6 +10,8 @@ class DatabaseOperations(BaseDatabaseOperations): compiler_module = 'rest_models.backend.compiler' + # postgis compatibility + select = '%s' def sql_flush(self, *args, **kwargs): # pragma: no cover return "" diff --git a/rest_models/backend/utils.py b/rest_models/backend/utils.py index bb0c24f..9f62239 100644 --- a/rest_models/backend/utils.py +++ b/rest_models/backend/utils.py @@ -16,10 +16,7 @@ def message_from_response(response): try: from django.db.models import JSONField as JSONFieldLegacy except ImportError: - try: - from django.contrib.postgres.fields import JSONField as JSONFieldLegacy - except ImportError: - pass + pass try: class JSONField(JSONFieldLegacy): diff --git a/rest_models/storage.py b/rest_models/storage.py index e2e91e2..c3ffbb1 100644 --- a/rest_models/storage.py +++ b/rest_models/storage.py @@ -5,12 +5,12 @@ import logging import os import threading +from urllib.parse import unquote import unidecode from django.core.files.base import ContentFile from django.core.files.storage import Storage from django.utils.deconstruct import deconstructible -from django.utils.http import urlunquote from rest_models.backend.connexion import get_basic_session @@ -85,7 +85,7 @@ def prepare_result_from_api(self, result, cursor): # and store the full url for later if result is None: return None - name = urlunquote(os.path.basename(result)) + name = unquote(os.path.basename(result)) self.result_file_pool[name] = result, cursor return name diff --git a/rest_models/tests/test_clients.py b/rest_models/tests/test_clients.py index cc43543..3a105b0 100644 --- a/rest_models/tests/test_clients.py +++ b/rest_models/tests/test_clients.py @@ -20,7 +20,7 @@ def test_existing_db(self): called = [] def tmp_exec(self_dc, args, env): - self.assertRegexpMatches(env['_resty_host'], r'http://localhost:[0-9]*/api/v2\*') + self.assertRegex(env['_resty_host'], r'http://localhost:[0-9]*/api/v2\*') called.append(args) DatabaseClient.execute_subprocess = tmp_exec @@ -33,7 +33,7 @@ def test_to_run_db(self): called = [] def tmp_exec(self_dc, args, env): - self.assertRegexpMatches(env['_resty_host'], r'http://localhost:[0-9]*/api/v2\*') + self.assertRegex(env['_resty_host'], r'http://localhost:[0-9]*/api/v2\*') called.append(args) DatabaseClient.execute_subprocess = tmp_exec diff --git a/rest_models/tests/test_restmodeltestcase.py b/rest_models/tests/test_restmodeltestcase.py index b6dffba..f530598 100644 --- a/rest_models/tests/test_restmodeltestcase.py +++ b/rest_models/tests/test_restmodeltestcase.py @@ -7,7 +7,10 @@ from rest_models.test import RestModelTestCase from rest_models.utils import Path -from testapp.models import Menu +from testapp.models import POSTGIS, Menu + +if POSTGIS: + from testapp.models import Restaurant class TestLoadFixtureTest(RestModelTestCase): @@ -44,7 +47,7 @@ def test_fixtures_loaded(self): def test_fixtures_loaded_missing(self): self.assertRaisesMessage(Exception, "the query 'a' was not provided as mocked data: " - "urls was %r" % (['b', 'c', 'd'], ), + "urls was %r" % (['b', 'c', 'd'],), self.client.get, 'a') def test_variable_fixtures(self): @@ -79,6 +82,7 @@ def test_track_queries(self): class TestMockDataSample(RestModelTestCase): databases = ['default', 'api'] + fixtures = (['restaurant.json'] if POSTGIS else []) database_rest_fixtures = {'api': { # api => response mocker for databasen named «api» 'menulol': [ # url menulol { @@ -192,9 +196,32 @@ class TestMockDataSample(RestModelTestCase): } ], + 'restaurant': [ # url restaurant + { + 'filter': { # set of filter to match + 'params': {} + }, + 'data': { + "restaurants": [{ + "id": 1, + "name": "ShopShoy", + "location": "POINT (7.3370199999999999 47.8078000000000003)" + }], + "meta": { + "per_page": 10, + "total_pages": 1, + "page": 1, + "total_results": 0 + } + } + }, + ] }} def test_multi_results_filter(self): + # postgis compatibility + if POSTGIS: + self.assertEqual(len(list(Restaurant.objects.all())), 1) # no filter/no sort => fallback self.assertEqual(len(list(Menu.objects.all())), 2) # no matching filter => fallback diff --git a/rest_models/tests/tests_queryset.py b/rest_models/tests/tests_queryset.py index 1ddd9ed..4cf179e 100644 --- a/rest_models/tests/tests_queryset.py +++ b/rest_models/tests/tests_queryset.py @@ -10,7 +10,7 @@ from django.db.models import Q, Sum from django.test import TestCase from django.urls import reverse -from dynamic_rest.filters import DynamicFilterBackend +from dynamic_rest.constants import VALID_FILTER_OPERATORS from rest_models.backend.compiler import SQLAggregateCompiler, SQLCompiler from testapi import models as api_models @@ -246,7 +246,7 @@ def test_jsonfield_create(self): metadata={'origine': 'france', 'abattage': 2018} ) self.assertIsNotNone(t) - self.assertEqual(t.metadata, {'origine': 'france', 'abattage': 2018}) + self.assertEqual(json.loads(t.metadata), {'origine': 'france', 'abattage': 2018}) self.assertEqual(t.cost, 2) self.assertEqual(t.name, 'lardons lux') @@ -263,7 +263,7 @@ def test_jsonfield_update(self): metadata={'origine': 'france', 'abattage': 2018} ) self.assertIsNotNone(t) - self.assertEqual(t.metadata, {'origine': 'france', 'abattage': 2018}) + self.assertEqual(json.loads(t.metadata), {'origine': 'france', 'abattage': 2018}) self.assertEqual(t.cost, 2) self.assertEqual(t.name, 'lardons lux') @@ -279,7 +279,7 @@ def test_jsonfield_update(self): @skipIf(settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3', 'no json in sqlite') -@skipIf('year' in DynamicFilterBackend.VALID_FILTER_OPERATORS, 'skip check not compatible with current drest') +@skipIf('year' in VALID_FILTER_OPERATORS, 'skip check not compatible with current drest') class TestJsonLookup(TestCase): fixtures = ['data.json'] @@ -336,7 +336,7 @@ def test_jsonfield_lookup_deeper(self): ), [(t.pk,)]) -@skipIf('year' in DynamicFilterBackend.VALID_FILTER_OPERATORS, 'skip check not compatible with current drest') +@skipIf('year' in VALID_FILTER_OPERATORS, 'skip check not compatible with current drest') class TestQueryLookupTransform(TestCase): fixtures = ['data.json'] @@ -648,7 +648,7 @@ def test_delete_obj(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) p = client_models.Pizza(pk=1) - with self.assertNumQueries(1, using='api'): + with self.assertNumQueries(2, using='api'): p.delete() self.assertEqual(api_models.Pizza.objects.count(), 2) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -657,7 +657,7 @@ def test_delete_qs_one(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) - with self.assertNumQueries(2, using='api'): + with self.assertNumQueries(3, using='api'): client_models.Pizza.objects.filter(pk=1).delete() self.assertEqual(api_models.Pizza.objects.count(), 2) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -667,7 +667,7 @@ def test_delete_qs_one(self): def test_delete_qs_many(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) - with self.assertNumQueries(3, using='api'): + with self.assertNumQueries(4, using='api'): client_models.Pizza.objects.filter(Q(pk__in=(1, 2))).delete() self.assertEqual(api_models.Pizza.objects.count(), 1) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -677,7 +677,7 @@ def test_delete_qs_many(self): def test_delete_qs_many_range(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) - with self.assertNumQueries(3, using='api'): + with self.assertNumQueries(4, using='api'): client_models.Pizza.objects.filter(pk__range=(1, 2)).delete() self.assertEqual(api_models.Pizza.objects.count(), 1) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -687,7 +687,7 @@ def test_delete_qs_many_range(self): def test_delete_qs_no_pk(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) - with self.assertNumQueries(2, using='api'): + with self.assertNumQueries(3, using='api'): client_models.Pizza.objects.filter(name='suprème').delete() self.assertEqual(api_models.Pizza.objects.count(), 2) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -697,7 +697,7 @@ def test_delete_qs_no_pk(self): def test_delete_qs_all(self): n = api_models.Pizza.objects.count() self.assertEqual(n, 3) - with self.assertNumQueries(4, using='api'): + with self.assertNumQueries(5, using='api'): client_models.Pizza.objects.all().delete() self.assertEqual(api_models.Pizza.objects.count(), 0) self.assertFalse(api_models.Pizza.objects.filter(pk=1).exists()) @@ -717,7 +717,7 @@ def test_manual_update(self): res = self.client.patch(reverse('pizza-detail', kwargs={'pk': p.pk}), data=json.dumps({'pizza': {'menu': menu2.pk}}), content_type='application/json', - HTTP_AUTHORIZATION='Basic YWRtaW46YWRtaW4=', + headers={"authorization": 'Basic YWRtaW46YWRtaW4='} ) self.assertEqual(res.status_code, 200) self.assertEqual(res.data['pizza']['menu'], menu2.pk) diff --git a/rest_models/tests/tests_upload_files.py b/rest_models/tests/tests_upload_files.py index 365829d..d7d599a 100644 --- a/rest_models/tests/tests_upload_files.py +++ b/rest_models/tests/tests_upload_files.py @@ -3,13 +3,13 @@ import logging import os +from urllib.parse import quote from uuid import uuid4 import unidecode from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from django.utils.http import urlquote from testapi import models as apimodels from testapp import models as clientmodels @@ -28,8 +28,8 @@ class TestUploadDRF(TestCase): def setUp(self): self.img_name = str(uuid4()) + '-b--é--o--ç--é--_--b--É.png' self.img_name2 = str(uuid4()) + '-b--é--o--ç--é--_--b--É.png' - self.img_name_quoted = urlquote(self.img_name) - self.img_name2_quoted = urlquote(self.img_name2) + self.img_name_quoted = quote(self.img_name) + self.img_name2_quoted = quote(self.img_name2) self.img_name_cleaned = unidecode.unidecode(self.img_name) self.img_name2_cleaned = unidecode.unidecode(self.img_name2) diff --git a/setup.py b/setup.py index 1b556ba..16966ec 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ install_requires=[ 'requests', 'six', - 'Django<3.3', + 'Django<5.2', 'unidecode', ], packages=[ diff --git a/testapi/badapi/urls.py b/testapi/badapi/urls.py index c30d98f..cb3eb3c 100644 --- a/testapi/badapi/urls.py +++ b/testapi/badapi/urls.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals -from django.conf.urls import include, url +from django.urls import include, path from dynamic_rest.routers import DynamicRouter from .viewset import AAViewSet, AViewSet, BBViewSet, BViewSet @@ -14,6 +14,6 @@ router.register('bb', BBViewSet) urlpatterns = [ - url(r'^api/v1/', include(router.urls)), - url(r'', include('testapi.urls')), + path('api/v1/', include(router.urls)), + path('', include('testapi.urls')), ] diff --git a/testapi/fixtures/restaurant.json b/testapi/fixtures/restaurant.json new file mode 100644 index 0000000..b9e93f6 --- /dev/null +++ b/testapi/fixtures/restaurant.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "testapi.restaurant", + "fields": { + "name": "ShopShoy", + "location": "POINT (7.3370199999999999 47.8078000000000003)" + } + } +] diff --git a/testapi/migrations/0001_initial.py b/testapi/migrations/0001_initial.py index b649886..ee95a02 100644 --- a/testapi/migrations/0001_initial.py +++ b/testapi/migrations/0001_initial.py @@ -4,11 +4,11 @@ import django.db.models.deletion from django.conf import settings +from django.contrib.gis.db.models import PointField from django.db import migrations, models -from django.db.models import CASCADE import testapi.models -from testapi.models import has_jsonfield +from testapi.models import POSTGIS, has_jsonfield class Migration(migrations.Migration): @@ -20,6 +20,13 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Restaurant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=135)), + ] + ([('location', PointField(blank=True, null=True, srid=4326))] if POSTGIS else []) + ), migrations.CreateModel( name='Menu', fields=[ @@ -55,7 +62,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=125)), ('cost', models.FloatField()), - ] + ([('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True))] if has_jsonfield else []), + ] + ([('metadata', django.db.models.JSONField(null=True))] if has_jsonfield else []), ), migrations.AddField( model_name='pizza', diff --git a/testapi/models.py b/testapi/models.py index bbdd5fa..a3493b5 100644 --- a/testapi/models.py +++ b/testapi/models.py @@ -5,17 +5,35 @@ import logging from django.conf import settings +from django.contrib.gis.db.models import PointField from django.db import models from django.db.models import CASCADE from django.utils import timezone -if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': - from django.contrib.postgres.fields import JSONField +POSTGIS = False +if settings.DATABASES['default']['ENGINE'] == 'django.contrib.gis.db.backends.postgis': + class Restaurant(models.Model): + name = models.CharField(max_length=135) + location = PointField( + null=True, + blank=True, + ) + + def __str__(self): + return self.name # pragma: no cover + + POSTGIS = True + +if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql', + 'django.contrib.gis.db.backends.postgis']: + from django.db.models import JSONField + has_jsonfield = True else: # fake useless jsonfield def JSONField(*args, **kwargs): return None + has_jsonfield = False logger = logging.getLogger(__name__) @@ -43,7 +61,6 @@ def __str__(self): class Pizza(models.Model): - name = models.CharField(max_length=125) price = models.FloatField() from_date = models.DateField(auto_now_add=True) @@ -66,7 +83,6 @@ def __str__(self): class PizzaGroup(models.Model): - parent = models.ForeignKey("self", related_name='children', null=True, on_delete=CASCADE) name = models.CharField(max_length=125) pizzas = models.ManyToManyField(Pizza, related_name='groups') diff --git a/testapi/serializers.py b/testapi/serializers.py index b887608..f9324e2 100644 --- a/testapi/serializers.py +++ b/testapi/serializers.py @@ -6,7 +6,18 @@ from dynamic_rest.serializers import DynamicModelSerializer from rest_framework.fields import IntegerField, SerializerMethodField -from testapi.models import Menu, Pizza, PizzaGroup, Review, Topping +from testapi.models import POSTGIS, Menu, Pizza, PizzaGroup, Review, Topping + +if POSTGIS: + from testapi.models import Restaurant + + class RestaurantSerializer(DynamicModelSerializer): + + class Meta: + model = Restaurant + name = 'restaurant' + fields = ('id', 'name', 'location', ) + # deferred_fields = (, ) class ToppingSerializer(DynamicModelSerializer): diff --git a/testapi/urls.py b/testapi/urls.py index ab0f254..41530ee 100644 --- a/testapi/urls.py +++ b/testapi/urls.py @@ -3,35 +3,41 @@ import django.views.static from django.conf import settings -from django.conf.urls import include, url from django.contrib import admin from django.http.response import HttpResponse, HttpResponseForbidden +from django.urls import include, path, re_path from django.views.generic.base import RedirectView from dynamic_rest.routers import DynamicRouter -from testapi.viewset import (AuthorizedPizzaViewSet, MenuViewSet, PizzaGroupViewSet, PizzaViewSet, ReviewViewSet, - ToppingViewSet, fake_oauth, fake_view, wait) +from testapi.viewset import (POSTGIS, AuthorizedPizzaViewSet, MenuViewSet, PizzaGroupViewSet, PizzaViewSet, + ReviewViewSet, ToppingViewSet, fake_oauth, fake_view, wait) router = DynamicRouter() -router.register('pizza', PizzaViewSet, base_name='pizza') + +if POSTGIS: + from testapi.viewset import RestaurantViewSet + + router.register('restaurant', RestaurantViewSet) + + +router.register('pizza', PizzaViewSet) router.register('review', ReviewViewSet) router.register('topping', ToppingViewSet) router.register('menulol', MenuViewSet) router.register('pizzagroup', PizzaGroupViewSet) - router.register('authpizza', AuthorizedPizzaViewSet) urlpatterns = [ - url(r'^api/v2/wait', wait), - url(r'^oauth2/token/$', fake_oauth), - url(r'^api/v2/view/$', fake_view), - url(r'^api/v2/', include(router.urls)), - url(r'^api/forbidden', lambda request: HttpResponseForbidden()), - url(r'^other/view/', lambda request: HttpResponse(b'{"result": "ok"}')), - url(r'admin/', admin.site.urls), - url(r'^$', RedirectView.as_view(url='api/v2', permanent=False)) + re_path(r'^api/v2/wait', wait), + path('oauth2/token/', fake_oauth), + path('api/v2/view/', fake_view), + path('api/v2/', include(router.urls)), + re_path(r'^api/forbidden', lambda request: HttpResponseForbidden()), + re_path(r'^other/view/', lambda request: HttpResponse(b'{"result": "ok"}')), + re_path(r'admin/', admin.site.urls), + path('', RedirectView.as_view(url='api/v2', permanent=False)) ] # static files (images, css, javascript, etc.) urlpatterns += [ - url(r'^media/(?P.*)$', django.views.static.serve, {'document_root': settings.MEDIA_ROOT}) + re_path(r'^media/(?P.*)$', django.views.static.serve, {'document_root': settings.MEDIA_ROOT}) ] diff --git a/testapi/viewset.py b/testapi/viewset.py index 005d433..48ca2f0 100644 --- a/testapi/viewset.py +++ b/testapi/viewset.py @@ -8,10 +8,18 @@ from dynamic_rest.viewsets import DynamicModelViewSet from rest_framework.permissions import DjangoModelPermissions -from testapi.models import Menu, Pizza, PizzaGroup, Review, Topping +from testapi.models import POSTGIS, Menu, Pizza, PizzaGroup, Review, Topping from testapi.serializers import (MenuSerializer, PizzaGroupSerializer, PizzaSerializer, ReviewSerializer, ToppingSerializer) +if POSTGIS: + from testapi.models import Restaurant + from testapi.serializers import RestaurantSerializer + + class RestaurantViewSet(DynamicModelViewSet): + queryset = Restaurant.objects.all() + serializer_class = RestaurantSerializer + class PizzaViewSet(DynamicModelViewSet): queryset = Pizza.objects.all() diff --git a/testapp/models.py b/testapp/models.py index 3846e04..4ca6a7d 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -5,18 +5,37 @@ from __future__ import absolute_import, print_function, unicode_literals from django.conf import settings +from django.contrib.gis.db.models import PointField from django.db import models from django.db.models import CASCADE from rest_models.storage import RestApiStorage -if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': +POSTGIS = False +if settings.DATABASES['default']['ENGINE'] == 'django.contrib.gis.db.backends.postgis': + class Restaurant(models.Model): + name = models.CharField(max_length=135) + location = PointField( + null=True, + blank=True, + ) + + class APIMeta: + db_name = 'api' + resource_path = 'restaurant' + + POSTGIS = True + +if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql', + 'django.contrib.gis.db.backends.postgis']: from rest_models.backend.utils import JSONField + has_jsonfield = True else: # fake useless jsonfield def JSONField(*args, **kwargs): return None + has_jsonfield = False @@ -39,7 +58,6 @@ class APIMeta: class Pizza(models.Model): - name = models.CharField(max_length=125) price = models.FloatField() from_date = models.DateField(auto_now_add=True) @@ -81,7 +99,6 @@ def pizza(self, pizza): class PizzaGroup(models.Model): - parent = models.ForeignKey("self", related_name='children', db_column='parent', on_delete=CASCADE) name = models.CharField(max_length=125) pizzas = models.ManyToManyField(Pizza, related_name='groups') diff --git a/testsettings.py b/testsettings.py index a32a01f..b301abe 100644 --- a/testsettings.py +++ b/testsettings.py @@ -27,7 +27,7 @@ 'NAME': 'http://localapi/api/v2/', }, 'OPTIONS': {'SKIP_CHECK': skip_check, 'IGNORE_INTROSPECT': True}, - 'PREVENT_DISTINCT': False + 'PREVENT_DISTINCT': False, }, 'apifail': { 'ENGINE': 'rest_models.backend', diff --git a/testsettings_psql.py b/testsettings_psql.py index fedc21f..d153dc5 100644 --- a/testsettings_psql.py +++ b/testsettings_psql.py @@ -4,10 +4,10 @@ DATABASES = copy.deepcopy(DATABASES) DATABASES['default'] = { - 'ENGINE': 'django.db.backends.postgresql', + 'ENGINE': 'django.contrib.gis.db.backends.postgis', 'NAME': 'django-rest-models', - 'USER': os.environ.get('PGUSER', 'postgres'), - 'PASSWORD': os.environ.get('PGPASSWORD', ''), - 'HOST': os.environ.get('PGHOST', '127.0.0.1'), - 'PORT': os.environ.get('PGPORT', '5433'), + 'USER': os.environ.get('PGUSER', 'yupeeposting'), + 'PASSWORD': os.environ.get('PGPASSWORD', 'yupeeposting'), + 'HOST': os.environ.get('PGHOST', 'yupeek-db1'), + 'PORT': os.environ.get('PGPORT', '5432'), } diff --git a/tox.ini b/tox.ini index b8440e8..a36004a 100644 --- a/tox.ini +++ b/tox.ini @@ -11,8 +11,7 @@ exclude = .tox,testsettings*,docs/,bin/,include/,lib/,.git/,*/migrations/*,build [tox] minversion=1.9.0 envlist = - py{37,38,39}-django2{0,1,2}-drest21-drf3{11,12,13}-Pillow{9,10} - py{37,38,39}-django3{0,1,2}-drest21-drf3{11,12,13}-Pillow{9,10} + py{39,310,311,312}-django{42,50,51}-drestbse24-drf{314,315}-Pillow11 isort flake8 postgresql @@ -22,33 +21,28 @@ toxworkdir = {toxinidir}/.tox [testenv] commands = {env:COMMAND_PREFIX:python} manage.py test --noinput -passenv = TEAMCITY_VERSION QUIET PGPASSWORD PGHOST PGUSER PGPORT PYTHONWARNINGS +passenv = TEAMCITY_VERSION,QUIET,PGPASSWORD,PGHOST,PGUSER,PGPORT,PYTHONWARNINGS deps = -rtest_requirements.txt coverage - Pillow9: Pillow<10 - Pillow10: Pillow<11 - django22: Django<2.3 - django21: Django><2.2 - django20: Django<2.1 - django30: Django<3.1 - django31: Django<3.2 - django32: Django<3.3 - drest21: dynamic-rest<2.2 - drf311: djangorestframework<3.12 - drf312: djangorestframework<3.13 - drf313: djangorestframework<3.14 + Pillow11: Pillow<12 + django42: Django<4.3 + django50: Django<5.1 + django51: Django<5.2 + drestbse24: dynamic-rest-bse<2.5 + drf314: djangorestframework<3.15 + drf315: djangorestframework<3.16 [testenv:postgresql] commands = {env:COMMAND_PREFIX:python} manage.py test --noinput --settings=testsettings_psql -passenv = TEAMCITY_VERSION QUIET PGPASSWORD PGHOST PGUSER PGPORT +passenv = TEAMCITY_VERSION,QUIET,PGPASSWORD,PGHOST,PGUSER,PGPORT deps = -rtest_requirements.txt coverage - django >=3.2,<3.3 - dynamic-rest<2.2,>=2.1 - djangorestframework<3.12,>=3.11 + django >=4.2,<4.3 + dynamic-rest-bse<2.5,>=2.4 + djangorestframework<3.15,>=3.14 psycopg2-binary Pillow @@ -65,4 +59,4 @@ basepython = python3 usedevelop = false deps = isort changedir = {toxinidir} -commands = isort --recursive --check-only --diff rest_models testapi testapp +commands = isort --check-only --diff rest_models testapi testapp