From 19e3a03208aade78449d6617f09e830c235012f7 Mon Sep 17 00:00:00 2001 From: David THENON Date: Tue, 10 Aug 2021 23:18:37 +0000 Subject: [PATCH] [0.3.0] Release from last cookiecutter template --- LICENCE.txt | 2 +- Makefile | 119 +++++-- README.rst | 4 +- djangoapp_sample/apps.py | 7 + djangoapp_sample/factories/__init__.py | 4 +- djangoapp_sample/factories/article.py | 12 + djangoapp_sample/factories/user.py | 69 ++++ djangoapp_sample/migrations/0001_initial.py | 43 +++ djangoapp_sample/models/article.py | 13 + djangoapp_sample/models/blog.py | 3 + djangoapp_sample/routers.py | 22 ++ djangoapp_sample/serializers/__init__.py | 10 + djangoapp_sample/serializers/article.py | 64 ++++ djangoapp_sample/serializers/blog.py | 64 ++++ .../djangoapp_sample/article_detail.html | 5 +- djangoapp_sample/urls.py | 6 +- djangoapp_sample/views/blog.py | 4 +- djangoapp_sample/viewsets/__init__.py | 8 + djangoapp_sample/viewsets/article.py | 24 ++ djangoapp_sample/viewsets/blog.py | 21 ++ djangoapp_sample/viewsets/mixins.py | 25 ++ docs/conf.py | 2 +- docs/django_app/index.rst | 2 + docs/django_app/serializers.rst | 11 + docs/django_app/viewsets.rst | 14 + docs/django_settings.py | 1 + docs/history.rst | 18 ++ docs/index.rst | 6 +- docs/install.rst | 10 +- freezer.py | 125 ++++++++ frozen.txt | 13 + sandbox/settings/base.py | 20 +- sandbox/urls.py | 1 + setup.cfg | 15 +- sphinx_reload.py | 8 +- tests/030_views/032_article.py | 6 +- tests/100_serializers/101_blog.py | 57 ++++ tests/100_serializers/102_article.py | 111 +++++++ tests/110_viewsets/111_blog.py | 219 +++++++++++++ tests/110_viewsets/112_article.py | 295 ++++++++++++++++++ tests/conftest.py | 2 +- tests/utils.py | 201 +++++++++++- 42 files changed, 1615 insertions(+), 51 deletions(-) create mode 100644 djangoapp_sample/apps.py create mode 100644 djangoapp_sample/factories/user.py create mode 100644 djangoapp_sample/migrations/0001_initial.py create mode 100644 djangoapp_sample/routers.py create mode 100644 djangoapp_sample/serializers/__init__.py create mode 100644 djangoapp_sample/serializers/article.py create mode 100644 djangoapp_sample/serializers/blog.py create mode 100644 djangoapp_sample/viewsets/__init__.py create mode 100644 djangoapp_sample/viewsets/article.py create mode 100644 djangoapp_sample/viewsets/blog.py create mode 100644 djangoapp_sample/viewsets/mixins.py create mode 100644 docs/django_app/serializers.rst create mode 100644 docs/django_app/viewsets.rst create mode 100644 freezer.py create mode 100644 frozen.txt create mode 100644 tests/100_serializers/101_blog.py create mode 100644 tests/100_serializers/102_article.py create mode 100644 tests/110_viewsets/111_blog.py create mode 100644 tests/110_viewsets/112_article.py diff --git a/LICENCE.txt b/LICENCE.txt index 468c39a..9bfeb71 100644 --- a/LICENCE.txt +++ b/LICENCE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020, David Thenon +Copyright (c) 2021, David Thenon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index c14ebbb..3b2afe8 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,11 @@ -PYTHON_INTERPRETER=python3 VENV_PATH=.venv - +PYTHON_INTERPRETER=python3 PYTHON_BIN=$(VENV_PATH)/bin/python PIP=$(VENV_PATH)/bin/pip TWINE=$(VENV_PATH)/bin/twine -BOUSSOLE=$(VENV_PATH)/bin/boussole DJANGO_MANAGE=$(VENV_PATH)/bin/python sandbox/manage.py FLAKE=$(VENV_PATH)/bin/flake8 PYTEST=$(VENV_PATH)/bin/pytest -TWINE=$(VENV_PATH)/bin/twine SPHINX_RELOAD=$(VENV_PATH)/bin/python sphinx_reload.py DEMO_DJANGO_SECRET_KEY=samplesecretfordev @@ -20,44 +17,59 @@ help: @echo "Please use \`make ' where is one of" @echo @echo " install -- to install this project with virtualenv and Pip" - @echo "" + @echo " freeze-dependencies -- to write a frozen.txt file with installed dependencies versions" + @echo @echo " clean -- to clean EVERYTHING (Warning)" @echo " clean-var -- to clean data (uploaded medias, database, etc..)" @echo " clean-doc -- to remove documentation builds" @echo " clean-install -- to clean Python side installation" @echo " clean-pycache -- to remove all __pycache__, this is recursive from current directory" - @echo "" + @echo @echo " run -- to run Django development server" @echo " migrate -- to apply demo database migrations" @echo " migrations -- to create new migrations for application after changes" @echo " superuser -- to create a superuser for Django admin" - @echo "" + @echo @echo " docs -- to build documentation" @echo " livedocs -- to run livereload server to rebuild documentation on source changes" - @echo "" + @echo @echo " flake -- to launch Flake8 checking" - @echo " tests -- to launch base test suite using Pytest" + @echo " test -- to launch base test suite using Pytest" + @echo " test-initial -- to launch tests with pytest and re-initialized database (for after new application or model changes)" @echo " quality -- to launch Flake8 checking and every tests suites" - @echo "" + @echo + @echo " check-release -- to check package release before uploading it to PyPi" @echo " release -- to release package for latest version on PyPi (once release has been pushed to repository)" @echo clean-pycache: + @echo "" + @echo "==== Clear Python cache ====" + @echo "" rm -Rf .pytest_cache find . -type d -name "__pycache__"|xargs rm -Rf find . -name "*\.pyc"|xargs rm -f .PHONY: clean-pycache clean-install: + @echo "" + @echo "==== Clear installation ====" + @echo "" rm -Rf $(VENV_PATH) rm -Rf $(PACKAGE_SLUG).egg-info .PHONY: clean-install clean-var: + @echo "" + @echo "==== Clear var/ directory ====" + @echo "" rm -Rf var .PHONY: clean-var clean-doc: + @echo "" + @echo "==== Clear documentation ====" + @echo "" rm -Rf docs/_build .PHONY: clean-doc @@ -65,6 +77,9 @@ clean: clean-var clean-doc clean-install clean-pycache .PHONY: clean venv: + @echo "" + @echo "==== Install virtual environment ====" + @echo "" virtualenv -p $(PYTHON_INTERPRETER) $(VENV_PATH) # This is required for those ones using old distribution $(PIP) install --upgrade pip @@ -79,54 +94,116 @@ create-var-dirs: @mkdir -p sandbox/static/css .PHONY: create-var-dirs +install: venv create-var-dirs + @echo "" + @echo "==== Install everything for development ====" + @echo "" + $(PIP) install -e .[dev] + ${MAKE} migrate +.PHONY: install + migrations: + @echo "" + @echo "==== Making application migrations ====" + @echo "" @DJANGO_SECRET_KEY=$(DEMO_DJANGO_SECRET_KEY) \ $(DJANGO_MANAGE) makemigrations $(APPLICATION_NAME) .PHONY: migrations migrate: + @echo "" + @echo "==== Apply pending migrations ====" + @echo "" @DJANGO_SECRET_KEY=$(DEMO_DJANGO_SECRET_KEY) \ $(DJANGO_MANAGE) migrate .PHONY: migrate superuser: + @echo "" + @echo "==== Create new superuser ====" + @echo "" @DJANGO_SECRET_KEY=$(DEMO_DJANGO_SECRET_KEY) \ $(DJANGO_MANAGE) createsuperuser .PHONY: superuser -install: venv create-var-dirs - $(PIP) install -e .[dev] - ${MAKE} migrate -.PHONY: install - run: + @echo "" + @echo "==== Running development server ====" + @echo "" @DJANGO_SECRET_KEY=$(DEMO_DJANGO_SECRET_KEY) \ $(DJANGO_MANAGE) runserver 0.0.0.0:8001 .PHONY: run docs: + @echo "" + @echo "==== Build documentation ====" + @echo "" cd docs && make html .PHONY: docs livedocs: + @echo "" + @echo "==== Watching documentation sources ====" + @echo "" $(SPHINX_RELOAD) .PHONY: livedocs flake: + @echo "" + @echo "==== Flake ====" + @echo "" $(FLAKE) --show-source $(APPLICATION_NAME) $(FLAKE) --show-source tests .PHONY: flake -tests: - $(PYTEST) -vv tests/ +test: + @echo "" + @echo "==== Tests ====" + @echo "" + $(PYTEST) -vv --reuse-db tests/ rm -Rf var/media-tests/ -.PHONY: tests +.PHONY: test -quality: tests flake -.PHONY: quality +test-initial: + @echo "" + @echo "==== Tests from zero ====" + @echo "" + $(PYTEST) -vv --reuse-db --create-db tests/ + rm -Rf var/media-tests/ +.PHONY: test-initial + +freeze-dependencies: + @echo "" + @echo "==== Freeze dependencies versions ====" + @echo "" + $(VENV_PATH)/bin/python freezer.py +.PHONY: freeze-dependencies -release: +build-package: + @echo "" + @echo "==== Build package ====" + @echo "" rm -Rf dist $(VENV_PATH)/bin/python setup.py sdist +.PHONY: build-package + +release: build-package + @echo "" + @echo "==== Release ====" + @echo "" $(TWINE) upload dist/* .PHONY: release + +check-release: build-package + @echo "" + @echo "==== Check package ====" + @echo "" + $(TWINE) check dist/* +.PHONY: check-release + + +quality: test-initial flake docs freeze-dependencies check-release + @echo "" + @echo "♥ ♥ Everything should be fine ♥ ♥" + @echo "" +.PHONY: quality diff --git a/README.rst b/README.rst index f347aa9..ff50248 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,6 @@ .. _Python: https://www.python.org/ .. _Django: https://www.djangoproject.com/ +.. _Django REST framework: https://www.django-rest-framework.org/ ========================= Sveetch Django app sample @@ -12,7 +13,8 @@ Dependancies ************ * `Python`_>=3.6; -* `Django`_>=2.1; +* `Django`_>=2.2; +* `Django REST framework`_>=3.12.0; Links ***** diff --git a/djangoapp_sample/apps.py b/djangoapp_sample/apps.py new file mode 100644 index 0000000..b32f96e --- /dev/null +++ b/djangoapp_sample/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class djangoapp_sampleConfig(AppConfig): + name = "djangoapp_sample" + verbose_name = "Sveetch Django app sample" + default_auto_field = "django.db.models.AutoField" diff --git a/djangoapp_sample/factories/__init__.py b/djangoapp_sample/factories/__init__.py index 0aa070f..1062ea5 100644 --- a/djangoapp_sample/factories/__init__.py +++ b/djangoapp_sample/factories/__init__.py @@ -1,8 +1,10 @@ from .blog import BlogFactory from .article import ArticleFactory +from .user import UserFactory __all__ = [ - "BlogFactory", "ArticleFactory", + "BlogFactory", + "UserFactory", ] diff --git a/djangoapp_sample/factories/article.py b/djangoapp_sample/factories/article.py index 24ef770..b917e37 100644 --- a/djangoapp_sample/factories/article.py +++ b/djangoapp_sample/factories/article.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from django.utils import timezone + import factory from ..models import Article @@ -16,3 +18,13 @@ class ArticleFactory(factory.django.DjangoModelFactory): class Meta: model = Article + + @factory.lazy_attribute + def publish_start(self): + """ + Return current date. + + Returns: + datetime.datetime: Current time. + """ + return timezone.now() diff --git a/djangoapp_sample/factories/user.py b/djangoapp_sample/factories/user.py new file mode 100644 index 0000000..296353d --- /dev/null +++ b/djangoapp_sample/factories/user.py @@ -0,0 +1,69 @@ +""" +============ +User factory +============ + +""" +import factory + +from django.apps import apps +from django.conf import settings +from django.db.models.signals import post_save + + +def safe_get_user_model(): + """ + Safe loading of the User model, customized or not. + """ + user_app, user_model = settings.AUTH_USER_MODEL.split('.') + return apps.get_registered_model(user_app, user_model) + + +@factory.django.mute_signals(post_save) +class UserFactory(factory.django.DjangoModelFactory): + """ + Factory to create an User object. + """ + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + username = factory.Sequence(lambda n: "demo-user-%d" % n) + is_active = True + is_staff = False + is_superuser = False + password = "secret" + + class Meta: + model = safe_get_user_model() + + class Params: + """ + Declare traits that add relevant parameters for admin and superuser + """ + flag_is_admin = factory.Trait( + is_superuser=False, + is_staff=True, + username=factory.Sequence(lambda n: "admin-%d" % n), + ) + flag_is_superuser = factory.Trait( + is_superuser=True, + is_staff=True, + username=factory.Sequence(lambda n: "superuser-%d" % n), + ) + + @factory.lazy_attribute + def email(self): + """ + Email is automatically build from username + """ + return "%s@test.com" % self.username + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """ + Ensure the raw password gets set after the initial save + """ + password = kwargs.pop("password", None) + obj = super(UserFactory, cls)._create(model_class, *args, **kwargs) + obj.set_password(password) + obj.save() + return obj diff --git a/djangoapp_sample/migrations/0001_initial.py b/djangoapp_sample/migrations/0001_initial.py new file mode 100644 index 0000000..e5528fb --- /dev/null +++ b/djangoapp_sample/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.13 on 2021-08-10 20:54 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Blog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(default='', max_length=55, unique=True, verbose_name='title')), + ], + options={ + 'verbose_name': 'Blog', + 'verbose_name_plural': 'Blogs', + 'ordering': ['title'], + }, + ), + migrations.CreateModel( + name='Article', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(default='', max_length=150, verbose_name='title')), + ('content', models.TextField(blank=True, default='', verbose_name='content')), + ('publish_start', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='publication start')), + ('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djangoapp_sample.blog', verbose_name='Related blog')), + ], + options={ + 'verbose_name': 'Article', + 'verbose_name_plural': 'Articles', + 'ordering': ['-publish_start'], + }, + ), + ] diff --git a/djangoapp_sample/models/article.py b/djangoapp_sample/models/article.py index 7244594..d9c8d4d 100644 --- a/djangoapp_sample/models/article.py +++ b/djangoapp_sample/models/article.py @@ -5,6 +5,7 @@ """ from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.urls import reverse @@ -43,9 +44,21 @@ class Article(models.Model): Optionnal text content. """ + publish_start = models.DateTimeField( + _("publication start"), + db_index=True, + default=timezone.now, + ) + """ + Required publication date determine when article will be available. + """ + class Meta: verbose_name = _("Article") verbose_name_plural = _("Articles") + ordering = [ + "-publish_start", + ] def __str__(self): return self.title diff --git a/djangoapp_sample/models/blog.py b/djangoapp_sample/models/blog.py index 4d0f163..55897bd 100644 --- a/djangoapp_sample/models/blog.py +++ b/djangoapp_sample/models/blog.py @@ -27,6 +27,9 @@ class Blog(models.Model): class Meta: verbose_name = _("Blog") verbose_name_plural = _("Blogs") + ordering = [ + "title", + ] def __str__(self): return self.title diff --git a/djangoapp_sample/routers.py b/djangoapp_sample/routers.py new file mode 100644 index 0000000..2dd4db2 --- /dev/null +++ b/djangoapp_sample/routers.py @@ -0,0 +1,22 @@ +""" +Application API URLs +""" +from rest_framework import routers + +from .viewsets import ArticleViewSet, BlogViewSet + + +# API router +router = routers.DefaultRouter() + +router.register( + r"blogs", + BlogViewSet, + basename="api-blog" +) + +router.register( + r"articles", + ArticleViewSet, + basename="api-article" +) diff --git a/djangoapp_sample/serializers/__init__.py b/djangoapp_sample/serializers/__init__.py new file mode 100644 index 0000000..e936947 --- /dev/null +++ b/djangoapp_sample/serializers/__init__.py @@ -0,0 +1,10 @@ +from .blog import BlogSerializer, BlogResumeSerializer +from .article import ArticleSerializer, ArticleResumeSerializer + + +__all__ = [ + "BlogSerializer", + "BlogResumeSerializer", + "ArticleSerializer", + "ArticleResumeSerializer", +] diff --git a/djangoapp_sample/serializers/article.py b/djangoapp_sample/serializers/article.py new file mode 100644 index 0000000..93744f0 --- /dev/null +++ b/djangoapp_sample/serializers/article.py @@ -0,0 +1,64 @@ +""" +======================= +Article API serializers +======================= + +""" +from rest_framework import serializers + +from ..models import Article +from .blog import BlogIdField, BlogResumeSerializer + + +class ArticleSerializer(serializers.HyperlinkedModelSerializer): + """ + Complete representation for detail and writing usage. + + Blog relation have two serializer fields, one for read only to return resumed + details and another one for write only with complete detail and which expect a + blog ID. + """ + id = serializers.ReadOnlyField() + view_url = serializers.SerializerMethodField() + blog = BlogResumeSerializer(read_only=True) + blog_id = BlogIdField(write_only=True, source='blog') + + class Meta: + model = Article + fields = '__all__' + extra_kwargs = { + "url": { + "view_name": "djangoapp_sample:api-article-detail" + }, + # DRF does not consider fields with ``blank=True`` and ``default=""`` as + # required + "title": { + "required": True + }, + } + + def get_view_url(self, obj): + """ + Return the HTML detail view URL. + + If request has been given to serializer this will be an absolute URL, else a + relative URL. + """ + url = obj.get_absolute_url() + request = self.context.get("request") + + if request: + return request.build_absolute_uri(url) + + return url + + +class ArticleResumeSerializer(ArticleSerializer): + """ + Simpler Article representation for nested list. It won't be suitable for writing + usage. + """ + class Meta: + model = ArticleSerializer.Meta.model + fields = ["id", "url", "view_url", "blog", "publish_start", "title"] + extra_kwargs = ArticleSerializer.Meta.extra_kwargs diff --git a/djangoapp_sample/serializers/blog.py b/djangoapp_sample/serializers/blog.py new file mode 100644 index 0000000..77fb506 --- /dev/null +++ b/djangoapp_sample/serializers/blog.py @@ -0,0 +1,64 @@ +""" +==================== +Blog API serializers +==================== + +""" +from rest_framework import serializers + +from ..models import Blog + + +class BlogIdField(serializers.PrimaryKeyRelatedField): + def get_queryset(self): + return Blog.objects.all() + + +class BlogSerializer(serializers.HyperlinkedModelSerializer): + """ + Complete representation for detail and writing usage. + """ + id = serializers.ReadOnlyField() + view_url = serializers.SerializerMethodField() + article_count = serializers.SerializerMethodField() + + class Meta: + model = Blog + fields = '__all__' + extra_kwargs = { + "url": { + "view_name": "djangoapp_sample:api-blog-detail" + }, + } + + def get_view_url(self, obj): + """ + Return the HTML detail view URL. + + If request has been given to serializer this will be an absolute URL, else a + relative URL. + """ + url = obj.get_absolute_url() + request = self.context.get("request") + + if request: + return request.build_absolute_uri(url) + + return url + + def get_article_count(self, obj): + """ + Return count of related articles. + """ + return obj.article_set.count() + + +class BlogResumeSerializer(BlogSerializer): + """ + Simpler Blog representation for nested list. It won't be suitable for writing + usage. + """ + class Meta: + model = BlogSerializer.Meta.model + fields = ["id", "url", "view_url", "title"] + extra_kwargs = BlogSerializer.Meta.extra_kwargs diff --git a/djangoapp_sample/templates/djangoapp_sample/article_detail.html b/djangoapp_sample/templates/djangoapp_sample/article_detail.html index e466977..aa2b5be 100644 --- a/djangoapp_sample/templates/djangoapp_sample/article_detail.html +++ b/djangoapp_sample/templates/djangoapp_sample/article_detail.html @@ -3,8 +3,9 @@ {% block app_content %}{% spaceless %}
-

