1
0
mirror of https://github.com/django/django.git synced 2025-03-31 11:37:06 +00:00

chore: merge with main, fix tests

This commit is contained in:
Keerthi Vasan 2024-06-22 10:23:43 +05:30
commit 38b6990abc
489 changed files with 12398 additions and 4126 deletions

View File

@ -1,7 +0,0 @@
**/*.min.js
**/vendor/**/*.js
django/contrib/gis/templates/**/*.js
django/views/templates/*.js
docs/_build/**/*.js
node_modules/**.js
tests/**/*.js

View File

@ -1,37 +0,0 @@
{
"rules": {
"camelcase": ["off", {"properties": "always"}],
"comma-spacing": ["error", {"before": false, "after": true}],
"curly": ["error", "all"],
"dot-notation": ["error", {"allowKeywords": true}],
"eqeqeq": ["error"],
"indent": ["error", 4],
"key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
"linebreak-style": ["error", "unix"],
"new-cap": ["off", {"newIsCap": true, "capIsNew": true}],
"no-alert": ["off"],
"no-eval": ["error"],
"no-extend-native": ["error", {"exceptions": ["Date", "String"]}],
"no-multi-spaces": ["error"],
"no-octal-escape": ["error"],
"no-script-url": ["error"],
"no-shadow": ["error", {"hoist": "functions"}],
"no-underscore-dangle": ["error"],
"no-unused-vars": ["error", {"vars": "local", "args": "none"}],
"no-var": ["error"],
"prefer-const": ["error"],
"quotes": ["off", "single"],
"semi": ["error", "always"],
"space-before-blocks": ["error", "always"],
"space-before-function-paren": ["error", {"anonymous": "never", "named": "never"}],
"space-infix-ops": ["error", {"int32Hint": false}],
"strict": ["error", "global"]
},
"env": {
"browser": true,
"es6": true
},
"globals": {
"django": false
}
}

15
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,15 @@
# Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->
ticket-XXXXX
# Branch description
Provide a concise overview of the issue or rationale behind the proposed changes.
# Checklist
- [ ] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [ ] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [ ] I have checked the "Has patch" ticket flag in the Trac system.
- [ ] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.

17
.github/workflows/reminders_check.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Check reminders
on:
schedule:
- cron: '0 * * * *' # At the start of every hour
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
reminders:
runs-on: ubuntu-latest
steps:
- name: Check reminders and notify users
uses: agrc/reminder-action@e59091b4e9705a6108120cb50823108df35b5392

17
.github/workflows/reminders_create.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Create reminders
on:
issue_comment:
types: [created, edited]
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
reminders:
runs-on: ubuntu-latest
steps:
- name: Check comments and create reminders
uses: agrc/create-reminder-action@922893a5705067719c4c4751843962f56aabf5eb

View File

@ -37,6 +37,32 @@ jobs:
- name: Run tests
run: python tests/runtests.py -v2
pyc-only:
runs-on: ubuntu-latest
name: Byte-compiled Django with no source files (only .pyc files)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install libmemcached-dev for pylibmc
run: sudo apt-get install libmemcached-dev
- name: Install and upgrade packaging tools
run: python -m pip install --upgrade pip setuptools wheel
- run: python -m pip install .
- name: Prepare site-packages
run: |
DJANGO_PACKAGE_ROOT=$(python -c 'import site; print(site.getsitepackages()[0])')/django
echo $DJANGO_PACKAGE_ROOT
python -m compileall -b $DJANGO_PACKAGE_ROOT
find $DJANGO_PACKAGE_ROOT -name '*.py' -print -delete
- run: python -m pip install -r tests/requirements/py3.txt
- name: Run tests
run: python tests/runtests.py --verbosity=2
pypy-sqlite:
runs-on: ubuntu-latest
name: Ubuntu, SQLite, PyPy3.10
@ -64,7 +90,7 @@ jobs:
continue-on-error: true
services:
postgres:
image: postgres:13-alpine
image: postgres:14-alpine
env:
POSTGRES_DB: django
POSTGRES_USER: user
@ -137,7 +163,7 @@ jobs:
name: Selenium tests, PostgreSQL
services:
postgres:
image: postgres:13-alpine
image: postgres:14-alpine
env:
POSTGRES_DB: django
POSTGRES_USER: user

View File

@ -30,17 +30,31 @@ jobs:
- name: Install and upgrade packaging tools
run: python -m pip install --upgrade pip setuptools wheel
- run: python -m pip install -r tests/requirements/py3.txt -e .
- name: Run Selenium tests with screenshots
id: generate-screenshots
working-directory: ./tests/
run: python -Wall runtests.py --verbosity=2 --noinput --selenium=chrome --headless --screenshots --settings=test_sqlite --parallel=2
- name: Cache oxipng
uses: actions/cache@v4
with:
path: ~/.cargo/
key: ${{ runner.os }}-cargo
- name: Install oxipng
run: which oxipng || cargo install oxipng
- name: Optimize screenshots
run: oxipng --interlace=0 --opt=4 --strip=safe tests/screenshots/*.png
- name: Organize screenshots
run: |
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --screenshots --settings=test_sqlite --parallel 2
echo "date=$(date)" >> $GITHUB_OUTPUT
echo "🖼️ **Screenshots created**" >> $GITHUB_STEP_SUMMARY
echo "Generated screenshots for ${{ github.event.pull_request.head.sha }} at $(date)" >> $GITHUB_STEP_SUMMARY
mkdir --parents "/tmp/screenshots/${{ github.event.pull_request.head.sha }}"
mv tests/screenshots/* "/tmp/screenshots/${{ github.event.pull_request.head.sha }}/"
- name: Upload screenshots
uses: actions/upload-artifact@v4
with:
name: screenshots-${{ github.event.pull_request.head.sha }}
path: tests/screenshots/
path: /tmp/screenshots/
if-no-files-found: error

View File

@ -43,7 +43,7 @@ jobs:
name: PostgreSQL
services:
postgres:
image: postgres:13-alpine
image: postgres:14-alpine
env:
POSTGRES_DB: django
POSTGRES_USER: user

1
.gitignore vendored
View File

@ -6,6 +6,7 @@
*.pot
*.py[co]
.tox/
venv/
__pycache__
MANIFEST
dist/

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.1.0
rev: 24.2.0
hooks:
- id: black
exclude: \.py-tpl$
@ -9,8 +9,9 @@ repos:
hooks:
- id: blacken-docs
additional_dependencies:
- black==24.1.0
- black==24.2.0
files: 'docs/.*\.txt$'
args: ["--rst-literal-block"]
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
@ -20,6 +21,6 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.56.0
rev: v9.2.0
hooks:
- id: eslint

12
AUTHORS
View File

@ -6,6 +6,7 @@ people who have submitted patches, reported bugs, added translations, helped
answer newbie questions, and generally made Django that much better:
Aaron Cannon <cannona@fireantproductions.com>
Aaron Linville <aaron@linville.org>
Aaron Swartz <http://www.aaronsw.com/>
Aaron T. Myers <atmyers@gmail.com>
Abeer Upadhyay <ab.esquarer@gmail.com>
@ -20,6 +21,7 @@ answer newbie questions, and generally made Django that much better:
Adam Johnson <https://github.com/adamchainz>
Adam Malinowski <https://adammalinowski.co.uk/>
Adam Vandenberg
Adam Zapletal <https://adamzap.com/>
Ade Lee <alee@redhat.com>
Adiyat Mubarak <adiyatmubarak@gmail.com>
Adnan Umer <u.adnan@outlook.com>
@ -44,6 +46,8 @@ answer newbie questions, and generally made Django that much better:
Albert Wang <https://github.com/albertyw/>
Alcides Fonseca
Aldian Fazrihady <mobile@aldian.net>
Alejandro García Ruiz de Oteiza <https://github.com/AlexOteiza>
Aleksander Milinkevich <milinsoft@gmail.com>
Aleksandra Sendecka <asendecka@hauru.eu>
Aleksi Häkli <aleksi.hakli@iki.fi>
Alex Dutton <django@alexdutton.co.uk>
@ -318,6 +322,7 @@ answer newbie questions, and generally made Django that much better:
Erik Karulf <erik@karulf.com>
Erik Romijn <django@solidlinks.nl>
eriks@win.tue.nl
Erin Kelly <erin.ch.kelly@gmail.com>
Erwin Junge <erwin@junge.nl>
Esdras Beleza <linux@esdrasbeleza.com>
Espen Grindhaug <http://grindhaug.org/>
@ -325,6 +330,7 @@ answer newbie questions, and generally made Django that much better:
Eugene Lazutkin <http://lazutkin.com/blog/>
Evan Grim <https://github.com/egrim>
Fabian Büchler <fabian.buechler@inoqo.com>
Fabian Braun <fsbraun@gmx.de>
Fabrice Aneche <akh@nobugware.com>
Faishal Manzar <https://github.com/faishal882>
Farhaan Bukhsh <farhaan.bukhsh@gmail.com>
@ -369,6 +375,7 @@ answer newbie questions, and generally made Django that much better:
George Karpenkov <george@metaworld.ru>
George Song <george@damacy.net>
George Vilches <gav@thataddress.com>
George Y. Kussumoto <georgeyk.dev@gmail.com>
Georg "Hugo" Bauer <gb@hugo.westfalen.de>
Georgi Stanojevski <glisha@gmail.com>
Gerardo Orozco <gerardo.orozco.mosqueda@gmail.com>
@ -415,7 +422,6 @@ answer newbie questions, and generally made Django that much better:
Iacopo Spalletti <i.spalletti@nephila.it>
Ian A Wilson <http://ianawilson.com>
Ian Clelland <clelland@gmail.com>
Ian G. Kelly <ian.g.kelly@gmail.com>
Ian Holsman <http://feh.holsman.net/>
Ian Lee <IanLee1521@gmail.com>
Ibon <ibonso@gmail.com>
@ -504,6 +510,7 @@ answer newbie questions, and generally made Django that much better:
Joe Topjian <http://joe.terrarum.net/geek/code/python/django/>
Johan C. Stöver <johan@nilling.nl>
Johann Queuniet <johann.queuniet@adh.naellia.eu>
Johannes Westphal <jojo@w-hat.de>
john@calixto.net
John D'Agostino <john.dagostino@gmail.com>
John D'Ambrosio <dambrosioj@gmail.com>
@ -560,6 +567,7 @@ answer newbie questions, and generally made Django that much better:
Karderio <karderio@gmail.com>
Karen Tracey <kmtracey@gmail.com>
Karol Sikora <elektrrrus@gmail.com>
Kasun Herath <kasunh01@gmail.com>
Katherine “Kati” Michel <kthrnmichel@gmail.com>
Kathryn Killebrew <kathryn.killebrew@gmail.com>
Katie Miller <katie@sub50.com>
@ -761,6 +769,7 @@ answer newbie questions, and generally made Django that much better:
Nicolas Noé <nicolas@niconoe.eu>
Nikita Marchant <nikita.marchant@gmail.com>
Nikita Sobolev <mail@sobolevn.me>
Nina Menezes <https://github.com/nmenezes0>
Niran Babalola <niran@niran.org>
Nis Jørgensen <nis@superlativ.dk>
Nowell Strite <https://nowell.strite.org/>
@ -918,6 +927,7 @@ answer newbie questions, and generally made Django that much better:
Sergey Fedoseev <fedoseev.sergey@gmail.com>
Sergey Kolosov <m17.admin@gmail.com>
Seth Hill <sethrh@gmail.com>
Shafiya Adzhani <adz.arsym@gmail.com>
Shai Berger <shai@platonix.com>
Shannon -jj Behrens <https://www.jjinux.com/>
Shawn Milochik <shawn@milochik.com>

View File

@ -1,6 +1,6 @@
from django.utils.version import get_version
VERSION = (5, 1, 0, "alpha", 0)
VERSION = (5, 2, 0, "alpha", 0)
__version__ = get_version(VERSION)

View File

@ -480,7 +480,7 @@ LANG_INFO = {
"bidi": False,
"code": "sk",
"name": "Slovak",
"name_local": "Slovensky",
"name_local": "slovensky",
},
"sl": {
"bidi": False,

View File

@ -4,7 +4,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-18 11:41-0300\n"
"POT-Creation-Date: 2024-05-22 11:46-0300\n"
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
"Last-Translator: Django team\n"
"Language-Team: English <en@li.org>\n"
@ -448,6 +448,10 @@ msgstr ""
msgid "Enter a valid value."
msgstr ""
#: core/validators.py:70
msgid "Enter a valid domain name."
msgstr ""
#: core/validators.py:104 forms/fields.py:759
msgid "Enter a valid URL."
msgstr ""
@ -472,16 +476,22 @@ msgid ""
"hyphens."
msgstr ""
#: core/validators.py:279 core/validators.py:306
msgid "Enter a valid IPv4 address."
#: core/validators.py:327 core/validators.py:336 core/validators.py:350
#: db/models/fields/__init__.py:2219
#, python-format
msgid "Enter a valid %(protocol)s address."
msgstr ""
#: core/validators.py:286 core/validators.py:307
msgid "Enter a valid IPv6 address."
#: core/validators.py:329
msgid "IPv4"
msgstr ""
#: core/validators.py:298 core/validators.py:305
msgid "Enter a valid IPv4 or IPv6 address."
#: core/validators.py:338 utils/ipv6.py:30
msgid "IPv6"
msgstr ""
#: core/validators.py:352
msgid "IPv4 or IPv6"
msgstr ""
#: core/validators.py:341

View File

@ -816,8 +816,7 @@ class ModelAdminChecks(BaseModelAdminChecks):
*self._check_list_editable(admin_obj),
*self._check_search_fields(admin_obj),
*self._check_date_hierarchy(admin_obj),
*self._check_action_permission_methods(admin_obj),
*self._check_actions_uniqueness(admin_obj),
*self._check_actions(admin_obj),
]
def _check_save_as(self, obj):
@ -915,21 +914,19 @@ class ModelAdminChecks(BaseModelAdminChecks):
try:
field = getattr(obj.model, item)
except AttributeError:
return [
checks.Error(
"The value of '%s' refers to '%s', which is not a "
"callable, an attribute of '%s', or an attribute or "
"method on '%s'."
% (
label,
item,
obj.__class__.__name__,
obj.model._meta.label,
),
obj=obj.__class__,
id="admin.E108",
)
]
try:
field = get_fields_from_path(obj.model, item)[-1]
except (FieldDoesNotExist, NotRelationField):
return [
checks.Error(
f"The value of '{label}' refers to '{item}', which is not "
f"a callable or attribute of '{obj.__class__.__name__}', "
"or an attribute, method, or field on "
f"'{obj.model._meta.label}'.",
obj=obj.__class__,
id="admin.E108",
)
]
if (
getattr(field, "is_relation", False)
and (field.many_to_many or field.one_to_many)
@ -1197,13 +1194,12 @@ class ModelAdminChecks(BaseModelAdminChecks):
else:
return []
def _check_action_permission_methods(self, obj):
"""
Actions with an allowed_permission attribute require the ModelAdmin to
implement a has_<perm>_permission() method for each permission.
"""
actions = obj._get_base_actions()
def _check_actions(self, obj):
errors = []
actions = obj._get_base_actions()
# Actions with an allowed_permission attribute require the ModelAdmin
# to implement a has_<perm>_permission() method for each permission.
for func, name, _ in actions:
if not hasattr(func, "allowed_permissions"):
continue
@ -1222,12 +1218,8 @@ class ModelAdminChecks(BaseModelAdminChecks):
id="admin.E129",
)
)
return errors
def _check_actions_uniqueness(self, obj):
"""Check that every action has a unique __name__."""
errors = []
names = collections.Counter(name for _, name, _ in obj._get_base_actions())
# Names need to be unique.
names = collections.Counter(name for _, name, _ in actions)
for name, count in names.items():
if count > 1:
errors.append(

View File

@ -140,7 +140,7 @@ class SimpleListFilter(FacetsMixin, ListFilter):
if lookup_qs is not None:
counts[f"{i}__c"] = models.Count(
pk_attname,
filter=lookup_qs.query.where,
filter=models.Q(pk__in=lookup_qs),
)
self.used_parameters[self.parameter_name] = original_value
return counts

View File

@ -18,6 +18,7 @@ from django.db.models.fields.related import (
from django.forms.utils import flatatt
from django.template.defaultfilters import capfirst, linebreaksbr
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
@ -116,10 +117,14 @@ class Fieldset:
@property
def media(self):
if "collapse" in self.classes:
return forms.Media(js=["admin/js/collapse.js"])
return forms.Media()
@cached_property
def is_collapsible(self):
if any([field in self.fields for field in self.form.errors]):
return False
return "collapse" in self.classes
def __iter__(self):
for field in self.fields:
yield Fieldline(
@ -438,6 +443,12 @@ class InlineAdminFormSet:
def forms(self):
return self.formset.forms
@cached_property
def is_collapsible(self):
if any(self.formset.errors):
return False
return "collapse" in self.classes
def non_form_errors(self):
return self.formset.non_form_errors()
@ -498,13 +509,18 @@ class InlineAdminForm(AdminForm):
# Auto fields are editable, so check for auto or non-editable pk.
self.form._meta.model._meta.auto_field
or not self.form._meta.model._meta.pk.editable
# The pk can be editable, but excluded from the inline.
or (
self.form._meta.exclude
and self.form._meta.model._meta.pk.name in self.form._meta.exclude
)
or
# Also search any parents for an auto field. (The pk info is
# propagated to child models so that does not need to be checked
# in parents.)
any(
parent._meta.auto_field or not parent._meta.model._meta.pk.editable
for parent in self.form._meta.model._meta.get_parent_list()
for parent in self.form._meta.model._meta.all_parents
)
)

View File

@ -4,7 +4,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-18 11:41-0300\n"
"POT-Creation-Date: 2024-05-22 11:46-0300\n"
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
"Last-Translator: Django team\n"
"Language-Team: English <en@li.org>\n"
@ -247,11 +247,6 @@ msgid ""
"The {name} “{obj}” was changed successfully. You may edit it again below."
msgstr ""
#: contrib/admin/options.py:1497
#, python-brace-format
msgid "The {name} “{obj}” was added successfully. You may edit it again below."
msgstr ""
#: contrib/admin/options.py:1516
#, python-brace-format
msgid ""
@ -475,6 +470,10 @@ msgstr ""
msgid "Change password"
msgstr ""
#: contrib/admin/templates/admin/auth/user/change_password.html:18
msgid "Set password"
msgstr ""
#: contrib/admin/templates/admin/auth/user/change_password.html:25
#: contrib/admin/templates/admin/change_form.html:43
#: contrib/admin/templates/admin/change_list.html:52
@ -490,6 +489,20 @@ msgstr[1] ""
msgid "Enter a new password for the user <strong>%(username)s</strong>."
msgstr ""
#: contrib/admin/templates/admin/auth/user/change_password.html:35
msgid ""
"This action will <strong>enable</strong> password-based authentication for "
"this user."
msgstr ""
#: contrib/admin/templates/admin/auth/user/change_password.html:71
msgid "Disable password-based authentication"
msgstr ""
#: contrib/admin/templates/admin/auth/user/change_password.html:73
msgid "Enable password-based authentication"
msgstr ""
#: contrib/admin/templates/admin/base.html:28
msgid "Skip to main content"
msgstr ""

View File

@ -4,7 +4,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-09-18 15:04-0300\n"
"POT-Creation-Date: 2024-05-22 11:46-0300\n"
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
"Last-Translator: Django team\n"
"Language-Team: English <en@li.org>\n"
@ -381,12 +381,3 @@ msgstr ""
msgctxt "one letter Saturday"
msgid "S"
msgstr ""
#: contrib/admin/static/admin/js/collapse.js:16
#: contrib/admin/static/admin/js/collapse.js:34
msgid "Show"
msgstr ""
#: contrib/admin/static/admin/js/collapse.js:30
msgid "Hide"
msgstr ""

View File

@ -6,7 +6,7 @@ import warnings
from functools import partial, update_wrapper
from urllib.parse import parse_qsl
from urllib.parse import quote as urlquote
from urllib.parse import urlparse
from urllib.parse import urlsplit
from django import forms
from django.conf import settings
@ -475,24 +475,25 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
# Lookups on nonexistent fields are ok, since they're ignored
# later.
break
if not prev_field or (
prev_field.is_relation
and field not in model._meta.parents.values()
and field is not model._meta.auto_field
and (
model._meta.auto_field is None
or part not in getattr(prev_field, "to_fields", [])
)
and (field.is_relation or not field.primary_key)
):
relation_parts.append(part)
if not getattr(field, "path_infos", None):
# This is not a relational field, so further parts
# must be transforms.
break
if (
not prev_field
or (field.is_relation and field not in model._meta.parents.values())
or (
prev_field.is_relation
and model._meta.auto_field is None
and part not in getattr(prev_field, "to_fields", [])
)
):
relation_parts.append(part)
prev_field = field
model = field.path_infos[-1].to_opts.model
if not relation_parts or len(parts) == 1:
if len(relation_parts) <= 1:
# Either a local field filter, or no fields at all.
return True
valid_lookups = {self.date_hierarchy}
@ -1032,7 +1033,10 @@ class ModelAdmin(BaseModelAdmin):
@staticmethod
def _get_action_description(func, name):
return getattr(func, "short_description", capfirst(name.replace("_", " ")))
try:
return func.short_description
except AttributeError:
return capfirst(name.replace("_", " "))
def _get_base_actions(self):
"""Return the list of actions, prior to any request-based filtering."""
@ -1380,7 +1384,7 @@ class ModelAdmin(BaseModelAdmin):
)
def _get_preserved_qsl(self, request, preserved_filters):
query_string = urlparse(request.build_absolute_uri()).query
query_string = urlsplit(request.build_absolute_uri()).query
return parse_qsl(query_string.replace(preserved_filters, ""))
def response_add(self, request, obj, post_url_continue=None):
@ -2394,8 +2398,6 @@ class InlineModelAdmin(BaseModelAdmin):
js = ["vendor/jquery/jquery%s.js" % extra, "jquery.init.js", "inlines.js"]
if self.filter_vertical or self.filter_horizontal:
js.extend(["SelectBox.js", "SelectFilter2.js"])
if self.classes and "collapse" in self.classes:
js.append("collapse.js")
return forms.Media(js=["admin/js/%s" % url for url in js])
def get_extra(self, request, obj=None, **kwargs):

View File

@ -7,11 +7,12 @@ from django.contrib.admin import ModelAdmin, actions
from django.contrib.admin.exceptions import AlreadyRegistered, NotRegistered
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_not_required
from django.core.exceptions import ImproperlyConfigured
from django.db.models.base import ModelBase
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import NoReverseMatch, Resolver404, resolve, reverse
from django.urls import NoReverseMatch, Resolver404, resolve, reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.functional import LazyObject
from django.utils.module_loading import import_string
@ -259,6 +260,8 @@ class AdminSite:
return self.admin_view(view, cacheable)(*args, **kwargs)
wrapper.admin_site = self
# Used by LoginRequiredMiddleware.
wrapper.login_url = reverse_lazy("admin:login", current_app=self.name)
return update_wrapper(wrapper, view)
# Admin-site-wide views.
@ -402,6 +405,7 @@ class AdminSite:
return LogoutView.as_view(**defaults)(request)
@method_decorator(never_cache)
@login_not_required
def login(self, request, extra_context=None):
"""
Display the login form for the given HttpRequest.

View File

@ -84,6 +84,8 @@ html[data-theme="light"],
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji";
color-scheme: light;
}
html, body {
@ -217,6 +219,10 @@ fieldset {
border-top: 1px solid var(--hairline-color);
}
details summary {
cursor: pointer;
}
blockquote {
font-size: 0.6875rem;
color: #777;

View File

@ -159,7 +159,6 @@
font-weight: 400;
padding: 0 15px;
margin-bottom: 10px;
cursor: pointer;
}
#changelist-filter details summary > * {

View File

@ -29,6 +29,8 @@
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
color-scheme: dark;
}
}
@ -63,6 +65,8 @@ html[data-theme="dark"] {
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
color-scheme: dark;
}
/* THEME SWITCH */

