diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b459bbea4f5..22d33644aaa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: services: mysql: - image: mysql:5.7 + image: mysql:8.0 env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: djangocms_test diff --git a/cms/__init__.py b/cms/__init__.py index 8f2dcac6cfa..7363a1e2fd9 100644 --- a/cms/__init__.py +++ b/cms/__init__.py @@ -1,2 +1,3 @@ __version__ = '4.0.1.dev2' +default_app_config = 'cms.apps.CMSConfig' diff --git a/cms/admin/pageadmin.py b/cms/admin/pageadmin.py index 06c87620474..0b8370b1970 100644 --- a/cms/admin/pageadmin.py +++ b/cms/admin/pageadmin.py @@ -1,5 +1,4 @@ from collections import namedtuple -import copy import json import django @@ -907,8 +906,6 @@ def duplicate(self, request, object_id): if obj is None: raise self._get_404_exception(object_id) - request = copy.copy(request) - if request.method == 'GET': # source is a field in the form # because its value is in the url, diff --git a/cms/middleware/language.py b/cms/middleware/language.py index 77c1aa07f35..c32f04abae7 100644 --- a/cms/middleware/language.py +++ b/cms/middleware/language.py @@ -1,22 +1,63 @@ -import datetime - -from django.utils.translation import get_language from django.conf import settings from django.utils.deprecation import MiddlewareMixin +from django.utils.translation import get_language + +from cms.utils.compat import DJANGO_2_2 + +if DJANGO_2_2: + from django.utils.translation import LANGUAGE_SESSION_KEY class LanguageCookieMiddleware(MiddlewareMixin): - def process_response(self, request, response): - language = get_language() - if hasattr(request, 'session'): - session_language = request.session.get(LANGUAGE_SESSION_KEY, None) - if session_language and not session_language == language: - request.session[LANGUAGE_SESSION_KEY] = language - request.session.save() - if settings.LANGUAGE_COOKIE_NAME in request.COOKIES and \ - request.COOKIES[settings.LANGUAGE_COOKIE_NAME] == language: + def __init__(self, get_response): + super().__init__(get_response) + + if DJANGO_2_2: + + def __call__(self, request): + response = self.get_response(request) + language = get_language() + if hasattr(request, 'session'): + session_language = request.session.get(LANGUAGE_SESSION_KEY, None) + if session_language and not session_language == language: + request.session[LANGUAGE_SESSION_KEY] = language + request.session.save() + if ( + settings.LANGUAGE_COOKIE_NAME in request.COOKIES + and request.COOKIES[settings.LANGUAGE_COOKIE_NAME] == language # noqa: W503 + ): + return response + response.set_cookie( + settings.LANGUAGE_COOKIE_NAME, + value=language, + domain=settings.LANGUAGE_COOKIE_DOMAIN, + max_age=settings.LANGUAGE_COOKIE_AGE or 365 * 24 * 60 * 60, # 1 year + path=settings.LANGUAGE_COOKIE_PATH, + ) + return response + else: + + def __call__(self, request): + response = self.get_response(request) + language = get_language() + if ( + settings.LANGUAGE_COOKIE_NAME in request.COOKIES # noqa: W503 + and request.COOKIES[settings.LANGUAGE_COOKIE_NAME] == language + ): + return response + + # To ensure support of very old browsers, Django processed automatically "expires" according + # to max_age value. + # https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.HttpResponse.set_cookie + + response.set_cookie( + settings.LANGUAGE_COOKIE_NAME, + value=language, + domain=settings.LANGUAGE_COOKIE_DOMAIN, + max_age=settings.LANGUAGE_COOKIE_AGE or 365 * 24 * 60 * 60, # 1 year + httponly=settings.LANGUAGE_COOKIE_HTTPONLY, + path=settings.LANGUAGE_COOKIE_PATH, + samesite=settings.LANGUAGE_COOKIE_SAMESITE, + secure=settings.LANGUAGE_COOKIE_SECURE, + ) return response - max_age = 365 * 24 * 60 * 60 # 10 years - expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=max_age) - response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, expires=expires) - return response diff --git a/cms/test_utils/project/app_using_non_feature/__init__.py b/cms/test_utils/project/app_using_non_feature/__init__.py index e69de29bb2d..7262828c538 100644 --- a/cms/test_utils/project/app_using_non_feature/__init__.py +++ b/cms/test_utils/project/app_using_non_feature/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_using_non_feature.apps.NonFeatureCMSConfig' diff --git a/cms/test_utils/project/app_with_bad_cms_file/__init__.py b/cms/test_utils/project/app_with_bad_cms_file/__init__.py index e69de29bb2d..f093da3cfa9 100644 --- a/cms/test_utils/project/app_with_bad_cms_file/__init__.py +++ b/cms/test_utils/project/app_with_bad_cms_file/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_bad_cms_file.apps.BadCMSFileConfig' diff --git a/cms/test_utils/project/app_with_cms_config/__init__.py b/cms/test_utils/project/app_with_cms_config/__init__.py index e69de29bb2d..ce7ec3f01c1 100644 --- a/cms/test_utils/project/app_with_cms_config/__init__.py +++ b/cms/test_utils/project/app_with_cms_config/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_cms_config.apps.CMSConfigConfig' diff --git a/cms/test_utils/project/app_with_cms_feature/__init__.py b/cms/test_utils/project/app_with_cms_feature/__init__.py index e69de29bb2d..137461ac6d6 100644 --- a/cms/test_utils/project/app_with_cms_feature/__init__.py +++ b/cms/test_utils/project/app_with_cms_feature/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_cms_feature.apps.CMSFeatureConfig' diff --git a/cms/test_utils/project/app_with_cms_feature_and_config/__init__.py b/cms/test_utils/project/app_with_cms_feature_and_config/__init__.py index e69de29bb2d..01465b286f0 100644 --- a/cms/test_utils/project/app_with_cms_feature_and_config/__init__.py +++ b/cms/test_utils/project/app_with_cms_feature_and_config/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_cms_feature_and_config.apps.CMSFeatureAndConfigConfig' diff --git a/cms/test_utils/project/app_with_feature_not_implemented/__init__.py b/cms/test_utils/project/app_with_feature_not_implemented/__init__.py index e69de29bb2d..15de5378e13 100644 --- a/cms/test_utils/project/app_with_feature_not_implemented/__init__.py +++ b/cms/test_utils/project/app_with_feature_not_implemented/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_feature_not_implemented.apps.CMSFeatureConfig' diff --git a/cms/test_utils/project/app_with_two_cms_config_classes/__init__.py b/cms/test_utils/project/app_with_two_cms_config_classes/__init__.py index e69de29bb2d..548761b502a 100644 --- a/cms/test_utils/project/app_with_two_cms_config_classes/__init__.py +++ b/cms/test_utils/project/app_with_two_cms_config_classes/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_two_cms_config_classes.apps.TwoCMSAppClassesConfig' diff --git a/cms/test_utils/project/app_with_two_cms_feature_classes/__init__.py b/cms/test_utils/project/app_with_two_cms_feature_classes/__init__.py index e69de29bb2d..a340059416c 100644 --- a/cms/test_utils/project/app_with_two_cms_feature_classes/__init__.py +++ b/cms/test_utils/project/app_with_two_cms_feature_classes/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_with_two_cms_feature_classes.apps.TwoCMSAppClassesConfig' diff --git a/cms/test_utils/project/app_without_cms_app_class/__init__.py b/cms/test_utils/project/app_without_cms_app_class/__init__.py index e69de29bb2d..4109b7b2d8d 100644 --- a/cms/test_utils/project/app_without_cms_app_class/__init__.py +++ b/cms/test_utils/project/app_without_cms_app_class/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_without_cms_app_class.apps.WithoutCMSAppClassConfig' diff --git a/cms/test_utils/project/app_without_cms_file/__init__.py b/cms/test_utils/project/app_without_cms_file/__init__.py index e69de29bb2d..12711318eb7 100644 --- a/cms/test_utils/project/app_without_cms_file/__init__.py +++ b/cms/test_utils/project/app_without_cms_file/__init__.py @@ -0,0 +1 @@ +default_app_config = 'cms.test_utils.project.app_without_cms_file.apps.WithoutCMSFileConfig' diff --git a/cms/test_utils/project/pluginapp/plugins/caching/cms_plugins.py b/cms/test_utils/project/pluginapp/plugins/caching/cms_plugins.py index 6b5af6de73f..8b060fc5b9c 100644 --- a/cms/test_utils/project/pluginapp/plugins/caching/cms_plugins.py +++ b/cms/test_utils/project/pluginapp/plugins/caching/cms_plugins.py @@ -92,7 +92,7 @@ def get_vary_cache_on(self, request, instance, placeholder): def render(self, context, instance, placeholder): request = context.get('request') - country_code = request.headers.get('country-code') or "any" + country_code = request.headers.get('Country-Code') or "any" context['now'] = country_code return context diff --git a/cms/test_utils/testcases.py b/cms/test_utils/testcases.py index 7d10adcffdd..9fc58187f5f 100644 --- a/cms/test_utils/testcases.py +++ b/cms/test_utils/testcases.py @@ -11,6 +11,7 @@ from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.forms.models import model_to_dict +from django.http import HttpResponse from django.template import engines from django.template.context import Context from django.test import testcases @@ -451,7 +452,7 @@ def get_page_request(self, page, user, path=None, lang_code='en', disable=False, if persist is not None: request.GET[get_cms_setting('CMS_TOOLBAR_URL__PERSIST')] = persist request.current_page = page - mid = ToolbarMiddleware() + mid = ToolbarMiddleware(lambda req: HttpResponse("")) mid.process_request(request) if hasattr(request, 'toolbar'): request.toolbar.populate() diff --git a/cms/tests/test_admin.py b/cms/tests/test_admin.py index e600bf419bc..e6dc12aa3bf 100644 --- a/cms/tests/test_admin.py +++ b/cms/tests/test_admin.py @@ -760,21 +760,21 @@ def test_smart_link_pages(self): self.assertEqual(403, self.client.get(page_url).status_code) self.assertEqual(200, - self.client.get(page_url, headers={"x-requested-with": 'XMLHttpRequest'}).status_code + self.client.get(page_url, HTTP_X_REQUESTED_WITH='XMLHttpRequest').status_code ) # Test that the query param is working as expected. self.assertEqual(1, len(json.loads(self.client.get(page_url, {'q':'main_title'}, - headers={"x-requested-with": 'XMLHttpRequest'}).content.decode("utf-8")))) + HTTP_X_REQUESTED_WITH='XMLHttpRequest').content.decode("utf-8")))) self.assertEqual(1, len(json.loads(self.client.get(page_url, {'q':'menu_title'}, - headers={"x-requested-with": 'XMLHttpRequest'}).content.decode("utf-8")))) + HTTP_X_REQUESTED_WITH='XMLHttpRequest').content.decode("utf-8")))) self.assertEqual(1, len(json.loads(self.client.get(page_url, {'q':'overwritten_url'}, - headers={"x-requested-with": 'XMLHttpRequest'}).content.decode("utf-8")))) + HTTP_X_REQUESTED_WITH='XMLHttpRequest').content.decode("utf-8")))) self.assertEqual(1, len(json.loads(self.client.get(page_url, {'q':'page_title'}, - headers={"x-requested-with": 'XMLHttpRequest'}).content.decode("utf-8")))) + HTTP_X_REQUESTED_WITH='XMLHttpRequest').content.decode("utf-8")))) class AdminPageEditContentSizeTests(AdminTestsBase): diff --git a/cms/tests/test_apphooks.py b/cms/tests/test_apphooks.py index 5cb77ef5d07..36410d434ea 100644 --- a/cms/tests/test_apphooks.py +++ b/cms/tests/test_apphooks.py @@ -22,6 +22,7 @@ from cms.test_utils.testcases import CMSTestCase from cms.tests.test_menu_utils import DumbPageLanguageUrl from cms.toolbar.toolbar import CMSToolbar +from cms.utils.compat import DJANGO_3 from menus.menu_pool import menu_pool from menus.utils import DefaultLanguageChanger @@ -284,7 +285,7 @@ def test_apphook_permissions_preserves_view_name(self): view_names = ( ('sample-settings', 'sample_view'), ('sample-class-view', 'ClassView'), - ('sample-class-based-view', 'ClassBasedView'), + ('sample-class-based-view', 'ClassBasedView' if DJANGO_3 else 'view'), ) with force_language("en"): diff --git a/cms/tests/test_docs.py b/cms/tests/test_docs.py deleted file mode 100644 index d8147781f9d..00000000000 --- a/cms/tests/test_docs.py +++ /dev/null @@ -1,102 +0,0 @@ -from contextlib import contextmanager -from unittest import skipIf, skipUnless - -import os -import socket -import sys - -from io import StringIO - -from sphinx.errors import SphinxWarning -from sphinx.application import Sphinx - -try: - import enchant -except ImportError: - enchant = None - -import cms -from cms.test_utils.testcases import CMSTestCase -from cms.test_utils.util.context_managers import TemporaryDirectory - - -ROOT_DIR = os.path.dirname(cms.__file__) -DOCS_DIR = os.path.abspath(os.path.join(ROOT_DIR, u'..', u'docs')) - - -def has_no_internet(): - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.settimeout(5) - s.connect(('4.4.4.2', 80)) - s.send(b"hello") - except socket.error: # no internet - return True - return False - - -@contextmanager -def tmp_list_append(l, x): - l.append(x) - try: - yield - finally: - if x in l: - l.remove(x) - - -class DocsTestCase(CMSTestCase): - """ - Test docs building correctly for HTML - """ - @skipIf(has_no_internet(), "No internet") - def test_html(self): - status = StringIO() - with TemporaryDirectory() as OUT_DIR: - app = Sphinx( - srcdir=DOCS_DIR, - confdir=DOCS_DIR, - outdir=OUT_DIR, - doctreedir=OUT_DIR, - buildername="html", - warningiserror=True, - status=status, - ) - try: - app.build() - except: - print(status.getvalue()) - raise - - @skipIf(has_no_internet(), "No internet") - @skipIf(enchant is None, "Enchant not installed") - @skipUnless(os.environ.get('TEST_DOCS') == '1', 'Skipping for simplicity') - def test_spelling(self): - status = StringIO() - with TemporaryDirectory() as OUT_DIR: - with tmp_list_append(sys.argv, 'spelling'): - try: - app = Sphinx( - srcdir=DOCS_DIR, - confdir=DOCS_DIR, - outdir=OUT_DIR, - doctreedir=OUT_DIR, - buildername="spelling", - warningiserror=True, - status=status, - confoverrides={ - 'extensions': [ - 'djangocms', - 'sphinx.ext.intersphinx', - 'sphinxcontrib.spelling' - ] - } - ) - app.build() - self.assertEqual(app.statuscode, 0, status.getvalue()) - except SphinxWarning: - # while normally harmless, causes a test failure - pass - except: - print(status.getvalue()) - raise diff --git a/cms/tests/test_i18n.py b/cms/tests/test_i18n.py index 6c6044a7ce9..0e48d8a5656 100644 --- a/cms/tests/test_i18n.py +++ b/cms/tests/test_i18n.py @@ -5,9 +5,11 @@ from cms import api from cms.test_utils.testcases import CMSTestCase -from cms.utils import i18n, get_language_from_request +from cms.utils import get_language_from_request, i18n +from cms.utils.compat import DJANGO_2_2 -from cms.utils.compat import DJANGO_3_0, DJANGO_3_1, DJANGO_3_2 +if DJANGO_2_2: + from django.utils.translation import LANGUAGE_SESSION_KEY @override_settings( @@ -361,7 +363,7 @@ def test_session_language(self): # ugly and long set of session session = self.client.session - if DJANGO_3_0 or DJANGO_3_1 or DJANGO_3_2: + if not DJANGO_2_2: self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = 'fr' else: session[LANGUAGE_SESSION_KEY] = 'fr' @@ -370,7 +372,7 @@ def test_session_language(self): self.assertEqual(response.status_code, 302) self.assertRedirects(response, '/fr/') self.client.get('/en/') - if DJANGO_3_0 or DJANGO_3_1 or DJANGO_3_2: + if not DJANGO_2_2: self.assertEqual(self.client.cookies[settings.LANGUAGE_COOKIE_NAME].value, 'en') else: self.assertEqual(self.client.session[LANGUAGE_SESSION_KEY], 'en') diff --git a/cms/tests/test_page_admin.py b/cms/tests/test_page_admin.py index bbf7876f8c2..3c302b45a08 100644 --- a/cms/tests/test_page_admin.py +++ b/cms/tests/test_page_admin.py @@ -6,7 +6,7 @@ from django.contrib.sites.models import Site from django.core.cache import cache from django.forms.models import model_to_dict -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.test.html import HTMLParseError, Parser from django.test.utils import override_settings from django.urls import clear_url_caches @@ -1286,7 +1286,7 @@ def test_form_url_page_change(self): content = self.get_page_title_obj(page, 'en') form_url = self.get_page_change_uri('en', page) # Middleware is needed to correctly setup the environment for the admin - middleware = CurrentUserMiddleware() + middleware = CurrentUserMiddleware(lambda req: HttpResponse("")) request = self.get_request() middleware.process_request(request) response = content_admin.change_view( diff --git a/cms/tests/test_templatetags.py b/cms/tests/test_templatetags.py index 6e5aefaa419..9dff4d3b727 100644 --- a/cms/tests/test_templatetags.py +++ b/cms/tests/test_templatetags.py @@ -6,6 +6,7 @@ from django.contrib.sites.models import Site from django.core import mail from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponse from django.test import RequestFactory from django.test.utils import override_settings from django.utils.encoding import force_str @@ -181,7 +182,7 @@ def test_get_page_by_pk_arg_edit_mode(self): user = self._create_user("admin", True, True) request.current_page = control request.user = user - middleware = ToolbarMiddleware() + middleware = ToolbarMiddleware(lambda req: HttpResponse("")) middleware.process_request(request) page = _get_page_by_untyped_arg(control.pk, request, 1) self.assertEqual(page, control) diff --git a/cms/utils/compat/__init__.py b/cms/utils/compat/__init__.py index d5bd669f12d..24f3814e57b 100644 --- a/cms/utils/compat/__init__.py +++ b/cms/utils/compat/__init__.py @@ -1,11 +1,7 @@ from platform import python_version -from django import get_version - -try: - from packaging.version import Version -except ModuleNotFoundError: - from distutils.version import LooseVersion as Version +from django import get_version +from packaging.version import Version DJANGO_VERSION = get_version() diff --git a/cms/views.py b/cms/views.py index f7d08959486..7beb1ba4b61 100644 --- a/cms/views.py +++ b/cms/views.py @@ -91,6 +91,8 @@ def details(request, slug): user_languages = get_public_languages(site_id=site.pk) request_language = get_language_from_request(request, check_path=True) + if not request_language: + request_language = get_default_language_for_site(get_current_site().pk) if not page.is_home and request_language not in user_languages: # The homepage is treated differently because diff --git a/docs/requirements.txt b/docs/requirements.txt index 79050b9b4d0..834e49f1b1c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,7 +2,7 @@ MarkupSafe==0.23 Pygments==2.0.2 sphinx sphinxcontrib-spelling -pyenchant==3.0.1 +pyenchant==3.2.2 sphinx-autobuild divio-docs-theme datetime diff --git a/menus/__init__.py b/menus/__init__.py index e69de29bb2d..8b2e2941cea 100644 --- a/menus/__init__.py +++ b/menus/__init__.py @@ -0,0 +1 @@ +default_app_config = 'menus.apps.MenusConfig' diff --git a/menus/menu_pool.py b/menus/menu_pool.py index 91a45b3df16..78ae91c6643 100644 --- a/menus/menu_pool.py +++ b/menus/menu_pool.py @@ -10,7 +10,9 @@ from django.utils.module_loading import autodiscover_modules from django.utils.translation import get_language_from_request, gettext_lazy as _ +from cms.utils import get_current_site from cms.utils.conf import get_cms_setting +from cms.utils.i18n import get_default_language_for_site from cms.utils.moderator import use_draft from menus.base import Menu @@ -102,6 +104,8 @@ def __init__(self, pool, request): self.menus = pool.get_registered_menus(for_rendering=True) self.request = request self.request_language = get_language_from_request(request, check_path=True) + if not self.request_language: + self.request_language = get_default_language_for_site(get_current_site().pk) self.site = Site.objects.get_current(request) self.draft_mode_active = use_draft(request) diff --git a/setup.py b/setup.py index 3d81f24001e..4f2202a4569 100644 --- a/setup.py +++ b/setup.py @@ -6,12 +6,13 @@ REQUIREMENTS = [ - 'Django>=3.2', + 'Django>=3.2, <5', 'django-classy-tags>=0.7.2', 'django-formtools>=2.1', 'django-treebeard>=4.3', 'django-sekizai>=0.7', 'djangocms-admin-style>=1.2', + 'packaging' ] @@ -24,14 +25,15 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Framework :: Django', 'Framework :: Django :: 2.2', 'Framework :: Django :: 3.0', 'Framework :: Django :: 3.1', 'Framework :: Django :: 3.2', + 'Framework :: Django :: 4.2', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Software Development', diff --git a/test_requirements/django-3.2.txt b/test_requirements/django-3.2.txt index 697d6f7e3b7..158c1e4daf4 100644 --- a/test_requirements/django-3.2.txt +++ b/test_requirements/django-3.2.txt @@ -1,3 +1,4 @@ -r requirements_base.txt Django>=3.2,<3.4 django-formtools==2.4.1 +djangocms-text-ckeditor<5.0.0 \ No newline at end of file diff --git a/test_requirements/django-4.2.txt b/test_requirements/django-4.2.txt index c738cab3be8..2d3fd24426f 100644 --- a/test_requirements/django-4.2.txt +++ b/test_requirements/django-4.2.txt @@ -1,3 +1,3 @@ -r requirements_base.txt Django>=4.2,<5.0 -django-formtools==2.4.1 +djangocms-text-ckeditor>=5.1.5 \ No newline at end of file diff --git a/test_requirements/requirements_base.txt b/test_requirements/requirements_base.txt index d762dbc6539..30dc3abdb71 100644 --- a/test_requirements/requirements_base.txt +++ b/test_requirements/requirements_base.txt @@ -20,6 +20,6 @@ sphinxcontrib-spelling<7.0.0 # restriction for py35 tests unittest-xml-reporting==1.11.0 # FIXME: - Remove when a django 3.0 compatible djangocms-text-ckeditor version is released -https://github.com/Aiky30/djangocms-text-ckeditor/archive/feature/cms-40-django-30-support-.zip +#https://github.com/Aiky30/djangocms-text-ckeditor/archive/feature/cms-40-django-30-support-.zip https://github.com/ojii/django-better-test/archive/8aa2407d097fe3789b74682f0e6bd7d15d449416.zip#egg=django-better-test https://github.com/ojii/django-app-manage/archive/65da18ef234a4e985710c2c0ec760023695b40fe.zip#egg=django-app-manage