diff --git a/tests/artist_app/urls.py b/tests/artist_app/urls.py index 6ce2b54..b49fec2 100644 --- a/tests/artist_app/urls.py +++ b/tests/artist_app/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('list/artists/', views.ArtistListView.as_view()), path('edit/artists/create/', views.ArtistCreate.as_view()), + path('view/artists/read/', views.ArtistRead.as_view()), path('edit/artists/edit/', views.ArtistUpdate.as_view()), path('edit/artists/delete/', views.ArtistDelete.as_view()), ] + artist_crud_patterns + song_crud_patterns + custom_artist_crud_patterns +\ diff --git a/tests/artist_app/views.py b/tests/artist_app/views.py index 18ff3de..9aafd27 100644 --- a/tests/artist_app/views.py +++ b/tests/artist_app/views.py @@ -8,7 +8,7 @@ from django_filters import FilterSet from vega_admin.mixins import SimpleURLPatternMixin from vega_admin.views import (VegaCreateView, VegaCRUDView, VegaDeleteView, - VegaListView, VegaUpdateView) + VegaListView, VegaUpdateView, VegaDetailView) from .forms import ArtistForm, CustomSearchForm, SongForm from .models import Artist, Song @@ -59,6 +59,14 @@ def get_success_url(self): return "/edit/artists/create/" +class ArtistRead(VegaDetailView): # pylint: disable=too-many-ancestors + """ + Artist detail view + """ + + model = Artist + + class ArtistListView(VegaListView): # pylint: disable=too-many-ancestors """ Artist list view @@ -78,6 +86,7 @@ class SongCRUD(VegaCRUDView): protected_actions = None permissions_actions = None list_fields = ["name", "artist", ] + read_fields = ["name", "artist", ] table_attrs = {"class": "song-table"} table_actions = ["create", "update", "delete", ] create_fields = ["name", "artist", ] @@ -97,7 +106,7 @@ class FooView(SimpleURLPatternMixin, TemplateView): """random template view""" template_name = "artist_app/empty.html" - protected_actions = ["create", "update", "delete", "template"] + protected_actions = ["create", "update", "delete", "template", "read", ] permissions_actions = None crud_path = "private-songs" view_classes = { @@ -112,8 +121,9 @@ class PermsSongCRUD(CustomSongCRUD): CRUD view for songs with permissions protection """ - protected_actions = ["create", "update", "delete", "artists", "list"] - permissions_actions = ["create", "update", "delete", "artists"] + protected_actions = [ + "create", "update", "delete", "artists", "list", "read", ] + permissions_actions = ["create", "update", "delete", "artists", "read", ] crud_path = "hidden-songs" form_class = SongForm @@ -148,8 +158,13 @@ class CustomDeleteView(ArtistDelete): """custom Delete view""" pass + class CustomReadView(ArtistRead): + """custom Read view""" + pass + view_classes = { "list": CustomListView, + "read": CustomReadView, "update": CustomUpdateView, "create": CustomCreateView, "delete": CustomDeleteView, diff --git a/tests/test_crud.py b/tests/test_crud.py index c219c70..ae08d14 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -40,6 +40,10 @@ def test_url_patterns(self): f"/artist_app.artist/delete/{artist.pk}/", reverse("artist_app.artist-delete", kwargs={"pk": artist.pk}), ) + self.assertEqual( + f"/artist_app.artist/read/{artist.pk}/", + reverse("artist_app.artist-read", kwargs={"pk": artist.pk}), + ) self.assertEqual( f"/artist_app.artist/update/{artist.pk}/", reverse("artist_app.artist-update", kwargs={"pk": artist.pk}), @@ -82,6 +86,34 @@ def test_create(self): html = f""" Create professional artist
""" # noqa self.assertHTMLEqual(html, res.content.decode("utf-8")) + def test_read(self): + """ + Test CRUD read + """ + artist = mommy.make("artist_app.Artist", name="tt", id=436) + url = reverse("artist_app.artist-read", kwargs={"pk": artist.id}) + + # test content + res = self.client.get(url) + self.assertEqual( + "/artist_app.artist/list/", res.context_data["vega_list_url"]) + self.assertEqual( + "/artist_app.artist/create/", res.context_data["vega_create_url"] + ) + self.assertEqual(artist.name, res.context_data["vega_object_title"]) + self.assertEqual( + ["id", "name", ], res.context_data["vega_read_fields"]) + self.assertDictEqual( + {"name": artist.name, "ID": artist.id}, + res.context_data["vega_object_data"] + ) + self.assertEqual( + f"/artist_app.artist/read/{artist.pk}/", + res.context_data["vega_read_url"], + ) + html = f""" tt | professional artist