View File

@ -76,6 +76,20 @@ form ul.inline li {
padding-right: 7px;
}
/* FIELDSETS */
fieldset .fieldset-heading,
fieldset .inline-heading,
:not(.inline-related) .collapse summary {
border: 1px solid var(--header-bg);
margin: 0;
padding: 8px;
font-weight: 400;
font-size: 0.8125rem;
background: var(--header-bg);
color: var(--header-link-color);
}
/* ALIGNED FIELDSETS */
.aligned label {
@ -84,14 +98,12 @@ form ul.inline li {
min-width: 160px;
width: 160px;
word-wrap: break-word;
line-height: 1;
}
.aligned label:not(.vCheckboxLabel):after {
content: '';
display: inline-block;
vertical-align: middle;
height: 1.625rem;
}
.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
@ -168,11 +180,7 @@ form .aligned table p {
}
.aligned .vCheckboxLabel {
float: none;
width: auto;
display: inline-block;
vertical-align: -3px;
padding: 0 0 5px 5px;
padding: 1px 0 0 5px;
}
.aligned .vCheckboxLabel + p.help,
@ -209,35 +217,16 @@ form div.help ul {
width: 450px;
}
/* COLLAPSED FIELDSETS */
/* COLLAPSIBLE FIELDSETS */
fieldset.collapsed * {
display: none;
}
fieldset.collapsed h2, fieldset.collapsed {
display: block;
}
fieldset.collapsed {
border: 1px solid var(--hairline-color);
border-radius: 4px;
overflow: hidden;
}
fieldset.collapsed h2 {
background: var(--darkened-bg);
color: var(--body-quiet-color);
}
fieldset .collapse-toggle {
color: var(--header-link-color);
}
fieldset.collapsed .collapse-toggle {
.collapse summary .fieldset-heading,
.collapse summary .inline-heading {
background: transparent;
border: none;
color: currentColor;
display: inline;
color: var(--link-fg);
margin: 0;
padding: 0;
}
/* MONOSPACE TEXTAREAS */
@ -389,14 +378,16 @@ body.popup .submit-row {
position: relative;
}
.inline-related h3 {
.inline-related h4,
.inline-related:not(.tabular) .collapse summary {
margin: 0;
color: var(--body-quiet-color);
padding: 5px;
font-size: 0.8125rem;
background: var(--darkened-bg);
border-top: 1px solid var(--hairline-color);
border-bottom: 1px solid var(--hairline-color);
border: 1px solid var(--hairline-color);
border-left-color: var(--darkened-bg);
border-right-color: var(--darkened-bg);
}
.inline-related h3 span.delete {
@ -415,16 +406,6 @@ body.popup .submit-row {
width: 100%;
}
.inline-related fieldset.module h3 {
margin: 0;
padding: 2px 5px 3px 5px;
font-size: 0.6875rem;
text-align: left;
font-weight: bold;
background: #bcd;
color: var(--body-bg);
}
.inline-group .tabular fieldset.module {
border: none;
}

View File

@ -171,7 +171,7 @@ input[type="submit"], button {
/* Forms */
label {
font-size: 0.875rem;
font-size: 1rem;
}
/*
@ -192,7 +192,7 @@ input[type="submit"], button {
margin: 0;
padding: 6px 8px;
min-height: 2.25rem;
font-size: 0.875rem;
font-size: 1rem;
}
.form-row select {
@ -565,10 +565,6 @@ input[type="submit"], button {
padding-top: 15px;
}
fieldset.collapsed .form-row {
display: none;
}
.aligned label {
width: 100%;
min-width: auto;

View File

@ -282,6 +282,10 @@ form .form-row p.datetime {
margin-right: 2px;
}
.inline-group .tabular td.original p {
right: 0;
}
.selector .selector-chooser {
margin: 0;
}

View File

@ -0,0 +1,19 @@
/* Hide warnings fields if usable password is selected */
form:has(#id_usable_password input[value="true"]:checked) .messagelist {
display: none;
}
/* Hide password fields if unusable password is selected */
form:has(#id_usable_password input[value="false"]:checked) .field-password1,
form:has(#id_usable_password input[value="false"]:checked) .field-password2 {
display: none;
}
/* Select appropriate submit button */
form:has(#id_usable_password input[value="true"]:checked) input[type="submit"].unset-password {
display: none;
}
form:has(#id_usable_password input[value="false"]:checked) input[type="submit"].set-password {
display: none;
}

View File

@ -1,4 +1,4 @@
/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/
/*global SelectBox, gettext, ngettext, interpolate, quickElement, SelectFilter*/
/*
SelectFilter2 - Turns a multiple-select box into a filter interface.

View File

@ -1,4 +1,4 @@
/*global gettext, interpolate, ngettext*/
/*global gettext, interpolate, ngettext, Actions*/
'use strict';
{
function show(selector) {

View File

@ -96,8 +96,8 @@
// Extract the model from the popup url '.../<model>/add/' or
// '.../<model>/<id>/change/' depending the action (add or change).
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
// Exclude autocomplete selects.
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`);
// Select elements with a specific model reference and context of "available-source".
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`);
selectsRelated.forEach(function(select) {
if (currentSelect === select) {

View File

@ -1,43 +0,0 @@
/*global gettext*/
'use strict';
{
window.addEventListener('load', function() {
// Add anchor tag for Show/Hide link
const fieldsets = document.querySelectorAll('fieldset.collapse');
for (const [i, elem] of fieldsets.entries()) {
// Don't hide if fields in this fieldset have errors
if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) {
elem.classList.add('collapsed');
const h2 = elem.querySelector('h2');
const link = document.createElement('a');
link.id = 'fieldsetcollapser' + i;
link.className = 'collapse-toggle';
link.href = '#';
link.textContent = gettext('Show');
h2.appendChild(document.createTextNode(' ('));
h2.appendChild(link);
h2.appendChild(document.createTextNode(')'));
}
}
// Add toggle to hide/show anchor tag
const toggleFunc = function(ev) {
if (ev.target.matches('.collapse-toggle')) {
ev.preventDefault();
ev.stopPropagation();
const fieldset = ev.target.closest('fieldset');
if (fieldset.classList.contains('collapsed')) {
// Show
ev.target.textContent = gettext('Hide');
fieldset.classList.remove('collapsed');
} else {
// Hide
ev.target.textContent = gettext('Show');
fieldset.classList.add('collapsed');
}
}
};
document.querySelectorAll('fieldset.module').forEach(function(el) {
el.addEventListener('click', toggleFunc);
});
});
}

View File

@ -1,4 +1,3 @@
/*global opener */
'use strict';
{
const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse);

View File

@ -0,0 +1,29 @@
"use strict";
// Fallback JS for browsers which do not support :has selector used in
// admin/css/unusable_password_fields.css
// Remove file once all supported browsers support :has selector
try {
// If browser does not support :has selector this will raise an error
document.querySelector("form:has(input)");
} catch (error) {
console.log("Defaulting to javascript for usable password form management: " + error);
// JS replacement for unsupported :has selector
document.querySelectorAll('input[name="usable_password"]').forEach(option => {
option.addEventListener('change', function() {
const usablePassword = (this.value === "true" ? this.checked : !this.checked);
const submit1 = document.querySelector('input[type="submit"].set-password');
const submit2 = document.querySelector('input[type="submit"].unset-password');
const messages = document.querySelector('#id_unusable_warning');
document.getElementById('id_password1').closest('.form-row').hidden = !usablePassword;
document.getElementById('id_password2').closest('.form-row').hidden = !usablePassword;
if (messages) {
messages.hidden = usablePassword;
}
if (submit1 && submit2) {
submit1.hidden = !usablePassword;
submit2.hidden = usablePassword;
}
});
option.dispatchEvent(new Event('change'));
});
}

View File

@ -1,5 +1,5 @@
{% extends "admin/change_form.html" %}
{% load i18n %}
{% load i18n static %}
{% block form_top %}
{% if not is_popup %}
@ -8,3 +8,11 @@
<p>{% translate "Enter a username and password." %}</p>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
<link rel="stylesheet" href="{% static 'admin/css/unusable_password_field.css' %}">
{% endblock %}
{% block admin_change_form_document_ready %}
{{ block.super }}
<script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
{% endblock %}

View File

@ -2,7 +2,11 @@
{% load i18n static %}
{% load admin_urls %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">
<link rel="stylesheet" href="{% static 'admin/css/unusable_password_field.css' %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
{% if not is_popup %}
{% block breadcrumbs %}
@ -11,7 +15,7 @@
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
&rsaquo; {% translate 'Change password' %}
&rsaquo; {% if form.user.has_usable_password %}{% translate 'Change password' %}{% else %}{% translate 'Set password' %}{% endif %}
</div>
{% endblock %}
{% endif %}
@ -27,10 +31,23 @@
{% endif %}
<p>{% blocktranslate with username=original %}Enter a new password for the user <strong>{{ username }}</strong>.{% endblocktranslate %}</p>
{% if not form.user.has_usable_password %}
<p>{% blocktranslate %}This action will <strong>enable</strong> password-based authentication for this user.{% endblocktranslate %}</p>
{% endif %}
<fieldset class="module aligned">
<div class="form-row">
{{ form.usable_password.errors }}
<div class="flex-container">{{ form.usable_password.label_tag }} {{ form.usable_password }}</div>
{% if form.usable_password.help_text %}
<div class="help"{% if form.usable_password.id_for_label %} id="{{ form.usable_password.id_for_label }}_helptext"{% endif %}>
<p>{{ form.usable_password.help_text|safe }}</p>
</div>
{% endif %}
</div>
<div class="form-row field-password1">
{{ form.password1.errors }}
<div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
{% if form.password1.help_text %}
@ -38,7 +55,7 @@
{% endif %}
</div>
<div class="form-row">
<div class="form-row field-password2">
{{ form.password2.errors }}
<div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
{% if form.password2.help_text %}
@ -49,9 +66,15 @@
</fieldset>
<div class="submit-row">
<input type="submit" value="{% translate 'Change password' %}" class="default">
{% if form.user.has_usable_password %}
<input type="submit" name="set-password" value="{% translate 'Change password' %}" class="default set-password">
<input type="submit" name="unset-password" value="{% translate 'Disable password-based authentication' %}" class="unset-password">
{% else %}
<input type="submit" name="set-password" value="{% translate 'Enable password-based authentication' %}" class="default set-password">
{% endif %}
</div>
</div>
</form></div>
<script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
{% endblock %}

View File

@ -121,5 +121,6 @@
<symbol viewBox="0 0 24 24" width="1rem" height="1rem" id="icon-sun"><path d="M0 0h24v24H0z" fill="currentColor"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-2a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/></symbol>
</svg>
<!-- END SVGs -->
{% block extrabody %}{% endblock extrabody %}
</body>
</html>

View File

@ -47,7 +47,7 @@
{% block field_sets %}
{% for fieldset in adminform %}
{% include "admin/includes/fieldset.html" %}
{% include "admin/includes/fieldset.html" with heading_level=2 id_suffix=forloop.counter0 %}
{% endfor %}
{% endblock %}

View File

@ -1,8 +1,8 @@
{% load i18n %}
<button class="theme-toggle">
<div class="visually-hidden theme-label-when-auto">{% translate 'Toggle theme (current theme: auto)' %}</div>
<div class="visually-hidden theme-label-when-light">{% translate 'Toggle theme (current theme: light)' %}</div>
<div class="visually-hidden theme-label-when-dark">{% translate 'Toggle theme (current theme: dark)' %}</div>
<span class="visually-hidden theme-label-when-auto">{% translate 'Toggle theme (current theme: auto)' %}</span>
<span class="visually-hidden theme-label-when-light">{% translate 'Toggle theme (current theme: light)' %}</span>
<span class="visually-hidden theme-label-when-dark">{% translate 'Toggle theme (current theme: dark)' %}</span>
<svg aria-hidden="true" class="theme-icon-when-auto">
<use xlink:href="#icon-auto" />
</svg>

View File

@ -3,12 +3,16 @@
id="{{ inline_admin_formset.formset.prefix }}-group"
data-inline-type="stacked"
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
<fieldset class="module {{ inline_admin_formset.classes }}">
<fieldset class="module {{ inline_admin_formset.classes }}" aria-labelledby="{{ inline_admin_formset.formset.prefix }}-heading">
{% if inline_admin_formset.is_collapsible %}<details><summary>{% endif %}
<h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading">
{% if inline_admin_formset.formset.max_num == 1 %}
<h2>{{ inline_admin_formset.opts.verbose_name|capfirst }}</h2>
{{ inline_admin_formset.opts.verbose_name|capfirst }}
{% else %}
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}
{% endif %}
</h2>
{% if inline_admin_formset.is_collapsible %}</summary>{% endif %}
{{ inline_admin_formset.formset.management_form }}
{{ inline_admin_formset.formset.non_form_errors }}
@ -19,11 +23,16 @@
{% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
</h3>
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
{% for fieldset in inline_admin_form %}
{% include "admin/includes/fieldset.html" %}
{% endfor %}
{% with parent_counter=forloop.counter0 %}
{% for fieldset in inline_admin_form %}
{% include "admin/includes/fieldset.html" with heading_level=4 id_prefix=parent_counter id_suffix=forloop.counter0 %}
{% endfor %}
{% endwith %}
{% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
{% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
</div>{% endfor %}
{% if inline_admin_formset.is_collapsible %}</details>{% endif %}
</fieldset>
</div>

View File

@ -4,12 +4,16 @@
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
<div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
{{ inline_admin_formset.formset.management_form }}
<fieldset class="module {{ inline_admin_formset.classes }}">
{% if inline_admin_formset.formset.max_num == 1 %}
<h2>{{ inline_admin_formset.opts.verbose_name|capfirst }}</h2>
{% else %}
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
{% endif %}
<fieldset class="module {{ inline_admin_formset.classes }}" aria-labelledby="{{ inline_admin_formset.formset.prefix }}-heading">
{% if inline_admin_formset.is_collapsible %}<details><summary>{% endif %}
<h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading">
{% if inline_admin_formset.formset.max_num == 1 %}
{{ inline_admin_formset.opts.verbose_name|capfirst }}
{% else %}
{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}
{% endif %}
</h2>
{% if inline_admin_formset.is_collapsible %}</summary>{% endif %}
{{ inline_admin_formset.formset.non_form_errors }}
<table>
<thead><tr>
@ -61,6 +65,7 @@
{% endfor %}
</tbody>
</table>
{% if inline_admin_formset.is_collapsible %}</details>{% endif %}
</fieldset>
</div>
</div>

View File

@ -1,5 +1,10 @@
<fieldset class="module aligned {{ fieldset.classes }}">
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
{% with prefix=fieldset.formset.prefix|default:"fieldset" id_prefix=id_prefix|default:"0" id_suffix=id_suffix|default:"0" name=fieldset.name|default:""|slugify %}
<fieldset class="module aligned {{ fieldset.classes }}"{% if name %} aria-labelledby="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading"{% endif %}>
{% if name %}
{% if fieldset.is_collapsible %}<details><summary>{% endif %}
<h{{ heading_level|default:2 }} id="{{ prefix }}-{{ id_prefix}}-{{ name }}-{{ id_suffix }}-heading" class="fieldset-heading">{{ fieldset.name }}</h{{ heading_level|default:2 }}>
{% if fieldset.is_collapsible %}</summary>{% endif %}
{% endif %}
{% if fieldset.description %}
<div class="description">{{ fieldset.description|safe }}</div>
{% endif %}
@ -9,7 +14,7 @@
{% for field in line %}
<div>
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% endif %}{% if field.is_checkbox %} checkbox-row{% endif %}">
{% if field.is_checkbox %}
{{ field.field }}{{ field.label_tag }}
{% else %}
@ -31,4 +36,6 @@
{% if not line.fields|length == 1 %}</div>{% endif %}
</div>
{% endfor %}
{% if name and fieldset.is_collapsible %}</details>{% endif %}
</fieldset>
{% endwith %}

View File

@ -1,6 +1,6 @@
{% if widget.is_initial %}<p class="file-upload">{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<span class="clearable-file-input">
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% include "django/forms/widgets/attrs.html" %}>
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}{% if widget.attrs.checked %} checked{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label></span>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.is_initial %}</p>{% endif %}

View File

@ -1,5 +1,5 @@
{% load i18n static %}
<div class="related-widget-wrapper" {% if not model_has_limit_choices_to %}data-model-ref="{{ model }}"{% endif %}>
<div class="related-widget-wrapper" {% if not model_has_limit_choices_to %}data-model-ref="{{ model_name }}"{% endif %}>
{{ rendered_widget }}
{% block links %}
{% spaceless %}

View File

@ -18,6 +18,7 @@ from django.contrib.admin.views.main import (
)
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.template import Library
from django.template.loader import get_template
from django.templatetags.static import static
@ -112,7 +113,7 @@ def result_headers(cl):
# Set ordering for attr that is a property, if defined.
if isinstance(attr, property) and hasattr(attr, "fget"):
admin_order_field = getattr(attr.fget, "admin_order_field", None)
if not admin_order_field:
if not admin_order_field and LOOKUP_SEP not in field_name:
is_field_sortable = False
if not is_field_sortable:

View File

@ -1,4 +1,4 @@
from urllib.parse import parse_qsl, unquote, urlparse, urlunparse
from urllib.parse import parse_qsl, unquote, urlsplit, urlunsplit
from django import template
from django.contrib.admin.utils import quote
@ -24,8 +24,8 @@ def add_preserved_filters(context, url, popup=False, to_field=None):
preserved_filters = context.get("preserved_filters")
preserved_qsl = context.get("preserved_qsl")
parsed_url = list(urlparse(url))
parsed_qs = dict(parse_qsl(parsed_url[4]))
parsed_url = list(urlsplit(url))
parsed_qs = dict(parse_qsl(parsed_url[3]))
merged_qs = {}
if preserved_qsl:
@ -66,5 +66,5 @@ def add_preserved_filters(context, url, popup=False, to_field=None):
merged_qs.update(parsed_qs)
parsed_url[4] = urlencode(merged_qs)
return urlunparse(parsed_url)
parsed_url[3] = urlencode(merged_qs)
return urlunsplit(parsed_url)

View File

@ -289,8 +289,8 @@ def lookup_field(name, obj, model_admin=None):
try:
f = _get_non_gfk_field(opts, name)
except (FieldDoesNotExist, FieldIsAForeignKeyColumnName):
# For non-field values, the value is either a method, property or
# returned via a callable.
# For non-regular field values, the value is either a method,
# property, related field, or returned via a callable.
if callable(name):
attr = name
value = attr(obj)
@ -298,10 +298,17 @@ def lookup_field(name, obj, model_admin=None):
attr = getattr(model_admin, name)
value = attr(obj)
else:
attr = getattr(obj, name)
sentinel = object()
attr = getattr(obj, name, sentinel)
if callable(attr):
value = attr()
else:
if attr is sentinel:
attr = obj
for part in name.split(LOOKUP_SEP):
attr = getattr(attr, part, sentinel)
if attr is sentinel:
return None, None, None
value = attr
if hasattr(model_admin, "model") and hasattr(model_admin.model, name):
attr = getattr(model_admin.model, name)
@ -345,9 +352,10 @@ def label_for_field(name, model, model_admin=None, return_attr=False, form=None)
"""
Return a sensible label for a field name. The name can be a callable,
property (but not created with @property decorator), or the name of an
object's attribute, as well as a model field. If return_attr is True, also
return the resolved attribute (which could be a callable). This will be
None if (and only if) the name refers to a field.
object's attribute, as well as a model field, including across related
objects. If return_attr is True, also return the resolved attribute
(which could be a callable). This will be None if (and only if) the name
refers to a field.
"""
attr = None
try:
@ -371,15 +379,15 @@ def label_for_field(name, model, model_admin=None, return_attr=False, form=None)
elif form and name in form.fields:
attr = form.fields[name]
else:
message = "Unable to lookup '%s' on %s" % (
name,
model._meta.object_name,
)
if model_admin:
message += " or %s" % model_admin.__class__.__name__
if form:
message += " or %s" % form.__class__.__name__
raise AttributeError(message)
try:
attr = get_fields_from_path(model, name)[-1]
except (FieldDoesNotExist, NotRelationField):
message = f"Unable to lookup '{name}' on {model._meta.object_name}"
if model_admin:
message += f" or {model_admin.__class__.__name__}"
if form:
message += f" or {form.__class__.__name__}"
raise AttributeError(message)
if hasattr(attr, "short_description"):
label = attr.short_description

View File

@ -30,6 +30,7 @@ from django.core.exceptions import (
)
from django.core.paginator import InvalidPage
from django.db.models import F, Field, ManyToOneRel, OrderBy
from django.db.models.constants import LOOKUP_SEP
from django.db.models.expressions import Combinable
from django.urls import reverse
from django.utils.deprecation import RemovedInDjango60Warning
@ -356,9 +357,9 @@ class ChangeList:
"""
Return the proper model field name corresponding to the given
field_name to use for ordering. field_name may either be the name of a
proper model field or the name of a method (on the admin or model) or a
callable with the 'admin_order_field' attribute. Return None if no
proper model field name can be matched.
proper model field, possibly across relations, or the name of a method
(on the admin or model) or a callable with the 'admin_order_field'
attribute. Return None if no proper model field name can be matched.
"""
try:
field = self.lookup_opts.get_field(field_name)
@ -371,7 +372,12 @@ class ChangeList:
elif hasattr(self.model_admin, field_name):
attr = getattr(self.model_admin, field_name)
else:
attr = getattr(self.model, field_name)
try:
attr = getattr(self.model, field_name)
except AttributeError:
if LOOKUP_SEP in field_name:
return field_name
raise
if isinstance(attr, property) and hasattr(attr, "fget"):
attr = attr.fget
return getattr(attr, "admin_order_field", None)
@ -612,7 +618,7 @@ class ChangeList:
else:
if isinstance(field.remote_field, ManyToOneRel):
# <FK>_id field names don't require a join.
if field_name != field.get_attname():
if field_name != field.attname:
return True
return False

View File

@ -272,6 +272,8 @@ class RelatedFieldWidgetWrapper(forms.Widget):
self.can_add_related = can_add_related
# XXX: The UX does not support multiple selected values.
multiple = getattr(widget, "allow_multiple_selected", False)
if not isinstance(widget, AutocompleteMixin):
self.attrs["data-context"] = "available-source"
self.can_change_related = not multiple and can_change_related
# XXX: The deletion UX can be confusing when dealing with cascading deletion.
cascade = getattr(rel, "on_delete", None) is CASCADE
@ -329,6 +331,7 @@ class RelatedFieldWidgetWrapper(forms.Widget):
"name": name,
"url_params": url_params,
"model": rel_opts.verbose_name,
"model_name": rel_opts.model_name,
"can_add_related": self.can_add_related,
"can_change_related": self.can_change_related,
"can_delete_related": self.can_delete_related,

View File

@ -3,7 +3,7 @@
{% block extrahead %}
{{ block.super }}
<style type="text/css">
<style>
.module table { width:100%; }
.module table p { padding: 0; margin: 0; }
</style>

View File

@ -359,7 +359,7 @@ class ModelDetailView(BaseAdminDocsView):
"app_label": rel.related_model._meta.app_label,
"object_name": rel.related_model._meta.object_name,
}
accessor = rel.get_accessor_name()
accessor = rel.accessor_name
fields.append(
{
"name": "%s.all" % accessor,

View File

@ -269,4 +269,6 @@ def update_session_auth_hash(request, user):
async def aupdate_session_auth_hash(request, user):
"""See update_session_auth_hash()."""
return await sync_to_async(update_session_auth_hash)(request, user)
await request.session.acycle_key()
if hasattr(user, "get_session_auth_hash") and request.user == user:
await request.session.aset(HASH_SESSION_KEY, user.get_session_auth_hash())

View File

@ -66,7 +66,7 @@ class UserAdmin(admin.ModelAdmin):
None,
{
"classes": ("wide",),
"fields": ("username", "password1", "password2"),
"fields": ("username", "usable_password", "password1", "password2"),
},
),
)
@ -164,10 +164,27 @@ class UserAdmin(admin.ModelAdmin):
if request.method == "POST":
form = self.change_password_form(user, request.POST)
if form.is_valid():
form.save()
# If disabling password-based authentication was requested
# (via the form field `usable_password`), the submit action
# must be "unset-password". This check is most relevant when
# the admin user has two submit buttons available (for example
# when Javascript is disabled).
valid_submission = (
form.cleaned_data["set_usable_password"]
or "unset-password" in request.POST
)
if not valid_submission:
msg = gettext("Conflicting form data submitted. Please try again.")
messages.error(request, msg)
return HttpResponseRedirect(request.get_full_path())
user = form.save()
change_message = self.construct_change_message(request, form, None)
self.log_change(request, user, change_message)
msg = gettext("Password changed successfully.")
if user.has_usable_password():
msg = gettext("Password changed successfully.")
else:
msg = gettext("Password-based authentication was disabled.")
messages.success(request, msg)
update_session_auth_hash(request, form.user)
return HttpResponseRedirect(
@ -187,8 +204,12 @@ class UserAdmin(admin.ModelAdmin):
fieldsets = [(None, {"fields": list(form.base_fields)})]
admin_form = admin.helpers.AdminForm(form, fieldsets, {})
if user.has_usable_password():
title = _("Change password: %s")
else:
title = _("Set password: %s")
context = {
"title": _("Change password: %s") % escape(user.get_username()),
"title": title % escape(user.get_username()),
"adminForm": admin_form,
"form_url": form_url,
"form": form,

View File

@ -5,7 +5,7 @@ from django.db.models.signals import post_migrate
from django.utils.translation import gettext_lazy as _
from . import get_user_model
from .checks import check_models_permissions, check_user_model
from .checks import check_middleware, check_models_permissions, check_user_model
from .management import create_permissions
from .signals import user_logged_in
@ -28,3 +28,4 @@ class AuthConfig(AppConfig):
user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
checks.register(check_user_model, checks.Tags.models)
checks.register(check_models_permissions, checks.Tags.models)
checks.register(check_middleware)

View File

@ -4,10 +4,27 @@ from types import MethodType
from django.apps import apps
from django.conf import settings
from django.core import checks
from django.utils.module_loading import import_string
from .management import _get_builtin_permissions
def _subclass_index(class_path, candidate_paths):
"""
Return the index of dotted class path (or a subclass of that class) in a
list of candidate paths. If it does not exist, return -1.
"""
cls = import_string(class_path)
for index, path in enumerate(candidate_paths):
try:
candidate_cls = import_string(path)
if issubclass(candidate_cls, cls):
return index
except (ImportError, TypeError):
continue
return -1
def check_user_model(app_configs=None, **kwargs):
if app_configs is None:
cls = apps.get_model(settings.AUTH_USER_MODEL)
@ -218,3 +235,28 @@ def check_models_permissions(app_configs=None, **kwargs):
codenames.add(codename)
return errors
def check_middleware(app_configs, **kwargs):
errors = []
login_required_index = _subclass_index(
"django.contrib.auth.middleware.LoginRequiredMiddleware",
settings.MIDDLEWARE,
)
if login_required_index != -1:
auth_index = _subclass_index(
"django.contrib.auth.middleware.AuthenticationMiddleware",
settings.MIDDLEWARE,
)
if auth_index == -1 or auth_index > login_required_index:
errors.append(
checks.Error(
"In order to use django.contrib.auth.middleware."
"LoginRequiredMiddleware, django.contrib.auth.middleware."
"AuthenticationMiddleware must be defined before it in MIDDLEWARE.",
id="auth.E013",
)
)
return errors

View File

@ -1,5 +1,8 @@
import asyncio
from functools import wraps
from urllib.parse import urlparse
from urllib.parse import urlsplit
from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
@ -17,16 +20,13 @@ def user_passes_test(
"""
def decorator(view_func):
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if test_func(request.user):
return view_func(request, *args, **kwargs)
def _redirect_to_login(request):
path = request.build_absolute_uri()
resolved_login_url = resolve_url(login_url or settings.LOGIN_URL)
# If the login url is the same scheme and net location then just
# use the path as the "next" url.
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
current_scheme, current_netloc = urlparse(path)[:2]
login_scheme, login_netloc = urlsplit(resolved_login_url)[:2]
current_scheme, current_netloc = urlsplit(path)[:2]
if (not login_scheme or login_scheme == current_scheme) and (
not login_netloc or login_netloc == current_netloc
):
@ -35,7 +35,36 @@ def user_passes_test(
return redirect_to_login(path, resolved_login_url, redirect_field_name)
return _wrapper_view
if asyncio.iscoroutinefunction(view_func):
async def _view_wrapper(request, *args, **kwargs):
auser = await request.auser()
if asyncio.iscoroutinefunction(test_func):
test_pass = await test_func(auser)
else:
test_pass = await sync_to_async(test_func)(auser)
if test_pass:
return await view_func(request, *args, **kwargs)
return _redirect_to_login(request)
else:
def _view_wrapper(request, *args, **kwargs):
if asyncio.iscoroutinefunction(test_func):
test_pass = async_to_sync(test_func)(request.user)
else:
test_pass = test_func(request.user)
if test_pass:
return view_func(request, *args, **kwargs)
return _redirect_to_login(request)
# Attributes used by LoginRequiredMiddleware.
_view_wrapper.login_url = login_url
_view_wrapper.redirect_field_name = redirect_field_name
return wraps(view_func)(_view_wrapper)
return decorator
@ -57,6 +86,14 @@ def login_required(
return actual_decorator
def login_not_required(view_func):
"""
Decorator for views that allows access to unauthenticated requests.
"""
view_func.login_required = False
return view_func
def permission_required(perm, login_url=None, raise_exception=False):
"""
Decorator for views that checks whether a user has a particular permission
@ -64,19 +101,36 @@ def permission_required(perm, login_url=None, raise_exception=False):
If the raise_exception parameter is given the PermissionDenied exception
is raised.
"""
if isinstance(perm, str):
perms = (perm,)
else:
perms = perm
def decorator(view_func):
if asyncio.iscoroutinefunction(view_func):
async def check_perms(user):
# First check if the user has the permission (even anon users).
if await sync_to_async(user.has_perms)(perms):
return True
# In case the 403 handler should be called raise the exception.
if raise_exception:
raise PermissionDenied
# As the last resort, show the login form.
return False
def check_perms(user):
if isinstance(perm, str):
perms = (perm,)
else:
perms = perm
# First check if the user has the permission (even anon users)
if user.has_perms(perms):
return True
# In case the 403 handler should be called raise the exception
if raise_exception:
raise PermissionDenied
# As the last resort, show the login form
return False
return user_passes_test(check_perms, login_url=login_url)
def check_perms(user):
# First check if the user has the permission (even anon users).
if user.has_perms(perms):
return True
# In case the 403 handler should be called raise the exception.
if raise_exception:
raise PermissionDenied
# As the last resort, show the login form.
return False
return user_passes_test(check_perms, login_url=login_url)(view_func)
return decorator

View File

@ -36,10 +36,9 @@ class ReadOnlyPasswordHashWidget(forms.Widget):
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
usable_password = value and not value.startswith(UNUSABLE_PASSWORD_PREFIX)
summary = []
if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX):
summary.append({"label": gettext("No password set.")})
else:
if usable_password:
try:
hasher = identify_hasher(value)
except ValueError:
@ -53,7 +52,12 @@ class ReadOnlyPasswordHashWidget(forms.Widget):
else:
for key, value_ in hasher.safe_summary(value).items():
summary.append({"label": gettext(key), "value": value_})
else:
summary.append({"label": gettext("No password set.")})
context["summary"] = summary
context["button_label"] = (
_("Reset password") if usable_password else _("Set password")
)
return context
def id_for_label(self, id_):
@ -89,28 +93,115 @@ class UsernameField(forms.CharField):
}
class BaseUserCreationForm(forms.ModelForm):
class SetPasswordMixin:
"""
A form that creates a user, with no privileges, from the given username and
password.
Form mixin that validates and sets a password for a user.
This mixin also support setting an unusable password for a user.
"""
error_messages = {
"password_mismatch": _("The two password fields didnt match."),
}
password1 = forms.CharField(
label=_("Password"),
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
help_text=password_validation.password_validators_help_text_html(),
)
password2 = forms.CharField(
label=_("Password confirmation"),
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
strip=False,
help_text=_("Enter the same password as before, for verification."),
usable_password_help_text = _(
"Whether the user will be able to authenticate using a password or not. "
"If disabled, they may still be able to authenticate using other backends, "
"such as Single Sign-On or LDAP."
)
@staticmethod
def create_password_fields(label1=_("Password"), label2=_("Password confirmation")):
password1 = forms.CharField(
label=label1,
required=False,
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
help_text=password_validation.password_validators_help_text_html(),
)
password2 = forms.CharField(
label=label2,
required=False,
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
strip=False,
help_text=_("Enter the same password as before, for verification."),
)
return password1, password2
@staticmethod
def create_usable_password_field(help_text=usable_password_help_text):
return forms.ChoiceField(
label=_("Password-based authentication"),
required=False,
initial="true",
choices={"true": _("Enabled"), "false": _("Disabled")},
widget=forms.RadioSelect(attrs={"class": "radiolist inline"}),
help_text=help_text,
)
def validate_passwords(
self,
password1_field_name="password1",
password2_field_name="password2",
usable_password_field_name="usable_password",
):
usable_password = (
self.cleaned_data.pop(usable_password_field_name, None) != "false"
)
self.cleaned_data["set_usable_password"] = usable_password
password1 = self.cleaned_data.get(password1_field_name)
password2 = self.cleaned_data.get(password2_field_name)
if not usable_password:
return self.cleaned_data
if not password1 and password1_field_name not in self.errors:
error = ValidationError(
self.fields[password1_field_name].error_messages["required"],
code="required",
)
self.add_error(password1_field_name, error)
if not password2 and password2_field_name not in self.errors:
error = ValidationError(
self.fields[password2_field_name].error_messages["required"],
code="required",
)
self.add_error(password2_field_name, error)
if password1 and password2 and password1 != password2:
error = ValidationError(
self.error_messages["password_mismatch"],
code="password_mismatch",
)
self.add_error(password2_field_name, error)
def validate_password_for_user(self, user, password_field_name="password2"):
password = self.cleaned_data.get(password_field_name)
if password and self.cleaned_data["set_usable_password"]:
try:
password_validation.validate_password(password, user)
except ValidationError as error:
self.add_error(password_field_name, error)
def set_password_and_save(self, user, password_field_name="password1", commit=True):
if self.cleaned_data["set_usable_password"]:
user.set_password(self.cleaned_data[password_field_name])
else:
user.set_unusable_password()
if commit:
user.save()
return user
class BaseUserCreationForm(SetPasswordMixin, forms.ModelForm):
"""
A form that creates a user, with no privileges, from the given username and
password.
"""
password1, password2 = SetPasswordMixin.create_password_fields()
usable_password = SetPasswordMixin.create_usable_password_field()
class Meta:
model = User
fields = ("username",)
@ -123,34 +214,21 @@ class BaseUserCreationForm(forms.ModelForm):
"autofocus"
] = True
def clean_password2(self):
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise ValidationError(
self.error_messages["password_mismatch"],
code="password_mismatch",
)
return password2
def clean(self):
self.validate_passwords()
return super().clean()
def _post_clean(self):
super()._post_clean()
# Validate the password after self.instance is updated with form data
# by super().
password = self.cleaned_data.get("password2")
if password:
try:
password_validation.validate_password(password, self.instance)
except ValidationError as error:
self.add_error("password2", error)
self.validate_password_for_user(self.instance)
def save(self, commit=True):
user = super().save(commit=False)
user.set_password(self.cleaned_data["password1"])
if commit:
user.save()
if hasattr(self, "save_m2m"):
self.save_m2m()
user = self.set_password_and_save(user, commit=commit)
if commit and hasattr(self, "save_m2m"):
self.save_m2m()
return user
@ -179,9 +257,8 @@ class UserChangeForm(forms.ModelForm):
password = ReadOnlyPasswordHashField(
label=_("Password"),
help_text=_(
"Raw passwords are not stored, so there is no way to see this "
"users password, but you can change the password using "
'<a href="{}">this form</a>.'
"Raw passwords are not stored, so there is no way to see "
"the users password."
),
)
@ -194,9 +271,11 @@ class UserChangeForm(forms.ModelForm):
super().__init__(*args, **kwargs)
password = self.fields.get("password")
if password:
password.help_text = password.help_text.format(
f"../../{self.instance.pk}/password/"
)
if self.instance and not self.instance.has_usable_password():
password.help_text = _(
"Enable password-based authentication for this user by setting a "
"password."
)
user_permissions = self.fields.get("user_permissions")
if user_permissions:
user_permissions.queryset = user_permissions.queryset.select_related(
@ -383,48 +462,27 @@ class PasswordResetForm(forms.Form):
)
class SetPasswordForm(forms.Form):
class SetPasswordForm(SetPasswordMixin, forms.Form):
"""
A form that lets a user set their password without entering the old
password
"""
error_messages = {
"password_mismatch": _("The two password fields didnt match."),
}
new_password1 = forms.CharField(
label=_("New password"),
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
strip=False,
help_text=password_validation.password_validators_help_text_html(),
)
new_password2 = forms.CharField(
label=_("New password confirmation"),
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
new_password1, new_password2 = SetPasswordMixin.create_password_fields(
label1=_("New password"), label2=_("New password confirmation")
)
def __init__(self, user, *args, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_new_password2(self):
password1 = self.cleaned_data.get("new_password1")
password2 = self.cleaned_data.get("new_password2")
if password1 and password2 and password1 != password2:
raise ValidationError(
self.error_messages["password_mismatch"],
code="password_mismatch",
)
password_validation.validate_password(password2, self.user)
return password2
def clean(self):
self.validate_passwords("new_password1", "new_password2")
self.validate_password_for_user(self.user, "new_password2")
return super().clean()
def save(self, commit=True):
password = self.cleaned_data["new_password1"]
self.user.set_password(password)
if commit:
self.user.save()
return self.user
return self.set_password_and_save(self.user, "new_password1", commit=commit)
class PasswordChangeForm(SetPasswordForm):
@ -462,57 +520,41 @@ class PasswordChangeForm(SetPasswordForm):
return old_password
class AdminPasswordChangeForm(forms.Form):
class AdminPasswordChangeForm(SetPasswordMixin, forms.Form):
"""
A form used to change the password of a user in the admin interface.
"""
error_messages = {
"password_mismatch": _("The two password fields didnt match."),
}
required_css_class = "required"
password1 = forms.CharField(
label=_("Password"),
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "autofocus": True}
),
strip=False,
help_text=password_validation.password_validators_help_text_html(),
)
password2 = forms.CharField(
label=_("Password (again)"),
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
strip=False,
help_text=_("Enter the same password as before, for verification."),
usable_password_help_text = SetPasswordMixin.usable_password_help_text + (
'<ul id="id_unusable_warning" class="messagelist"><li class="warning">'
"If disabled, the current password for this user will be lost.</li></ul>"
)
password1, password2 = SetPasswordMixin.create_password_fields()
def __init__(self, user, *args, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_password2(self):
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise ValidationError(
self.error_messages["password_mismatch"],
code="password_mismatch",
self.fields["password1"].widget.attrs["autofocus"] = True
if self.user.has_usable_password():
self.fields["usable_password"] = (
SetPasswordMixin.create_usable_password_field(
self.usable_password_help_text
)
)
password_validation.validate_password(password2, self.user)
return password2
def clean(self):
self.validate_passwords()
self.validate_password_for_user(self.user)
return super().clean()
def save(self, commit=True):
"""Save the new password."""
password = self.cleaned_data["password1"]
self.user.set_password(password)
if commit:
self.user.save()
return self.user
return self.set_password_and_save(self.user, commit=commit)
@property
def changed_data(self):
data = super().changed_data
for name in self.fields:
if name not in data:
return []
return ["password"]
if "set_usable_password" in data or "password1" in data and "password2" in data:
return ["password"]
return []

View File

@ -312,7 +312,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
"""
algorithm = "pbkdf2_sha256"
iterations = 870000
iterations = 1_000_000
digest = hashlib.sha256
def encode(self, password, salt, iterations=None):
@ -570,7 +570,7 @@ class ScryptPasswordHasher(BasePasswordHasher):
algorithm = "scrypt"
block_size = 8
maxmem = 0
parallelism = 1
parallelism = 5
work_factor = 2**14
def encode(self, password, salt, n=None, r=None, p=None):

View File

@ -4,7 +4,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-03-17 03:19-0500\n"
"POT-Creation-Date: 2024-05-22 11:46-0300\n"
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
"Last-Translator: Django team\n"
"Language-Team: English <en@li.org>\n"
@ -31,15 +31,28 @@ msgstr ""
msgid "%(name)s object with primary key %(key)r does not exist."
msgstr ""
#: contrib/auth/admin.py:177
msgid "Conflicting form data submitted. Please try again."
msgstr ""
#: contrib/auth/admin.py:168
msgid "Password changed successfully."
msgstr ""
#: contrib/auth/admin.py:187
msgid "Password-based authentication was disabled."
msgstr ""
#: contrib/auth/admin.py:189
#, python-format
msgid "Change password: %s"
msgstr ""
#: contrib/auth/admin.py:210
#, python-format
msgid "Set password: %s"
msgstr ""
#: contrib/auth/apps.py:16
msgid "Authentication and Authorization"
msgstr ""
@ -60,10 +73,25 @@ msgstr ""
msgid "Invalid password format or unknown hashing algorithm."
msgstr ""
#: contrib/auth/forms.py:59
msgid "Reset password"
msgstr ""
#: contrib/auth/forms.py:59
msgid "Set password"
msgstr ""
#: contrib/auth/forms.py:91 contrib/auth/forms.py:379 contrib/auth/forms.py:457
msgid "The two password fields didnt match."
msgstr ""
#: contrib/auth/forms.py:107
msgid ""
"Whether the user will be able to authenticate using a password or not. If "
"disabled, they may still be able to authenticate using other backends, such "
"as Single Sign-On or LDAP."
msgstr ""
#: contrib/auth/forms.py:94 contrib/auth/forms.py:166 contrib/auth/forms.py:201
#: contrib/auth/forms.py:461
msgid "Password"
@ -77,10 +105,26 @@ msgstr ""
msgid "Enter the same password as before, for verification."
msgstr ""
#: contrib/auth/forms.py:168
#: contrib/auth/forms.py:133
msgid "Password-based authentication"
msgstr ""
#: contrib/auth/forms.py:136
msgid "Enabled"
msgstr ""
#: contrib/auth/forms.py:136
msgid "Disabled"
msgstr ""
#: contrib/auth/forms.py:260
msgid ""
"Raw passwords are not stored, so there is no way to see this users "
"password, but you can change the password using <a href=\"{}\">this form</a>."
"Raw passwords are not stored, so there is no way to see the users password."
msgstr ""
#: contrib/auth/forms.py:276
msgid ""
"Enable password-based authentication for this user by setting a password."
msgstr ""
#: contrib/auth/forms.py:208
@ -114,10 +158,6 @@ msgstr ""
msgid "Old password"
msgstr ""
#: contrib/auth/forms.py:469
msgid "Password (again)"
msgstr ""
#: contrib/auth/hashers.py:327 contrib/auth/hashers.py:420
#: contrib/auth/hashers.py:510 contrib/auth/hashers.py:605
#: contrib/auth/hashers.py:665 contrib/auth/hashers.py:707

View File

@ -46,6 +46,13 @@ def create_permissions(
if not app_config.models_module:
return
try:
Permission = apps.get_model("auth", "Permission")
except LookupError:
return
if not router.allow_migrate_model(using, Permission):
return
# Ensure that contenttypes are created for this app. Needed if
# 'django.contrib.auth' is in INSTALLED_APPS before
# 'django.contrib.contenttypes'.
@ -62,28 +69,15 @@ def create_permissions(
try:
app_config = apps.get_app_config(app_label)
ContentType = apps.get_model("contenttypes", "ContentType")
Permission = apps.get_model("auth", "Permission")
except LookupError:
return
if not router.allow_migrate_model(using, Permission):
return
models = list(app_config.get_models())
# This will hold the permissions we're looking for as
# (content_type, (codename, name))
searched_perms = []
# The codenames and ctypes that should exist.
ctypes = set()
for klass in app_config.get_models():
# Force looking up the content types in the current database
# before creating foreign keys to them.
ctype = ContentType.objects.db_manager(using).get_for_model(
klass, for_concrete_model=False
)
ctypes.add(ctype)
for perm in _get_all_permissions(klass._meta):
searched_perms.append((ctype, perm))
# Grab all the ContentTypes.
ctypes = ContentType.objects.db_manager(using).get_for_models(
*models, for_concrete_models=False
)
# Find all the Permissions that have a content_type for a model we're
# looking for. We don't need to check for codenames since we already have
@ -91,20 +85,22 @@ def create_permissions(
all_perms = set(
Permission.objects.using(using)
.filter(
content_type__in=ctypes,
content_type__in=set(ctypes.values()),
)
.values_list("content_type", "codename")
)
perms = []
for ct, (codename, name) in searched_perms:
if (ct.pk, codename) not in all_perms:
permission = Permission()
permission._state.db = using
permission.codename = codename
permission.name = name
permission.content_type = ct
perms.append(permission)
for model in models:
ctype = ctypes[model]
for codename, name in _get_all_permissions(model._meta):
if (ctype.pk, codename) not in all_perms:
permission = Permission()
permission._state.db = using
permission.codename = codename
permission.name = name
permission.content_type = ctype
perms.append(permission)
Permission.objects.using(using).bulk_create(perms)
if verbosity >= 2:

View File

@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import DEFAULT_DB_ALIAS
from django.db import DEFAULT_DB_ALIAS, connections
UserModel = get_user_model()
@ -32,6 +32,7 @@ class Command(BaseCommand):
parser.add_argument(
"--database",
default=DEFAULT_DB_ALIAS,
choices=tuple(connections),
help='Specifies the database to use. Default is "default".',
)

View File

@ -11,7 +11,7 @@ from django.contrib.auth.management import get_default_username
from django.contrib.auth.password_validation import validate_password
from django.core import exceptions
from django.core.management.base import BaseCommand, CommandError
from django.db import DEFAULT_DB_ALIAS
from django.db import DEFAULT_DB_ALIAS, connections
from django.utils.functional import cached_property
from django.utils.text import capfirst
@ -56,6 +56,7 @@ class Command(BaseCommand):
parser.add_argument(
"--database",
default=DEFAULT_DB_ALIAS,
choices=tuple(connections),
help='Specifies the database to use. Default is "default".',
)
for field_name in self.UserModel.REQUIRED_FIELDS:

View File

@ -1,9 +1,13 @@
from functools import partial
from urllib.parse import urlsplit
from django.conf import settings
from django.contrib import auth
from django.contrib.auth import load_backend
from django.contrib.auth import REDIRECT_FIELD_NAME, load_backend
from django.contrib.auth.backends import RemoteUserBackend
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import resolve_url
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
@ -34,6 +38,56 @@ class AuthenticationMiddleware(MiddlewareMixin):
request.auser = partial(auser, request)
class LoginRequiredMiddleware(MiddlewareMixin):
"""
Middleware that redirects all unauthenticated requests to a login page.
Views using the login_not_required decorator will not be redirected.
"""
redirect_field_name = REDIRECT_FIELD_NAME
def process_view(self, request, view_func, view_args, view_kwargs):
if request.user.is_authenticated:
return None
if not getattr(view_func, "login_required", True):
return None
return self.handle_no_permission(request, view_func)
def get_login_url(self, view_func):
login_url = getattr(view_func, "login_url", None) or settings.LOGIN_URL
if not login_url:
raise ImproperlyConfigured(
"No login URL to redirect to. Define settings.LOGIN_URL or "
"provide a login_url via the 'django.contrib.auth.decorators."
"login_required' decorator."
)
return str(login_url)
def get_redirect_field_name(self, view_func):
return getattr(view_func, "redirect_field_name", self.redirect_field_name)
def handle_no_permission(self, request, view_func):
path = request.build_absolute_uri()
resolved_login_url = resolve_url(self.get_login_url(view_func))
# If the login url is the same scheme and net location then use the
# path as the "next" url.
login_scheme, login_netloc = urlsplit(resolved_login_url)[:2]
current_scheme, current_netloc = urlsplit(path)[:2]
if (not login_scheme or login_scheme == current_scheme) and (
not login_netloc or login_netloc == current_netloc
):
path = request.get_full_path()
return redirect_to_login(
path,
resolved_login_url,
self.get_redirect_field_name(view_func),
)
class RemoteUserMiddleware(MiddlewareMixin):
"""
Middleware for utilizing web-server-provided authentication.

View File

@ -1,4 +1,4 @@
from urllib.parse import urlparse
from urllib.parse import urlsplit
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
@ -51,8 +51,8 @@ class AccessMixin:
resolved_login_url = resolve_url(self.get_login_url())
# If the login url is the same scheme and net location then use the
# path as the "next" url.
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
current_scheme, current_netloc = urlparse(path)[:2]
login_scheme, login_netloc = urlsplit(resolved_login_url)[:2]
current_scheme, current_netloc = urlsplit(path)[:2]
if (not login_scheme or login_scheme == current_scheme) and (
not login_netloc or login_netloc == current_netloc
):

View File

@ -1,5 +1,8 @@
<div{% include 'django/forms/widgets/attrs.html' %}>
{% for entry in summary %}
<strong>{{ entry.label }}</strong>{% if entry.value %}: <bdi>{{ entry.value }}</bdi>{% endif %}
{% endfor %}
<p>
{% for entry in summary %}
<strong>{{ entry.label }}</strong>{% if entry.value %}: <bdi>{{ entry.value }}</bdi>{% endif %}
{% endfor %}
</p>
<p><a class="button" href="{{ password_url|default:"../password/" }}">{{ button_label }}</a></p>
</div>

View File

@ -1,4 +1,4 @@
from urllib.parse import urlparse, urlunparse
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings
@ -7,7 +7,7 @@ from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import login_not_required, login_required
from django.contrib.auth.forms import (
AuthenticationForm,
PasswordChangeForm,
@ -62,6 +62,7 @@ class RedirectURLMixin:
raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
@method_decorator(login_not_required, name="dispatch")
class LoginView(RedirectURLMixin, FormView):
"""
Display the login form and handle the login action.
@ -182,13 +183,13 @@ def redirect_to_login(next, login_url=None, redirect_field_name=REDIRECT_FIELD_N
"""
resolved_url = resolve_url(login_url or settings.LOGIN_URL)
login_url_parts = list(urlparse(resolved_url))
login_url_parts = list(urlsplit(resolved_url))
if redirect_field_name:
querystring = QueryDict(login_url_parts[4], mutable=True)
querystring = QueryDict(login_url_parts[3], mutable=True)
querystring[redirect_field_name] = next
login_url_parts[4] = querystring.urlencode(safe="/")
login_url_parts[3] = querystring.urlencode(safe="/")
return HttpResponseRedirect(urlunparse(login_url_parts))
return HttpResponseRedirect(urlunsplit(login_url_parts))
# Class-based password reset views
@ -210,6 +211,7 @@ class PasswordContextMixin:
return context
@method_decorator(login_not_required, name="dispatch")
class PasswordResetView(PasswordContextMixin, FormView):
email_template_name = "registration/password_reset_email.html"
extra_email_context = None
@ -244,11 +246,13 @@ class PasswordResetView(PasswordContextMixin, FormView):
INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
@method_decorator(login_not_required, name="dispatch")
class PasswordResetDoneView(PasswordContextMixin, TemplateView):
template_name = "registration/password_reset_done.html"
title = _("Password reset sent")
@method_decorator(login_not_required, name="dispatch")
class PasswordResetConfirmView(PasswordContextMixin, FormView):
form_class = SetPasswordForm
post_reset_login = False
@ -335,6 +339,7 @@ class PasswordResetConfirmView(PasswordContextMixin, FormView):
return context
@method_decorator(login_not_required, name="dispatch")
class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
template_name = "registration/password_reset_complete.html"
title = _("Password reset complete")

View File

@ -11,6 +11,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models, router, transaction
from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel
from django.db.models.base import ModelBase, make_foreign_order_accessors
from django.db.models.fields import Field
from django.db.models.fields.mixins import FieldCacheMixin
from django.db.models.fields.related import (
ReverseManyToOneDescriptor,
@ -24,7 +25,7 @@ from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.functional import cached_property
class GenericForeignKey(FieldCacheMixin):
class GenericForeignKey(FieldCacheMixin, Field):
"""
Provide a generic many-to-one relation through the ``content_type`` and
``object_id`` fields.
@ -33,35 +34,28 @@ class GenericForeignKey(FieldCacheMixin):
ForwardManyToOneDescriptor) by adding itself as a model attribute.
"""
# Field flags
auto_created = False
concrete = False
editable = False
hidden = False
is_relation = True
many_to_many = False
many_to_one = True
one_to_many = False
one_to_one = False
related_model = None
remote_field = None
def __init__(
self, ct_field="content_type", fk_field="object_id", for_concrete_model=True
):
super().__init__(editable=False)
self.ct_field = ct_field
self.fk_field = fk_field
self.for_concrete_model = for_concrete_model
self.editable = False
self.rel = None
self.column = None
self.is_relation = True
def contribute_to_class(self, cls, name, **kwargs):
self.name = name
self.model = cls
cls._meta.add_field(self, private=True)
setattr(cls, name, self)
super().contribute_to_class(cls, name, private_only=True, **kwargs)
# GenericForeignKey is its own descriptor.
setattr(cls, self.attname, self)
def get_attname_column(self):
attname, column = super().get_attname_column()
return attname, None
def get_filter_kwargs_for_object(self, obj):
"""See corresponding method on Field"""
@ -77,10 +71,6 @@ class GenericForeignKey(FieldCacheMixin):
self.ct_field: ContentType.objects.get_for_model(obj).pk,
}
def __str__(self):
model = self.model
return "%s.%s" % (model._meta.label, self.name)
def check(self, **kwargs):
return [
*self._check_field_name(),
@ -88,18 +78,6 @@ class GenericForeignKey(FieldCacheMixin):
*self._check_content_type_field(),
]
def _check_field_name(self):
if self.name.endswith("_"):
return [
checks.Error(
"Field names must not end with an underscore.",
obj=self,
id="fields.E001",
)
]
else:
return []
def _check_object_id_field(self):
try:
self.model._meta.get_field(self.fk_field)
@ -162,7 +140,8 @@ class GenericForeignKey(FieldCacheMixin):
else:
return []
def get_cache_name(self):
@cached_property
def cache_name(self):
return self.name
def get_content_type(self, obj=None, id=None, using=None, model=None):
@ -209,7 +188,7 @@ class GenericForeignKey(FieldCacheMixin):
fk_dict = defaultdict(set)
# We need one instance for each group in order to get the right db:
instance_dict = {}
ct_attname = self.model._meta.get_field(self.ct_field).get_attname()
ct_attname = self.model._meta.get_field(self.ct_field).attname
for instance in instances:
# We avoid looking for values if either ct_id or fkey value is None
ct_id = getattr(instance, ct_attname)
@ -262,7 +241,7 @@ class GenericForeignKey(FieldCacheMixin):
# content type ID here, and later when the actual instance is needed,
# use ContentType.objects.get_for_id(), which has a global cache.
f = self.model._meta.get_field(self.ct_field)
ct_id = getattr(instance, f.get_attname(), None)
ct_id = getattr(instance, f.attname, None)
pk_val = getattr(instance, self.fk_field)
rel_obj = self.get_cached_value(instance, default=None)
@ -280,7 +259,9 @@ class GenericForeignKey(FieldCacheMixin):
if ct_id is not None:
ct = self.get_content_type(id=ct_id, using=instance._state.db)
try:
rel_obj = ct.get_object_for_this_type(pk=pk_val)
rel_obj = ct.get_object_for_this_type(
using=instance._state.db, pk=pk_val
)
except ObjectDoesNotExist:
pass
self.set_cached_value(instance, rel_obj)

View File

@ -67,10 +67,10 @@ class BaseGenericInlineFormSet(BaseModelFormSet):
def save_new(self, form, commit=True):
setattr(
form.instance,
self.ct_field.get_attname(),
self.ct_field.attname,
ContentType.objects.get_for_model(self.instance).pk,
)
setattr(form.instance, self.ct_fk_field.get_attname(), self.instance.pk)
setattr(form.instance, self.ct_fk_field.attname, self.instance.pk)
return form.save(commit=commit)

View File

@ -3,7 +3,7 @@ import itertools
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.core.management import BaseCommand
from django.db import DEFAULT_DB_ALIAS, router
from django.db import DEFAULT_DB_ALIAS, connections, router
from django.db.models.deletion import Collector
@ -21,6 +21,7 @@ class Command(BaseCommand):
parser.add_argument(
"--database",
default=DEFAULT_DB_ALIAS,
choices=tuple(connections),
help='Nominates the database to use. Defaults to the "default" database.',
)
parser.add_argument(

View File

@ -174,20 +174,20 @@ class ContentType(models.Model):
except LookupError:
return None
def get_object_for_this_type(self, **kwargs):
def get_object_for_this_type(self, using=None, **kwargs):
"""
Return an object of this type for the keyword arguments given.
Basically, this is a proxy around this object_type's get_object() model
method. The ObjectNotExist exception, if thrown, will not be caught,
so code that calls this method should catch it.
"""
return self.model_class()._base_manager.using(self._state.db).get(**kwargs)
return self.model_class()._base_manager.using(using).get(**kwargs)
def get_all_objects_for_this_type(self, **kwargs):
"""
Return all objects of this type for the keyword arguments given.
"""
return self.model_class()._base_manager.using(self._state.db).filter(**kwargs)
return self.model_class()._base_manager.filter(**kwargs)
def natural_key(self):
return (self.app_label, self.model)

View File

@ -3,7 +3,7 @@ from django.db.models.query import ModelIterable, RawQuerySet
class GenericPrefetch(Prefetch):
def __init__(self, lookup, querysets=None, to_attr=None):
def __init__(self, lookup, querysets, to_attr=None):
for queryset in querysets:
if queryset is not None and (
isinstance(queryset, RawQuerySet)

View File

@ -54,7 +54,7 @@ class MySQLGISSchemaEditor(DatabaseSchemaEditor):
self.create_spatial_indexes()
def remove_field(self, model, field):
if isinstance(field, GeometryField) and field.spatial_index:
if isinstance(field, GeometryField) and field.spatial_index and not field.null:
index_name = self._create_spatial_index_name(model, field)
sql = self._delete_index_sql(model, index_name)
try:

View File

@ -203,7 +203,7 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
raise ImproperlyConfigured(
'Cannot determine PostGIS version for database "%s" '
'using command "SELECT postgis_lib_version()". '
"GeoDjango requires at least PostGIS version 3.0. "
"GeoDjango requires at least PostGIS version 3.1. "
"Was the database created from a spatial database "
"template?" % self.connection.settings_dict["NAME"]
)

View File

@ -34,17 +34,14 @@ class GeoAggregate(Aggregate):
tolerance = self.extra.get("tolerance") or getattr(self, "tolerance", 0.05)
clone = self.copy()
source_expressions = self.get_source_expressions()
if self.filter:
source_expressions.pop()
source_expressions.pop() # Don't wrap filters with SDOAGGRTYPE().
spatial_type_expr = Func(
*source_expressions,
Value(tolerance),
function="SDOAGGRTYPE",
output_field=self.output_field,
)
source_expressions = [spatial_type_expr]
if self.filter:
source_expressions.append(self.filter)
source_expressions = [spatial_type_expr, self.filter]
clone.set_source_expressions(source_expressions)
return clone.as_sql(compiler, connection, **extra_context)
return self.as_sql(compiler, connection, **extra_context)

View File

@ -367,15 +367,28 @@ class ForcePolygonCW(GeomOutputGeoFunc):
class FromWKB(GeoFunc):
output_field = GeometryField(srid=0)
arity = 1
arity = 2
geom_param_pos = ()
def __init__(self, expression, srid=0, **extra):
expressions = [
expression,
self._handle_param(srid, "srid", int),
]
if "output_field" not in extra:
extra["output_field"] = GeometryField(srid=srid)
super().__init__(*expressions, **extra)
class FromWKT(GeoFunc):
output_field = GeometryField(srid=0)
arity = 1
geom_param_pos = ()
def as_oracle(self, compiler, connection, **extra_context):
# Oracle doesn't support the srid parameter.
source_expressions = self.get_source_expressions()
clone = self.copy()
clone.set_source_expressions(source_expressions[:1])
return super(FromWKB, clone).as_sql(compiler, connection, **extra_context)
class FromWKT(FromWKB):
pass
class GeoHash(GeoFunc):

View File

@ -801,14 +801,22 @@ GEO_CLASSES = {
2001: Point, # POINT M
2002: LineString, # LINESTRING M
2003: Polygon, # POLYGON M
2004: MultiPoint, # MULTIPOINT M
2005: MultiLineString, # MULTILINESTRING M
2006: MultiPolygon, # MULTIPOLYGON M
2007: GeometryCollection, # GEOMETRYCOLLECTION M
3001: Point, # POINT ZM
3002: LineString, # LINESTRING ZM
3003: Polygon, # POLYGON ZM
3004: MultiPoint, # MULTIPOINT ZM
3005: MultiLineString, # MULTILINESTRING ZM
3006: MultiPolygon, # MULTIPOLYGON ZM
3007: GeometryCollection, # GEOMETRYCOLLECTION ZM
1 + OGRGeomType.wkb25bit: Point, # POINT Z
2 + OGRGeomType.wkb25bit: LineString, # LINESTRING Z
3 + OGRGeomType.wkb25bit: Polygon, # POLYGON Z
4 + OGRGeomType.wkb25bit: MultiPoint,
5 + OGRGeomType.wkb25bit: MultiLineString,
6 + OGRGeomType.wkb25bit: MultiPolygon,
7 + OGRGeomType.wkb25bit: GeometryCollection,
4 + OGRGeomType.wkb25bit: MultiPoint, # MULTIPOINT Z
5 + OGRGeomType.wkb25bit: MultiLineString, # MULTILINESTRING Z
6 + OGRGeomType.wkb25bit: MultiPolygon, # MULTIPOLYGON Z
7 + OGRGeomType.wkb25bit: GeometryCollection, # GEOMETRYCOLLECTION Z
}

View File

@ -54,7 +54,7 @@ class MapWidget {
// Altering using user-provided options
for (const property in options) {
if (options.hasOwnProperty(property)) {
if (Object.hasOwn(options, property)) {
this.options[property] = options[property];
}
}

View File

@ -24,12 +24,14 @@ register = template.Library()
def ordinal(value):
"""
Convert an integer to its ordinal as a string. 1 is '1st', 2 is '2nd',
3 is '3rd', etc. Works for any integer.
3 is '3rd', etc. Works for any non-negative integer.
"""
try:
value = int(value)
except (TypeError, ValueError):
return value
if value < 0:
return str(value)
if value % 100 in (11, 12, 13):
# Translators: Ordinal format for 11 (11th), 12 (12th), and 13 (13th).
value = pgettext("ordinal 11, 12, 13", "{}th").format(value)
@ -75,12 +77,15 @@ def intcomma(value, use_l10n=True):
return intcomma(value, False)
else:
return number_format(value, use_l10n=True, force_grouping=True)
orig = str(value)
new = re.sub(r"^(-?\d+)(\d{3})", r"\g<1>,\g<2>", orig)
if orig == new:
return new
else:
return intcomma(new, use_l10n)
result = str(value)
match = re.match(r"-?\d+", result)
if match:
prefix = match[0]
prefix_with_commas = re.sub(r"\d{3}", r"\g<0>,", prefix[::-1])[::-1]
# Remove a leading comma, if needed.
prefix_with_commas = re.sub(r"^(-?),", r"\1", prefix_with_commas)
result = prefix_with_commas + result[len(prefix) :]
return result
# A tuple of standard large number to their converters

View File

@ -1,3 +1,4 @@
from django.core.exceptions import FullResultSet
from django.db.models.expressions import OrderByList
@ -17,19 +18,30 @@ class OrderableAggMixin:
return super().resolve_expression(*args, **kwargs)
def get_source_expressions(self):
if self.order_by is not None:
return super().get_source_expressions() + [self.order_by]
return super().get_source_expressions()
return super().get_source_expressions() + [self.order_by]
def set_source_expressions(self, exprs):
if isinstance(exprs[-1], OrderByList):
*exprs, self.order_by = exprs
*exprs, self.order_by = exprs
return super().set_source_expressions(exprs)
def as_sql(self, compiler, connection):
if self.order_by is not None:
order_by_sql, order_by_params = compiler.compile(self.order_by)
else:
order_by_sql, order_by_params = "", ()
sql, sql_params = super().as_sql(compiler, connection, ordering=order_by_sql)
return sql, (*sql_params, *order_by_params)
*source_exprs, filtering_expr, ordering_expr = self.get_source_expressions()
order_by_sql = ""
order_by_params = []
if ordering_expr is not None:
order_by_sql, order_by_params = compiler.compile(ordering_expr)
filter_params = []
if filtering_expr is not None:
try:
_, filter_params = compiler.compile(filtering_expr)
except FullResultSet:
pass
source_params = []
for source_expr in source_exprs:
source_params += compiler.compile(source_expr)[1]
sql, _ = super().as_sql(compiler, connection, ordering=order_by_sql)
return sql, (*source_params, *order_by_params, *filter_params)

View File

@ -1,8 +1,7 @@
from types import NoneType
from django.contrib.postgres.indexes import OpClass
from django.core.exceptions import ValidationError
from django.db import DEFAULT_DB_ALIAS, NotSupportedError
from django.db import DEFAULT_DB_ALIAS
from django.db.backends.ddl_references import Expressions, Statement, Table
from django.db.models import BaseConstraint, Deferrable, F, Q
from django.db.models.expressions import Exists, ExpressionList
@ -77,6 +76,14 @@ class ExclusionConstraint(BaseConstraint):
expressions.append(expression)
return ExpressionList(*expressions).resolve_expression(query)
def _check(self, model, connection):
references = set()
for expr, _ in self.expressions:
if isinstance(expr, str):
expr = F(expr)
references.update(model._get_expr_references(expr))
return self._check_references(model, references)
def _get_condition_sql(self, compiler, schema_editor, query):
if self.condition is None:
return None
@ -107,7 +114,6 @@ class ExclusionConstraint(BaseConstraint):
)
def create_sql(self, model, schema_editor):
self.check_supported(schema_editor)
return Statement(
"ALTER TABLE %(table)s ADD %(constraint)s",
table=Table(model._meta.db_table, schema_editor.quote_name),
@ -121,17 +127,6 @@ class ExclusionConstraint(BaseConstraint):
schema_editor.quote_name(self.name),
)
def check_supported(self, schema_editor):
if (
self.include
and self.index_type.lower() == "spgist"
and not schema_editor.connection.features.supports_covering_spgist_indexes
):
raise NotSupportedError(
"Covering exclusion constraints using an SP-GiST index "
"require PostgreSQL 14+."
)
def deconstruct(self):
path, args, kwargs = super().deconstruct()
kwargs["expressions"] = self.expressions
@ -200,12 +195,10 @@ class ExclusionConstraint(BaseConstraint):
if isinstance(expr, F) and expr.name in exclude:
return
rhs_expression = expression.replace_expressions(replacements)
# Remove OpClass because it only has sense during the constraint
# creation.
if isinstance(expression, OpClass):
expression = expression.get_source_expressions()[0]
if isinstance(rhs_expression, OpClass):
rhs_expression = rhs_expression.get_source_expressions()[0]
if hasattr(expression, "get_expression_for_validation"):
expression = expression.get_expression_for_validation()
if hasattr(rhs_expression, "get_expression_for_validation"):
rhs_expression = rhs_expression.get_expression_for_validation()
lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression)
lookup.postgres_operator = operator
lookups.append(lookup)

View File

@ -20,7 +20,7 @@ class HStoreField(forms.CharField):
def prepare_value(self, value):
if isinstance(value, dict):
return json.dumps(value)
return json.dumps(value, ensure_ascii=False)
return value
def to_python(self, value):

View File

@ -1,4 +1,3 @@
from django.db import NotSupportedError
from django.db.models import Func, Index
from django.utils.functional import cached_property
@ -234,16 +233,10 @@ class SpGistIndex(PostgresIndex):
with_params.append("fillfactor = %d" % self.fillfactor)
return with_params
def check_supported(self, schema_editor):
if (
self.include
and not schema_editor.connection.features.supports_covering_spgist_indexes
):
raise NotSupportedError("Covering SP-GiST indexes require PostgreSQL 14+.")
class OpClass(Func):
template = "%(expressions)s %(name)s"
constraint_validation_compatible = False
def __init__(self, expression, name):
super().__init__(expression, name=name)

View File

@ -2,6 +2,8 @@ import logging
import string
from datetime import datetime, timedelta
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import signing
from django.utils import timezone
@ -56,6 +58,10 @@ class SessionBase:
self._session[key] = value
self.modified = True
async def aset(self, key, value):
(await self._aget_session())[key] = value
self.modified = True
def __delitem__(self, key):
del self._session[key]
self.modified = True
@ -67,28 +73,52 @@ class SessionBase:
def get(self, key, default=None):
return self._session.get(key, default)
async def aget(self, key, default=None):
return (await self._aget_session()).get(key, default)
def pop(self, key, default=__not_given):
self.modified = self.modified or key in self._session
args = () if default is self.__not_given else (default,)
return self._session.pop(key, *args)
async def apop(self, key, default=__not_given):
self.modified = self.modified or key in (await self._aget_session())
args = () if default is self.__not_given else (default,)
return (await self._aget_session()).pop(key, *args)
def setdefault(self, key, value):
if key in self._session:
return self._session[key]
else:
self.modified = True
self._session[key] = value
self[key] = value
return value
async def asetdefault(self, key, value):
session = await self._aget_session()
if key in session:
return session[key]
else:
await self.aset(key, value)
return value
def set_test_cookie(self):
self[self.TEST_COOKIE_NAME] = self.TEST_COOKIE_VALUE
async def aset_test_cookie(self):
await self.aset(self.TEST_COOKIE_NAME, self.TEST_COOKIE_VALUE)
def test_cookie_worked(self):
return self.get(self.TEST_COOKIE_NAME) == self.TEST_COOKIE_VALUE
async def atest_cookie_worked(self):
return (await self.aget(self.TEST_COOKIE_NAME)) == self.TEST_COOKIE_VALUE
def delete_test_cookie(self):
del self[self.TEST_COOKIE_NAME]
async def adelete_test_cookie(self):
del (await self._aget_session())[self.TEST_COOKIE_NAME]
def encode(self, session_dict):
"Return the given session dictionary serialized and encoded as a string."
return signing.dumps(
@ -116,18 +146,34 @@ class SessionBase:
self._session.update(dict_)
self.modified = True
async def aupdate(self, dict_):
(await self._aget_session()).update(dict_)
self.modified = True
def has_key(self, key):
return key in self._session
async def ahas_key(self, key):
return key in (await self._aget_session())
def keys(self):
return self._session.keys()
async def akeys(self):
return (await self._aget_session()).keys()
def values(self):
return self._session.values()
async def avalues(self):
return (await self._aget_session()).values()
def items(self):
return self._session.items()
async def aitems(self):
return (await self._aget_session()).items()
def clear(self):
# To avoid unnecessary persistent storage accesses, we set up the
# internals directly (loading data wastes time, since we are going to
@ -150,11 +196,22 @@ class SessionBase:
if not self.exists(session_key):
return session_key
async def _aget_new_session_key(self):
while True:
session_key = get_random_string(32, VALID_KEY_CHARS)
if not await self.aexists(session_key):
return session_key
def _get_or_create_session_key(self):
if self._session_key is None:
self._session_key = self._get_new_session_key()
return self._session_key
async def _aget_or_create_session_key(self):
if self._session_key is None:
self._session_key = await self._aget_new_session_key()
return self._session_key
def _validate_session_key(self, key):
"""
Key must be truthy and at least 8 characters long. 8 characters is an
@ -192,6 +249,17 @@ class SessionBase:
self._session_cache = self.load()
return self._session_cache
async def _aget_session(self, no_load=False):
self.accessed = True
try:
return self._session_cache
except AttributeError:
if self.session_key is None or no_load:
self._session_cache = {}
else:
self._session_cache = await self.aload()
return self._session_cache
_session = property(_get_session)
def get_session_cookie_age(self):
@ -224,6 +292,25 @@ class SessionBase:
delta = expiry - modification
return delta.days * 86400 + delta.seconds
async def aget_expiry_age(self, **kwargs):
try:
modification = kwargs["modification"]
except KeyError:
modification = timezone.now()
try:
expiry = kwargs["expiry"]
except KeyError:
expiry = await self.aget("_session_expiry")
if not expiry: # Checks both None and 0 cases
return self.get_session_cookie_age()
if not isinstance(expiry, (datetime, str)):
return expiry
if isinstance(expiry, str):
expiry = datetime.fromisoformat(expiry)
delta = expiry - modification
return delta.days * 86400 + delta.seconds
def get_expiry_date(self, **kwargs):
"""Get session the expiry date (as a datetime object).
@ -247,6 +334,23 @@ class SessionBase:
expiry = expiry or self.get_session_cookie_age()
return modification + timedelta(seconds=expiry)
async def aget_expiry_date(self, **kwargs):
try:
modification = kwargs["modification"]
except KeyError:
modification = timezone.now()
try:
expiry = kwargs["expiry"]
except KeyError:
expiry = await self.aget("_session_expiry")
if isinstance(expiry, datetime):
return expiry
elif isinstance(expiry, str):
return datetime.fromisoformat(expiry)
expiry = expiry or self.get_session_cookie_age()
return modification + timedelta(seconds=expiry)
def set_expiry(self, value):
"""
Set a custom expiration for the session. ``value`` can be an integer,
@ -275,6 +379,20 @@ class SessionBase:
value = value.isoformat()
self["_session_expiry"] = value
async def aset_expiry(self, value):
if value is None:
# Remove any custom expiration for this session.
try:
await self.apop("_session_expiry")
except KeyError:
pass
return
if isinstance(value, timedelta):
value = timezone.now() + value
if isinstance(value, datetime):
value = value.isoformat()
await self.aset("_session_expiry", value)
def get_expire_at_browser_close(self):
"""
Return ``True`` if the session is set to expire when the browser
@ -286,6 +404,11 @@ class SessionBase:
return settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
return expiry == 0
async def aget_expire_at_browser_close(self):
if (expiry := await self.aget("_session_expiry")) is None:
return settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
return expiry == 0
def flush(self):
"""
Remove the current session data from the database and regenerate the
@ -295,6 +418,11 @@ class SessionBase:
self.delete()
self._session_key = None
async def aflush(self):
self.clear()
await self.adelete()
self._session_key = None
def cycle_key(self):
"""
Create a new session key, while retaining the current session data.
@ -306,6 +434,17 @@ class SessionBase:
if key:
self.delete(key)
async def acycle_key(self):
"""
Create a new session key, while retaining the current session data.
"""
data = await self._aget_session()
key = self.session_key
await self.acreate()
self._session_cache = data
if key:
await self.adelete(key)
# Methods that child classes must implement.
def exists(self, session_key):
@ -316,6 +455,9 @@ class SessionBase:
"subclasses of SessionBase must provide an exists() method"
)
async def aexists(self, session_key):
return await sync_to_async(self.exists)(session_key)
def create(self):
"""
Create a new session instance. Guaranteed to create a new object with
@ -326,6 +468,9 @@ class SessionBase:
"subclasses of SessionBase must provide a create() method"
)
async def acreate(self):
return await sync_to_async(self.create)()
def save(self, must_create=False):
"""
Save the session data. If 'must_create' is True, create a new session
@ -336,6 +481,9 @@ class SessionBase:
"subclasses of SessionBase must provide a save() method"
)
async def asave(self, must_create=False):
return await sync_to_async(self.save)(must_create)
def delete(self, session_key=None):
"""
Delete the session data under this key. If the key is None, use the
@ -345,6 +493,9 @@ class SessionBase:
"subclasses of SessionBase must provide a delete() method"
)
async def adelete(self, session_key=None):
return await sync_to_async(self.delete)(session_key)
def load(self):
"""
Load the session data and return a dictionary.
@ -353,6 +504,9 @@ class SessionBase:
"subclasses of SessionBase must provide a load() method"
)
async def aload(self):
return await sync_to_async(self.load)()
@classmethod
def clear_expired(cls):
"""
@ -363,3 +517,7 @@ class SessionBase:
a built-in expiration mechanism, it should be a no-op.
"""
raise NotImplementedError("This backend does not support clear_expired().")
@classmethod
async def aclear_expired(cls):
return await sync_to_async(cls.clear_expired)()

View File

@ -20,6 +20,9 @@ class SessionStore(SessionBase):
def cache_key(self):
return self.cache_key_prefix + self._get_or_create_session_key()
async def acache_key(self):
return self.cache_key_prefix + await self._aget_or_create_session_key()
def load(self):
try:
session_data = self._cache.get(self.cache_key)
@ -32,6 +35,16 @@ class SessionStore(SessionBase):
self._session_key = None
return {}
async def aload(self):
try:
session_data = await self._cache.aget(await self.acache_key())
except Exception:
session_data = None
if session_data is not None:
return session_data
self._session_key = None
return {}
def create(self):
# Because a cache can fail silently (e.g. memcache), we don't know if
# we are failing to create a new session because of a key collision or
@ -51,6 +64,20 @@ class SessionStore(SessionBase):
"It is likely that the cache is unavailable."
)
async def acreate(self):
for i in range(10000):
self._session_key = await self._aget_new_session_key()
try:
await self.asave(must_create=True)
except CreateError:
continue
self.modified = True
return
raise RuntimeError(
"Unable to create a new session key. "
"It is likely that the cache is unavailable."
)
def save(self, must_create=False):
if self.session_key is None:
return self.create()
@ -68,11 +95,33 @@ class SessionStore(SessionBase):
if must_create and not result:
raise CreateError
async def asave(self, must_create=False):
if self.session_key is None:
return await self.acreate()
if must_create:
func = self._cache.aadd
elif await self._cache.aget(await self.acache_key()) is not None:
func = self._cache.aset
else:
raise UpdateError
result = await func(
await self.acache_key(),
await self._aget_session(no_load=must_create),
await self.aget_expiry_age(),
)
if must_create and not result:
raise CreateError
def exists(self, session_key):
return (
bool(session_key) and (self.cache_key_prefix + session_key) in self._cache
)
async def aexists(self, session_key):
return bool(session_key) and await self._cache.ahas_key(
self.cache_key_prefix + session_key
)
def delete(self, session_key=None):
if session_key is None:
if self.session_key is None:
@ -80,6 +129,17 @@ class SessionStore(SessionBase):
session_key = self.session_key
self._cache.delete(self.cache_key_prefix + session_key)
async def adelete(self, session_key=None):
if session_key is None:
if self.session_key is None:
return
session_key = self.session_key
await self._cache.adelete(self.cache_key_prefix + session_key)
@classmethod
def clear_expired(cls):
pass
@classmethod
async def aclear_expired(cls):
pass

View File

@ -2,12 +2,16 @@
Cached, database-backed sessions.
"""
import logging
from django.conf import settings
from django.contrib.sessions.backends.db import SessionStore as DBStore
from django.core.cache import caches
KEY_PREFIX = "django.contrib.sessions.cached_db"
logger = logging.getLogger("django.contrib.sessions")
class SessionStore(DBStore):
"""
@ -24,6 +28,9 @@ class SessionStore(DBStore):
def cache_key(self):
return self.cache_key_prefix + self._get_or_create_session_key()
async def acache_key(self):
return self.cache_key_prefix + await self._aget_or_create_session_key()
def load(self):
try:
data = self._cache.get(self.cache_key)
@ -43,6 +50,27 @@ class SessionStore(DBStore):
data = {}
return data
async def aload(self):
try:
data = await self._cache.aget(await self.acache_key())
except Exception:
# Some backends (e.g. memcache) raise an exception on invalid
# cache keys. If this happens, reset the session. See #17810.
data = None
if data is None:
s = await self._aget_session_from_db()
if s:
data = self.decode(s.session_data)
await self._cache.aset(
await self.acache_key(),
data,
await self.aget_expiry_age(expiry=s.expire_date),
)
else:
data = {}
return data
def exists(self, session_key):
return (
session_key
@ -50,9 +78,30 @@ class SessionStore(DBStore):
or super().exists(session_key)
)
async def aexists(self, session_key):
return (
session_key
and (self.cache_key_prefix + session_key) in self._cache
or await super().aexists(session_key)
)
def save(self, must_create=False):
super().save(must_create)
self._cache.set(self.cache_key, self._session, self.get_expiry_age())
try:
self._cache.set(self.cache_key, self._session, self.get_expiry_age())
except Exception:
logger.exception("Error saving to cache (%s)", self._cache)
async def asave(self, must_create=False):
await super().asave(must_create)
try:
await self._cache.aset(
await self.acache_key(),
self._session,
await self.aget_expiry_age(),
)
except Exception:
logger.exception("Error saving to cache (%s)", self._cache)
def delete(self, session_key=None):
super().delete(session_key)
@ -62,6 +111,14 @@ class SessionStore(DBStore):
session_key = self.session_key
self._cache.delete(self.cache_key_prefix + session_key)
async def adelete(self, session_key=None):
await super().adelete(session_key)
if session_key is None:
if self.session_key is None:
return
session_key = self.session_key
await self._cache.adelete(self.cache_key_prefix + session_key)
def flush(self):
"""
Remove the current session data from the database and regenerate the
@ -70,3 +127,9 @@ class SessionStore(DBStore):
self.clear()
self.delete(self.session_key)
self._session_key = None
async def aflush(self):
"""See flush()."""
self.clear()
await self.adelete(self.session_key)
self._session_key = None

View File

@ -1,5 +1,7 @@
import logging
from asgiref.sync import sync_to_async
from django.contrib.sessions.backends.base import CreateError, SessionBase, UpdateError
from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, IntegrityError, router, transaction
@ -38,13 +40,31 @@ class SessionStore(SessionBase):
logger.warning(str(e))
self._session_key = None
async def _aget_session_from_db(self):
try:
return await self.model.objects.aget(
session_key=self.session_key, expire_date__gt=timezone.now()
)
except (self.model.DoesNotExist, SuspiciousOperation) as e:
if isinstance(e, SuspiciousOperation):
logger = logging.getLogger("django.security.%s" % e.__class__.__name__)
logger.warning(str(e))
self._session_key = None
def load(self):
s = self._get_session_from_db()
return self.decode(s.session_data) if s else {}
async def aload(self):
s = await self._aget_session_from_db()
return self.decode(s.session_data) if s else {}
def exists(self, session_key):
return self.model.objects.filter(session_key=session_key).exists()
async def aexists(self, session_key):
return await self.model.objects.filter(session_key=session_key).aexists()
def create(self):
while True:
self._session_key = self._get_new_session_key()
@ -58,6 +78,19 @@ class SessionStore(SessionBase):
self.modified = True
return
async def acreate(self):
while True:
self._session_key = await self._aget_new_session_key()
try:
# Save immediately to ensure we have a unique entry in the
# database.
await self.asave(must_create=True)
except CreateError:
# Key wasn't unique. Try again.
continue
self.modified = True
return
def create_model_instance(self, data):
"""
Return a new instance of the session model object, which represents the
@ -70,6 +103,14 @@ class SessionStore(SessionBase):
expire_date=self.get_expiry_date(),
)
async def acreate_model_instance(self, data):
"""See create_model_instance()."""
return self.model(
session_key=await self._aget_or_create_session_key(),
session_data=self.encode(data),
expire_date=await self.aget_expiry_date(),
)
def save(self, must_create=False):
"""
Save the current session data to the database. If 'must_create' is
@ -95,6 +136,36 @@ class SessionStore(SessionBase):
raise UpdateError
raise
async def asave(self, must_create=False):
"""See save()."""
if self.session_key is None:
return await self.acreate()
data = await self._aget_session(no_load=must_create)
obj = await self.acreate_model_instance(data)
using = router.db_for_write(self.model, instance=obj)
try:
# This code MOST run in a transaction, so it requires
# @sync_to_async wrapping until transaction.atomic() supports
# async.
@sync_to_async
def sync_transaction():
with transaction.atomic(using=using):
obj.save(
force_insert=must_create,
force_update=not must_create,
using=using,
)
await sync_transaction()
except IntegrityError:
if must_create:
raise CreateError
raise
except DatabaseError:
if not must_create:
raise UpdateError
raise
def delete(self, session_key=None):
if session_key is None:
if self.session_key is None:
@ -105,6 +176,23 @@ class SessionStore(SessionBase):
except self.model.DoesNotExist:
pass
async def adelete(self, session_key=None):
if session_key is None:
if self.session_key is None:
return
session_key = self.session_key
try:
obj = await self.model.objects.aget(session_key=session_key)
await obj.adelete()
except self.model.DoesNotExist:
pass
@classmethod
def clear_expired(cls):
cls.get_model_class().objects.filter(expire_date__lt=timezone.now()).delete()
@classmethod
async def aclear_expired(cls):
await cls.get_model_class().objects.filter(
expire_date__lt=timezone.now()
).adelete()

View File

@ -104,6 +104,9 @@ class SessionStore(SessionBase):
self._session_key = None
return session_data
async def aload(self):
return self.load()
def create(self):
while True:
self._session_key = self._get_new_session_key()
@ -114,6 +117,9 @@ class SessionStore(SessionBase):
self.modified = True
return
async def acreate(self):
return self.create()
def save(self, must_create=False):
if self.session_key is None:
return self.create()
@ -177,9 +183,15 @@ class SessionStore(SessionBase):
except (EOFError, OSError):
pass
async def asave(self, must_create=False):
return self.save(must_create=must_create)
def exists(self, session_key):
return os.path.exists(self._key_to_file(session_key))
async def aexists(self, session_key):
return self.exists(session_key)
def delete(self, session_key=None):
if session_key is None:
if self.session_key is None:
@ -190,8 +202,8 @@ class SessionStore(SessionBase):
except OSError:
pass
def clean(self):
pass
async def adelete(self, session_key=None):
return self.delete(session_key=session_key)
@classmethod
def clear_expired(cls):
@ -208,3 +220,7 @@ class SessionStore(SessionBase):
# the create() method.
session.create = lambda: None
session.load()
@classmethod
async def aclear_expired(cls):
cls.clear_expired()

View File

@ -23,6 +23,9 @@ class SessionStore(SessionBase):
self.create()
return {}
async def aload(self):
return self.load()
def create(self):
"""
To create a new key, set the modified flag so that the cookie is set
@ -30,6 +33,9 @@ class SessionStore(SessionBase):
"""
self.modified = True
async def acreate(self):
return self.create()
def save(self, must_create=False):
"""
To save, get the session key as a securely signed string and then set
@ -39,6 +45,9 @@ class SessionStore(SessionBase):
self._session_key = self._get_session_key()
self.modified = True
async def asave(self, must_create=False):
return self.save(must_create=must_create)
def exists(self, session_key=None):
"""
This method makes sense when you're talking to a shared resource, but
@ -47,6 +56,9 @@ class SessionStore(SessionBase):
"""
return False
async def aexists(self, session_key=None):
return self.exists(session_key=session_key)
def delete(self, session_key=None):
"""
To delete, clear the session key and the underlying data structure
@ -57,6 +69,9 @@ class SessionStore(SessionBase):
self._session_cache = {}
self.modified = True
async def adelete(self, session_key=None):
return self.delete(session_key=session_key)
def cycle_key(self):
"""
Keep the same data but with a new key. Call save() and it will
@ -64,6 +79,9 @@ class SessionStore(SessionBase):
"""
self.save()
async def acycle_key(self):
return self.cycle_key()
def _get_session_key(self):
"""
Instead of generating a random string, generate a secure url-safe
@ -79,3 +97,7 @@ class SessionStore(SessionBase):
@classmethod
def clear_expired(cls):
pass
@classmethod
async def aclear_expired(cls):
pass

View File

@ -36,13 +36,13 @@ class StaticFilesHandlerMixin:
* the host is provided as part of the base_url
* the request's path isn't under the media path (or equal)
"""
return path.startswith(self.base_url[2]) and not self.base_url[1]
return path.startswith(self.base_url.path) and not self.base_url.netloc
def file_path(self, url):
"""
Return the relative path to the media file on disk for the given URL.
"""
relative_url = url.removeprefix(self.base_url[2])
relative_url = url.removeprefix(self.base_url.path)
return url2pathname(relative_url)
def serve(self, request):

View File

@ -221,7 +221,7 @@ class HashedFilesMixin:
url = matches["url"]
# Ignore absolute/protocol-relative and data-uri URLs.
if re.match(r"^[a-z]+:", url):
if re.match(r"^[a-z]+:", url) or url.startswith("//"):
return matched
# Ignore absolute URLs that don't point to a static file (dynamic

View File

@ -160,6 +160,7 @@ class Feed:
feed_copyright=self._get_dynamic_attr("feed_copyright", obj),
feed_guid=self._get_dynamic_attr("feed_guid", obj),
ttl=self._get_dynamic_attr("ttl", obj),
stylesheets=self._get_dynamic_attr("stylesheets", obj),
**self.feed_extra_kwargs(obj),
)

View File

@ -1,75 +1,12 @@
import copy
from collections import defaultdict
from django.conf import settings
from django.template.backends.django import get_template_tag_modules
from . import Error, Tags, Warning, register
E001 = Error(
"You have 'APP_DIRS': True in your TEMPLATES but also specify 'loaders' "
"in OPTIONS. Either remove APP_DIRS or remove the 'loaders' option.",
id="templates.E001",
)
E002 = Error(
"'string_if_invalid' in TEMPLATES OPTIONS must be a string but got: {} ({}).",
id="templates.E002",
)
W003 = Warning(
"{} is used for multiple template tag modules: {}",
id="templates.E003",
)
from . import Tags, register
@register(Tags.templates)
def check_setting_app_dirs_loaders(app_configs, **kwargs):
return (
[E001]
if any(
conf.get("APP_DIRS") and "loaders" in conf.get("OPTIONS", {})
for conf in settings.TEMPLATES
)
else []
)
def check_templates(app_configs, **kwargs):
"""Check all registered template engines."""
from django.template import engines
@register(Tags.templates)
def check_string_if_invalid_is_string(app_configs, **kwargs):
errors = []
for conf in settings.TEMPLATES:
string_if_invalid = conf.get("OPTIONS", {}).get("string_if_invalid", "")
if not isinstance(string_if_invalid, str):
error = copy.copy(E002)
error.msg = error.msg.format(
string_if_invalid, type(string_if_invalid).__name__
)
errors.append(error)
return errors
@register(Tags.templates)
def check_for_template_tags_with_the_same_name(app_configs, **kwargs):
errors = []
libraries = defaultdict(set)
for conf in settings.TEMPLATES:
custom_libraries = conf.get("OPTIONS", {}).get("libraries", {})
for module_name, module_path in custom_libraries.items():
libraries[module_name].add(module_path)
for module_name, module_path in get_template_tag_modules():
libraries[module_name].add(module_path)
for library_name, items in libraries.items():
if len(items) > 1:
errors.append(
Warning(
W003.msg.format(
repr(library_name),
", ".join(repr(item) for item in sorted(items)),
),
id=W003.id,
)
)
for engine in engines.all():
errors.extend(engine.check())
return errors

View File

@ -1,6 +1,8 @@
import inspect
from collections import Counter
from django.conf import settings
from django.core.exceptions import ViewDoesNotExist
from . import Error, Tags, Warning, register
@ -115,3 +117,43 @@ def E006(name):
"The {} setting must end with a slash.".format(name),
id="urls.E006",
)
@register(Tags.urls)
def check_custom_error_handlers(app_configs, **kwargs):
if not getattr(settings, "ROOT_URLCONF", None):
return []
from django.urls import get_resolver
resolver = get_resolver()
errors = []
# All handlers take (request, exception) arguments except handler500
# which takes (request).
for status_code, num_parameters in [(400, 2), (403, 2), (404, 2), (500, 1)]:
try:
handler = resolver.resolve_error_handler(status_code)
except (ImportError, ViewDoesNotExist) as e:
path = getattr(resolver.urlconf_module, "handler%s" % status_code)
msg = (
"The custom handler{status_code} view '{path}' could not be "
"imported."
).format(status_code=status_code, path=path)
errors.append(Error(msg, hint=str(e), id="urls.E008"))
continue
signature = inspect.signature(handler)
args = [None] * num_parameters
try:
signature.bind(*args)
except TypeError:
msg = (
"The custom handler{status_code} view '{path}' does not "
"take the correct number of arguments ({args})."
).format(
status_code=status_code,
path=handler.__module__ + "." + handler.__qualname__,
args="request, exception" if num_parameters == 2 else "request",
)
errors.append(Error(msg, id="urls.E007"))
return errors

View File

@ -13,20 +13,6 @@ from django.core.files import locks
__all__ = ["file_move_safe"]
def _samefile(src, dst):
# Macintosh, Unix.
if hasattr(os.path, "samefile"):
try:
return os.path.samefile(src, dst)
except OSError:
return False
# All other platforms: check for same pathname.
return os.path.normcase(os.path.abspath(src)) == os.path.normcase(
os.path.abspath(dst)
)
def file_move_safe(
old_file_name, new_file_name, chunk_size=1024 * 64, allow_overwrite=False
):
@ -40,16 +26,18 @@ def file_move_safe(
``FileExistsError``.
"""
# There's no reason to move if we don't have to.
if _samefile(old_file_name, new_file_name):
return
try:
if os.path.samefile(old_file_name, new_file_name):
return
except OSError:
pass
if not allow_overwrite and os.access(new_file_name, os.F_OK):
raise FileExistsError(
f"Destination file {new_file_name} exists and allow_overwrite is False."
)
try:
if not allow_overwrite and os.access(new_file_name, os.F_OK):
raise FileExistsError(
"Destination file %s exists and allow_overwrite is False."
% new_file_name
)
os.rename(old_file_name, new_file_name)
return
except OSError:

View File

@ -69,7 +69,8 @@ class Storage:
"Detected path traversal attempt in '%s'" % dir_name
)
validate_file_name(file_name)
file_root, file_ext = os.path.splitext(file_name)
file_ext = "".join(pathlib.PurePath(file_name).suffixes)
file_root = file_name.removesuffix(file_ext)
# If the filename already exists, generate an alternative filename
# until it doesn't exist.
# Truncate original name if required, so the new filename does not

View File

@ -1,13 +1,16 @@
import os
import warnings
from datetime import datetime, timezone
from urllib.parse import urljoin
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.core.files import File, locks
from django.core.files.move import file_move_safe
from django.core.signals import setting_changed
from django.utils._os import safe_join
from django.utils.deconstruct import deconstructible
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.encoding import filepath_to_uri
from django.utils.functional import cached_property
@ -21,8 +24,7 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
Standard filesystem storage
"""
# The combination of O_CREAT and O_EXCL makes os.open() raise OSError if
# the file already exists before it's opened.
# RemovedInDjango60Warning: remove OS_OPEN_FLAGS.
OS_OPEN_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
def __init__(
@ -31,12 +33,23 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
base_url=None,
file_permissions_mode=None,
directory_permissions_mode=None,
allow_overwrite=False,
):
self._location = location
self._base_url = base_url
self._file_permissions_mode = file_permissions_mode
self._directory_permissions_mode = directory_permissions_mode
self._allow_overwrite = allow_overwrite
setting_changed.connect(self._clear_cached_properties)
# RemovedInDjango60Warning: remove this warning.
if self.OS_OPEN_FLAGS != os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(
os, "O_BINARY", 0
):
warnings.warn(
"Overriding OS_OPEN_FLAGS is deprecated. Use "
"the allow_overwrite parameter instead.",
RemovedInDjango60Warning,
)
@cached_property
def base_location(self):
@ -98,12 +111,30 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
try:
# This file has a file path that we can move.
if hasattr(content, "temporary_file_path"):
file_move_safe(content.temporary_file_path(), full_path)
file_move_safe(
content.temporary_file_path(),
full_path,
allow_overwrite=self._allow_overwrite,
)
# This is a normal uploadedfile that we can stream.
else:
# The current umask value is masked out by os.open!
fd = os.open(full_path, self.OS_OPEN_FLAGS, 0o666)
# The combination of O_CREAT and O_EXCL makes os.open() raises an
# OSError if the file already exists before it's opened.
open_flags = (
os.O_WRONLY
| os.O_CREAT
| os.O_EXCL
| getattr(os, "O_BINARY", 0)
)
# RemovedInDjango60Warning: when the deprecation ends, replace with:
# if self._allow_overwrite:
# open_flags = open_flags & ~os.O_EXCL
if self.OS_OPEN_FLAGS != open_flags:
open_flags = self.OS_OPEN_FLAGS
elif self._allow_overwrite:
open_flags = open_flags & ~os.O_EXCL
fd = os.open(full_path, open_flags, 0o666)
_file = None
try:
locks.lock(fd, locks.LOCK_EX)
@ -162,7 +193,13 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
pass
def exists(self, name):
return os.path.lexists(self.path(name))
try:
exists = os.path.lexists(self.path(name))
except SuspiciousFileOperation:
raise
if self._allow_overwrite:
return False
return exists
def listdir(self, path):
path = self.path(path)

View File

@ -50,21 +50,13 @@ class ASGIRequest(HttpRequest):
self._post_parse_error = False
self._read_started = False
self.resolver_match = None
self.path = scope["path"]
self.script_name = get_script_prefix(scope)
if self.script_name:
# TODO: Better is-prefix checking, slash handling?
self.path_info = scope["path"].removeprefix(self.script_name)
else:
self.path_info = scope["path"]
# The Django path is different from ASGI scope path args, it should
# combine with script name.
if self.script_name:
self.path = "%s/%s" % (
self.script_name.rstrip("/"),
self.path_info.replace("/", "", 1),
)
else:
self.path = scope["path"]
# HTTP basics.
self.method = self.scope["method"].upper()
# Ensure query string is encoded correctly.

View File

@ -1,4 +1,5 @@
import mimetypes
from collections import namedtuple
from email import charset as Charset
from email import encoders as Encoders
from email import generator, message_from_string
@ -168,7 +169,8 @@ class SafeMIMEText(MIMEMixin, MIMEText):
def set_payload(self, payload, charset=None):
if charset == "utf-8" and not isinstance(charset, Charset.Charset):
has_long_lines = any(
len(line.encode()) > RFC5322_EMAIL_LINE_LENGTH_LIMIT
len(line.encode(errors="surrogateescape"))
> RFC5322_EMAIL_LINE_LENGTH_LIMIT
for line in payload.splitlines()
)
# Quoted-Printable encoding has the side effect of shortening long
@ -189,6 +191,10 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
MIMEMultipart.__setitem__(self, name, val)
Alternative = namedtuple("Alternative", ["content", "mimetype"])
EmailAttachment = namedtuple("Attachment", ["filename", "content", "mimetype"])
class EmailMessage:
"""A container for email information."""
@ -337,7 +343,7 @@ class EmailMessage:
# actually binary, read() raises a UnicodeDecodeError.
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
self.attachments.append((filename, content, mimetype))
self.attachments.append(EmailAttachment(filename, content, mimetype))
def attach_file(self, path, mimetype=None):
"""
@ -470,13 +476,15 @@ class EmailMultiAlternatives(EmailMessage):
cc,
reply_to,
)
self.alternatives = alternatives or []
self.alternatives = [
Alternative(*alternative) for alternative in (alternatives or [])
]
def attach_alternative(self, content, mimetype):
"""Attach an alternative content representation."""
if content is None or mimetype is None:
raise ValueError("Both content and mimetype must be provided.")
self.alternatives.append((content, mimetype))
self.alternatives.append(Alternative(content, mimetype))
def _create_message(self, msg):
return self._create_attachments(self._create_alternatives(msg))
@ -491,5 +499,22 @@ class EmailMultiAlternatives(EmailMessage):
if self.body:
msg.attach(body_msg)
for alternative in self.alternatives:
msg.attach(self._create_mime_attachment(*alternative))
msg.attach(
self._create_mime_attachment(
alternative.content, alternative.mimetype
)
)
return msg
def body_contains(self, text):
"""
Checks that ``text`` occurs in the email body and in all attached MIME
type text/* alternatives.
"""
if text not in self.body:
return False
for content, mimetype in self.alternatives:
if mimetype.startswith("text/") and text not in content:
return False
return True

View File

@ -345,7 +345,7 @@ class BaseCommand:
parser,
"--traceback",
action="store_true",
help="Raise on CommandError exceptions.",
help="Display a full stack trace on CommandError exceptions.",
)
self.add_base_argument(
parser,

View File

@ -2,6 +2,7 @@ from django.apps import apps
from django.core import checks
from django.core.checks.registry import registry
from django.core.management.base import BaseCommand, CommandError
from django.db import connections
class Command(BaseCommand):
@ -43,6 +44,7 @@ class Command(BaseCommand):
parser.add_argument(
"--database",
action="append",
choices=tuple(connections),
dest="databases",
help="Run database related checks against these aliases.",
)

Some files were not shown because too many files have changed in this diff Show More