{{ blog_object.title }}

-

{{ article_object.title }}

+

{{ blog_object.title }}

+

{{ article_object.title }}

+

{{ article_object.publish_start }}

{{ article_object.content }}
diff --git a/djangoapp_sample/urls.py b/djangoapp_sample/urls.py index 2bcea98..4f947e6 100644 --- a/djangoapp_sample/urls.py +++ b/djangoapp_sample/urls.py @@ -1,12 +1,13 @@ """ Application URLs """ -from django.urls import path +from django.urls import path, include -from djangoapp_sample.views import ( +from .views import ( BlogIndexView, BlogDetailView, ArticleDetailView, ) +from .routers import router app_name = "djangoapp_sample" @@ -14,6 +15,7 @@ urlpatterns = [ path("", BlogIndexView.as_view(), name="blog-index"), + path("api/", include(router.urls)), path("/", BlogDetailView.as_view(), name="blog-detail"), path( "//", diff --git a/djangoapp_sample/views/blog.py b/djangoapp_sample/views/blog.py index b52da96..65688ff 100644 --- a/djangoapp_sample/views/blog.py +++ b/djangoapp_sample/views/blog.py @@ -10,7 +10,7 @@ class BlogIndexView(ListView): List of blogs """ model = Blog - queryset = Blog.objects.order_by('title') + queryset = Blog.objects.order_by("title") template_name = "djangoapp_sample/blog_index.html" paginate_by = settings.BLOG_PAGINATION @@ -25,7 +25,7 @@ class BlogDetailView(SingleObjectMixin, ListView): context_object_name = "blog_object" def get_queryset(self): - return self.object.article_set.order_by('title') + return self.object.article_set.order_by("-publish_start") def get(self, request, *args, **kwargs): self.object = self.get_object(queryset=Blog.objects.all()) diff --git a/djangoapp_sample/viewsets/__init__.py b/djangoapp_sample/viewsets/__init__.py new file mode 100644 index 0000000..ea18492 --- /dev/null +++ b/djangoapp_sample/viewsets/__init__.py @@ -0,0 +1,8 @@ +from .article import ArticleViewSet +from .blog import BlogViewSet + + +__all__ = [ + "ArticleViewSet", + "BlogViewSet", +] diff --git a/djangoapp_sample/viewsets/article.py b/djangoapp_sample/viewsets/article.py new file mode 100644 index 0000000..665046c --- /dev/null +++ b/djangoapp_sample/viewsets/article.py @@ -0,0 +1,24 @@ +""" +================= +Article API views +================= + +""" +from rest_framework import viewsets + +from ..models import Article +from ..serializers import ArticleSerializer, ArticleResumeSerializer + +from .mixins import ConditionalResumedSerializerMixin + + +class ArticleViewSet(ConditionalResumedSerializerMixin, viewsets.ModelViewSet): + """ + Viewset for all HTTP methods on Article model. + """ + model = Article + serializer_class = ArticleSerializer + resumed_serializer_class = ArticleResumeSerializer + + def get_queryset(self): + return self.model.objects.all() diff --git a/djangoapp_sample/viewsets/blog.py b/djangoapp_sample/viewsets/blog.py new file mode 100644 index 0000000..f9a7a43 --- /dev/null +++ b/djangoapp_sample/viewsets/blog.py @@ -0,0 +1,21 @@ +""" +============== +Blog API views +============== + +""" +from rest_framework import viewsets + +from ..models import Blog +from ..serializers import BlogSerializer + + +class BlogViewSet(viewsets.ModelViewSet): + """ + Viewset for all HTTP methods on Blog model. + """ + model = Blog + serializer_class = BlogSerializer + + def get_queryset(self): + return self.model.objects.all() diff --git a/djangoapp_sample/viewsets/mixins.py b/djangoapp_sample/viewsets/mixins.py new file mode 100644 index 0000000..53b4d56 --- /dev/null +++ b/djangoapp_sample/viewsets/mixins.py @@ -0,0 +1,25 @@ +""" +================ +API views mixins +================ + +""" + + +class ConditionalResumedSerializerMixin(object): + """ + Overrides get_serializer_class to use a resumed Serializer in list. + + Set ``resumed_serializer_class`` attribute on your viewset to enable this behavior + else the default serializer from ``serializer_class`` is always used. + + This won't work with classes which does not set attribute ``action`` like + ``APIView``. + + The goal of this behavior is to have lighter payload on lists which does not need + to return everything from an object. + """ + def get_serializer_class(self): + if self.action == "list": + return self.resumed_serializer_class + return super().get_serializer_class() diff --git a/docs/conf.py b/docs/conf.py index 3d0d8db..baeaaea 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ # -- Project information ----------------------------------------------------- project = 'sveetch-djangoapp-sample' -copyright = '2020, David Thenon' +copyright = '2021, David Thenon' author = 'David Thenon' # The short X.Y version diff --git a/docs/django_app/index.rst b/docs/django_app/index.rst index c31cfc0..2b4660a 100644 --- a/docs/django_app/index.rst +++ b/docs/django_app/index.rst @@ -8,3 +8,5 @@ Django application :maxdepth: 2 models.rst + serializers.rst + viewsets.rst diff --git a/docs/django_app/serializers.rst b/docs/django_app/serializers.rst new file mode 100644 index 0000000..3453ddc --- /dev/null +++ b/docs/django_app/serializers.rst @@ -0,0 +1,11 @@ +.. _intro_django-app_serializers: + +=========== +Serializers +=========== + +.. automodule:: djangoapp_sample.serializers.blog + :members: BlogSerializer, BlogResumeSerializer + +.. automodule:: djangoapp_sample.serializers.article + :members: ArticleSerializer, ArticleResumeSerializer diff --git a/docs/django_app/viewsets.rst b/docs/django_app/viewsets.rst new file mode 100644 index 0000000..6c7a781 --- /dev/null +++ b/docs/django_app/viewsets.rst @@ -0,0 +1,14 @@ +.. _intro_django-app_viewsets: + +======== +Viewsets +======== + +.. automodule:: djangoapp_sample.viewsets.mixins + :members: ConditionalResumedSerializerMixin + +.. automodule:: djangoapp_sample.viewsets.blog + :members: BlogViewSet + +.. automodule:: djangoapp_sample.viewsets.article + :members: ArticleViewSet diff --git a/docs/django_settings.py b/docs/django_settings.py index 4e1a47b..d36958c 100644 --- a/docs/django_settings.py +++ b/docs/django_settings.py @@ -154,6 +154,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", "djangoapp_sample", ] diff --git a/docs/history.rst b/docs/history.rst index a8bc2b8..78238d6 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -4,6 +4,24 @@ History ======= +Version 0.3.0 - 2021/08/11 +-------------------------- + +* Add API with Django REST framework; +* Add API test coverage; +* Update documentation; +* Improve Makefile; +* Add Django app file; +* Add missing app migrations; +* Change Django support to ``>=2.2``; + + +Version 0.2.0 - Unreleased +-------------------------- + +Minor fixes and changes. + + Version 0.1.0 - 2020/10/19 -------------------------- diff --git a/docs/index.rst b/docs/index.rst index 8aa2d5f..d6bd3ab 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,8 @@ .. _Python: https://www.python.org .. _Django: https://www.djangoproject.com/ +.. _Django REST framework: https://www.django-rest-framework.org/ -.. cookiecutter-sveetch-djangoapp documentation master file, created by David Thenon +.. sveetch-djangoapp-sample documentation master file, created by David Thenon ========================= Sveetch Django app sample @@ -14,7 +15,8 @@ Dependancies ************ * `Python`_>=3.6; -* `Django`_>=2.1; +* `Django`_>=2.2; +* `Django REST framework`_>=3.12.0; Links ***** diff --git a/docs/install.rst b/docs/install.rst index 8b4a855..fe464b2 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -17,13 +17,21 @@ Add it to your installed Django apps in settings : :: INSTALLED_APPS = ( ... - 'djangoapp_sample', + "rest_framework", + "djangoapp_sample", ) Then load default application settings in your settings file: :: from djangoapp_sample.settings import * +Then mount applications URLs: :: + + urlpatterns = [ + ... + path("", include("djangoapp_sample.urls")), + ] + And finally apply database migrations. Settings diff --git a/freezer.py b/freezer.py new file mode 100644 index 0000000..4a2f1c9 --- /dev/null +++ b/freezer.py @@ -0,0 +1,125 @@ +""" +A script to collect every installed dependencies versions from "pip freeze" but +only those ones explicitely required in package configuration. + +It will create a "frozen.txt" file which can help users to find an exact list of +dependencies versions which have been tested. + +This require a recent install of setuptools (>=39.1.0). + +You must call this script with the same Python interpreter used in your virtual +environment. +""" +import subprocess +import sys + +import pkg_resources + + +def flatten_requirement(requirement): + """ + Return only the package name from a requirement. + + Arguments: + requirement (pkg_resources.Requirement): A requirement object. + + Returns: + string: Package name. + """ + return requirement.key + + +def extract_pkg_version(package_name): + """ + Get package version from installed distribution or configuration file if not + installed + + Arguments: + package_name (string): Package name to search and extract informations. + + Returns: + string: Version name. + """ + return pkg_resources.get_distribution(package_name).version + + +def extract_pkg_requirements(package_name): + """ + Get all required dependency names from every requirement sections. + + Arguments: + package_name (string): Package name to search and extract informations. + + Returns: + list: A list of all required package names. + """ + distrib = pkg_resources.get_distribution(package_name) + + requirements = set([]) + + for r in distrib.requires(): + requirements.add(flatten_requirement(r)) + + for item in distrib.extras: + for r in distrib.requires(extras=(item,)): + requirements.add(flatten_requirement(r)) + + return list(requirements) + + +def get_install_dependencies(requirements=None, ignore=[]): + """ + Use "pip freeze" command to get installed dependencies and possibly filtered + them from a list of names. + + This does not support installed dependencies from a VCS or in editable mode. + + Keyword Arguments: + requirements (list): List of package names to retain from installed + dependencies. If not given, all installed dependencies are retained. + ignore (list): List of package names to ignore from installed + dependencies. + + Returns: + list: List of installed dependencies with their version. Either all or + only those ones from given ``names``. + """ + reqs = subprocess.check_output([sys.executable, '-m', 'pip', 'freeze']) + + # Filter from requirement names (if any) and ignored ones + deps = [] + for item in reqs.splitlines(): + pkg = item.decode('utf-8') + name = pkg.split("==")[0].lower() + + if ( + (requirements is None or name in requirements) and + name not in ignore + ): + deps.append(pkg) + + return deps + + +def write_frozen_requirements(package_name, filename="frozen.txt"): + """ + Write a file of frozen requirement versions for current version of a + package. + """ + version = extract_pkg_version(package_name) + requirements = extract_pkg_requirements(package_name) + installed = get_install_dependencies(requirements) + + lines = [ + "# Frozen requirement versions from '{}' installation".format(version) + ] + installed + + with open(filename, "w") as fp: + fp.write("\n".join(lines)) + + return filename + + +if __name__ == "__main__": + filename = write_frozen_requirements("sveetch-djangoapp-sample") + print("Created file for frozen dependencies:", filename) diff --git a/frozen.txt b/frozen.txt new file mode 100644 index 0000000..922b2f3 --- /dev/null +++ b/frozen.txt @@ -0,0 +1,13 @@ +# Frozen requirement versions from '0.3.0' installation +Django==3.1.13 +djangorestframework==3.12.4 +factory-boy==3.2.0 +flake8==3.9.2 +freezegun==1.1.0 +livereload==2.6.3 +pyquery==1.4.3 +pytest==6.2.4 +pytest-django==4.4.0 +Sphinx==4.1.2 +sphinx-rtd-theme==0.5.2 +twine==3.4.2 \ No newline at end of file diff --git a/sandbox/settings/base.py b/sandbox/settings/base.py index 451cc21..6ebb010 100644 --- a/sandbox/settings/base.py +++ b/sandbox/settings/base.py @@ -44,8 +44,7 @@ # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # In a Windows environment this must be set to your system time zone. -# TIME_ZONE = "America/Chicago" -TIME_ZONE = "Europe/Paris" +TIME_ZONE = "America/Chicago" # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html @@ -147,14 +146,29 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + "django.contrib.sites", "django.contrib.staticfiles", - "djangoapp_sample", + "django.forms", + "djangoapp_sample.apps.djangoapp_sampleConfig", + "rest_framework", ] LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" +# Ensure we can override applications widgets templates from project template +# directory, require also 'django.forms' in INSTALLED_APPS +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + +# Django REST Framework configuration +REST_FRAMEWORK = { + # Use Django's standard `django.contrib.auth` permissions, + # or allow read-only access for unauthenticated users. + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} """ SPECIFIC BASE APPLICATIONS SETTINGS BELOW """ diff --git a/sandbox/urls.py b/sandbox/urls.py index 637ec12..9ae5b0f 100644 --- a/sandbox/urls.py +++ b/sandbox/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("admin/", admin.site.urls), + path("api-auth/", include("rest_framework.urls")), path("", include("djangoapp_sample.urls")), ] diff --git a/setup.cfg b/setup.cfg index d1352d6..42bdcfa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ ;; [metadata] name = sveetch-djangoapp-sample -version = 0.1.0 +version = 0.3.0 description = A project sample created from cookiecutter-sveetch-djangoapp long_description = file:README.rst long_description_content_type = text/x-rst @@ -19,8 +19,9 @@ classifiers = Natural Language :: English Programming Language :: Python :: 3 Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 Framework :: Django - Framework :: Django :: 2.1 Framework :: Django :: 2.2 Framework :: Django :: 3.0 Framework :: Django :: 3.1 @@ -28,7 +29,8 @@ classifiers = [options] include_package_data = True install_requires = - Django>=2.1 + Django>=2.2,<3.2 + djangorestframework>=3.12.0 packages = find: zip_safe = True @@ -39,6 +41,7 @@ dev = pytest-django factory-boy pyquery + freezegun sphinx sphinx-rtd-theme livereload @@ -77,15 +80,17 @@ testpaths = [tox:tox] minversion = 3.4.0 -envlist = py36-django{21,22,30,31}-cms +envlist = py{36,37,38}-django{22,30,31}-api [testenv] deps = - django21: Django>=2.1,<2.2 django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 + django22-api: djangorestframework>=3.12.0 + django30-api: djangorestframework>=3.12.0 + django31-api: djangorestframework>=3.12.0 commands = pip install -e .[dev] diff --git a/sphinx_reload.py b/sphinx_reload.py index 1172407..0189de9 100644 --- a/sphinx_reload.py +++ b/sphinx_reload.py @@ -28,18 +28,18 @@ ) ) -# Watch application core documents +# Watch application documents server.watch( - 'docs/core/*.rst', + 'docs/django_app/*.rst', shell( 'make html', cwd='docs' ) ) -# Watch root modules for autodoc review from core docs +# Watch root modules for autodoc review from application documents server.watch( - 'djangoapp_sample/*.py', + 'djangoapp_sample/*/**.py', shell( 'make html', cwd='docs' diff --git a/tests/030_views/032_article.py b/tests/030_views/032_article.py index 0f561a6..da5b928 100644 --- a/tests/030_views/032_article.py +++ b/tests/030_views/032_article.py @@ -45,9 +45,9 @@ def test_article_detail_content(db, client): assert response.status_code == 200 dom = html_pyquery(response) - blog_title = dom.find(".article-detail p") - article_title = dom.find(".article-detail h2") - article_content = dom.find(".article-detail div.content") + blog_title = dom.find(".article-detail .blog-title") + article_title = dom.find(".article-detail .title") + article_content = dom.find(".article-detail .content") assert blog_title.text() == blog.title assert article_title.text() == article.title diff --git a/tests/100_serializers/101_blog.py b/tests/100_serializers/101_blog.py new file mode 100644 index 0000000..ffcbf21 --- /dev/null +++ b/tests/100_serializers/101_blog.py @@ -0,0 +1,57 @@ +from djangoapp_sample.factories import ArticleFactory, BlogFactory +from djangoapp_sample.serializers import BlogSerializer + + +def test_blog_serialize_single(db): + """ + Single object serialization. + """ + # Create blog + blog = BlogFactory(title="Foo") + + # Serialize blog + serializer = BlogSerializer(blog, context={"request": None}) + + expected = { + "id": blog.id, + "url": "/api/blogs/{}/".format(blog.id), + "view_url": "/{}/".format(blog.id), + "title": "Foo", + "article_count": 0, + } + + assert expected == serializer.data + + +def test_blog_serialize_many(db): + """ + Many objects serialization. + """ + # Create some blogs + foo = BlogFactory(title="Foo") + bar = BlogFactory(title="Bar") + + # Create article + ArticleFactory(blog=foo, title="Lorem") + + # Serialize blogs + serializer = BlogSerializer([foo, bar], many=True, context={"request": None}) + + expected = [ + { + "id": foo.id, + "url": "/api/blogs/{}/".format(foo.id), + "view_url": "/{}/".format(foo.id), + "title": "Foo", + "article_count": 1, + }, + { + "id": bar.id, + "url": "/api/blogs/{}/".format(bar.id), + "view_url": "/{}/".format(bar.id), + "title": "Bar", + "article_count": 0, + }, + ] + + assert expected == serializer.data diff --git a/tests/100_serializers/102_article.py b/tests/100_serializers/102_article.py new file mode 100644 index 0000000..3ba3607 --- /dev/null +++ b/tests/100_serializers/102_article.py @@ -0,0 +1,111 @@ +import datetime + +import pytz + +from django.conf import settings + +from djangoapp_sample.factories import ArticleFactory, BlogFactory +from djangoapp_sample.serializers import ArticleSerializer + + +def test_article_serialize_single(db): + """ + Single object serialization. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Create blog + foo = BlogFactory(title="Foo") + + # Create article + lorem = ArticleFactory( + blog=foo, + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + + # Serialize article + serializer = ArticleSerializer(lorem, context={"request": None}) + + expected = { + "id": lorem.id, + "url": "/api/articles/{}/".format(lorem.id), + "view_url": "/{}/{}/".format(foo.id, lorem.id), + "blog": { + "id": foo.id, + "url": "/api/blogs/{}/".format(foo.id), + "view_url": "/{}/".format(foo.id), + "title": "Foo", + }, + "title": "Lorem", + "content": "Ipsume salace nec vergiture", + "publish_start": "2012-10-15T12:00:00-05:00", + } + + assert expected == serializer.data + + +def test_article_serialize_many(db): + """ + Many objects serialization. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Create some blogs + foo = BlogFactory(title="Foo") + bar = BlogFactory(title="Bar") + + # Create some articles + lorem = ArticleFactory( + blog=foo, + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + bonorum = ArticleFactory( + blog=bar, + title="Bonorum", + content="Sed ut perspiciatis unde", + publish_start=default_tz.localize(datetime.datetime(2021, 8, 7, 15, 30)), + ) + + # Serialize articles + serializer = ArticleSerializer( + [lorem, bonorum], + many=True, + context={"request": None} + ) + + expected = [ + { + "id": lorem.id, + "url": "/api/articles/{}/".format(lorem.id), + "view_url": "/{}/{}/".format(foo.id, lorem.id), + "blog": { + "id": foo.id, + "url": "/api/blogs/{}/".format(foo.id), + "view_url": "/{}/".format(foo.id), + "title": "Foo", + }, + "title": "Lorem", + "content": "Ipsume salace nec vergiture", + "publish_start": "2012-10-15T12:00:00-05:00", + }, + { + "id": bonorum.id, + "url": "/api/articles/{}/".format(bonorum.id), + "view_url": "/{}/{}/".format(bar.id, bonorum.id), + "blog": { + "id": bar.id, + "url": "/api/blogs/{}/".format(bar.id), + "view_url": "/{}/".format(bar.id), + "title": "Bar", + }, + "title": "Bonorum", + "content": "Sed ut perspiciatis unde", + "publish_start": "2021-08-07T15:30:00-05:00", + }, + ] + + assert expected == serializer.data diff --git a/tests/110_viewsets/111_blog.py b/tests/110_viewsets/111_blog.py new file mode 100644 index 0000000..299be69 --- /dev/null +++ b/tests/110_viewsets/111_blog.py @@ -0,0 +1,219 @@ +import pytest + +from rest_framework.test import APIClient + +from djangoapp_sample.factories import BlogFactory, UserFactory +from djangoapp_sample.models import Blog + +from tests.utils import DRF_DUMMY_HOST_URL as HOSTURL + + +def test_blog_viewset_list(db): + """ + Read response from API viewset list. + """ + # Create some blogs + foo = BlogFactory(title="Foo") + bar = BlogFactory(title="Bar") + + # Use test client to get blog list + client = APIClient() + response = client.get("/api/blogs/", format="json") + json_data = response.json() + + # Expected payload from JSON response + expected = [ + { + "id": bar.id, + "url": "{}/api/blogs/{}/".format(HOSTURL, bar.id), + "view_url": "{}/{}/".format(HOSTURL, bar.id), + "title": "Bar", + "article_count": 0, + }, + { + "id": foo.id, + "url": "{}/api/blogs/{}/".format(HOSTURL, foo.id), + "view_url": "{}/{}/".format(HOSTURL, foo.id), + "title": "Foo", + "article_count": 0, + }, + ] + + assert response.status_code == 200 + assert expected == json_data + + +def test_blog_viewset_detail(db): + """ + Read response from API viewset detail. + """ + # Create blog object + foo = BlogFactory(title="Foo") + + # Use test client to get blog object + client = APIClient() + response = client.get( + "/api/blogs/{}/".format(foo.id), + format="json" + ) + json_data = response.json() + + # Expected payload from JSON response + expected = { + "id": foo.id, + "url": "{}/api/blogs/{}/".format(HOSTURL, foo.id), + "view_url": "{}/{}/".format(HOSTURL, foo.id), + "title": "Foo", + "article_count": 0, + } + + assert response.status_code == 200 + assert expected == json_data + + +def test_blog_viewset_forbidden(db): + """ + Write methods require to be authenticated with the right permissions. + """ + # Use test client with anonymous user + client = APIClient() + + # Create blog object + foo = BlogFactory(title="Foo") + + # Try to create a new blog + response = client.post( + "/api/blogs/", + { + "title": "Ping", + }, + format="json" + ) + assert response.status_code == 403 + + # Try to edit an existing blog + response = client.post( + "/api/blogs/{}/".format(foo.id), + { + "title": "Bar", + }, + format="json" + ) + assert response.status_code == 403 + + # Try to delete an existing blog + response = client.delete( + "/api/blogs/{}/".format(foo.id), + format="json" + ) + assert response.status_code == 403 + + +def test_blog_viewset_post(db): + """ + Create a new blog with HTTP POST method. + """ + # Create a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Use test client with authenticated user to create a new blog + client = APIClient() + client.force_authenticate(user=user) + response = client.post( + "/api/blogs/", + { + "title": "Foo", + }, + format="json" + ) + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 201 + + # Check created object + blog = Blog.objects.get(pk=json_data["id"]) + assert blog.title == json_data["title"] + + +def test_blog_viewset_put(db): + """ + Edit an existing blog with HTTP PUT method. + """ + # Create a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create blog object + foo = BlogFactory(title="Foo") + + # Use test client with authenticated user to create a new blog + client = APIClient() + client.force_authenticate(user=user) + response = client.put( + "/api/blogs/{}/".format(foo.id), + { + "title": "Bar", + }, + format="json" + ) + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 200 + + # Check edited object + blog = Blog.objects.get(pk=foo.id) + assert blog.title == json_data["title"] + + +def test_blog_viewset_patch(db): + """ + Edit an existing blog with HTTP PATCH method. + """ + # Create a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create blog object + foo = BlogFactory(title="Foo") + + # Use test client with authenticated user to create a new blog + client = APIClient() + client.force_authenticate(user=user) + response = client.patch( + "/api/blogs/{}/".format(foo.id), + { + "title": "Bar", + }, + format="json" + ) + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 200 + + # Check edited object + blog = Blog.objects.get(pk=foo.id) + assert blog.title == json_data["title"] + + +def test_blog_viewset_delete(db): + """ + Edit an existing blog with HTTP DELETE method. + """ + # Create a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create blog object + foo = BlogFactory() + + # Use test client with authenticated user to create a new blog + client = APIClient() + client.force_authenticate(user=user) + response = client.delete("/api/blogs/{}/".format(foo.id)) + + # Check response status code according to HTTP method + assert response.status_code == 204 + + # Check deleted object does not exist anymore + with pytest.raises(Blog.DoesNotExist): + Blog.objects.get(pk=foo.id) diff --git a/tests/110_viewsets/112_article.py b/tests/110_viewsets/112_article.py new file mode 100644 index 0000000..04399ca --- /dev/null +++ b/tests/110_viewsets/112_article.py @@ -0,0 +1,295 @@ +import datetime + +import pytest +import pytz + +from django.conf import settings +from rest_framework.test import APIClient + +from djangoapp_sample.factories import ArticleFactory, BlogFactory, UserFactory +from djangoapp_sample.models import Article + +from tests.utils import DRF_DUMMY_HOST_URL as HOSTURL + + +def test_article_viewset_list(db): + """ + Read response from API viewset list. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Create some articles + lorem = ArticleFactory( + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + bonorum = ArticleFactory( + title="Bonorum", + content="Sed ut perspiciatis unde", + publish_start=default_tz.localize(datetime.datetime(2021, 8, 7, 15, 30)), + ) + + # Use test client to get article list + client = APIClient() + response = client.get("/api/articles/", format="json") + json_data = response.json() + + # Expected payload from JSON response + expected = [ + { + "id": bonorum.id, + "url": "{}/api/articles/{}/".format(HOSTURL, bonorum.id), + "view_url": "{}/{}/{}/".format(HOSTURL, bonorum.blog_id, bonorum.id), + "blog": { + "id": bonorum.blog_id, + "url": "{}/api/blogs/{}/".format(HOSTURL, bonorum.blog_id), + "view_url": "{}/{}/".format(HOSTURL, bonorum.blog_id), + "title": bonorum.blog.title, + }, + "publish_start": bonorum.publish_start.isoformat(), + "title": bonorum.title, + }, + { + "id": lorem.id, + "url": "{}/api/articles/{}/".format(HOSTURL, lorem.id), + "view_url": "{}/{}/{}/".format(HOSTURL, lorem.blog_id, lorem.id), + "blog": { + "id": lorem.blog_id, + "url": "{}/api/blogs/{}/".format(HOSTURL, lorem.blog_id), + "view_url": "{}/{}/".format(HOSTURL, lorem.blog_id), + "title": lorem.blog.title, + }, + "publish_start": lorem.publish_start.isoformat(), + "title": lorem.title, + }, + ] + + assert response.status_code == 200 + assert expected == json_data + + +def test_article_viewset_detail(db): + """ + Read response from API viewset detail. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Create article object + lorem = ArticleFactory( + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + + # Use test client to get article object + client = APIClient() + response = client.get( + "/api/articles/{}/".format(lorem.id), + format="json" + ) + json_data = response.json() + + # Expected payload from JSON response + expected = { + "id": lorem.id, + "url": "{}/api/articles/{}/".format(HOSTURL, lorem.id), + "view_url": "{}/{}/{}/".format(HOSTURL, lorem.blog_id, lorem.id), + "blog": { + "id": lorem.blog_id, + "url": "{}/api/blogs/{}/".format(HOSTURL, lorem.blog_id), + "view_url": "{}/{}/".format(HOSTURL, lorem.blog_id), + "title": lorem.blog.title, + }, + "publish_start": lorem.publish_start.isoformat(), + "title": lorem.title, + "content": lorem.content, + } + + assert response.status_code == 200 + assert expected == json_data + + +def test_article_viewset_forbidden(db): + """ + Write methods require to be authenticated with the right permissions. + """ + # Use test client with anonymous user + client = APIClient() + + # Create article object + foo = ArticleFactory(title="Foo") + + # Try to create a new article + response = client.post( + "/api/articles/", + { + "title": "Ping", + }, + format="json" + ) + assert response.status_code == 403 + + # Try to edit an existing article + response = client.post( + "/api/articles/{}/".format(foo.id), + { + "title": "Bar", + }, + format="json" + ) + assert response.status_code == 403 + + # Try to delete an existing article + response = client.delete( + "/api/articles/{}/".format(foo.id), + format="json" + ) + assert response.status_code == 403 + + +def test_article_viewset_post(db): + """ + Create a new article with HTTP POST method. + """ + # Create a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create blog object + foo = BlogFactory(title="Foo") + + # Use test client with authenticated user to create a new article + client = APIClient() + client.force_authenticate(user=user) + + # This will fail because of missing required fields + response = client.post("/api/articles/", {}, format="json") + assert response.status_code == 400 + assert response.json() == { + "blog_id": ["This field is required."], + "title": ["This field is required."], + } + + # This will succeed because every required field are given + payload = { + "title": "Lorem", + "blog_id": foo.id, + "content": "Ping pong", + } + response = client.post("/api/articles/", payload, format="json") + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 201 + + # Check created object + article = Article.objects.get(pk=json_data["id"]) + assert payload["title"] == json_data["title"] + assert payload["content"] == json_data["content"] + assert payload["title"] == article.title + assert payload["content"] == article.content + + +def test_article_viewset_put(db): + """ + Edit an existing article with HTTP PUT method. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Use a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create article object + lorem = ArticleFactory( + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + + # Use test client with authenticated user to create a new article + payload = { + "title": "Bar", + "content": "Ping pong", + "blog_id": lorem.blog_id, + } + client = APIClient() + client.force_authenticate(user=user) + response = client.put( + "/api/articles/{}/".format(lorem.id), + payload, + format="json" + ) + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 200 + + # Check edited object + article = Article.objects.get(pk=lorem.id) + assert payload["title"] == json_data["title"] + assert payload["content"] == json_data["content"] + assert payload["title"] == article.title + assert payload["content"] == article.content + + +def test_article_viewset_patch(db): + """ + Edit an existing article with HTTP PATCH method. + """ + default_tz = pytz.timezone(settings.TIME_ZONE) + + # Use a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Create article object + lorem = ArticleFactory( + title="Lorem", + content="Ipsume salace nec vergiture", + publish_start=default_tz.localize(datetime.datetime(2012, 10, 15, 12, 00)), + ) + + # Use test client with authenticated user to create a new article + payload = { + "title": "Bar", + } + client = APIClient() + client.force_authenticate(user=user) + response = client.patch( + "/api/articles/{}/".format(lorem.id), + payload, + format="json" + ) + json_data = response.json() + + # Check response status code according to HTTP method + assert response.status_code == 200 + + # Check edited object + article = Article.objects.get(pk=lorem.id) + assert payload["title"] == json_data["title"] + assert payload["title"] == article.title + # content value has not been modified with patch, ensure it still original value + assert json_data["content"] == article.content + + +def test_article_viewset_delete(db): + """ + Edit an existing article with HTTP DELETE method. + """ + # Create article object + lorem = ArticleFactory() + + # Use a superuser to not bother with permissions + user = UserFactory(flag_is_superuser=True) + + # Use test client with authenticated user to create a new article + client = APIClient() + client.force_authenticate(user=user) + response = client.delete("/api/articles/{}/".format(lorem.id)) + + # Check response status code according to HTTP method + assert response.status_code == 204 + + # Check deleted object does not exist anymore + with pytest.raises(Article.DoesNotExist): + Article.objects.get(pk=lorem.id) diff --git a/tests/conftest.py b/tests/conftest.py index e6dfab1..4af5d71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,7 @@ def format(self, content): @pytest.fixture(scope="session") def temp_builds_dir(tmpdir_factory): """ - Shortand to prepare a temporary build directory where to create temporary + Shortcut to prepare a temporary build directory where to create temporary content from tests. """ fn = tmpdir_factory.mktemp("builds") diff --git a/tests/utils.py b/tests/utils.py index 98e3a05..6a5c584 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,12 +1,135 @@ """ +============== Test utilities +============== + """ -from django.test.html import parse_html +from django.contrib.sites.models import Site from django.template.response import TemplateResponse +from django.test.html import parse_html +from django.urls import reverse from pyquery import PyQuery as pq +# A dummy password that should pass form validation +VALID_PASSWORD_SAMPLE = "Azerty12345678" + +# This is the common dummy URL which Django REST Framework will use when it does not +# have any request when it resolve URL to absolute (like from Hyperlinked classes) +DRF_DUMMY_HOST_URL = "http://testserver" + + +# A dummy blank GIF file in byte value to simulate an uploaded file like with +# 'django.core.files.uploadedfile.SimpleUploadedFile' +DUMMY_GIF_BYTES = ( + b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x00\x00\x00\x21\xf9\x04' + b'\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02' + b'\x02\x4c\x01\x00\x3b' +) + + +def get_website_url(site_settings=None): + """ + A shortand to retrieve the full website URL according to Site ID and HTTP + protocole settings. + + Keyword Arguments: + site_settings (django.conf.settings): Settings object, if not given the method + will be unable to determine if HTTPS is enabled or not, so it will always + return a HTTP URL. + + Returns: + string: Full website URL. + """ + domain = Site.objects.get_current().domain + + protocol = "http://" + if site_settings and site_settings.HTTPS_ENABLED: + protocol = "https://" + + return "{}{}".format(protocol, domain) + + +def get_relative_path(site_url, url): + """ + From given URL, retrieve the relative path (URL without domain and starting + slash). + + Arguments: + site_url (string): Website URL to remove from given ``url`` + argument. + url (string): Full URL (starting with http/https) to make relative to + website URL. + + Returns: + string: Admin change view URL path for given model object. + """ + if url.startswith(site_url): + return url[len(site_url):] + + return url + + +def get_admin_add_url(model): + """ + Return the right admin URL for add form view for given class. + + Arguments: + model (Model object): A model object to use to find its admin + add form view URL. + + Returns: + string: Admin add form view URL path. + """ + url_pattern = "admin:{app}_{model}_add" + + return reverse(url_pattern.format( + app=model._meta.app_label, + model=model._meta.model_name + )) + + +def get_admin_change_url(obj): + """ + Return the right admin URL for a change view for given object. + + Arguments: + obj (Model object): A model object instance to use to find its admin + change view URL. + + Returns: + string: Admin change view URL path. + """ + url_pattern = "admin:{app}_{model}_change" + + return reverse(url_pattern.format( + app=obj._meta.app_label, + model=obj._meta.model_name + ), args=[ + obj.pk + ]) + + +def get_admin_list_url(model): + """ + Return the right admin URL for a list view for given class. + + Arguments: + model (Model object): A model object to use to find its admin + list view URL. + + Returns: + string: Admin list view URL path. + """ + url_pattern = "admin:{app}_{model}_changelist" + + return reverse(url_pattern.format( + app=model._meta.app_label, + model=model._meta.model_name + )) + + def decode_response_or_string(content): """ Shortand to get HTML string from either a TemplateResponse (as returned @@ -64,3 +187,79 @@ def html_pyquery(content): decode_response_or_string(content), parser='html' ) + + +def queryset_values(queryset, names=["slug", "language"], + orders=["slug", "language"]): + """ + An helper to just return a list of dict values ordered from given queryset. + + Arguments: + queryset (Queryset): A queryset to turn to values. + + Keyword Arguments: + names (list): A list of field names to return as values for each object. + Default return "slug" and "language" values only. + orders (list): A list of field names to order results. + Default order first on "slug" then "language". + + Returns: + list: A list of dict items for all result objects. + """ + return list( + queryset.values(*names).order_by(*orders) + ) + + +def compact_form_errors(form): + """ + Build a compact dict of field errors without messages. + + This is a helper for errors, keeping it more easy to test since messages + may be too long and can be translated which is more difficult to test. + + Arguments: + form (django.forms.Form): A bounded form. + + Returns: + dict: A dict of invalid fields, each item is indexed by field name and + value is a list of error codes. + """ + errors = {} + + for name, validationerror in form.errors.as_data().items(): + errors[name] = [item.code for item in validationerror] + + return errors + + +def build_post_data_from_object(model, obj, ignore=["id"]): + """ + Build a payload suitable to a POST request from given object data. + + Arguments: + model (django.db.models.Model): A model object used to find object + attributes to extract values. + obj (object): A instance of given model or a dict (like the one returned + by a factory ``build()`` method. + ignore (list): List of field name to ignore for value + extraction. Default to "id" but will not be enough for any field + with foreign keys, automatic primary keys, etc.. + + Returns: + dict: Payload data to use in POST request. + """ + data = {} + + fields = [ + f.name for f in model._meta.get_fields() + if f.name not in ignore + ] + + for name in fields: + if obj is dict: + data[name] = obj.get(name) + else: + data[name] = getattr(obj, name) + + return data