tt

ID: 436
name: tt
""" # noqa + self.assertHTMLEqual(html, res.content.decode("utf-8")) + def test_update(self): """ Test CRUD update @@ -102,14 +134,13 @@ def test_update(self): # test content res = self.client.get(url) - self.assertEqual( - "/artist_app.artist/list/", res.context_data["vega_list_url"]) - self.assertEqual( - "/artist_app.artist/create/", res.context_data["vega_create_url"] - ) - self.assertEqual( - "/artist_app.artist/list/", res.context_data["vega_cancel_url"] - ) + self.assertEqual("/artist_app.artist/list/", + res.context_data["vega_list_url"]) + self.assertEqual("/artist_app.artist/create/", + res.context_data["vega_create_url"]) + self.assertEqual("/artist_app.artist/list/", + res.context_data["vega_cancel_url"]) + self.assertEqual(artist.name, res.context_data["vega_object_title"]) self.assertEqual( f"/artist_app.artist/update/{artist.pk}/", res.context_data["vega_update_url"], @@ -155,6 +186,7 @@ def test_delete(self): self.assertEqual( "/artist_app.artist/list/", res.context_data["vega_cancel_url"] ) + self.assertEqual(str(artist2), res.context_data["vega_object_title"]) self.assertEqual( f"/artist_app.artist/delete/{artist2.pk}/", res.context_data["vega_delete_url"], @@ -221,6 +253,13 @@ def test_custom_default_views(self): self.assertIsInstance(res.context["view"], CustomDefaultActions.CustomUpdateView) + url = reverse( + "custom-default-actions-read", kwargs={"pk": artist.pk}) + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + self.assertIsInstance(res.context["view"], + CustomDefaultActions.CustomReadView) + url = reverse( "custom-default-actions-delete", kwargs={"pk": artist.pk}) res = self.client.get(url) @@ -256,6 +295,25 @@ def test_create_options(self): html = f""" Create Song
""" # noqa self.assertHTMLEqual(html, res.content.decode("utf-8")) + def test_read_options(self): + """ + Test CRUD update with options + """ + artist = mommy.make("artist_app.Artist", name="Mosh") + song = mommy.make("artist_app.Song", name="Song 1", artist=artist) + url = reverse("artist_app.song-read", kwargs={"pk": song.id}) + # test content + res = self.client.get(url) + self.assertEqual( + ["name", "artist", ], res.context_data["vega_read_fields"]) + self.assertDictEqual( + {"name": song.name, "artist": str(artist)}, + res.context_data["vega_object_data"] + ) + self.assertEqual(res.status_code, 200) + html = f""" Song 1 | Song

Song 1

