diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py new file mode 100644 index 0000000000..2a3f0c3cd1 --- /dev/null +++ b/django/contrib/admin/tests.py @@ -0,0 +1,52 @@ +import sys + +from django.test import LiveServerTestCase +from django.utils.importlib import import_module +from django.utils.unittest import SkipTest +from django.utils.translation import ugettext as _ + +class AdminSeleniumWebDriverTestCase(LiveServerTestCase): + webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver' + + @classmethod + def setUpClass(cls): + if sys.version_info < (2, 6): + raise SkipTest('Selenium Webdriver does not support Python < 2.6.') + try: + # Import and start the WebDriver class. + module, attr = cls.webdriver_class.rsplit('.', 1) + mod = import_module(module) + WebDriver = getattr(mod, attr) + cls.selenium = WebDriver() + except Exception: + raise SkipTest('Selenium webdriver "%s" not installed or not ' + 'operational.' % cls.webdriver_class) + super(AdminSeleniumWebDriverTestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + super(AdminSeleniumWebDriverTestCase, cls).tearDownClass() + if hasattr(cls, 'selenium'): + cls.selenium.quit() + + def admin_login(self, username, password, login_url='/admin/'): + """ + Helper function to log into the admin. + """ + self.selenium.get('%s%s' % (self.live_server_url, login_url)) + username_input = self.selenium.find_element_by_name('username') + username_input.send_keys(username) + password_input = self.selenium.find_element_by_name('password') + password_input.send_keys(password) + login_text = _('Log in') + self.selenium.find_element_by_xpath( + '//input[@value="%s"]' % login_text).click() + + def get_css_value(self, selector, attribute): + """ + Helper function that returns the value for the CSS attribute of an + DOM element specified by the given selector. Uses the jQuery that ships + with Django. + """ + return self.selenium.execute_script( + 'return django.jQuery("%s").css("%s")' % (selector, attribute)) \ No newline at end of file diff --git a/django/core/management/commands/test.py b/django/core/management/commands/test.py index 2a6dbfc387..ad49184e8a 100644 --- a/django/core/management/commands/test.py +++ b/django/core/management/commands/test.py @@ -1,20 +1,32 @@ +import sys +import os +from optparse import make_option, OptionParser + from django.conf import settings from django.core.management.base import BaseCommand -from optparse import make_option, OptionParser -import sys from django.test.utils import get_runner class Command(BaseCommand): option_list = BaseCommand.option_list + ( - make_option('--noinput', action='store_false', dest='interactive', default=True, + make_option('--noinput', + action='store_false', dest='interactive', default=True, help='Tells Django to NOT prompt the user for input of any kind.'), - make_option('--failfast', action='store_true', dest='failfast', default=False, - help='Tells Django to stop running the test suite after first failed test.'), - make_option('--testrunner', action='store', dest='testrunner', - help='Tells Django to use specified test runner class instead of the one '+ - 'specified by the TEST_RUNNER setting.') + make_option('--failfast', + action='store_true', dest='failfast', default=False, + help='Tells Django to stop running the test suite after first ' + 'failed test.'), + make_option('--testrunner', + action='store', dest='testrunner', + help='Tells Django to use specified test runner class instead of ' + 'the one specified by the TEST_RUNNER setting.'), + make_option('--liveserver', + action='store', dest='liveserver', default=None, + help='Overrides the default address where the live server (used ' + 'with LiveServerTestCase) is expected to run from. The ' + 'default value is localhost:8081.'), ) - help = 'Runs the test suite for the specified applications, or the entire site if no apps are specified.' + help = ('Runs the test suite for the specified applications, or the ' + 'entire site if no apps are specified.') args = '[appname ...]' requires_model_validation = False @@ -35,7 +47,8 @@ class Command(BaseCommand): def create_parser(self, prog_name, subcommand): test_runner_class = get_runner(settings, self.test_runner) - options = self.option_list + getattr(test_runner_class, 'option_list', ()) + options = self.option_list + getattr( + test_runner_class, 'option_list', ()) return OptionParser(prog=prog_name, usage=self.usage(subcommand), version=self.get_version(), @@ -48,6 +61,10 @@ class Command(BaseCommand): TestRunner = get_runner(settings, options.get('testrunner')) options['verbosity'] = int(options.get('verbosity')) + if options.get('liveserver') is not None: + os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = options['liveserver'] + del options['liveserver'] + test_runner = TestRunner(**options) failures = test_runner.run_tests(test_labels) diff --git a/django/test/__init__.py b/django/test/__init__.py index a3a03e3e5d..21a4841a6b 100644 --- a/django/test/__init__.py +++ b/django/test/__init__.py @@ -4,5 +4,6 @@ Django Unit Test and Doctest framework. from django.test.client import Client, RequestFactory from django.test.testcases import (TestCase, TransactionTestCase, - SimpleTestCase, skipIfDBFeature, skipUnlessDBFeature) + SimpleTestCase, LiveServerTestCase, skipIfDBFeature, + skipUnlessDBFeature) from django.test.utils import Approximate diff --git a/django/test/testcases.py b/django/test/testcases.py index ee22ac219e..d77eb39bde 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -1,16 +1,23 @@ from __future__ import with_statement +import os import re import sys from functools import wraps from urlparse import urlsplit, urlunsplit from xml.dom.minidom import parseString, Node +import select +import socket +import threading from django.conf import settings +from django.contrib.staticfiles.handlers import StaticFilesHandler from django.core import mail -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ImproperlyConfigured +from django.core.handlers.wsgi import WSGIHandler from django.core.management import call_command from django.core.signals import request_started +from django.core.servers.basehttp import (WSGIRequestHandler, WSGIServer) from django.core.urlresolvers import clear_url_caches from django.core.validators import EMPTY_VALUES from django.db import (transaction, connection, connections, DEFAULT_DB_ALIAS, @@ -23,6 +30,7 @@ from django.test.utils import (get_warnings_state, restore_warnings_state, override_settings) from django.utils import simplejson, unittest as ut2 from django.utils.encoding import smart_str +from django.views.static import serve __all__ = ('DocTestRunner', 'OutputChecker', 'TestCase', 'TransactionTestCase', 'SimpleTestCase', 'skipIfDBFeature', 'skipUnlessDBFeature') @@ -68,7 +76,8 @@ def restore_transaction_methods(): class OutputChecker(doctest.OutputChecker): def check_output(self, want, got, optionflags): """ - The entry method for doctest output checking. Defers to a sequence of child checkers + The entry method for doctest output checking. Defers to a sequence of + child checkers """ checks = (self.check_output_default, self.check_output_numeric, @@ -219,6 +228,7 @@ class DocTestRunner(doctest.DocTestRunner): for conn in connections: transaction.rollback_unless_managed(using=conn) + class _AssertNumQueriesContext(object): def __init__(self, test_case, num, connection): self.test_case = test_case @@ -247,6 +257,7 @@ class _AssertNumQueriesContext(object): ) ) + class SimpleTestCase(ut2.TestCase): def save_warnings_state(self): @@ -335,6 +346,7 @@ class SimpleTestCase(ut2.TestCase): self.assertTrue(isinstance(fieldclass(*field_args, **field_kwargs), fieldclass)) + class TransactionTestCase(SimpleTestCase): # The class we'll use for the test client self.client. # Can be overridden in derived classes. @@ -643,6 +655,7 @@ class TransactionTestCase(SimpleTestCase): with context: func(*args, **kwargs) + def connections_support_transactions(): """ Returns True if all connections support transactions. @@ -650,6 +663,7 @@ def connections_support_transactions(): return all(conn.features.supports_transactions for conn in connections.all()) + class TestCase(TransactionTestCase): """ Does basically the same as TransactionTestCase, but surrounds every test @@ -703,6 +717,7 @@ class TestCase(TransactionTestCase): transaction.rollback(using=db) transaction.leave_transaction_management(using=db) + def _deferredSkip(condition, reason): def decorator(test_func): if not (isinstance(test_func, type) and @@ -719,6 +734,7 @@ def _deferredSkip(condition, reason): return test_item return decorator + def skipIfDBFeature(feature): """ Skip a test if a database has the named feature @@ -726,9 +742,234 @@ def skipIfDBFeature(feature): return _deferredSkip(lambda: getattr(connection.features, feature), "Database has feature %s" % feature) + def skipUnlessDBFeature(feature): """ Skip a test unless a database has the named feature """ return _deferredSkip(lambda: not getattr(connection.features, feature), "Database doesn't support feature %s" % feature) + + +class QuietWSGIRequestHandler(WSGIRequestHandler): + """ + Just a regular WSGIRequestHandler except it doesn't log to the standard + output any of the requests received, so as to not clutter the output for + the tests' results. + """ + + def log_message(*args): + pass + + +class _ImprovedEvent(threading._Event): + """ + Does the same as `threading.Event` except it overrides the wait() method + with some code borrowed from Python 2.7 to return the set state of the + event (see: http://hg.python.org/cpython/rev/b5aa8aa78c0f/). This allows + to know whether the wait() method exited normally or because of the + timeout. This class can be removed when Django supports only Python >= 2.7. + """ + + def wait(self, timeout=None): + self._Event__cond.acquire() + try: + if not self._Event__flag: + self._Event__cond.wait(timeout) + return self._Event__flag + finally: + self._Event__cond.release() + + +class StoppableWSGIServer(WSGIServer): + """ + The code in this class is borrowed from the `SocketServer.BaseServer` class + in Python 2.6. The important functionality here is that the server is non- + blocking and that it can be shut down at any moment. This is made possible + by the server regularly polling the socket and checking if it has been + asked to stop. + Note for the future: Once Django stops supporting Python 2.6, this class + can be removed as `WSGIServer` will have this ability to shutdown on + demand and will not require the use of the _ImprovedEvent class whose code + is borrowed from Python 2.7. + """ + + def __init__(self, *args, **kwargs): + super(StoppableWSGIServer, self).__init__(*args, **kwargs) + self.__is_shut_down = _ImprovedEvent() + self.__serving = False + + def serve_forever(self, poll_interval=0.5): + """ + Handle one request at a time until shutdown. + + Polls for shutdown every poll_interval seconds. + """ + self.__serving = True + self.__is_shut_down.clear() + while self.__serving: + r, w, e = select.select([self], [], [], poll_interval) + if r: + self._handle_request_noblock() + self.__is_shut_down.set() + + def shutdown(self): + """ + Stops the serve_forever loop. + + Blocks until the loop has finished. This must be called while + serve_forever() is running in another thread, or it will + deadlock. + """ + self.__serving = False + if not self.__is_shut_down.wait(2): + raise RuntimeError( + "Failed to shutdown the live test server in 2 seconds. The " + "server might be stuck or generating a slow response.") + + def handle_request(self): + """Handle one request, possibly blocking. + """ + fd_sets = select.select([self], [], [], None) + if not fd_sets[0]: + return + self._handle_request_noblock() + + def _handle_request_noblock(self): + """ + Handle one request, without blocking. + + I assume that select.select has returned that the socket is + readable before this function was called, so there should be + no risk of blocking in get_request(). + """ + try: + request, client_address = self.get_request() + except socket.error: + return + if self.verify_request(request, client_address): + try: + self.process_request(request, client_address) + except Exception: + self.handle_error(request, client_address) + self.close_request(request) + + +class _MediaFilesHandler(StaticFilesHandler): + """ + Handler for serving the media files. This is a private class that is + meant to be used solely as a convenience by LiveServerThread. + """ + + def get_base_dir(self): + return settings.MEDIA_ROOT + + def get_base_url(self): + return settings.MEDIA_URL + + def serve(self, request): + return serve(request, self.file_path(request.path), + document_root=self.get_base_dir()) + + +class LiveServerThread(threading.Thread): + """ + Thread for running a live http server while the tests are running. + """ + + def __init__(self, address, port, connections_override=None): + self.address = address + self.port = port + self.is_ready = threading.Event() + self.error = None + self.connections_override = connections_override + super(LiveServerThread, self).__init__() + + def run(self): + """ + Sets up the live server and databases, and then loops over handling + http requests. + """ + if self.connections_override: + from django.db import connections + # Override this thread's database connections with the ones + # provided by the main thread. + for alias, conn in self.connections_override.items(): + connections[alias] = conn + try: + # Create the handler for serving static and media files + handler = StaticFilesHandler(_MediaFilesHandler(WSGIHandler())) + # Instantiate and start the WSGI server + self.httpd = StoppableWSGIServer( + (self.address, self.port), QuietWSGIRequestHandler) + self.httpd.set_app(handler) + self.is_ready.set() + self.httpd.serve_forever() + except Exception, e: + self.error = e + self.is_ready.set() + + def join(self, timeout=None): + if hasattr(self, 'httpd'): + # Stop the WSGI server + self.httpd.shutdown() + self.httpd.server_close() + super(LiveServerThread, self).join(timeout) + + +class LiveServerTestCase(TransactionTestCase): + """ + Does basically the same as TransactionTestCase but also launches a live + http server in a separate thread so that the tests may use another testing + framework, such as Selenium for example, instead of the built-in dummy + client. + Note that it inherits from TransactionTestCase instead of TestCase because + the threads do not share the same transactions (unless if using in-memory + sqlite) and each thread needs to commit all their transactions so that the + other thread can see the changes. + """ + + @property + def live_server_url(self): + return 'http://%s' % self.__test_server_address + + @classmethod + def setUpClass(cls): + connections_override = {} + for conn in connections.all(): + # If using in-memory sqlite databases, pass the connections to + # the server thread. + if (conn.settings_dict['ENGINE'] == 'django.db.backends.sqlite3' + and conn.settings_dict['NAME'] == ':memory:'): + # Explicitly enable thread-shareability for this connection + conn.allow_thread_sharing = True + connections_override[conn.alias] = conn + + # Launch the live server's thread + cls.__test_server_address = os.environ.get( + 'DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081') + try: + host, port = cls.__test_server_address.split(':') + except Exception: + raise ImproperlyConfigured('Invalid address ("%s") for live ' + 'server.' % cls.__test_server_address) + cls.server_thread = LiveServerThread( + host, int(port), connections_override) + cls.server_thread.daemon = True + cls.server_thread.start() + + # Wait for the live server to be ready + cls.server_thread.is_ready.wait() + if cls.server_thread.error: + raise cls.server_thread.error + + super(LiveServerTestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + # There may not be a 'server_thread' attribute if setUpClass() for some + # reasons has raised an exception. + if hasattr(cls, 'server_thread'): + # Terminate the live server's thread + cls.server_thread.join() + super(LiveServerTestCase, cls).tearDownClass() diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt index 5ec09fe84c..275ee154f6 100644 --- a/docs/internals/contributing/writing-code/unit-tests.txt +++ b/docs/internals/contributing/writing-code/unit-tests.txt @@ -122,6 +122,19 @@ Going beyond that, you can specify an individual test method like this: ./runtests.py --settings=path.to.settings i18n.TranslationTests.test_lazy_objects +Running the Selenium tests +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some admin tests require Selenium 2, Firefox and Python >= 2.6 to work via a +real Web browser. To allow those tests to run and not be skipped, you must +install the selenium_ package (version > 2.13) into your Python path. + +Then, run the tests normally, for example: + +.. code-block:: bash + + ./runtests.py --settings=test_sqlite admin_inlines + Running all the tests ~~~~~~~~~~~~~~~~~~~~~ @@ -135,6 +148,7 @@ dependencies: * setuptools_ * memcached_, plus a :ref:`supported Python binding ` * gettext_ (:ref:`gettext_on_windows`) +* selenium_ (if also using Python >= 2.6) If you want to test the memcached cache backend, you'll also need to define a :setting:`CACHES` setting that points at your memcached instance. @@ -149,6 +163,7 @@ associated tests will be skipped. .. _setuptools: http://pypi.python.org/pypi/setuptools/ .. _memcached: http://www.danga.com/memcached/ .. _gettext: http://www.gnu.org/software/gettext/manual/gettext.html +.. _selenium: http://pypi.python.org/pypi/selenium .. _contrib-apps: diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index 8b83a43179..fac18f3b48 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -976,15 +976,22 @@ information. .. versionadded:: 1.2 .. django-admin-option:: --failfast -Use the :djadminopt:`--failfast` option to stop running tests and report the failure -immediately after a test fails. +The ``--failfast`` option can be used to stop running tests and report the +failure immediately after a test fails. .. versionadded:: 1.4 .. django-admin-option:: --testrunner -The :djadminopt:`--testrunner` option can be used to control the test runner -class that is used to execute tests. If this value is provided, it overrides -the value provided by the :setting:`TEST_RUNNER` setting. +The ``--testrunner`` option can be used to control the test runner class that +is used to execute tests. If this value is provided, it overrides the value +provided by the :setting:`TEST_RUNNER` setting. + +.. versionadded:: 1.4 +.. django-admin-option:: --liveserver + +The ``--liveserver`` option can be used to override the default address where +the live server (used with :class:`~django.test.LiveServerTestCase`) is +expected to run from. The default value is ``localhost:8081``. testserver -------------------------------- diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index 7ffc1aa654..51abdf1b03 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -40,6 +40,19 @@ before the release of Django 1.4. What's new in Django 1.4 ======================== +Support for in-browser testing frameworks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Django 1.4 now supports the integration with in-browser testing frameworks such +as Selenium_ or Windmill_ thanks to the :class:`django.test.LiveServerTestCase` +base class, allowing you to test the interactions between your site's front and +back ends more comprehensively. See the +:class:`documentation` for more details and +concrete examples. + +.. _Windmill: http://www.getwindmill.com/ +.. _Selenium: http://seleniumhq.org/ + ``SELECT FOR UPDATE`` support ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index d6f54ad691..727ef2cb8e 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -581,21 +581,20 @@ Some of the things you can do with the test client are: * Test that a given request is rendered by a given Django template, with a template context that contains certain values. -Note that the test client is not intended to be a replacement for Twill_, +Note that the test client is not intended to be a replacement for Windmill_, Selenium_, or other "in-browser" frameworks. Django's test client has a different focus. In short: * Use Django's test client to establish that the correct view is being called and that the view is collecting the correct context data. -* Use in-browser frameworks such as Twill and Selenium to test *rendered* - HTML and the *behavior* of Web pages, namely JavaScript functionality. +* Use in-browser frameworks such as Windmill_ and Selenium_ to test *rendered* + HTML and the *behavior* of Web pages, namely JavaScript functionality. Django + also provides special support for those frameworks; see the section on + :class:`~django.test.LiveServerTestCase` for more details. A comprehensive test suite should use a combination of both test types. -.. _Twill: http://twill.idyll.org/ -.. _Selenium: http://seleniumhq.org/ - Overview and a quick example ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1753,6 +1752,97 @@ under MySQL with MyISAM tables):: def test_transaction_behavior(self): # ... conditional test code +Live test server +---------------- + +.. versionadded:: 1.4 + +.. currentmodule:: django.test + +.. class:: LiveServerTestCase() + +``LiveServerTestCase`` does basically the same as +:class:`~django.test.TransactionTestCase` with one extra feature: it launches a +live Django server in the background on setup, and shuts it down on teardown. +This allows the use of automated test clients other than the +:ref:`Django dummy client ` such as, for example, the Selenium_ or +Windmill_ clients, to execute a series of functional tests inside a browser and +simulate a real user's actions. + +By default the live server's address is `'localhost:8081'` and the full URL +can be accessed during the tests with ``self.live_server_url``. If you'd like +to change the default address (in the case, for example, where the 8081 port is +already taken) you may pass a different one to the :djadmin:`test` command via +the :djadminopt:`--liveserver` option, for example: + +.. code-block:: bash + + ./manage.py test --liveserver=localhost:8082 + +Another way of changing the default server address is by setting the +`DJANGO_LIVE_TEST_SERVER_ADDRESS` environment variable. + +To demonstrate how to use ``LiveServerTestCase``, let's write a simple Selenium +test. First of all, you need to install the `selenium package`_ into your +Python path: + +.. code-block:: bash + + pip install selenium + +Then, add a ``LiveServerTestCase``-based test to your app's tests module +(for example: ``myapp/tests.py``). The code for this test may look as follows: + +.. code-block:: python + + from django.test import LiveServerTestCase + from selenium.webdriver.firefox.webdriver import WebDriver + + class MySeleniumTests(LiveServerTestCase): + fixtures = ['user-data.json'] + + @classmethod + def setUpClass(cls): + cls.selenium = WebDriver() + super(MySeleniumTests, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + super(MySeleniumTests, cls).tearDownClass() + cls.selenium.quit() + + def test_login(self): + self.selenium.get('%s%s' % (self.live_server_url, '/login/')) + username_input = self.selenium.find_element_by_name("username") + username_input.send_keys('myuser') + password_input = self.selenium.find_element_by_name("password") + password_input.send_keys('secret') + self.selenium.find_element_by_xpath('//input[@value="Log in"]').click() + +Finally, you may run the test as follows: + +.. code-block:: bash + + ./manage.py test myapp.MySeleniumTests.test_login + +This example will automatically open Firefox then go to the login page, enter +the credentials and press the "Log in" button. Selenium offers other drivers in +case you do not have Firefox installed or wish to use another browser. The +example above is just a tiny fraction of what the Selenium client can do; check +out the `full reference`_ for more details. + +.. _Windmill: http://www.getwindmill.com/ +.. _Selenium: http://seleniumhq.org/ +.. _selenium package: http://pypi.python.org/pypi/selenium +.. _full reference: http://readthedocs.org/docs/selenium-python/en/latest/api.html +.. _Firefox: http://www.mozilla.com/firefox/ + +.. note:: + + ``LiveServerTestCase`` makes use of the :doc:`staticfiles contrib app + ` so you'll need to have your project configured + accordingly (in particular by setting :setting:`STATIC_URL`). + Using different testing frameworks ================================== @@ -1833,11 +1923,9 @@ set up, execute and tear down the test suite. those options will be added to the list of command-line options that the :djadmin:`test` command can use. - Attributes ~~~~~~~~~~ - .. attribute:: DjangoTestSuiteRunner.option_list .. versionadded:: 1.4 diff --git a/tests/regressiontests/admin_inlines/admin.py b/tests/regressiontests/admin_inlines/admin.py index 4edd361d09..508f30223f 100644 --- a/tests/regressiontests/admin_inlines/admin.py +++ b/tests/regressiontests/admin_inlines/admin.py @@ -109,6 +109,10 @@ class SottoCapoInline(admin.TabularInline): model = SottoCapo +class ProfileInline(admin.TabularInline): + model = Profile + extra = 1 + site.register(TitleCollection, inlines=[TitleInline]) # Test bug #12561 and #12778 # only ModelAdmin media @@ -124,3 +128,4 @@ site.register(Fashionista, inlines=[InlineWeakness]) site.register(Holder4, Holder4Admin) site.register(Author, AuthorAdmin) site.register(CapoFamiglia, inlines=[ConsigliereInline, SottoCapoInline]) +site.register(ProfileCollection, inlines=[ProfileInline]) \ No newline at end of file diff --git a/tests/regressiontests/admin_inlines/models.py b/tests/regressiontests/admin_inlines/models.py index 748280d8ab..f2add00288 100644 --- a/tests/regressiontests/admin_inlines/models.py +++ b/tests/regressiontests/admin_inlines/models.py @@ -136,3 +136,13 @@ class Consigliere(models.Model): class SottoCapo(models.Model): name = models.CharField(max_length=100) capo_famiglia = models.ForeignKey(CapoFamiglia, related_name='+') + +# Other models + +class ProfileCollection(models.Model): + pass + +class Profile(models.Model): + collection = models.ForeignKey(ProfileCollection, blank=True, null=True) + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) \ No newline at end of file diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py index c2e3bbc397..7b417d8995 100644 --- a/tests/regressiontests/admin_inlines/tests.py +++ b/tests/regressiontests/admin_inlines/tests.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase from django.contrib.admin.helpers import InlineAdminForm from django.contrib.auth.models import User, Permission from django.contrib.contenttypes.models import ContentType @@ -8,7 +9,8 @@ from django.test import TestCase # local test models from .admin import InnerInline from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person, - OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book) + OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book, Profile, + ProfileCollection) class TestInline(TestCase): @@ -380,3 +382,105 @@ class TestInlinePermissions(TestCase): self.assertContains(response, 'value="4" id="id_inner2_set-TOTAL_FORMS"') self.assertContains(response, '