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:
commit
38b6990abc
@ -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
|
37
.eslintrc
37
.eslintrc
@ -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
15
.github/pull_request_template.md
vendored
Normal 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
17
.github/workflows/reminders_check.yml
vendored
Normal 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
17
.github/workflows/reminders_create.yml
vendored
Normal 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
|
30
.github/workflows/schedule_tests.yml
vendored
30
.github/workflows/schedule_tests.yml
vendored
@ -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
|
||||
|
26
.github/workflows/screenshots.yml
vendored
26
.github/workflows/screenshots.yml
vendored
@ -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
|
||||
|
2
.github/workflows/selenium.yml
vendored
2
.github/workflows/selenium.yml
vendored
@ -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
1
.gitignore
vendored
@ -6,6 +6,7 @@
|
||||
*.pot
|
||||
*.py[co]
|
||||
.tox/
|
||||
venv/
|
||||
__pycache__
|
||||
MANIFEST
|
||||
dist/
|
||||
|
@ -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
12
AUTHORS
@ -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>
|
||||
|
@ -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)
|
||||
|
||||
|
@ -480,7 +480,7 @@ LANG_INFO = {
|
||||
"bidi": False,
|
||||
"code": "sk",
|
||||
"name": "Slovak",
|
||||
"name_local": "Slovensky",
|
||||
"name_local": "slovensky",
|
||||
},
|
||||
"sl": {
|
||||
"bidi": False,
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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 ""
|
||||
|
@ -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 ""
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -159,7 +159,6 @@
|
||||
font-weight: 400;
|
||||
padding: 0 15px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#changelist-filter details summary > * {
|
||||
|
@ -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 */
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
/*global gettext, interpolate, ngettext*/
|
||||
/*global gettext, interpolate, ngettext, Actions*/
|
||||
'use strict';
|
||||
{
|
||||
function show(selector) {
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
/*global opener */
|
||||
'use strict';
|
||||
{
|
||||
const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse);
|
||||
|
@ -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'));
|
||||
});
|
||||
}
|
@ -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 %}
|
||||
|
@ -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 @@
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
|
||||
› {% translate 'Change password' %}
|
||||
› {% 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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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())
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 didn’t 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 "
|
||||
"user’s 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 user’s 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 didn’t 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 didn’t 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 []
|
||||
|
@ -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):
|
||||
|
@ -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 didn’t 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 user’s "
|
||||
"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 user’s 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
|
||||
|
@ -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:
|
||||
|
@ -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".',
|
||||
)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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>
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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"]
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user