name: Song 1
artist: Mosh
""" # noqa + self.assertHTMLEqual(html, res.content.decode("utf-8")) + def test_update_options(self): """ Test CRUD update with options @@ -309,6 +367,7 @@ def test_login_protection(self): song = mommy.make("artist_app.Song", name="Song 1", artist=artist) create_url = reverse("private-songs-create") update_url = reverse("private-songs-update", kwargs={"pk": song.id}) + read_url = reverse("private-songs-read", kwargs={"pk": song.id}) delete_url = reverse("private-songs-delete", kwargs={"pk": song.id}) list_url = reverse("private-songs-list") @@ -316,12 +375,19 @@ def test_login_protection(self): create_res = self.client.get(create_url) self.assertEqual(302, create_res.status_code) self.assertRedirects(create_res, f"/list/artists/?next={create_url}") + update_res = self.client.get(update_url) self.assertEqual(302, update_res.status_code) self.assertRedirects(update_res, f"/list/artists/?next={update_url}") + + read_res = self.client.get(read_url) + self.assertEqual(302, read_res.status_code) + self.assertRedirects(read_res, f"/list/artists/?next={read_url}") + delete_res = self.client.get(delete_url) self.assertEqual(302, delete_res.status_code) self.assertRedirects(delete_res, f"/list/artists/?next={delete_url}") + list_res = self.client.get(list_url) # the list action is not set to be protected self.assertEqual(200, list_res.status_code) @@ -335,6 +401,8 @@ def test_login_protection(self): self.assertEqual(200, update_res.status_code) delete_res = self.client.get(delete_url) self.assertEqual(200, delete_res.status_code) + read_res = self.client.get(read_url) + self.assertEqual(200, read_res.status_code) list_res = self.client.get(list_url) self.assertEqual(200, list_res.status_code) @@ -367,6 +435,7 @@ def test_permission_protection(self): song = mommy.make("artist_app.Song", name="Song 42", artist=artist) create_url = reverse("hidden-songs-create") update_url = reverse("hidden-songs-update", kwargs={"pk": song.id}) + read_url = reverse("hidden-songs-read", kwargs={"pk": song.id}) delete_url = reverse("hidden-songs-delete", kwargs={"pk": song.id}) artists_url = reverse("hidden-songs-artists") list_url = reverse("hidden-songs-list") # not protected @@ -381,6 +450,10 @@ def test_permission_protection(self): self.assertEqual(302, update_res.status_code) self.assertRedirects(update_res, f"/list/artists/?next={update_url}") + read_res = self.client.get(read_url) + self.assertEqual(302, read_res.status_code) + self.assertRedirects(read_res, f"/list/artists/?next={read_url}") + delete_res = self.client.get(delete_url) self.assertEqual(302, delete_res.status_code) self.assertRedirects(delete_res, f"/list/artists/?next={delete_url}") @@ -409,6 +482,10 @@ def test_permission_protection(self): self.assertEqual(302, update_res.status_code) self.assertRedirects(update_res, f"/list/artists/?next={update_url}") + read_res = self.client.get(read_url) + self.assertEqual(302, read_res.status_code) + self.assertRedirects(read_res, f"/list/artists/?next={read_url}") + delete_res = self.client.get(delete_url) self.assertEqual(302, delete_res.status_code) self.assertRedirects(delete_res, f"/list/artists/?next={delete_url}") @@ -437,6 +514,9 @@ def test_permission_protection(self): update_res = self.client.get(update_url) self.assertEqual(200, update_res.status_code) + read_res = self.client.get(read_url) + self.assertEqual(200, read_res.status_code) + delete_res = self.client.get(delete_url) self.assertEqual(200, delete_res.status_code) diff --git a/tests/test_views.py b/tests/test_views.py index 5c7b28b..e60eea6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -9,12 +9,12 @@ from model_mommy import mommy from vega_admin.views import (VegaCreateView, VegaCRUDView, VegaDeleteView, - VegaListView, VegaUpdateView) + VegaListView, VegaDetailView, VegaUpdateView) from .artist_app.forms import ArtistForm from .artist_app.models import Artist, Song from .artist_app.views import (ArtistCreate, ArtistDelete, ArtistListView, - ArtistUpdate) + ArtistRead, ArtistUpdate) class TestViewsBase(TestCase): @@ -37,6 +37,11 @@ def _song_permissions(self): content_type=content_type, defaults=dict(name='Can Create Songs'), ) + read_permission, _ = Permission.objects.get_or_create( + codename='read_song', + content_type=content_type, + defaults=dict(name='Can View Songs'), + ) update_permission, _ = Permission.objects.get_or_create( codename='update_song', content_type=content_type, @@ -53,7 +58,7 @@ def _song_permissions(self): defaults=dict(name='Can List Song Artists'), ) return [list_permission, create_permission, update_permission, - delete_permission, artists_permission, ] + delete_permission, artists_permission, read_permission, ] def _artist_permissions(self): """ @@ -84,7 +89,7 @@ def setUp(self): def tearDown(self): """tearDown""" - super().setUp() + super().tearDown() Song.objects.all().delete() Artist.objects.all().delete() Permission.objects.filter( @@ -104,7 +109,7 @@ def test_vega_crud_view(self): """ Test VegaCRUDView """ - default_actions = ["create", "update", "list", "delete"] + default_actions = ["create", "read", "update", "list", "delete"] class ArtistCrud(VegaCRUDView): model = Artist @@ -126,6 +131,8 @@ class ArtistCrud(VegaCRUDView): Artist, view.get_view_class_for_action("delete")().model) self.assertEqual( Artist, view.get_view_class_for_action("list")().model) + self.assertEqual( + Artist, view.get_view_class_for_action("read")().model) self.assertIsInstance( view.get_view_class_for_action("create")(), VegaCreateView @@ -138,6 +145,8 @@ class ArtistCrud(VegaCRUDView): ) self.assertIsInstance( view.get_view_class_for_action("list")(), VegaListView) + self.assertIsInstance( + view.get_view_class_for_action("read")(), VegaDetailView) self.assertEqual( f"{view.crud_path}/create/", @@ -145,6 +154,12 @@ class ArtistCrud(VegaCRUDView): view.get_view_class_for_action("create"), "create" ), ) + self.assertEqual( + f"{view.crud_path}/read//", + view.get_url_pattern_for_action( + view.get_view_class_for_action("read"), "read" + ), + ) self.assertEqual( f"{view.crud_path}/list/", view.get_url_pattern_for_action( @@ -194,6 +209,17 @@ def test_vega_list_view(self): self.assertEqual(res.context["object_list"].count(), 1) self.assertEqual(res.context["object_list"].first(), artist) + def test_vega_read_view(self): + """ + Test VegaReadView + """ + artist = mommy.make("artist_app.Artist", name="Bob") + res = self.client.get(f"/view/artists/read/{artist.id}") + self.assertEqual(res.status_code, 200) + self.assertIsInstance(res.context["view"], ArtistRead) + self.assertIsInstance(res.context["view"], VegaDetailView) + self.assertTemplateUsed(res, "vega_admin/basic/read.html") + def test_vega_create_view(self): """ Test VegaCreateView diff --git a/vega_admin/__init__.py b/vega_admin/__init__.py index f9211bd..0250cae 100644 --- a/vega_admin/__init__.py +++ b/vega_admin/__init__.py @@ -1,7 +1,7 @@ """ Main init file for vega_admin """ -VERSION = (0, 0, 4) +VERSION = (0, 0, 5) __version__ = '.'.join(str(v) for v in VERSION) # pylint: disable=invalid-name default_app_config = 'vega_admin.apps.VegaAdminConfig' # noqa diff --git a/vega_admin/mixins.py b/vega_admin/mixins.py index d07e6fc..c30facc 100644 --- a/vega_admin/mixins.py +++ b/vega_admin/mixins.py @@ -3,6 +3,7 @@ """ from django.conf import settings from django.contrib import messages +from django.core.exceptions import FieldDoesNotExist from django.db.models import ProtectedError, Q from django.shortcuts import redirect from django.urls import reverse_lazy @@ -133,6 +134,8 @@ class CRUDURLsMixin: cancel_url_name = None delete_url = "/" delete_url_name = None + read_url = "/" + read_url_name = None list_url = "/" list_url_name = None create_url = "/" @@ -193,6 +196,18 @@ def get_update_url(self): url_kwargs={"pk": self.object.pk}, ) + def get_read_url(self): + """ + Get the read url for the object in question + + :return: url + """ + return self.get_crud_url( + url=self.read_url, + url_name=self.read_url_name, + url_kwargs={"pk": self.object.pk}, + ) + def get_delete_url(self): """ Get the delete url for the object in question @@ -223,6 +238,7 @@ def get_context_data(self, **kwargs): context["vega_list_url"] = self.get_list_url() context["vega_cancel_url"] = self.get_cancel_url() if hasattr(self, "object") and self.object is not None: + context["vega_read_url"] = self.get_read_url() context["vega_delete_url"] = self.get_delete_url() context["vega_update_url"] = self.get_update_url() return context @@ -237,6 +253,74 @@ def get_form_kwargs(self): return kwargs +class ObjectTitleMixin: + """Mixin for getting object title""" + + def get_title(self): + """ + By default we just return the string representation of our object + """ + return str(self.object) + + def get_context_data(self, **kwargs): + """ + Get context data + """ + context = super().get_context_data(**kwargs) + context["vega_object_title"] = self.get_title() + return context + + +class DetailViewMixin: + """Mixin for detail views""" + + fields = None + + def get_fields(self): + """ + We first default to using our 'fields' variable if available, + otherwise we figure it out from our object. + """ + if self.fields and isinstance(self.fields, list): + return self.fields + return [ + _.name for _ in self.object._meta.fields + ] + + def get_field_value(self, field): + """Get the value of a field""" + if field.is_relation: + try: + return str(getattr(self.object, field.name)) + except AttributeError: + return None + # pylint: disable=protected-access + return self.object._get_FIELD_display(field) + + def get_object_data(self): + """Returns a dict of the data in the object""" + result = {} + fields_list = self.get_fields() + for item in fields_list: + try: + field = self.object._meta.get_field(item) + except FieldDoesNotExist: + pass + else: + result[field.verbose_name] = self.get_field_value(field) + + return result + + def get_context_data(self, **kwargs): + """ + Get context data + """ + context = super().get_context_data(**kwargs) + context["vega_read_fields"] = self.get_fields() + context["vega_object_data"] = self.get_object_data() + return context + + class DeleteViewMixin: """ Mixin for delete views that adds in missing elements diff --git a/vega_admin/settings.py b/vega_admin/settings.py index 9a243ff..39c9305 100644 --- a/vega_admin/settings.py +++ b/vega_admin/settings.py @@ -5,11 +5,13 @@ # general VEGA_CREATE_ACTION = "create" +VEGA_READ_ACTION = "read" VEGA_UPDATE_ACTION = "update" VEGA_LIST_ACTION = "list" VEGA_DELETE_ACTION = "delete" VEGA_DEFAULT_ACTIONS = [ VEGA_CREATE_ACTION, + VEGA_READ_ACTION, VEGA_UPDATE_ACTION, VEGA_LIST_ACTION, VEGA_DELETE_ACTION, diff --git a/vega_admin/templates/vega_admin/basic/read.html b/vega_admin/templates/vega_admin/basic/read.html new file mode 100644 index 0000000..33ae041 --- /dev/null +++ b/vega_admin/templates/vega_admin/basic/read.html @@ -0,0 +1,11 @@ +{% extends "vega_admin/basic/base.html" %} +{% load i18n %} + +{% block title %}{{ vega_object_title }} | {{ vega_verbose_name }}{% endblock%} + +{% block content %} +

