diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecc33f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +**/*.pyc +**/.project +**/.pydevproject +**/dist +**/*~ +**/MANIFEST +**/*egg* +**/*.bak +**/*.tmproj + +**/Dockerfile +**/.sh +**/.md diff --git a/LICENCE b/LICENCE index 432ee23..5724db8 100644 --- a/LICENCE +++ b/LICENCE @@ -1,6 +1,7 @@ Copyright (c) 2009, Weston Nielson (wnielson@gmail.com) 2010, Jan Schrewe (jschrewe@googlemail.com) 2017, Arthur Hanson (worldnomad@gmail.com) + 2024, Ipamo (dev@ipamo.net) 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/genericadmin/admin.py b/genericadmin/admin.py index 40aff4c..041da93 100755 --- a/genericadmin/admin.py +++ b/genericadmin/admin.py @@ -1,8 +1,14 @@ import json from functools import update_wrapper +from django import VERSION from django.contrib import admin -from django.conf.urls import url + +try: + from django.conf.urls import url +except ImportError: + from django.urls import re_path as url # Django >= 4.0 + from django.conf import settings try: from django.contrib.contenttypes.generic import GenericForeignKey, GenericTabularInline, GenericStackedInline @@ -14,7 +20,10 @@ try: from django.utils.encoding import force_text except ImportError: - from django.utils.encoding import force_unicode as force_text + try: + from django.utils.encoding import force_unicode as force_text + except ImportError: + from django.utils.encoding import force_str as force_text # Django >= 4.0 from django.utils.text import capfirst from django.contrib.admin.widgets import url_params_from_lookup_dict from django.http import HttpResponse, HttpResponseNotAllowed, Http404 @@ -40,6 +49,8 @@ def __init__(self, model, admin_site): media = list(self.Media.js) except: media = [] + if VERSION >= (2,2): + media.append('admin/js/jquery.init.js') # Django >= 2.2 media.append(JS_PATH + 'genericadmin.js') self.Media.js = tuple(media) @@ -63,7 +74,12 @@ def get_generic_field_list(self, request, prefix=''): fields['prefix'] = prefix field_list.append(fields) else: - for field in self.model._meta.virtual_fields: + try: + virtual_fields = self.model._meta.virtual_fields + except AttributeError: + virtual_fields = self.model._meta.private_fields # Django >= 2.0 + + for field in virtual_fields: if isinstance(field, GenericForeignKey) and \ field.ct_field not in exclude and field.fk_field not in exclude: field_list.append({ diff --git a/genericadmin/static/genericadmin/js/genericadmin.js b/genericadmin/static/genericadmin/js/genericadmin.js index 529dbb9..33da232 100755 --- a/genericadmin/static/genericadmin/js/genericadmin.js +++ b/genericadmin/static/genericadmin/js/genericadmin.js @@ -7,12 +7,32 @@ updated by Jonathan Ellenberger (jon@respondcreate.com) + updated by Ipamo (dev@ipamo.net) + */ (function($) { + function prefixWithObjRoot(url) { + // prefix the given URL with the necessary reverse paths to reach object root ('changelist' admin page) + if (window.location.pathname.endsWith('/change/') || window.location.pathname.endsWith('/change')) { // Django >= 1.9 + return `../../${url}`; + } + else { + return `../${url}`; + } + } + + if (! id_to_windowname) { // Django >= 3.1 + function id_to_windowname(text) { + text = text.replace(/\./g, '__dot__'); + text = text.replace(/\-/g, '__dash__'); + return text; + } + } + var GenericAdmin = { url_array: null, fields: null, - obj_url: "../obj-data/", + obj_url: prefixWithObjRoot('obj-data/'), admin_media_url: window.__admin_media_prefix__, popup: '_popup', @@ -73,7 +93,7 @@ }, getLookupUrl: function(cID) { - return '../../../' + this.url_array[cID][0] + '/' + this.getLookupUrlParams(cID); + return prefixWithObjRoot('../../' + this.url_array[cID][0] + '/' + this.getLookupUrlParams(cID)); }, getFkId: function() { @@ -308,7 +328,7 @@ $(document).ready(function() { $.ajax({ - url: '../genericadmin-init/', + url: prefixWithObjRoot('genericadmin-init/'), dataType: 'json', success: function(data) { var url_array = data.url_array, @@ -324,6 +344,9 @@ $.extend({}, InlineAdmin).install(fields, url_array, popup_var); } } + }, + error: function(res, err) { + console.error(`cannot fetch genericadmin-init: ${err} - ${res.status} ${res.statusText}`); } }); }); diff --git a/setup.py b/setup.py index faf0c0f..e04b8b6 100644 --- a/setup.py +++ b/setup.py @@ -14,8 +14,8 @@ def convert_readme(): name='django-genericadmin', version='0.7.0', description="Adds support for generic relations within Django's admin interface.", - author='Weston Nielson, Jan Schrewe, Arthur Hanson', - author_email='wnielson@gmail.com, jschrewe@googlemail.com, worldnomad@gmail.com', + author='Weston Nielson, Jan Schrewe, Arthur Hanson, Ipamo', + author_email='wnielson@gmail.com, jschrewe@googlemail.com, worldnomad@gmail.com, dev@ipamo.net', url='https://github.com/arthanson/django-genericadmin', packages = ['genericadmin'], # package_data={'genericadmin': ['static/genericadmin/js/genericadmin.js']}, diff --git a/tests/Dockerfile b/tests/Dockerfile new file mode 100644 index 0000000..f33d968 --- /dev/null +++ b/tests/Dockerfile @@ -0,0 +1,14 @@ +ARG PYTHON_VERSION +FROM python:${PYTHON_VERSION} + +ARG DJANGO_VERSION +RUN pip install django==${DJANGO_VERSION} + +WORKDIR /app +COPY genericadmin ./genericadmin +COPY tests ./tests +RUN python tests/manage.py migrate &&\ + python tests/manage.py prepare + +EXPOSE 8000 +CMD ["python", "tests/manage.py", "runserver", "0.0.0.0:8000"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo/__init__.py b/tests/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo/admin.py b/tests/demo/admin.py new file mode 100644 index 0000000..d0a86d5 --- /dev/null +++ b/tests/demo/admin.py @@ -0,0 +1,7 @@ +from django.contrib.admin import register +from genericadmin.admin import GenericAdminModelAdmin +from .models import TaggedItem + +@register(TaggedItem) +class TaggedItemAdmin(GenericAdminModelAdmin): + list_display = ['tag', 'content_object'] diff --git a/tests/demo/management/__init__.py b/tests/demo/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo/management/commands/__init__.py b/tests/demo/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo/management/commands/prepare.py b/tests/demo/management/commands/prepare.py new file mode 100644 index 0000000..bd86cc4 --- /dev/null +++ b/tests/demo/management/commands/prepare.py @@ -0,0 +1,41 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.core.management import BaseCommand +from ...models import TaggedItem + +User = get_user_model() + +class Command(BaseCommand): + def handle(self, **options): + self._seed_admin_user() + self._seed_other_user() + self._seed_tagged_item() + + def _seed_admin_user(self): + try: + user = User.objects.get(username='admin') + except User.DoesNotExist: + user = User(username='admin', email='admin@example.org') + + if not user.is_staff: + user.is_staff = True + + if not user.is_superuser: + user.is_superuser = True + + if not user.password: + user.set_password('admin') + + user.save() + + def _seed_other_user(self): + try: + user = User.objects.get(username='other') + except User.DoesNotExist: + user = User(username='other', email='other@example.org') + + user.save() + + def _seed_tagged_item(self): + user = User.objects.get(username='admin') + TaggedItem.objects.get_or_create(content_type=ContentType.objects.get_for_model(User), object_id=user.id, tag='admin-tag') diff --git a/tests/demo/migrations/0001_initial.py b/tests/demo/migrations/0001_initial.py new file mode 100644 index 0000000..30a2ee0 --- /dev/null +++ b/tests/demo/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='TaggedItem', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('tag', models.SlugField()), + ('object_id', models.PositiveIntegerField()), + ('content_type', models.ForeignKey(to='contenttypes.ContentType',on_delete=models.CASCADE)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterIndexTogether( + name='taggeditem', + index_together=set([('content_type', 'object_id')]), + ), + ] diff --git a/tests/demo/migrations/__init__.py b/tests/demo/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo/models.py b/tests/demo/models.py new file mode 100644 index 0000000..03141ab --- /dev/null +++ b/tests/demo/models.py @@ -0,0 +1,18 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class TaggedItem(models.Model): + tag = models.SlugField() + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + def __str__(self): + return self.tag + + class Meta: + index_together = [ + ("content_type", "object_id"), + ] diff --git a/tests/demo/settings.py b/tests/demo/settings.py new file mode 100644 index 0000000..da67cb0 --- /dev/null +++ b/tests/demo/settings.py @@ -0,0 +1,129 @@ +""" +Django settings for demo project. + +Generated by 'django-admin startproject' using Django 1.11. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" +from django import VERSION +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 't8no3y00_n&0lxz9#1!0$knft$c%3t7@s5j-ix4b_63if7f7!y' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'genericadmin', + 'demo', +] + +MIDDLEWARE = [] + +if VERSION >= (1, 8): + MIDDLEWARE.append('django.middleware.security.SecurityMiddleware') + +MIDDLEWARE += [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +if VERSION < (1, 10): + MIDDLEWARE_CLASSES = MIDDLEWARE + +ROOT_URLCONF = 'tests.demo.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'tests.demo.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/tests/demo/urls.py b/tests/demo/urls.py new file mode 100644 index 0000000..3168697 --- /dev/null +++ b/tests/demo/urls.py @@ -0,0 +1,29 @@ +"""demo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django import get_version +from django.contrib import admin + +try: + from django.conf.urls import url +except ImportError: + from django.urls import re_path as url + + +admin.site.site_header = get_version() + +urlpatterns = [ + url(r'^', admin.site.urls), +] diff --git a/tests/demo/wsgi.py b/tests/demo/wsgi.py new file mode 100644 index 0000000..e091463 --- /dev/null +++ b/tests/demo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for demo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.demo.settings") + +application = get_wsgi_application() diff --git a/tests/howto-test.md b/tests/howto-test.md new file mode 100644 index 0000000..2ef18af --- /dev/null +++ b/tests/howto-test.md @@ -0,0 +1,20 @@ +How to test +=========== + +## Prerequisites + +- Python +- Docker + +## Prepare environment + + python3 -m venv .venv~ + source .venv~/bin/activate + + pip install pytest-playwright + playwright install-deps + playwright install chromium + +## Run tests + + ./tests/run-tests.sh diff --git a/tests/manage.py b/tests/manage.py new file mode 100755 index 0000000..038605d --- /dev/null +++ b/tests/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +import os +import sys +sys.path.insert(0, '.') + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.demo.settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..9e51f06 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +YELLOW='\033[0;33m' +RESET='\033[0m' # No Color + +for version in 1.7 1.8 1.9 1.10 1.11 2.0 2.1 2.2 3.0 3.1 3.2 4.0 4.1 4.2 5.0; do + ./tests/start-server.sh $version + sleep 1 + + printf "$YELLOW%s$RESET\n" "run tests on server (Django version $version)" + pytest + + ./tests/stop-server.sh --if-started +done diff --git a/tests/start-server.sh b/tests/start-server.sh new file mode 100755 index 0000000..c9f572b --- /dev/null +++ b/tests/start-server.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +YELLOW='\033[0;33m' +RESET='\033[0m' # No Color + +DJANGO_VERSION=${1:-1.7} # first Django version with migrations + +if [[ $DJANGO_VERSION = 1.* ]]; then + default_python_version=3.4 +elif [[ $DJANGO_VERSION = 2.* ]] || [[ $DJANGO_VERSION = 3.* ]]; then + default_python_version=3.7 +elif [[ $DJANGO_VERSION = 4.* ]]; then + default_python_version=3.9 +else + default_python_version=3.12 +fi + +PYTHON_VERSION=${2:-$default_python_version} + +tag=genericadmin-tests:$DJANGO_VERSION + +printf "$YELLOW%s$RESET\n" "build server (Django version $DJANGO_VERSION)" +sudo docker build --tag $tag --build-arg DJANGO_VERSION=$DJANGO_VERSION --build-arg PYTHON_VERSION=$PYTHON_VERSION -f tests/Dockerfile . + +./tests/stop-server.sh --if-started + +printf "$YELLOW%s$RESET\n" "run server (Django version $DJANGO_VERSION)" +sudo docker run -it -d --name genericadmin-tests --rm -p 127.0.0.1:8000:8000 $tag diff --git a/tests/stop-server.sh b/tests/stop-server.sh new file mode 100755 index 0000000..ea93328 --- /dev/null +++ b/tests/stop-server.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +YELLOW='\033[0;33m' +RESET='\033[0m' # No Color + +if [ "$1" = "--if-started" ]; then + if [ ! "$(sudo docker ps -a | grep genericadmin-tests)" ]; then + exit 0 + fi +fi + +printf "$YELLOW%s$RESET\n" "stop server" +sudo docker kill genericadmin-tests diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..961d13c --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,75 @@ +import re +import pytest +from playwright.sync_api import Page, expect + + +class Params: + def __init__(self): + self.obj_root_from_change = '../../' + self.user_model_option = 'Authentication and Authorization | user' + + +def connect(page: Page): + page.goto("http://localhost:8000/") + page.locator('#id_username').fill('admin') + page.locator('#id_password').fill('admin') + page.locator('#login-form input[type="submit"]').click() + expect(page.locator('#user-tools')).to_have_text(re.compile(r'^\s*Welcome,\s*admin.')) + + params = Params() + django_version = page.locator('#site-name a').text_content() + if django_version in ['1.7', '1.8']: + params.obj_root_from_change = '../' + + if django_version.startswith(('1.', '2.')): + params.user_model_option = 'User' + elif django_version.startswith(('3.', '4.')): + params.user_model_option = 'Auth | user' + + return params + + +def test_change(page: Page): + params = connect(page) + + page.goto('http://localhost:8000/demo/taggeditem/1/') + expect(page.locator('#lookup_id_object_id')).to_have_attribute('href', f'{params.obj_root_from_change}../../auth/user/') + expect(page.locator('#lookup_text_id_object_id a')).to_have_attribute('target', '_new') + expect(page.locator('#lookup_text_id_object_id a')).to_have_attribute('href', f'{params.obj_root_from_change}../../auth/user/1') + expect(page.locator('#lookup_text_id_object_id a')).to_have_text(f'{params.user_model_option}: Admin') + + with page.expect_popup() as popup_info: + page.locator('#lookup_id_object_id').click() + popup = popup_info.value + + popup.wait_for_load_state() + expect(popup).to_have_title(re.compile(r'^Select user ')) + + with popup.expect_event('close'): + popup.locator('.field-username a:has-text("other")').click() + + expect(page.locator('#id_object_id')).to_have_value('2') + expect(page.locator('#lookup_text_id_object_id a')).to_have_text(f'{params.user_model_option}: Other') + + page.locator('#id_object_id').fill('1') + page.focus('input[type="submit"]') + expect(page.locator('#lookup_text_id_object_id a')).to_have_text(f'{params.user_model_option}: Admin') + + +def test_add(page: Page): + params = connect(page) + + page.goto('http://localhost:8000/demo/taggeditem/add/') + + expect(page.locator('#lookup_id_object_id')).not_to_be_visible() + + page.locator('#id_tag').fill('other-tag') + page.locator('#id_content_type').select_option(params.user_model_option) + + expect(page.locator('#lookup_id_object_id')).to_be_visible() + + page.locator('#id_object_id').fill('2') + page.focus('input[type="submit"]') + expect(page.locator('#lookup_text_id_object_id a')).to_have_attribute('target', '_new') + expect(page.locator('#lookup_text_id_object_id a')).to_have_attribute('href', '../../../auth/user/2') + expect(page.locator('#lookup_text_id_object_id a')).to_have_text(f'{params.user_model_option}: Other')