From ba610cb319d8882a663effcaf0a4e53c04593f98 Mon Sep 17 00:00:00 2001 From: James Bennett Date: Thu, 20 Jun 2013 01:36:13 -0500 Subject: Fixed #19881 -- Documented that get_next/previous_by_FOO uses default manager. --- docs/ref/models/instances.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'docs/ref') diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index cfc95db092..17c9aa9fb7 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -679,8 +679,11 @@ For every :class:`~django.db.models.DateField` and returns the next and previous object with respect to the date field, raising a :exc:`~django.core.exceptions.DoesNotExist` exception when appropriate. -Both methods accept optional keyword arguments, which should be in the format -described in :ref:`Field lookups `. +Both of these methods will perform their queries using the default +manager for the model. If you need to emulate filtering used by a +custom manager, or want to perform one-off custom filtering, both +methods also accept optional keyword arguments, which should be in the +format described in :ref:`Field lookups `. Note that in the case of identical date values, these methods will use the primary key as a tie-breaker. This guarantees that no records are skipped or -- cgit v1.3 From 9be93aa809c34083ebef8392e52c83df0e383be3 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 21 Jun 2013 14:55:59 -0400 Subject: Fixed #20634 - Corrected doc mistake re: staticfiles finders strategy. Thanks claudep for the catch and bmispelon for the research. --- docs/howto/static-files/index.txt | 2 +- docs/ref/settings.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) (limited to 'docs/ref') diff --git a/docs/howto/static-files/index.txt b/docs/howto/static-files/index.txt index 3668c5dc41..db8bd38e9c 100644 --- a/docs/howto/static-files/index.txt +++ b/docs/howto/static-files/index.txt @@ -68,7 +68,7 @@ details on how ``staticfiles`` finds your files. Now we *might* be able to get away with putting our static files directly in ``my_app/static/`` (rather than creating another ``my_app`` subdirectory), but it would actually be a bad idea. Django will use the - last static file it finds whose name matches, and if you had a static file + first static file it finds whose name matches, and if you had a static file with the same name in a *different* application, Django would be unable to distinguish between them. We need to be able to point Django at the right one, and the easiest way to ensure this is by *namespacing* them. That is, diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 897af275a0..902eefa86a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2564,7 +2564,9 @@ various locations. The default will find files stored in the :setting:`STATICFILES_DIRS` setting (using ``django.contrib.staticfiles.finders.FileSystemFinder``) and in a ``static`` subdirectory of each app (using -``django.contrib.staticfiles.finders.AppDirectoriesFinder``) +``django.contrib.staticfiles.finders.AppDirectoriesFinder``). If multiple +files with the same name are present, the first file that is found will be +used. One finder is disabled by default: ``django.contrib.staticfiles.finders.DefaultStorageFinder``. If added to -- cgit v1.3 From ef37b23050637da643b47b1ee744702d4d603f4c Mon Sep 17 00:00:00 2001 From: Gilberto Gonçalves Date: Sat, 22 Jun 2013 12:12:43 +0100 Subject: Fixed #18872 -- Added prefix to FormMixin Thanks @ibustama for the initial patch and dragonsnaker for opening the report. --- AUTHORS | 1 + django/views/generic/edit.py | 13 ++++++++++++- docs/ref/class-based-views/mixins-editing.txt | 4 ++++ docs/releases/1.6.txt | 3 +++ tests/generic_views/test_edit.py | 21 ++++++++++++++++++++- 5 files changed, 40 insertions(+), 2 deletions(-) (limited to 'docs/ref') diff --git a/AUTHORS b/AUTHORS index 3eb0a68be9..e4803dbe9a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -249,6 +249,7 @@ answer newbie questions, and generally made Django that much better: martin.glueck@gmail.com Ben Godfrey GomoX + Gil Gonçalves Guilherme Mesquita Gondim Mario Gonzalez David Gouldin diff --git a/django/views/generic/edit.py b/django/views/generic/edit.py index b31d7a218f..193071efc5 100644 --- a/django/views/generic/edit.py +++ b/django/views/generic/edit.py @@ -17,6 +17,7 @@ class FormMixin(ContextMixin): initial = {} form_class = None success_url = None + prefix = None def get_initial(self): """ @@ -24,6 +25,12 @@ class FormMixin(ContextMixin): """ return self.initial.copy() + def get_prefix(self): + """ + Returns the prefix to use for forms on this view + """ + return self.prefix + def get_form_class(self): """ Returns the form class to use in this view @@ -40,7 +47,11 @@ class FormMixin(ContextMixin): """ Returns the keyword arguments for instantiating the form. """ - kwargs = {'initial': self.get_initial()} + kwargs = { + 'initial': self.get_initial(), + 'prefix': self.get_prefix(), + } + if self.request.method in ('POST', 'PUT'): kwargs.update({ 'data': self.request.POST, diff --git a/docs/ref/class-based-views/mixins-editing.txt b/docs/ref/class-based-views/mixins-editing.txt index 48d363b3b2..a0160610d2 100644 --- a/docs/ref/class-based-views/mixins-editing.txt +++ b/docs/ref/class-based-views/mixins-editing.txt @@ -35,6 +35,10 @@ FormMixin The URL to redirect to when the form is successfully processed. + .. attribute:: prefix + + Sets the :attr:`~django.forms.Form.prefix` for the generated form. + .. method:: get_initial() Retrieve initial data for the form. By default, returns a copy of diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 16e0b94a9d..95bfedc74e 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -731,6 +731,9 @@ Miscellaneous of the admin views. You should update your custom templates if they use the previous parameter name. +* Added :attr:`~django.views.generic.edit.FormMixin.prefix` to allow you to + customize the prefix on the form. + Features deprecated in 1.6 ========================== diff --git a/tests/generic_views/test_edit.py b/tests/generic_views/test_edit.py index 435e48ba99..84d18ebcb2 100644 --- a/tests/generic_views/test_edit.py +++ b/tests/generic_views/test_edit.py @@ -7,8 +7,9 @@ from django.core.urlresolvers import reverse from django import forms from django.test import TestCase from django.utils.unittest import expectedFailure +from django.test.client import RequestFactory from django.views.generic.base import View -from django.views.generic.edit import FormMixin, CreateView, UpdateView +from django.views.generic.edit import FormMixin, CreateView from . import views from .models import Artist, Author @@ -22,6 +23,24 @@ class FormMixinTests(TestCase): initial_2 = FormMixin().get_initial() self.assertNotEqual(initial_1, initial_2) + def test_get_prefix(self): + """ Test prefix can be set (see #18872) """ + test_string = 'test' + + rf = RequestFactory() + get_request = rf.get('/') + + class TestFormMixin(FormMixin): + request = get_request + + default_kwargs = TestFormMixin().get_form_kwargs() + self.assertEqual(None, default_kwargs.get('prefix')) + + set_mixin = TestFormMixin() + set_mixin.prefix = test_string + set_kwargs = set_mixin.get_form_kwargs() + self.assertEqual(test_string, set_kwargs.get('prefix')) + class BasicFormTests(TestCase): urls = 'generic_views.urls' -- cgit v1.3 From ecf63d5d89c5b511a023a4dcc32a142e1eed97e6 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Sat, 22 Jun 2013 16:49:24 -0400 Subject: Added missing `versionadded` for `FormMixin.prefix`. --- docs/ref/class-based-views/mixins-editing.txt | 2 ++ 1 file changed, 2 insertions(+) (limited to 'docs/ref') diff --git a/docs/ref/class-based-views/mixins-editing.txt b/docs/ref/class-based-views/mixins-editing.txt index a0160610d2..23b781c2e4 100644 --- a/docs/ref/class-based-views/mixins-editing.txt +++ b/docs/ref/class-based-views/mixins-editing.txt @@ -37,6 +37,8 @@ FormMixin .. attribute:: prefix + .. versionadded:: 1.6 + Sets the :attr:`~django.forms.Form.prefix` for the generated form. .. method:: get_initial() -- cgit v1.3 From 299983616ffc146a6f5aa03af9b3f4a56853f05c Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Sun, 23 Jun 2013 23:43:09 +0200 Subject: Fixed #20644 -- Add ModelFormMixin.fields to the CBV flattened index Thanks to Tim Graham for the report and review. --- django/views/generic/edit.py | 6 +++--- docs/ref/class-based-views/flattened-index.txt | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'docs/ref') diff --git a/django/views/generic/edit.py b/django/views/generic/edit.py index 193071efc5..fccacf0bd3 100644 --- a/django/views/generic/edit.py +++ b/django/views/generic/edit.py @@ -89,6 +89,7 @@ class ModelFormMixin(FormMixin, SingleObjectMixin): """ A mixin that provides a way to show and handle a modelform in a request. """ + fields = None def get_form_class(self): """ @@ -109,13 +110,12 @@ class ModelFormMixin(FormMixin, SingleObjectMixin): # from that model = self.get_queryset().model - fields = getattr(self, 'fields', None) - if fields is None: + if self.fields is None: warnings.warn("Using ModelFormMixin (base class of %s) without " "the 'fields' attribute is deprecated." % self.__class__.__name__, PendingDeprecationWarning) - return model_forms.modelform_factory(model, fields=fields) + return model_forms.modelform_factory(model, fields=self.fields) def get_form_kwargs(self): """ diff --git a/docs/ref/class-based-views/flattened-index.txt b/docs/ref/class-based-views/flattened-index.txt index df00f87aa0..7634a07300 100644 --- a/docs/ref/class-based-views/flattened-index.txt +++ b/docs/ref/class-based-views/flattened-index.txt @@ -177,6 +177,7 @@ CreateView * :attr:`~django.views.generic.base.TemplateResponseMixin.content_type` * :attr:`~django.views.generic.detail.SingleObjectMixin.context_object_name` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_context_object_name`] +* :attr:`~django.views.generic.edit.ModelFormMixin.fields` * :attr:`~django.views.generic.edit.FormMixin.form_class` [:meth:`~django.views.generic.edit.FormMixin.get_form_class`] * :attr:`~django.views.generic.base.View.http_method_names` * :attr:`~django.views.generic.edit.FormMixin.initial` [:meth:`~django.views.generic.edit.FormMixin.get_initial`] @@ -216,6 +217,7 @@ UpdateView * :attr:`~django.views.generic.base.TemplateResponseMixin.content_type` * :attr:`~django.views.generic.detail.SingleObjectMixin.context_object_name` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_context_object_name`] +* :attr:`~django.views.generic.edit.ModelFormMixin.fields` * :attr:`~django.views.generic.edit.FormMixin.form_class` [:meth:`~django.views.generic.edit.FormMixin.get_form_class`] * :attr:`~django.views.generic.base.View.http_method_names` * :attr:`~django.views.generic.edit.FormMixin.initial` [:meth:`~django.views.generic.edit.FormMixin.get_initial`] -- cgit v1.3 From 9a2b07f1b45741da39a7606474aec3548780032b Mon Sep 17 00:00:00 2001 From: Daniel Izquierdo Date: Tue, 25 Jun 2013 14:40:56 +0900 Subject: Fixed #20654 -- Fixed type of `m2m_changed`'s `pk_set` arg in docs --- docs/ref/signals.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'docs/ref') diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 06ba2cb3e8..0fdfb0ee14 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -255,7 +255,7 @@ Arguments sent with this signal: ``pk_set`` For the ``pre_add``, ``post_add``, ``pre_remove`` and ``post_remove`` - actions, this is a list of primary key values that have been added to + actions, this is a set of primary key values that have been added to or removed from the relation. For the ``pre_clear`` and ``post_clear`` actions, this is ``None``. @@ -307,7 +307,7 @@ Argument Value ``model`` ``Topping`` (the class of the objects added to the ``Pizza``) -``pk_set`` ``[t.id]`` (since only ``Topping t`` was added to the relation) +``pk_set`` ``set([t.id])`` (since only ``Topping t`` was added to the relation) ``using`` ``"default"`` (since the default router sends writes here) ============== ============================================================ @@ -334,7 +334,7 @@ Argument Value ``model`` ``Pizza`` (the class of the objects removed from the ``Topping``) -``pk_set`` ``[p.id]`` (since only ``Pizza p`` was removed from the +``pk_set`` ``set([p.id])`` (since only ``Pizza p`` was removed from the relation) ``using`` ``"default"`` (since the default router sends writes here) -- cgit v1.3 From e10757ff4dbbc1aedd09df6c542948409c49d75f Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Tue, 25 Jun 2013 07:50:43 -0400 Subject: Doc cleanup for FormMixin.prefix; refs #18872. --- docs/ref/class-based-views/flattened-index.txt | 3 +++ docs/ref/class-based-views/mixins-editing.txt | 9 ++++++++- docs/releases/1.6.txt | 10 +++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) (limited to 'docs/ref') diff --git a/docs/ref/class-based-views/flattened-index.txt b/docs/ref/class-based-views/flattened-index.txt index 7634a07300..272c852181 100644 --- a/docs/ref/class-based-views/flattened-index.txt +++ b/docs/ref/class-based-views/flattened-index.txt @@ -151,6 +151,7 @@ FormView * :attr:`~django.views.generic.edit.FormMixin.form_class` [:meth:`~django.views.generic.edit.FormMixin.get_form_class`] * :attr:`~django.views.generic.base.View.http_method_names` * :attr:`~django.views.generic.edit.FormMixin.initial` [:meth:`~django.views.generic.edit.FormMixin.get_initial`] +* :attr:`~django.views.generic.edit.FormMixin.prefix` [:meth:`~django.views.generic.edit.FormMixin.get_prefix`] * :attr:`~django.views.generic.base.TemplateResponseMixin.response_class` [:meth:`~django.views.generic.base.TemplateResponseMixin.render_to_response`] * :attr:`~django.views.generic.edit.FormMixin.success_url` [:meth:`~django.views.generic.edit.FormMixin.get_success_url`] * :attr:`~django.views.generic.base.TemplateResponseMixin.template_name` [:meth:`~django.views.generic.base.TemplateResponseMixin.get_template_names`] @@ -183,6 +184,7 @@ CreateView * :attr:`~django.views.generic.edit.FormMixin.initial` [:meth:`~django.views.generic.edit.FormMixin.get_initial`] * :attr:`~django.views.generic.detail.SingleObjectMixin.model` * :attr:`~django.views.generic.detail.SingleObjectMixin.pk_url_kwarg` +* :attr:`~django.views.generic.edit.FormMixin.prefix` [:meth:`~django.views.generic.edit.FormMixin.get_prefix`] * :attr:`~django.views.generic.detail.SingleObjectMixin.queryset` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_queryset`] * :attr:`~django.views.generic.base.TemplateResponseMixin.response_class` [:meth:`~django.views.generic.base.TemplateResponseMixin.render_to_response`] * :attr:`~django.views.generic.detail.SingleObjectMixin.slug_field` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_slug_field`] @@ -223,6 +225,7 @@ UpdateView * :attr:`~django.views.generic.edit.FormMixin.initial` [:meth:`~django.views.generic.edit.FormMixin.get_initial`] * :attr:`~django.views.generic.detail.SingleObjectMixin.model` * :attr:`~django.views.generic.detail.SingleObjectMixin.pk_url_kwarg` +* :attr:`~django.views.generic.edit.FormMixin.prefix` [:meth:`~django.views.generic.edit.FormMixin.get_prefix`] * :attr:`~django.views.generic.detail.SingleObjectMixin.queryset` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_queryset`] * :attr:`~django.views.generic.base.TemplateResponseMixin.response_class` [:meth:`~django.views.generic.base.TemplateResponseMixin.render_to_response`] * :attr:`~django.views.generic.detail.SingleObjectMixin.slug_field` [:meth:`~django.views.generic.detail.SingleObjectMixin.get_slug_field`] diff --git a/docs/ref/class-based-views/mixins-editing.txt b/docs/ref/class-based-views/mixins-editing.txt index 23b781c2e4..bf1c10df13 100644 --- a/docs/ref/class-based-views/mixins-editing.txt +++ b/docs/ref/class-based-views/mixins-editing.txt @@ -39,7 +39,7 @@ FormMixin .. versionadded:: 1.6 - Sets the :attr:`~django.forms.Form.prefix` for the generated form. + The :attr:`~django.forms.Form.prefix` for the generated form. .. method:: get_initial() @@ -64,6 +64,13 @@ FormMixin request is a ``POST`` or ``PUT``, the request data (``request.POST`` and ``request.FILES``) will also be provided. + .. method:: get_prefix() + + .. versionadded:: 1.6 + + Determine the :attr:`~django.forms.Form.prefix` for the generated form. + Returns :attr:`~django.views.generic.edit.FormMixin.prefix` by default. + .. method:: get_success_url() Determine the URL to redirect to when the form is successfully diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 086c10c389..5d1a37ac53 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -330,6 +330,13 @@ Minor features behavior of clearing filters by setting the :attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``. +* Added + :meth:`FormMixin.get_prefix` + (which returns + :attr:`FormMixin.prefix` by + default) to allow customizing the :attr:`~django.forms.Form.prefix` of the + form. + Backwards incompatible changes in 1.6 ===================================== @@ -731,9 +738,6 @@ Miscellaneous of the admin views. You should update your custom templates if they use the previous parameter name. -* Added :attr:`~django.views.generic.edit.FormMixin.prefix` to allow you to - customize the prefix on the form. - Features deprecated in 1.6 ========================== -- cgit v1.3 From 1184d077893ff1bc947e45b00a4d565f3df81776 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 21 Jun 2013 16:59:33 -0400 Subject: Fixed #14881 -- Modified password reset to work with a non-integer UserModel.pk. uid is now base64 encoded in password reset URLs/views. A backwards compatible password_reset_confirm view/URL will allow password reset links generated before this change to continue to work. This view will be removed in Django 1.7. Thanks jonash for the initial patch and claudep for the review. --- .../registration/password_reset_email.html | 2 +- django/contrib/auth/forms.py | 5 +- .../registration/password_reset_email.html | 2 +- django/contrib/auth/tests/test_views.py | 24 +++++++++- django/contrib/auth/tests/urls.py | 5 +- django/contrib/auth/urls.py | 3 ++ django/contrib/auth/views.py | 21 ++++++--- django/utils/http.py | 21 ++++++++- docs/internals/deprecation.txt | 8 ++++ docs/ref/contrib/admin/index.txt | 7 ++- docs/ref/utils.txt | 14 ++++++ docs/releases/1.6.txt | 53 ++++++++++++++++++++++ docs/topics/auth/default.txt | 22 ++++++--- 13 files changed, 164 insertions(+), 23 deletions(-) (limited to 'docs/ref') diff --git a/django/contrib/admin/templates/registration/password_reset_email.html b/django/contrib/admin/templates/registration/password_reset_email.html index 44ae5850b1..01b3bccbbc 100644 --- a/django/contrib/admin/templates/registration/password_reset_email.html +++ b/django/contrib/admin/templates/registration/password_reset_email.html @@ -3,7 +3,7 @@ {% trans "Please go to the following page and choose a new password:" %} {% block reset_link %} -{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} {% endblock %} {% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index a9ecba45c2..43f5303b63 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -6,8 +6,9 @@ from django import forms from django.forms.util import flatatt from django.template import loader from django.utils.datastructures import SortedDict +from django.utils.encoding import force_bytes from django.utils.html import format_html, format_html_join -from django.utils.http import int_to_base36 +from django.utils.http import urlsafe_base64_encode from django.utils.safestring import mark_safe from django.utils.text import capfirst from django.utils.translation import ugettext, ugettext_lazy as _ @@ -243,7 +244,7 @@ class PasswordResetForm(forms.Form): 'email': user.email, 'domain': domain, 'site_name': site_name, - 'uid': int_to_base36(user.pk), + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), 'user': user, 'token': token_generator.make_token(user), 'protocol': 'https' if use_https else 'http', diff --git a/django/contrib/auth/tests/templates/registration/password_reset_email.html b/django/contrib/auth/tests/templates/registration/password_reset_email.html index 1b9a48255a..baac2fc2dd 100644 --- a/django/contrib/auth/tests/templates/registration/password_reset_email.html +++ b/django/contrib/auth/tests/templates/registration/password_reset_email.html @@ -1 +1 @@ -{{ protocol }}://{{ domain }}/reset/{{ uid }}-{{ token }}/ \ No newline at end of file +{{ protocol }}://{{ domain }}/reset/{{ uid }}/{{ token }}/ diff --git a/django/contrib/auth/tests/test_views.py b/django/contrib/auth/tests/test_views.py index 3a1be5bb7b..ba06a6af4d 100644 --- a/django/contrib/auth/tests/test_views.py +++ b/django/contrib/auth/tests/test_views.py @@ -13,7 +13,7 @@ from django.core import mail from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict, HttpRequest from django.utils.encoding import force_text -from django.utils.http import urlquote +from django.utils.http import int_to_base36, urlsafe_base64_decode, urlquote from django.utils._os import upath from django.test import TestCase from django.test.utils import override_settings, patch_logger @@ -91,7 +91,7 @@ class AuthViewNamedURLTests(AuthViewsTestCase): ('password_reset', [], {}), ('password_reset_done', [], {}), ('password_reset_confirm', [], { - 'uidb36': 'aaaaaaa', + 'uidb64': 'aaaaaaa', 'token': '1111-aaaaa', }), ('password_reset_complete', [], {}), @@ -193,6 +193,16 @@ class PasswordResetTest(AuthViewsTestCase): # redirect to a 'complete' page: self.assertContains(response, "Please enter your new password") + def test_confirm_valid_base36(self): + # Remove in Django 1.7 + url, path = self._test_confirm_start() + path_parts = path.strip("/").split("/") + # construct an old style (base36) URL by converting the base64 ID + path_parts[1] = int_to_base36(int(urlsafe_base64_decode(path_parts[1]))) + response = self.client.get("/%s/%s-%s/" % tuple(path_parts)) + # redirect to a 'complete' page: + self.assertContains(response, "Please enter your new password") + def test_confirm_invalid(self): url, path = self._test_confirm_start() # Let's munge the token in the path, but keep the same length, @@ -204,11 +214,21 @@ class PasswordResetTest(AuthViewsTestCase): def test_confirm_invalid_user(self): # Ensure that we get a 200 response for a non-existant user, not a 404 + response = self.client.get('/reset/123456/1-1/') + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_invalid_user_base36(self): + # Remove in Django 1.7 response = self.client.get('/reset/123456-1-1/') self.assertContains(response, "The password reset link was invalid") def test_confirm_overflow_user(self): # Ensure that we get a 200 response for a base36 user id that overflows int + response = self.client.get('/reset/zzzzzzzzzzzzz/1-1/') + self.assertContains(response, "The password reset link was invalid") + + def test_confirm_overflow_user_base36(self): + # Remove in Django 1.7 response = self.client.get('/reset/zzzzzzzzzzzzz-1-1/') self.assertContains(response, "The password reset link was invalid") diff --git a/django/contrib/auth/tests/urls.py b/django/contrib/auth/tests/urls.py index 835ff41de7..502fc659d4 100644 --- a/django/contrib/auth/tests/urls.py +++ b/django/contrib/auth/tests/urls.py @@ -67,10 +67,10 @@ urlpatterns = urlpatterns + patterns('', (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')), (r'^password_reset/custom_redirect/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='/custom/')), (r'^password_reset/custom_redirect/named/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='password_reset')), - (r'^reset/custom/(?P[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + (r'^reset/custom/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 'django.contrib.auth.views.password_reset_confirm', dict(post_reset_redirect='/custom/')), - (r'^reset/custom/named/(?P[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + (r'^reset/custom/named/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 'django.contrib.auth.views.password_reset_confirm', dict(post_reset_redirect='password_reset')), (r'^password_change/custom/$', 'django.contrib.auth.views.password_change', dict(post_change_redirect='/custom/')), @@ -88,4 +88,3 @@ urlpatterns = urlpatterns + patterns('', (r'^custom_request_auth_login/$', custom_request_auth_login), url(r'^userpage/(.+)/$', userpage, name="userpage"), ) - diff --git a/django/contrib/auth/urls.py b/django/contrib/auth/urls.py index c5e87ed2eb..801d133437 100644 --- a/django/contrib/auth/urls.py +++ b/django/contrib/auth/urls.py @@ -12,7 +12,10 @@ urlpatterns = patterns('', url(r'^password_change/done/$', 'django.contrib.auth.views.password_change_done', name='password_change_done'), url(r'^password_reset/$', 'django.contrib.auth.views.password_reset', name='password_reset'), url(r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'), + # Support old style base36 password reset links; remove in Django 1.7 url(r'^reset/(?P[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + 'django.contrib.auth.views.password_reset_confirm_uidb36'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'), url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'), diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index fe21683323..e9affb33cd 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -7,9 +7,10 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect, QueryDict from django.template.response import TemplateResponse -from django.utils.http import base36_to_int, is_safe_url +from django.utils.http import base36_to_int, is_safe_url, urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import ugettext as _ from django.shortcuts import resolve_url +from django.utils.encoding import force_bytes, force_text from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect @@ -184,7 +185,7 @@ def password_reset_done(request, # Doesn't need csrf_protect since no-one can guess the URL @sensitive_post_parameters() @never_cache -def password_reset_confirm(request, uidb36=None, token=None, +def password_reset_confirm(request, uidb64=None, token=None, template_name='registration/password_reset_confirm.html', token_generator=default_token_generator, set_password_form=SetPasswordForm, @@ -195,15 +196,15 @@ def password_reset_confirm(request, uidb36=None, token=None, form for entering a new password. """ UserModel = get_user_model() - assert uidb36 is not None and token is not None # checked by URLconf + assert uidb64 is not None and token is not None # checked by URLconf if post_reset_redirect is None: post_reset_redirect = reverse('password_reset_complete') else: post_reset_redirect = resolve_url(post_reset_redirect) try: - uid_int = base36_to_int(uidb36) - user = UserModel._default_manager.get(pk=uid_int) - except (ValueError, OverflowError, UserModel.DoesNotExist): + uid = urlsafe_base64_decode(uidb64) + user = UserModel._default_manager.get(pk=uid) + except (TypeError, ValueError, OverflowError, UserModel.DoesNotExist): user = None if user is not None and token_generator.check_token(user, token): @@ -227,6 +228,14 @@ def password_reset_confirm(request, uidb36=None, token=None, return TemplateResponse(request, template_name, context, current_app=current_app) +def password_reset_confirm_uidb36(request, uidb36=None, **kwargs): + # Support old password reset URLs that used base36 encoded user IDs. + # Remove in Django 1.7 + try: + uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36)))) + except ValueError: + uidb64 = '1' # dummy invalid ID (incorrect padding for base64) + return password_reset_confirm(request, uidb64=uidb64, **kwargs) def password_reset_complete(request, template_name='registration/password_reset_complete.html', diff --git a/django/utils/http.py b/django/utils/http.py index f4911b4ec0..4647d89847 100644 --- a/django/utils/http.py +++ b/django/utils/http.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import base64 import calendar import datetime import re @@ -11,7 +12,7 @@ except ImportError: # Python 2 import urlparse urllib_parse.urlparse = urlparse.urlparse - +from binascii import Error as BinasciiError from email.utils import formatdate from django.utils.datastructures import MultiValueDict @@ -202,6 +203,24 @@ def int_to_base36(i): factor -= 1 return ''.join(base36) +def urlsafe_base64_encode(s): + """ + Encodes a bytestring in base64 for use in URLs, stripping any trailing + equal signs. + """ + return base64.urlsafe_b64encode(s).rstrip(b'\n=') + +def urlsafe_base64_decode(s): + """ + Decodes a base64 encoded string, adding back any trailing equal signs that + might have been stripped. + """ + s = s.encode('utf-8') # base64encode should only return ASCII. + try: + return base64.urlsafe_b64decode(s.ljust(len(s) + len(s) % 4, b'=')) + except (LookupError, BinasciiError) as e: + raise ValueError(e) + def parse_etags(etag_str): """ Parses a string with one or several etags passed in If-None-Match and diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 45f82b49e6..9672746717 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -326,6 +326,14 @@ these changes. remove calls to this method, and instead ensure that their auth related views are CSRF protected, which ensures that cookies are enabled. +* The version of :func:`django.contrib.auth.views.password_reset_confirm` that + supports base36 encoded user IDs + (``django.contrib.auth.views.password_reset_confirm_uidb36``) will be + removed. If your site has been running Django 1.6 for more than + :setting:`PASSWORD_RESET_TIMEOUT_DAYS`, this change will have no effect. If + not, then any password reset links generated before you upgrade to Django 1.7 + won't work after the upgrade. + 1.8 --- diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 90ef68837a..318ce297a2 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -2278,9 +2278,14 @@ your URLconf. Specifically, add these four patterns: url(r'^admin/password_reset/$', 'django.contrib.auth.views.password_reset', name='admin_password_reset'), url(r'^admin/password_reset/done/$', 'django.contrib.auth.views.password_reset_done', name='password_reset_done'), - url(r'^reset/(?P[0-9A-Za-z]+)-(?P.+)/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'), + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', 'django.contrib.auth.views.password_reset_confirm', name='password_reset_confirm'), url(r'^reset/done/$', 'django.contrib.auth.views.password_reset_complete', name='password_reset_complete'), +.. versionchanged:: 1.6 + + The pattern for :func:`~django.contrib.auth.views.password_reset_confirm` + changed as the ``uid`` is now base 64 encoded. + (This assumes you've added the admin at ``admin/`` and requires that you put the URLs starting with ``^admin/`` before the line that includes the admin app itself). diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 45d7781403..8d722829fb 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -649,6 +649,20 @@ escaping HTML. Converts a positive integer to a base 36 string. On Python 2 ``i`` must be smaller than :data:`sys.maxint`. +.. function:: urlsafe_base64_encode(s) + + .. versionadded:: 1.6 + + Encodes a bytestring in base64 for use in URLs, stripping any trailing + equal signs. + +.. function:: urlsafe_base64_decode(s) + + .. versionadded:: 1.6 + + Decodes a base64 encoded string, adding back any trailing equal signs that + might have been stripped. + ``django.utils.module_loading`` =============================== diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 3d59ce771b..2c1fffd8cd 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -656,6 +656,59 @@ rely on the previous URLs. If you want to revert to the original behavior you can set the :attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``. +``django.contrib.auth`` password reset uses base 64 encoding of ``User`` PK +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Past versions of Django used base 36 encoding of the ``User`` primary key in +the password reset views and URLs +(:func:`django.contrib.auth.views.password_reset_confirm`). Base 36 encoding is +sufficient if the user primary key is an integer, however, with the +introduction of custom user models in Django 1.5, that assumption may no longer +be true. + +:func:`django.contrib.auth.views.password_reset_confirm` has been modified to +take a ``uidb64`` parameter instead of ``uidb36``. If you are reversing this +view, for example in a custom ``password_reset_email.html`` template, be sure +to update your code. + +A temporary shim for :func:`django.contrib.auth.views.password_reset_confirm` +that will allow password reset links generated prior to Django 1.6 to continue +to work has been added to provide backwards compatibility; this will be removed +in Django 1.7. Thus, as long as your site has been running Django 1.6 for more +than :setting:`PASSWORD_RESET_TIMEOUT_DAYS`, this change will have no effect. +If not (for example, if you upgrade directly from Django 1.5 to Django 1.7), +then any password reset links generated before you upgrade to Django 1.7 or +later won't work after the upgrade. + +In addition, if you have any custom password reset URLs, you will need to +update them by replacing ``uidb36`` with ``uidb64`` and the dash that follows +that pattern with a slash. Also add ``_\-`` to the list of characters that may +match the ``uidb64`` pattern. + +For example:: + + url(r'^reset/(?P[0-9A-Za-z]+)-(?P.+)/$', + 'django.contrib.auth.views.password_reset_confirm', + name='password_reset_confirm'), + +becomes:: + + url(r'^reset/(?P[0-9A-Za-z_\-]+)/(?P.+)/$', + 'django.contrib.auth.views.password_reset_confirm', + name='password_reset_confirm'), + +You may also want to add the shim to support the old style reset links. Using +the example above, you would modify the existing url by replacing +``django.contrib.auth.views.password_reset_confirm`` with +``django.contrib.auth.views.password_reset_confirm_uidb36`` and also remove +the ``name`` argument so it doesn't conflict with the new url:: + + url(r'^reset/(?P[0-9A-Za-z]+)-(?P.+)/$', + 'django.contrib.auth.views.password_reset_confirm_uidb36'), + +You can remove this url pattern after your app has been deployed with Django +1.6 for :setting:`PASSWORD_RESET_TIMEOUT_DAYS`. + Miscellaneous ~~~~~~~~~~~~~ diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index 8849520b11..e2fa0c287e 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -817,7 +817,7 @@ patterns. * ``protocol``: http or https - * ``uid``: The user's id encoded in base 36. + * ``uid``: The user's primary key encoded in base 64. * ``token``: Token to check that the reset link is valid. @@ -826,7 +826,12 @@ patterns. .. code-block:: html+django Someone asked for password reset for email {{ email }}. Follow the link below: - {{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb36=uid token=token %} + {{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} + + .. versionchanged:: 1.6 + + Reversing ``password_reset_confirm`` takes a ``uidb64`` argument instead + of ``uidb36``. The same template context is used for subject template. Subject must be single line plain text string. @@ -846,7 +851,7 @@ patterns. Defaults to :file:`registration/password_reset_done.html` if not supplied. -.. function:: password_reset_confirm(request[, uidb36, token, template_name, token_generator, set_password_form, post_reset_redirect]) +.. function:: password_reset_confirm(request[, uidb64, token, template_name, token_generator, set_password_form, post_reset_redirect]) Presents a form for entering a new password. @@ -854,7 +859,12 @@ patterns. **Optional arguments:** - * ``uidb36``: The user's id encoded in base 36. Defaults to ``None``. + * ``uidb64``: The user's id encoded in base 64. Defaults to ``None``. + + .. versionchanged:: 1.6 + + The ``uidb64`` parameter was previously base 36 encoded and named + ``uidb36``. * ``token``: Token to check that the password is valid. Defaults to ``None``. @@ -877,8 +887,8 @@ patterns. * ``form``: The form (see ``set_password_form`` above) for setting the new user's password. - * ``validlink``: Boolean, True if the link (combination of uidb36 and - token) is valid or unused yet. + * ``validlink``: Boolean, True if the link (combination of ``uidb64`` and + ``token``) is valid or unused yet. .. function:: password_reset_complete(request[,template_name]) -- cgit v1.3 From 5005303ae7919eef26dab9f8ba279696966ebf1d Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Thu, 27 Jun 2013 09:42:09 +0200 Subject: Fixed #20665 -- Missing backslash in sitemaps documentation Thanks to roman for the report. --- docs/ref/contrib/sitemaps.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'docs/ref') diff --git a/docs/ref/contrib/sitemaps.txt b/docs/ref/contrib/sitemaps.txt index 56a15cb9e0..4467ed3a6e 100644 --- a/docs/ref/contrib/sitemaps.txt +++ b/docs/ref/contrib/sitemaps.txt @@ -358,7 +358,7 @@ with a caching decorator -- you must name your sitemap view and pass from django.views.decorators.cache import cache_page urlpatterns = patterns('', - url(r'^sitemap.xml$', + url(r'^sitemap\.xml$', cache_page(86400)(sitemaps_views.index), {'sitemaps': sitemaps, 'sitemap_url_name': 'sitemaps'}), url(r'^sitemap-(?P
.+)\.xml$', -- cgit v1.3 From 8db264cbc88e46eb8bc79e8e819a72f814db503e Mon Sep 17 00:00:00 2001 From: Claude Paroz Date: Thu, 27 Jun 2013 16:29:26 +0200 Subject: Fixed LOGGING setting docs --- docs/ref/settings.txt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'docs/ref') diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 902eefa86a..215931768c 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1350,8 +1350,14 @@ A data structure containing configuration information. The contents of this data structure will be passed as the argument to the configuration method described in :setting:`LOGGING_CONFIG`. -The default logging configuration passes HTTP 500 server errors to an -email log handler; all other log messages are given to a NullHandler. +Among other things, the default logging configuration passes HTTP 500 server +errors to an email log handler when :setting:`DEBUG` is ``False``. See also +:ref:`configuring-logging`. + +You can see the default logging configuration by looking in +``django/utils/log.py`` (or view the `online source`__). + +__ https://github.com/django/django/blob/master/django/utils/log.py .. setting:: LOGGING_CONFIG -- cgit v1.3 From 12cb0df10f12e715bcaafbee4290c92d4ed6f111 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 27 Jun 2013 15:12:35 +0100 Subject: Docs for related_query_name --- docs/ref/models/fields.txt | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'docs/ref') diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 8146dfd341..f5c1058b17 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1083,6 +1083,22 @@ define the details of how the relation works. user = models.ForeignKey(User, related_name='+') +.. attribute:: ForeignKey.related_query_name + + .. versionadded:: 1.6 + + The name to use for the reverse filter name from the target model. + Defaults to the value of :attr:`related_name` if it is set, otherwise it + defaults to the name of the model:: + + # Declare the ForeignKey with related_query_name + class Tag(models.Model): + article = models.ForeignKey(Article, related_name="tags", related_query_name="tag") + name = models.CharField(max_length=255) + + # That's now the name of the reverse filter + article_instance.filter(tag__name="important") + .. attribute:: ForeignKey.to_field The field on the related object that the relation is to. By default, Django @@ -1207,6 +1223,12 @@ that control how the relationship functions. users = models.ManyToManyField(User, related_name='u+') referents = models.ManyToManyField(User, related_name='ref+') +.. attribute:: ForeignKey.related_query_name + + .. versionadded:: 1.6 + + Same as :attr:`ForeignKey.related_query_name`. + .. attribute:: ManyToManyField.limit_choices_to Same as :attr:`ForeignKey.limit_choices_to`. -- cgit v1.3 From 6fcb7ba84239ef1f6b3451c70d40a5ae1cdffcf5 Mon Sep 17 00:00:00 2001 From: Ken Bolton Date: Thu, 27 Jun 2013 12:39:50 -0400 Subject: Fix typo --- docs/ref/signals.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'docs/ref') diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 0fdfb0ee14..0253832b8d 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -284,7 +284,7 @@ If we connected a handler like this:: and then did something like this:: - >>> p = Pizza.object.create(...) + >>> p = Pizza.objects.create(...) >>> t = Topping.objects.create(...) >>> p.toppings.add(t) -- cgit v1.3 From d097417025e71286ad5bbde6e0a79caacabbbd64 Mon Sep 17 00:00:00 2001 From: Shai Berger Date: Fri, 28 Jun 2013 06:15:03 +0300 Subject: Support 'pyformat' style parameters in raw queries, Refs #10070 Add support for Oracle, fix an issue with the repr of RawQuerySet, add tests and documentations. Also added a 'supports_paramstyle_pyformat' database feature, True by default, False for SQLite. Thanks Donald Stufft for review of documentation. --- django/db/backends/__init__.py | 5 +++ django/db/backends/oracle/base.py | 66 ++++++++++++++++++++++++-------------- django/db/backends/sqlite3/base.py | 1 + django/db/models/query.py | 5 ++- docs/ref/databases.txt | 8 +++++ docs/releases/1.6.txt | 6 ++++ docs/topics/db/sql.txt | 25 ++++++++++++--- tests/backends/tests.py | 44 +++++++++++++++++++++++-- tests/raw_query/tests.py | 21 ++++++++++-- 9 files changed, 147 insertions(+), 34 deletions(-) (limited to 'docs/ref') diff --git a/django/db/backends/__init__.py b/django/db/backends/__init__.py index 1a74232704..9abb9a9637 100644 --- a/django/db/backends/__init__.py +++ b/django/db/backends/__init__.py @@ -613,6 +613,11 @@ class BaseDatabaseFeatures(object): # when autocommit is disabled? http://bugs.python.org/issue8145#msg109965 autocommits_when_autocommit_is_off = False + # Does the backend support 'pyformat' style ("... %(name)s ...", {'name': value}) + # parameter passing? Note this can be provided by the backend even if not + # supported by the Python driver + supports_paramstyle_pyformat = True + def __init__(self, connection): self.connection = connection diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index 3f39a15aa7..5e2b763f52 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -757,20 +757,37 @@ class FormatStylePlaceholderCursor(object): self.cursor.arraysize = 100 def _format_params(self, params): - return tuple([OracleParam(p, self, True) for p in params]) + try: + return dict((k,OracleParam(v, self, True)) for k,v in params.items()) + except AttributeError: + return tuple([OracleParam(p, self, True) for p in params]) def _guess_input_sizes(self, params_list): - sizes = [None] * len(params_list[0]) - for params in params_list: - for i, value in enumerate(params): - if value.input_size: - sizes[i] = value.input_size - self.setinputsizes(*sizes) + # Try dict handling; if that fails, treat as sequence + if hasattr(params_list[0], 'keys'): + sizes = {} + for params in params_list: + for k, value in params.items(): + if value.input_size: + sizes[k] = value.input_size + self.setinputsizes(**sizes) + else: + # It's not a list of dicts; it's a list of sequences + sizes = [None] * len(params_list[0]) + for params in params_list: + for i, value in enumerate(params): + if value.input_size: + sizes[i] = value.input_size + self.setinputsizes(*sizes) def _param_generator(self, params): - return [p.force_bytes for p in params] + # Try dict handling; if that fails, treat as sequence + if hasattr(params, 'items'): + return dict((k, v.force_bytes) for k,v in params.items()) + else: + return [p.force_bytes for p in params] - def execute(self, query, params=None): + def _fix_for_params(self, query, params): # cx_Oracle wants no trailing ';' for SQL statements. For PL/SQL, it # it does want a trailing ';' but not a trailing '/'. However, these # characters must be included in the original query in case the query @@ -780,10 +797,18 @@ class FormatStylePlaceholderCursor(object): if params is None: params = [] query = convert_unicode(query, self.charset) + elif hasattr(params, 'keys'): + # Handle params as dict + args = dict((k, ":%s"%k) for k in params.keys()) + query = convert_unicode(query % args, self.charset) else: - params = self._format_params(params) + # Handle params as sequence args = [(':arg%d' % i) for i in range(len(params))] query = convert_unicode(query % tuple(args), self.charset) + return query, self._format_params(params) + + def execute(self, query, params=None): + query, params = self._fix_for_params(query, params) self._guess_input_sizes([params]) try: return self.cursor.execute(query, self._param_generator(params)) @@ -794,22 +819,15 @@ class FormatStylePlaceholderCursor(object): raise def executemany(self, query, params=None): - # cx_Oracle doesn't support iterators, convert them to lists - if params is not None and not isinstance(params, (list, tuple)): - params = list(params) - try: - args = [(':arg%d' % i) for i in range(len(params[0]))] - except (IndexError, TypeError): + if not params: # No params given, nothing to do return None - # cx_Oracle wants no trailing ';' for SQL statements. For PL/SQL, it - # it does want a trailing ';' but not a trailing '/'. However, these - # characters must be included in the original query in case the query - # is being passed to SQL*Plus. - if query.endswith(';') or query.endswith('/'): - query = query[:-1] - query = convert_unicode(query % tuple(args), self.charset) - formatted = [self._format_params(i) for i in params] + # uniform treatment for sequences and iterables + params_iter = iter(params) + query, firstparams = self._fix_for_params(query, next(params_iter)) + # we build a list of formatted params; as we're going to traverse it + # more than once, we can't make it lazy by using a generator + formatted = [firstparams]+[self._format_params(p) for p in params_iter] self._guess_input_sizes(formatted) try: return self.cursor.executemany(query, diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 324adfd97b..92dbf354ae 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -101,6 +101,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): has_bulk_insert = True can_combine_inserts_with_and_without_auto_increment_pk = False autocommits_when_autocommit_is_off = True + supports_paramstyle_pyformat = False @cached_property def uses_savepoints(self): diff --git a/django/db/models/query.py b/django/db/models/query.py index b0ce25f5b5..27a87a3f65 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -1445,7 +1445,10 @@ class RawQuerySet(object): yield instance def __repr__(self): - return "" % (self.raw_query % tuple(self.params)) + text = self.raw_query + if self.params: + text = text % (self.params if hasattr(self.params, 'keys') else tuple(self.params)) + return "" % text def __getitem__(self, k): return list(self)[k] diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index a648ac1709..4e5f136e2e 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -623,6 +623,14 @@ If you're getting this error, you can solve it by: SQLite does not support the ``SELECT ... FOR UPDATE`` syntax. Calling it will have no effect. +"pyformat" parameter style in raw queries not supported +------------------------------------------------------- + +For most backends, raw queries (``Manager.raw()`` or ``cursor.execute()``) +can use the "pyformat" parameter style, where placeholders in the query +are given as ``'%(name)s'`` and the parameters are passed as a dictionary +rather than a list. SQLite does not support this. + .. _sqlite-connection-queries: Parameters not quoted in ``connection.queries`` diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 2c1fffd8cd..1fd98e1271 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -337,6 +337,12 @@ Minor features default) to allow customizing the :attr:`~django.forms.Form.prefix` of the form. +* Raw queries (``Manager.raw()`` or ``cursor.execute()``) can now use the + "pyformat" parameter style, where placeholders in the query are given as + ``'%(name)s'`` and the parameters are passed as a dictionary rather than + a list (except on SQLite). This has long been possible (but not officially + supported) on MySQL and PostgreSQL, and is now also available on Oracle. + Backwards incompatible changes in 1.6 ===================================== diff --git a/docs/topics/db/sql.txt b/docs/topics/db/sql.txt index 2ec31a4988..7437d51d28 100644 --- a/docs/topics/db/sql.txt +++ b/docs/topics/db/sql.txt @@ -166,9 +166,17 @@ argument to ``raw()``:: >>> lname = 'Doe' >>> Person.objects.raw('SELECT * FROM myapp_person WHERE last_name = %s', [lname]) -``params`` is a list of parameters. You'll use ``%s`` placeholders in the -query string (regardless of your database engine); they'll be replaced with -parameters from the ``params`` list. +``params`` is a list or dictionary of parameters. You'll use ``%s`` +placeholders in the query string for a list, or ``%(key)s`` +placeholders for a dictionary (where ``key`` is replaced by a +dictionary key, of course), regardless of your database engine. Such +placeholders will be replaced with parameters from the ``params`` +argument. + +.. note:: Dictionary params not supported with SQLite + + Dictionary params are not supported with the SQLite backend; with + this backend, you must pass parameters as a list. .. warning:: @@ -181,14 +189,21 @@ parameters from the ``params`` list. **Don't.** - Using the ``params`` list completely protects you from `SQL injection + Using the ``params`` argument completely protects you from `SQL injection attacks`__, a common exploit where attackers inject arbitrary SQL into your database. If you use string interpolation, sooner or later you'll fall victim to SQL injection. As long as you remember to always use the - ``params`` list you'll be protected. + ``params`` argument you'll be protected. __ http://en.wikipedia.org/wiki/SQL_injection +.. versionchanged:: 1.6 + + In Django 1.5 and earlier, you could pass parameters as dictionaries + when using PostgreSQL or MySQL, although this wasn't documented. Now + you can also do this whem using Oracle, and it is officially supported. + + .. _executing-custom-sql: Executing custom SQL directly diff --git a/tests/backends/tests.py b/tests/backends/tests.py index c6cad56ec1..c1a26df7fc 100644 --- a/tests/backends/tests.py +++ b/tests/backends/tests.py @@ -456,13 +456,24 @@ class SqliteChecks(TestCase): class BackendTestCase(TestCase): def create_squares_with_executemany(self, args): + self.create_squares(args, 'format', True) + + def create_squares(self, args, paramstyle, multiple): cursor = connection.cursor() opts = models.Square._meta tbl = connection.introspection.table_name_converter(opts.db_table) f1 = connection.ops.quote_name(opts.get_field('root').column) f2 = connection.ops.quote_name(opts.get_field('square').column) - query = 'INSERT INTO %s (%s, %s) VALUES (%%s, %%s)' % (tbl, f1, f2) - cursor.executemany(query, args) + if paramstyle=='format': + query = 'INSERT INTO %s (%s, %s) VALUES (%%s, %%s)' % (tbl, f1, f2) + elif paramstyle=='pyformat': + query = 'INSERT INTO %s (%s, %s) VALUES (%%(root)s, %%(square)s)' % (tbl, f1, f2) + else: + raise ValueError("unsupported paramstyle in test") + if multiple: + cursor.executemany(query, args) + else: + cursor.execute(query, args) def test_cursor_executemany(self): #4896: Test cursor.executemany @@ -491,6 +502,35 @@ class BackendTestCase(TestCase): self.create_squares_with_executemany(args) self.assertEqual(models.Square.objects.count(), 9) + @skipUnlessDBFeature('supports_paramstyle_pyformat') + def test_cursor_execute_with_pyformat(self): + #10070: Support pyformat style passing of paramters + args = {'root': 3, 'square': 9} + self.create_squares(args, 'pyformat', multiple=False) + self.assertEqual(models.Square.objects.count(), 1) + + @skipUnlessDBFeature('supports_paramstyle_pyformat') + def test_cursor_executemany_with_pyformat(self): + #10070: Support pyformat style passing of paramters + args = [{'root': i, 'square': i**2} for i in range(-5, 6)] + self.create_squares(args, 'pyformat', multiple=True) + self.assertEqual(models.Square.objects.count(), 11) + for i in range(-5, 6): + square = models.Square.objects.get(root=i) + self.assertEqual(square.square, i**2) + + @skipUnlessDBFeature('supports_paramstyle_pyformat') + def test_cursor_executemany_with_pyformat_iterator(self): + args = iter({'root': i, 'square': i**2} for i in range(-3, 2)) + self.create_squares(args, 'pyformat', multiple=True) + self.assertEqual(models.Square.objects.count(), 5) + + args = iter({'root': i, 'square': i**2} for i in range(3, 7)) + with override_settings(DEBUG=True): + # same test for DebugCursorWrapper + self.create_squares(args, 'pyformat', multiple=True) + self.assertEqual(models.Square.objects.count(), 9) + def test_unicode_fetches(self): #6254: fetchone, fetchmany, fetchall return strings as unicode objects qn = connection.ops.quote_name diff --git a/tests/raw_query/tests.py b/tests/raw_query/tests.py index e404c8b065..7242b8309b 100644 --- a/tests/raw_query/tests.py +++ b/tests/raw_query/tests.py @@ -3,7 +3,7 @@ from __future__ import absolute_import from datetime import date from django.db.models.query_utils import InvalidQuery -from django.test import TestCase +from django.test import TestCase, skipUnlessDBFeature from .models import Author, Book, Coffee, Reviewer, FriendlyAuthor @@ -123,10 +123,27 @@ class RawQueryTests(TestCase): query = "SELECT * FROM raw_query_author WHERE first_name = %s" author = Author.objects.all()[2] params = [author.first_name] - results = list(Author.objects.raw(query, params=params)) + qset = Author.objects.raw(query, params=params) + results = list(qset) self.assertProcessed(Author, results, [author]) self.assertNoAnnotations(results) self.assertEqual(len(results), 1) + self.assertIsInstance(repr(qset), str) + + @skipUnlessDBFeature('supports_paramstyle_pyformat') + def testPyformatParams(self): + """ + Test passing optional query parameters + """ + query = "SELECT * FROM raw_query_author WHERE first_name = %(first)s" + author = Author.objects.all()[2] + params = {'first': author.first_name} + qset = Author.objects.raw(query, params=params) + results = list(qset) + self.assertProcessed(Author, results, [author]) + self.assertNoAnnotations(results) + self.assertEqual(len(results), 1) + self.assertIsInstance(repr(qset), str) def testManyToMany(self): """ -- cgit v1.3