{{ vega_object_title }}

+ {% for key, val in vega_object_data.items %} + {{key}}: {{val}}
+ {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/vega_admin/views.py b/vega_admin/views.py index 6838943..eadf934 100644 --- a/vega_admin/views.py +++ b/vega_admin/views.py @@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.urls import path, reverse_lazy from django.utils.translation import ugettext as _ +from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.list import ListView @@ -14,10 +15,11 @@ from django_tables2.export.views import ExportMixin from vega_admin.forms import ListViewSearchForm -from vega_admin.mixins import (CRUDURLsMixin, DeleteViewMixin, - ListViewSearchMixin, ObjectURLPatternMixin, - PageTitleMixin, SimpleURLPatternMixin, - VegaFormMixin, VerboseNameMixin) +from vega_admin.mixins import (CRUDURLsMixin, DeleteViewMixin, DetailViewMixin, + ListViewSearchMixin, ObjectTitleMixin, + ObjectURLPatternMixin, PageTitleMixin, + SimpleURLPatternMixin, VegaFormMixin, + VerboseNameMixin) from vega_admin.utils import (get_filterclass, get_listview_form, get_modelform, get_table) @@ -56,6 +58,21 @@ class VegaCreateView( form_invalid_message = _(settings.VEGA_FORM_INVALID_TXT) +class VegaDetailView( + PageTitleMixin, + VerboseNameMixin, + CRUDURLsMixin, + ObjectURLPatternMixin, + ObjectTitleMixin, + DetailViewMixin, + DetailView,): + """ + vega-admin Generic Detail View + """ + + template_name = "vega_admin/basic/read.html" + + class VegaUpdateView( FormMessagesMixin, PageTitleMixin, @@ -63,6 +80,7 @@ class VegaUpdateView( VegaFormMixin, CRUDURLsMixin, ObjectURLPatternMixin, + ObjectTitleMixin, UpdateView,): """ vega-admin Generic Update View @@ -80,6 +98,7 @@ class VegaDeleteView( DeleteViewMixin, CRUDURLsMixin, ObjectURLPatternMixin, + ObjectTitleMixin, DeleteView,): """ vega-admin Generic Delete View @@ -104,6 +123,7 @@ class VegaCRUDView: # pylint: disable=too-many-public-methods permissions_actions = actions view_classes = {} list_fields = None + read_fields = None search_fields = None filter_fields = None filter_class = None @@ -160,6 +180,10 @@ def get_search_fields(self): """Get search fields for list view""" return self.search_fields + def get_read_fields(self): + """Get read fields for read view""" + return self.read_fields + def get_filter_fields(self): """Get filter fields for list view""" return self.filter_fields @@ -285,6 +309,10 @@ def get_update_view_class(self): # pylint: disable=no-self-use """Get view class for update action""" return VegaUpdateView + def get_read_view_class(self): # pylint: disable=no-self-use + """Get view class for read action""" + return VegaDetailView + def get_list_view_class(self): # pylint: disable=no-self-use """Get view class for list action""" return VegaListView @@ -356,6 +384,8 @@ def get_default_action_view_classes(self, action: str): return self.get_list_view_class() if action == settings.VEGA_CREATE_ACTION: return self.get_create_view_class() + if action == settings.VEGA_READ_ACTION: + return self.get_read_view_class() if action == settings.VEGA_UPDATE_ACTION: return self.get_update_view_class() if action == settings.VEGA_DELETE_ACTION: @@ -417,6 +447,12 @@ def get_view_class_for_action( # pylint: disable=too-many-branches options["update_url_name"] = self.get_url_name_for_action( settings.VEGA_UPDATE_ACTION) + # add the read url + if action == settings.VEGA_READ_ACTION: + options["read_url_name"] = self.get_url_name_for_action( + settings.VEGA_READ_ACTION) + options["fields"] = self.get_read_fields() + # add the delete url if action == settings.VEGA_DELETE_ACTION: options["delete_url_name"] = self.get_url_name_for_action(