mirror of
https://github.com/django/django.git
synced 2025-06-05 11:39:13 +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
|
- name: Run tests
|
||||||
run: python tests/runtests.py -v2
|
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:
|
pypy-sqlite:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Ubuntu, SQLite, PyPy3.10
|
name: Ubuntu, SQLite, PyPy3.10
|
||||||
@ -64,7 +90,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13-alpine
|
image: postgres:14-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: django
|
POSTGRES_DB: django
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
@ -137,7 +163,7 @@ jobs:
|
|||||||
name: Selenium tests, PostgreSQL
|
name: Selenium tests, PostgreSQL
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13-alpine
|
image: postgres:14-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: django
|
POSTGRES_DB: django
|
||||||
POSTGRES_USER: user
|
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
|
- name: Install and upgrade packaging tools
|
||||||
run: python -m pip install --upgrade pip setuptools wheel
|
run: python -m pip install --upgrade pip setuptools wheel
|
||||||
- run: python -m pip install -r tests/requirements/py3.txt -e .
|
- run: python -m pip install -r tests/requirements/py3.txt -e .
|
||||||
|
|
||||||
- name: Run Selenium tests with screenshots
|
- name: Run Selenium tests with screenshots
|
||||||
id: generate-screenshots
|
|
||||||
working-directory: ./tests/
|
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: |
|
run: |
|
||||||
python -Wall runtests.py --verbosity 2 --noinput --selenium=chrome --headless --screenshots --settings=test_sqlite --parallel 2
|
mkdir --parents "/tmp/screenshots/${{ github.event.pull_request.head.sha }}"
|
||||||
echo "date=$(date)" >> $GITHUB_OUTPUT
|
mv tests/screenshots/* "/tmp/screenshots/${{ github.event.pull_request.head.sha }}/"
|
||||||
echo "🖼️ **Screenshots created**" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "Generated screenshots for ${{ github.event.pull_request.head.sha }} at $(date)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: screenshots-${{ github.event.pull_request.head.sha }}
|
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
|
name: PostgreSQL
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13-alpine
|
image: postgres:14-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: django
|
POSTGRES_DB: django
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: user
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@
|
|||||||
*.pot
|
*.pot
|
||||||
*.py[co]
|
*.py[co]
|
||||||
.tox/
|
.tox/
|
||||||
|
venv/
|
||||||
__pycache__
|
__pycache__
|
||||||
MANIFEST
|
MANIFEST
|
||||||
dist/
|
dist/
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 24.1.0
|
rev: 24.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
exclude: \.py-tpl$
|
exclude: \.py-tpl$
|
||||||
@ -9,8 +9,9 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: blacken-docs
|
- id: blacken-docs
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- black==24.1.0
|
- black==24.2.0
|
||||||
files: 'docs/.*\.txt$'
|
files: 'docs/.*\.txt$'
|
||||||
|
args: ["--rst-literal-block"]
|
||||||
- repo: https://github.com/PyCQA/isort
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: 5.13.2
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
@ -20,6 +21,6 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
rev: v8.56.0
|
rev: v9.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- 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:
|
answer newbie questions, and generally made Django that much better:
|
||||||
|
|
||||||
Aaron Cannon <cannona@fireantproductions.com>
|
Aaron Cannon <cannona@fireantproductions.com>
|
||||||
|
Aaron Linville <aaron@linville.org>
|
||||||
Aaron Swartz <http://www.aaronsw.com/>
|
Aaron Swartz <http://www.aaronsw.com/>
|
||||||
Aaron T. Myers <atmyers@gmail.com>
|
Aaron T. Myers <atmyers@gmail.com>
|
||||||
Abeer Upadhyay <ab.esquarer@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 Johnson <https://github.com/adamchainz>
|
||||||
Adam Malinowski <https://adammalinowski.co.uk/>
|
Adam Malinowski <https://adammalinowski.co.uk/>
|
||||||
Adam Vandenberg
|
Adam Vandenberg
|
||||||
|
Adam Zapletal <https://adamzap.com/>
|
||||||
Ade Lee <alee@redhat.com>
|
Ade Lee <alee@redhat.com>
|
||||||
Adiyat Mubarak <adiyatmubarak@gmail.com>
|
Adiyat Mubarak <adiyatmubarak@gmail.com>
|
||||||
Adnan Umer <u.adnan@outlook.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/>
|
Albert Wang <https://github.com/albertyw/>
|
||||||
Alcides Fonseca
|
Alcides Fonseca
|
||||||
Aldian Fazrihady <mobile@aldian.net>
|
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>
|
Aleksandra Sendecka <asendecka@hauru.eu>
|
||||||
Aleksi Häkli <aleksi.hakli@iki.fi>
|
Aleksi Häkli <aleksi.hakli@iki.fi>
|
||||||
Alex Dutton <django@alexdutton.co.uk>
|
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 Karulf <erik@karulf.com>
|
||||||
Erik Romijn <django@solidlinks.nl>
|
Erik Romijn <django@solidlinks.nl>
|
||||||
eriks@win.tue.nl
|
eriks@win.tue.nl
|
||||||
|
Erin Kelly <erin.ch.kelly@gmail.com>
|
||||||
Erwin Junge <erwin@junge.nl>
|
Erwin Junge <erwin@junge.nl>
|
||||||
Esdras Beleza <linux@esdrasbeleza.com>
|
Esdras Beleza <linux@esdrasbeleza.com>
|
||||||
Espen Grindhaug <http://grindhaug.org/>
|
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/>
|
Eugene Lazutkin <http://lazutkin.com/blog/>
|
||||||
Evan Grim <https://github.com/egrim>
|
Evan Grim <https://github.com/egrim>
|
||||||
Fabian Büchler <fabian.buechler@inoqo.com>
|
Fabian Büchler <fabian.buechler@inoqo.com>
|
||||||
|
Fabian Braun <fsbraun@gmx.de>
|
||||||
Fabrice Aneche <akh@nobugware.com>
|
Fabrice Aneche <akh@nobugware.com>
|
||||||
Faishal Manzar <https://github.com/faishal882>
|
Faishal Manzar <https://github.com/faishal882>
|
||||||
Farhaan Bukhsh <farhaan.bukhsh@gmail.com>
|
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 Karpenkov <george@metaworld.ru>
|
||||||
George Song <george@damacy.net>
|
George Song <george@damacy.net>
|
||||||
George Vilches <gav@thataddress.com>
|
George Vilches <gav@thataddress.com>
|
||||||
|
George Y. Kussumoto <georgeyk.dev@gmail.com>
|
||||||
Georg "Hugo" Bauer <gb@hugo.westfalen.de>
|
Georg "Hugo" Bauer <gb@hugo.westfalen.de>
|
||||||
Georgi Stanojevski <glisha@gmail.com>
|
Georgi Stanojevski <glisha@gmail.com>
|
||||||
Gerardo Orozco <gerardo.orozco.mosqueda@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>
|
Iacopo Spalletti <i.spalletti@nephila.it>
|
||||||
Ian A Wilson <http://ianawilson.com>
|
Ian A Wilson <http://ianawilson.com>
|
||||||
Ian Clelland <clelland@gmail.com>
|
Ian Clelland <clelland@gmail.com>
|
||||||
Ian G. Kelly <ian.g.kelly@gmail.com>
|
|
||||||
Ian Holsman <http://feh.holsman.net/>
|
Ian Holsman <http://feh.holsman.net/>
|
||||||
Ian Lee <IanLee1521@gmail.com>
|
Ian Lee <IanLee1521@gmail.com>
|
||||||
Ibon <ibonso@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/>
|
Joe Topjian <http://joe.terrarum.net/geek/code/python/django/>
|
||||||
Johan C. Stöver <johan@nilling.nl>
|
Johan C. Stöver <johan@nilling.nl>
|
||||||
Johann Queuniet <johann.queuniet@adh.naellia.eu>
|
Johann Queuniet <johann.queuniet@adh.naellia.eu>
|
||||||
|
Johannes Westphal <jojo@w-hat.de>
|
||||||
john@calixto.net
|
john@calixto.net
|
||||||
John D'Agostino <john.dagostino@gmail.com>
|
John D'Agostino <john.dagostino@gmail.com>
|
||||||
John D'Ambrosio <dambrosioj@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>
|
Karderio <karderio@gmail.com>
|
||||||
Karen Tracey <kmtracey@gmail.com>
|
Karen Tracey <kmtracey@gmail.com>
|
||||||
Karol Sikora <elektrrrus@gmail.com>
|
Karol Sikora <elektrrrus@gmail.com>
|
||||||
|
Kasun Herath <kasunh01@gmail.com>
|
||||||
Katherine “Kati” Michel <kthrnmichel@gmail.com>
|
Katherine “Kati” Michel <kthrnmichel@gmail.com>
|
||||||
Kathryn Killebrew <kathryn.killebrew@gmail.com>
|
Kathryn Killebrew <kathryn.killebrew@gmail.com>
|
||||||
Katie Miller <katie@sub50.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>
|
Nicolas Noé <nicolas@niconoe.eu>
|
||||||
Nikita Marchant <nikita.marchant@gmail.com>
|
Nikita Marchant <nikita.marchant@gmail.com>
|
||||||
Nikita Sobolev <mail@sobolevn.me>
|
Nikita Sobolev <mail@sobolevn.me>
|
||||||
|
Nina Menezes <https://github.com/nmenezes0>
|
||||||
Niran Babalola <niran@niran.org>
|
Niran Babalola <niran@niran.org>
|
||||||
Nis Jørgensen <nis@superlativ.dk>
|
Nis Jørgensen <nis@superlativ.dk>
|
||||||
Nowell Strite <https://nowell.strite.org/>
|
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 Fedoseev <fedoseev.sergey@gmail.com>
|
||||||
Sergey Kolosov <m17.admin@gmail.com>
|
Sergey Kolosov <m17.admin@gmail.com>
|
||||||
Seth Hill <sethrh@gmail.com>
|
Seth Hill <sethrh@gmail.com>
|
||||||
|
Shafiya Adzhani <adz.arsym@gmail.com>
|
||||||
Shai Berger <shai@platonix.com>
|
Shai Berger <shai@platonix.com>
|
||||||
Shannon -jj Behrens <https://www.jjinux.com/>
|
Shannon -jj Behrens <https://www.jjinux.com/>
|
||||||
Shawn Milochik <shawn@milochik.com>
|
Shawn Milochik <shawn@milochik.com>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django.utils.version import get_version
|
from django.utils.version import get_version
|
||||||
|
|
||||||
VERSION = (5, 1, 0, "alpha", 0)
|
VERSION = (5, 2, 0, "alpha", 0)
|
||||||
|
|
||||||
__version__ = get_version(VERSION)
|
__version__ = get_version(VERSION)
|
||||||
|
|
||||||
|
@ -480,7 +480,7 @@ LANG_INFO = {
|
|||||||
"bidi": False,
|
"bidi": False,
|
||||||
"code": "sk",
|
"code": "sk",
|
||||||
"name": "Slovak",
|
"name": "Slovak",
|
||||||
"name_local": "Slovensky",
|
"name_local": "slovensky",
|
||||||
},
|
},
|
||||||
"sl": {
|
"sl": {
|
||||||
"bidi": False,
|
"bidi": False,
|
||||||
|
@ -4,7 +4,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Django\n"
|
"Project-Id-Version: Django\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
|
||||||
"Last-Translator: Django team\n"
|
"Last-Translator: Django team\n"
|
||||||
"Language-Team: English <en@li.org>\n"
|
"Language-Team: English <en@li.org>\n"
|
||||||
@ -448,6 +448,10 @@ msgstr ""
|
|||||||
msgid "Enter a valid value."
|
msgid "Enter a valid value."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/validators.py:70
|
||||||
|
msgid "Enter a valid domain name."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: core/validators.py:104 forms/fields.py:759
|
#: core/validators.py:104 forms/fields.py:759
|
||||||
msgid "Enter a valid URL."
|
msgid "Enter a valid URL."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -472,16 +476,22 @@ msgid ""
|
|||||||
"hyphens."
|
"hyphens."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/validators.py:279 core/validators.py:306
|
#: core/validators.py:327 core/validators.py:336 core/validators.py:350
|
||||||
msgid "Enter a valid IPv4 address."
|
#: db/models/fields/__init__.py:2219
|
||||||
|
#, python-format
|
||||||
|
msgid "Enter a valid %(protocol)s address."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/validators.py:286 core/validators.py:307
|
#: core/validators.py:329
|
||||||
msgid "Enter a valid IPv6 address."
|
msgid "IPv4"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/validators.py:298 core/validators.py:305
|
#: core/validators.py:338 utils/ipv6.py:30
|
||||||
msgid "Enter a valid IPv4 or IPv6 address."
|
msgid "IPv6"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/validators.py:352
|
||||||
|
msgid "IPv4 or IPv6"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/validators.py:341
|
#: core/validators.py:341
|
||||||
|
@ -816,8 +816,7 @@ class ModelAdminChecks(BaseModelAdminChecks):
|
|||||||
*self._check_list_editable(admin_obj),
|
*self._check_list_editable(admin_obj),
|
||||||
*self._check_search_fields(admin_obj),
|
*self._check_search_fields(admin_obj),
|
||||||
*self._check_date_hierarchy(admin_obj),
|
*self._check_date_hierarchy(admin_obj),
|
||||||
*self._check_action_permission_methods(admin_obj),
|
*self._check_actions(admin_obj),
|
||||||
*self._check_actions_uniqueness(admin_obj),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _check_save_as(self, obj):
|
def _check_save_as(self, obj):
|
||||||
@ -915,21 +914,19 @@ class ModelAdminChecks(BaseModelAdminChecks):
|
|||||||
try:
|
try:
|
||||||
field = getattr(obj.model, item)
|
field = getattr(obj.model, item)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return [
|
try:
|
||||||
checks.Error(
|
field = get_fields_from_path(obj.model, item)[-1]
|
||||||
"The value of '%s' refers to '%s', which is not a "
|
except (FieldDoesNotExist, NotRelationField):
|
||||||
"callable, an attribute of '%s', or an attribute or "
|
return [
|
||||||
"method on '%s'."
|
checks.Error(
|
||||||
% (
|
f"The value of '{label}' refers to '{item}', which is not "
|
||||||
label,
|
f"a callable or attribute of '{obj.__class__.__name__}', "
|
||||||
item,
|
"or an attribute, method, or field on "
|
||||||
obj.__class__.__name__,
|
f"'{obj.model._meta.label}'.",
|
||||||
obj.model._meta.label,
|
obj=obj.__class__,
|
||||||
),
|
id="admin.E108",
|
||||||
obj=obj.__class__,
|
)
|
||||||
id="admin.E108",
|
]
|
||||||
)
|
|
||||||
]
|
|
||||||
if (
|
if (
|
||||||
getattr(field, "is_relation", False)
|
getattr(field, "is_relation", False)
|
||||||
and (field.many_to_many or field.one_to_many)
|
and (field.many_to_many or field.one_to_many)
|
||||||
@ -1197,13 +1194,12 @@ class ModelAdminChecks(BaseModelAdminChecks):
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _check_action_permission_methods(self, obj):
|
def _check_actions(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()
|
|
||||||
errors = []
|
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:
|
for func, name, _ in actions:
|
||||||
if not hasattr(func, "allowed_permissions"):
|
if not hasattr(func, "allowed_permissions"):
|
||||||
continue
|
continue
|
||||||
@ -1222,12 +1218,8 @@ class ModelAdminChecks(BaseModelAdminChecks):
|
|||||||
id="admin.E129",
|
id="admin.E129",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return errors
|
# Names need to be unique.
|
||||||
|
names = collections.Counter(name for _, name, _ in actions)
|
||||||
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())
|
|
||||||
for name, count in names.items():
|
for name, count in names.items():
|
||||||
if count > 1:
|
if count > 1:
|
||||||
errors.append(
|
errors.append(
|
||||||
|
@ -140,7 +140,7 @@ class SimpleListFilter(FacetsMixin, ListFilter):
|
|||||||
if lookup_qs is not None:
|
if lookup_qs is not None:
|
||||||
counts[f"{i}__c"] = models.Count(
|
counts[f"{i}__c"] = models.Count(
|
||||||
pk_attname,
|
pk_attname,
|
||||||
filter=lookup_qs.query.where,
|
filter=models.Q(pk__in=lookup_qs),
|
||||||
)
|
)
|
||||||
self.used_parameters[self.parameter_name] = original_value
|
self.used_parameters[self.parameter_name] = original_value
|
||||||
return counts
|
return counts
|
||||||
|
@ -18,6 +18,7 @@ from django.db.models.fields.related import (
|
|||||||
from django.forms.utils import flatatt
|
from django.forms.utils import flatatt
|
||||||
from django.template.defaultfilters import capfirst, linebreaksbr
|
from django.template.defaultfilters import capfirst, linebreaksbr
|
||||||
from django.urls import NoReverseMatch, reverse
|
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.html import conditional_escape, format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
@ -116,10 +117,14 @@ class Fieldset:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def media(self):
|
def media(self):
|
||||||
if "collapse" in self.classes:
|
|
||||||
return forms.Media(js=["admin/js/collapse.js"])
|
|
||||||
return forms.Media()
|
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):
|
def __iter__(self):
|
||||||
for field in self.fields:
|
for field in self.fields:
|
||||||
yield Fieldline(
|
yield Fieldline(
|
||||||
@ -438,6 +443,12 @@ class InlineAdminFormSet:
|
|||||||
def forms(self):
|
def forms(self):
|
||||||
return self.formset.forms
|
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):
|
def non_form_errors(self):
|
||||||
return self.formset.non_form_errors()
|
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.
|
# Auto fields are editable, so check for auto or non-editable pk.
|
||||||
self.form._meta.model._meta.auto_field
|
self.form._meta.model._meta.auto_field
|
||||||
or not self.form._meta.model._meta.pk.editable
|
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
|
or
|
||||||
# Also search any parents for an auto field. (The pk info is
|
# Also search any parents for an auto field. (The pk info is
|
||||||
# propagated to child models so that does not need to be checked
|
# propagated to child models so that does not need to be checked
|
||||||
# in parents.)
|
# in parents.)
|
||||||
any(
|
any(
|
||||||
parent._meta.auto_field or not parent._meta.model._meta.pk.editable
|
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 ""
|
msgstr ""
|
||||||
"Project-Id-Version: Django\n"
|
"Project-Id-Version: Django\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
|
||||||
"Last-Translator: Django team\n"
|
"Last-Translator: Django team\n"
|
||||||
"Language-Team: English <en@li.org>\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."
|
"The {name} “{obj}” was changed successfully. You may edit it again below."
|
||||||
msgstr ""
|
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
|
#: contrib/admin/options.py:1516
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid ""
|
msgid ""
|
||||||
@ -475,6 +470,10 @@ msgstr ""
|
|||||||
msgid "Change password"
|
msgid "Change password"
|
||||||
msgstr ""
|
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/auth/user/change_password.html:25
|
||||||
#: contrib/admin/templates/admin/change_form.html:43
|
#: contrib/admin/templates/admin/change_form.html:43
|
||||||
#: contrib/admin/templates/admin/change_list.html:52
|
#: 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>."
|
msgid "Enter a new password for the user <strong>%(username)s</strong>."
|
||||||
msgstr ""
|
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
|
#: contrib/admin/templates/admin/base.html:28
|
||||||
msgid "Skip to main content"
|
msgid "Skip to main content"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -4,7 +4,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Django\n"
|
"Project-Id-Version: Django\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
|
||||||
"Last-Translator: Django team\n"
|
"Last-Translator: Django team\n"
|
||||||
"Language-Team: English <en@li.org>\n"
|
"Language-Team: English <en@li.org>\n"
|
||||||
@ -381,12 +381,3 @@ msgstr ""
|
|||||||
msgctxt "one letter Saturday"
|
msgctxt "one letter Saturday"
|
||||||
msgid "S"
|
msgid "S"
|
||||||
msgstr ""
|
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 functools import partial, update_wrapper
|
||||||
from urllib.parse import parse_qsl
|
from urllib.parse import parse_qsl
|
||||||
from urllib.parse import quote as urlquote
|
from urllib.parse import quote as urlquote
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -475,24 +475,25 @@ class BaseModelAdmin(metaclass=forms.MediaDefiningClass):
|
|||||||
# Lookups on nonexistent fields are ok, since they're ignored
|
# Lookups on nonexistent fields are ok, since they're ignored
|
||||||
# later.
|
# later.
|
||||||
break
|
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):
|
if not getattr(field, "path_infos", None):
|
||||||
# This is not a relational field, so further parts
|
# This is not a relational field, so further parts
|
||||||
# must be transforms.
|
# must be transforms.
|
||||||
break
|
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
|
prev_field = field
|
||||||
model = field.path_infos[-1].to_opts.model
|
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.
|
# Either a local field filter, or no fields at all.
|
||||||
return True
|
return True
|
||||||
valid_lookups = {self.date_hierarchy}
|
valid_lookups = {self.date_hierarchy}
|
||||||
@ -1032,7 +1033,10 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_action_description(func, name):
|
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):
|
def _get_base_actions(self):
|
||||||
"""Return the list of actions, prior to any request-based filtering."""
|
"""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):
|
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, ""))
|
return parse_qsl(query_string.replace(preserved_filters, ""))
|
||||||
|
|
||||||
def response_add(self, request, obj, post_url_continue=None):
|
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"]
|
js = ["vendor/jquery/jquery%s.js" % extra, "jquery.init.js", "inlines.js"]
|
||||||
if self.filter_vertical or self.filter_horizontal:
|
if self.filter_vertical or self.filter_horizontal:
|
||||||
js.extend(["SelectBox.js", "SelectFilter2.js"])
|
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])
|
return forms.Media(js=["admin/js/%s" % url for url in js])
|
||||||
|
|
||||||
def get_extra(self, request, obj=None, **kwargs):
|
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.exceptions import AlreadyRegistered, NotRegistered
|
||||||
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
|
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
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.core.exceptions import ImproperlyConfigured
|
||||||
from django.db.models.base import ModelBase
|
from django.db.models.base import ModelBase
|
||||||
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
|
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
|
||||||
from django.template.response import TemplateResponse
|
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.decorators import method_decorator
|
||||||
from django.utils.functional import LazyObject
|
from django.utils.functional import LazyObject
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
@ -259,6 +260,8 @@ class AdminSite:
|
|||||||
return self.admin_view(view, cacheable)(*args, **kwargs)
|
return self.admin_view(view, cacheable)(*args, **kwargs)
|
||||||
|
|
||||||
wrapper.admin_site = self
|
wrapper.admin_site = self
|
||||||
|
# Used by LoginRequiredMiddleware.
|
||||||
|
wrapper.login_url = reverse_lazy("admin:login", current_app=self.name)
|
||||||
return update_wrapper(wrapper, view)
|
return update_wrapper(wrapper, view)
|
||||||
|
|
||||||
# Admin-site-wide views.
|
# Admin-site-wide views.
|
||||||
@ -402,6 +405,7 @@ class AdminSite:
|
|||||||
return LogoutView.as_view(**defaults)(request)
|
return LogoutView.as_view(**defaults)(request)
|
||||||
|
|
||||||
@method_decorator(never_cache)
|
@method_decorator(never_cache)
|
||||||
|
@login_not_required
|
||||||
def login(self, request, extra_context=None):
|
def login(self, request, extra_context=None):
|
||||||
"""
|
"""
|
||||||
Display the login form for the given HttpRequest.
|
Display the login form for the given HttpRequest.
|
||||||
|
@ -84,6 +84,8 @@ html[data-theme="light"],
|
|||||||
"Segoe UI Emoji",
|
"Segoe UI Emoji",
|
||||||
"Segoe UI Symbol",
|
"Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@ -217,6 +219,10 @@ fieldset {
|
|||||||
border-top: 1px solid var(--hairline-color);
|
border-top: 1px solid var(--hairline-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
color: #777;
|
color: #777;
|
||||||
|
@ -159,7 +159,6 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#changelist-filter details summary > * {
|
#changelist-filter details summary > * {
|
||||||
|
@ -29,6 +29,8 @@
|
|||||||
|
|
||||||
--close-button-bg: #333333;
|
--close-button-bg: #333333;
|
||||||
--close-button-hover-bg: #666666;
|
--close-button-hover-bg: #666666;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +65,8 @@ html[data-theme="dark"] {
|
|||||||
|
|
||||||
--close-button-bg: #333333;
|
--close-button-bg: #333333;
|
||||||
--close-button-hover-bg: #666666;
|
--close-button-hover-bg: #666666;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* THEME SWITCH */
|
/* THEME SWITCH */
|
||||||
|
@ -76,6 +76,20 @@ form ul.inline li {
|
|||||||
padding-right: 7px;
|
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 FIELDSETS */
|
||||||
|
|
||||||
.aligned label {
|
.aligned label {
|
||||||
@ -84,14 +98,12 @@ form ul.inline li {
|
|||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
width: 160px;
|
width: 160px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aligned label:not(.vCheckboxLabel):after {
|
.aligned label:not(.vCheckboxLabel):after {
|
||||||
content: '';
|
content: '';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
height: 1.625rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
|
.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly {
|
||||||
@ -168,11 +180,7 @@ form .aligned table p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aligned .vCheckboxLabel {
|
.aligned .vCheckboxLabel {
|
||||||
float: none;
|
padding: 1px 0 0 5px;
|
||||||
width: auto;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -3px;
|
|
||||||
padding: 0 0 5px 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aligned .vCheckboxLabel + p.help,
|
.aligned .vCheckboxLabel + p.help,
|
||||||
@ -209,35 +217,16 @@ form div.help ul {
|
|||||||
width: 450px;
|
width: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* COLLAPSED FIELDSETS */
|
/* COLLAPSIBLE FIELDSETS */
|
||||||
|
|
||||||
fieldset.collapsed * {
|
.collapse summary .fieldset-heading,
|
||||||
display: none;
|
.collapse summary .inline-heading {
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: currentColor;
|
||||||
display: inline;
|
display: inline;
|
||||||
color: var(--link-fg);
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MONOSPACE TEXTAREAS */
|
/* MONOSPACE TEXTAREAS */
|
||||||
@ -389,14 +378,16 @@ body.popup .submit-row {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-related h3 {
|
.inline-related h4,
|
||||||
|
.inline-related:not(.tabular) .collapse summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--body-quiet-color);
|
color: var(--body-quiet-color);
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
background: var(--darkened-bg);
|
background: var(--darkened-bg);
|
||||||
border-top: 1px solid var(--hairline-color);
|
border: 1px solid var(--hairline-color);
|
||||||
border-bottom: 1px solid var(--hairline-color);
|
border-left-color: var(--darkened-bg);
|
||||||
|
border-right-color: var(--darkened-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-related h3 span.delete {
|
.inline-related h3 span.delete {
|
||||||
@ -415,16 +406,6 @@ body.popup .submit-row {
|
|||||||
width: 100%;
|
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 {
|
.inline-group .tabular fieldset.module {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
@ -171,7 +171,7 @@ input[type="submit"], button {
|
|||||||
/* Forms */
|
/* Forms */
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 0.875rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -192,7 +192,7 @@ input[type="submit"], button {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row select {
|
.form-row select {
|
||||||
@ -565,10 +565,6 @@ input[type="submit"], button {
|
|||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset.collapsed .form-row {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aligned label {
|
.aligned label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
|
@ -282,6 +282,10 @@ form .form-row p.datetime {
|
|||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-group .tabular td.original p {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.selector .selector-chooser {
|
.selector .selector-chooser {
|
||||||
margin: 0;
|
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.
|
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';
|
'use strict';
|
||||||
{
|
{
|
||||||
function show(selector) {
|
function show(selector) {
|
||||||
|
@ -96,8 +96,8 @@
|
|||||||
// Extract the model from the popup url '.../<model>/add/' or
|
// Extract the model from the popup url '.../<model>/add/' or
|
||||||
// '.../<model>/<id>/change/' depending the action (add or change).
|
// '.../<model>/<id>/change/' depending the action (add or change).
|
||||||
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
|
const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)];
|
||||||
// Exclude autocomplete selects.
|
// Select elements with a specific model reference and context of "available-source".
|
||||||
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`);
|
const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] [data-context="available-source"]`);
|
||||||
|
|
||||||
selectsRelated.forEach(function(select) {
|
selectsRelated.forEach(function(select) {
|
||||||
if (currentSelect === 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';
|
'use strict';
|
||||||
{
|
{
|
||||||
const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse);
|
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" %}
|
{% extends "admin/change_form.html" %}
|
||||||
{% load i18n %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block form_top %}
|
{% block form_top %}
|
||||||
{% if not is_popup %}
|
{% if not is_popup %}
|
||||||
@ -8,3 +8,11 @@
|
|||||||
<p>{% translate "Enter a username and password." %}</p>
|
<p>{% translate "Enter a username and password." %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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 i18n static %}
|
||||||
{% load admin_urls %}
|
{% 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 %}
|
{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %}
|
||||||
{% if not is_popup %}
|
{% if not is_popup %}
|
||||||
{% block breadcrumbs %}
|
{% 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 '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:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||||
› <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -27,10 +31,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>{% blocktranslate with username=original %}Enter a new password for the user <strong>{{ username }}</strong>.{% endblocktranslate %}</p>
|
<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">
|
<fieldset class="module aligned">
|
||||||
|
|
||||||
<div class="form-row">
|
<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 }}
|
{{ form.password1.errors }}
|
||||||
<div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
|
<div class="flex-container">{{ form.password1.label_tag }} {{ form.password1 }}</div>
|
||||||
{% if form.password1.help_text %}
|
{% if form.password1.help_text %}
|
||||||
@ -38,7 +55,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row field-password2">
|
||||||
{{ form.password2.errors }}
|
{{ form.password2.errors }}
|
||||||
<div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
|
<div class="flex-container">{{ form.password2.label_tag }} {{ form.password2 }}</div>
|
||||||
{% if form.password2.help_text %}
|
{% if form.password2.help_text %}
|
||||||
@ -49,9 +66,15 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="submit-row">
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
<script src="{% static 'admin/js/unusable_password_field.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% 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>
|
<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>
|
</svg>
|
||||||
<!-- END SVGs -->
|
<!-- END SVGs -->
|
||||||
|
{% block extrabody %}{% endblock extrabody %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
{% block field_sets %}
|
{% block field_sets %}
|
||||||
{% for fieldset in adminform %}
|
{% for fieldset in adminform %}
|
||||||
{% include "admin/includes/fieldset.html" %}
|
{% include "admin/includes/fieldset.html" with heading_level=2 id_suffix=forloop.counter0 %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<button class="theme-toggle">
|
<button class="theme-toggle">
|
||||||
<div class="visually-hidden theme-label-when-auto">{% translate 'Toggle theme (current theme: auto)' %}</div>
|
<span class="visually-hidden theme-label-when-auto">{% translate 'Toggle theme (current theme: auto)' %}</span>
|
||||||
<div class="visually-hidden theme-label-when-light">{% translate 'Toggle theme (current theme: light)' %}</div>
|
<span class="visually-hidden theme-label-when-light">{% translate 'Toggle theme (current theme: light)' %}</span>
|
||||||
<div class="visually-hidden theme-label-when-dark">{% translate 'Toggle theme (current theme: dark)' %}</div>
|
<span class="visually-hidden theme-label-when-dark">{% translate 'Toggle theme (current theme: dark)' %}</span>
|
||||||
<svg aria-hidden="true" class="theme-icon-when-auto">
|
<svg aria-hidden="true" class="theme-icon-when-auto">
|
||||||
<use xlink:href="#icon-auto" />
|
<use xlink:href="#icon-auto" />
|
||||||
</svg>
|
</svg>
|
||||||
|
@ -3,12 +3,16 @@
|
|||||||
id="{{ inline_admin_formset.formset.prefix }}-group"
|
id="{{ inline_admin_formset.formset.prefix }}-group"
|
||||||
data-inline-type="stacked"
|
data-inline-type="stacked"
|
||||||
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
|
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 %}
|
{% 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 %}
|
{% else %}
|
||||||
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
|
{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% if inline_admin_formset.is_collapsible %}</summary>{% endif %}
|
||||||
{{ inline_admin_formset.formset.management_form }}
|
{{ inline_admin_formset.formset.management_form }}
|
||||||
{{ inline_admin_formset.formset.non_form_errors }}
|
{{ 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 %}
|
{% 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>
|
</h3>
|
||||||
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}
|
{% 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" %}
|
{% with parent_counter=forloop.counter0 %}
|
||||||
{% endfor %}
|
{% 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.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
|
||||||
{% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
|
{% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
|
||||||
</div>{% endfor %}
|
</div>{% endfor %}
|
||||||
|
{% if inline_admin_formset.is_collapsible %}</details>{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,12 +4,16 @@
|
|||||||
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
|
data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
|
||||||
<div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
|
<div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
|
||||||
{{ inline_admin_formset.formset.management_form }}
|
{{ inline_admin_formset.formset.management_form }}
|
||||||
<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.formset.max_num == 1 %}
|
{% if inline_admin_formset.is_collapsible %}<details><summary>{% endif %}
|
||||||
<h2>{{ inline_admin_formset.opts.verbose_name|capfirst }}</h2>
|
<h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading">
|
||||||
{% else %}
|
{% if inline_admin_formset.formset.max_num == 1 %}
|
||||||
<h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
|
{{ inline_admin_formset.opts.verbose_name|capfirst }}
|
||||||
{% endif %}
|
{% 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 }}
|
{{ inline_admin_formset.formset.non_form_errors }}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
@ -61,6 +65,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% if inline_admin_formset.is_collapsible %}</details>{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
<fieldset class="module aligned {{ fieldset.classes }}">
|
{% with prefix=fieldset.formset.prefix|default:"fieldset" id_prefix=id_prefix|default:"0" id_suffix=id_suffix|default:"0" name=fieldset.name|default:""|slugify %}
|
||||||
{% if fieldset.name %}<h2>{{ fieldset.name }}</h2>{% endif %}
|
<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 %}
|
{% if fieldset.description %}
|
||||||
<div class="description">{{ fieldset.description|safe }}</div>
|
<div class="description">{{ fieldset.description|safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -9,7 +14,7 @@
|
|||||||
{% for field in line %}
|
{% for field in line %}
|
||||||
<div>
|
<div>
|
||||||
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
|
{% 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 %}
|
{% if field.is_checkbox %}
|
||||||
{{ field.field }}{{ field.label_tag }}
|
{{ field.field }}{{ field.label_tag }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -31,4 +36,6 @@
|
|||||||
{% if not line.fields|length == 1 %}</div>{% endif %}
|
{% if not line.fields|length == 1 %}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if name and fieldset.is_collapsible %}</details>{% endif %}
|
||||||
</fieldset>
|
</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 %}
|
{% 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">
|
<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>
|
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label></span>{% endif %}<br>
|
||||||
{{ widget.input_text }}:{% endif %}
|
{{ widget.input_text }}:{% endif %}
|
||||||
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.is_initial %}</p>{% 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 %}
|
{% 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 }}
|
{{ rendered_widget }}
|
||||||
{% block links %}
|
{% block links %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
|
@ -18,6 +18,7 @@ from django.contrib.admin.views.main import (
|
|||||||
)
|
)
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.constants import LOOKUP_SEP
|
||||||
from django.template import Library
|
from django.template import Library
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
@ -112,7 +113,7 @@ def result_headers(cl):
|
|||||||
# Set ordering for attr that is a property, if defined.
|
# Set ordering for attr that is a property, if defined.
|
||||||
if isinstance(attr, property) and hasattr(attr, "fget"):
|
if isinstance(attr, property) and hasattr(attr, "fget"):
|
||||||
admin_order_field = getattr(attr.fget, "admin_order_field", None)
|
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
|
is_field_sortable = False
|
||||||
|
|
||||||
if not is_field_sortable:
|
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 import template
|
||||||
from django.contrib.admin.utils import quote
|
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_filters = context.get("preserved_filters")
|
||||||
preserved_qsl = context.get("preserved_qsl")
|
preserved_qsl = context.get("preserved_qsl")
|
||||||
|
|
||||||
parsed_url = list(urlparse(url))
|
parsed_url = list(urlsplit(url))
|
||||||
parsed_qs = dict(parse_qsl(parsed_url[4]))
|
parsed_qs = dict(parse_qsl(parsed_url[3]))
|
||||||
merged_qs = {}
|
merged_qs = {}
|
||||||
|
|
||||||
if preserved_qsl:
|
if preserved_qsl:
|
||||||
@ -66,5 +66,5 @@ def add_preserved_filters(context, url, popup=False, to_field=None):
|
|||||||
|
|
||||||
merged_qs.update(parsed_qs)
|
merged_qs.update(parsed_qs)
|
||||||
|
|
||||||
parsed_url[4] = urlencode(merged_qs)
|
parsed_url[3] = urlencode(merged_qs)
|
||||||
return urlunparse(parsed_url)
|
return urlunsplit(parsed_url)
|
||||||
|
@ -289,8 +289,8 @@ def lookup_field(name, obj, model_admin=None):
|
|||||||
try:
|
try:
|
||||||
f = _get_non_gfk_field(opts, name)
|
f = _get_non_gfk_field(opts, name)
|
||||||
except (FieldDoesNotExist, FieldIsAForeignKeyColumnName):
|
except (FieldDoesNotExist, FieldIsAForeignKeyColumnName):
|
||||||
# For non-field values, the value is either a method, property or
|
# For non-regular field values, the value is either a method,
|
||||||
# returned via a callable.
|
# property, related field, or returned via a callable.
|
||||||
if callable(name):
|
if callable(name):
|
||||||
attr = name
|
attr = name
|
||||||
value = attr(obj)
|
value = attr(obj)
|
||||||
@ -298,10 +298,17 @@ def lookup_field(name, obj, model_admin=None):
|
|||||||
attr = getattr(model_admin, name)
|
attr = getattr(model_admin, name)
|
||||||
value = attr(obj)
|
value = attr(obj)
|
||||||
else:
|
else:
|
||||||
attr = getattr(obj, name)
|
sentinel = object()
|
||||||
|
attr = getattr(obj, name, sentinel)
|
||||||
if callable(attr):
|
if callable(attr):
|
||||||
value = attr()
|
value = attr()
|
||||||
else:
|
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
|
value = attr
|
||||||
if hasattr(model_admin, "model") and hasattr(model_admin.model, name):
|
if hasattr(model_admin, "model") and hasattr(model_admin.model, name):
|
||||||
attr = getattr(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,
|
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
|
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
|
object's attribute, as well as a model field, including across related
|
||||||
return the resolved attribute (which could be a callable). This will be
|
objects. If return_attr is True, also return the resolved attribute
|
||||||
None if (and only if) the name refers to a field.
|
(which could be a callable). This will be None if (and only if) the name
|
||||||
|
refers to a field.
|
||||||
"""
|
"""
|
||||||
attr = None
|
attr = None
|
||||||
try:
|
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:
|
elif form and name in form.fields:
|
||||||
attr = form.fields[name]
|
attr = form.fields[name]
|
||||||
else:
|
else:
|
||||||
message = "Unable to lookup '%s' on %s" % (
|
try:
|
||||||
name,
|
attr = get_fields_from_path(model, name)[-1]
|
||||||
model._meta.object_name,
|
except (FieldDoesNotExist, NotRelationField):
|
||||||
)
|
message = f"Unable to lookup '{name}' on {model._meta.object_name}"
|
||||||
if model_admin:
|
if model_admin:
|
||||||
message += " or %s" % model_admin.__class__.__name__
|
message += f" or {model_admin.__class__.__name__}"
|
||||||
if form:
|
if form:
|
||||||
message += " or %s" % form.__class__.__name__
|
message += f" or {form.__class__.__name__}"
|
||||||
raise AttributeError(message)
|
raise AttributeError(message)
|
||||||
|
|
||||||
if hasattr(attr, "short_description"):
|
if hasattr(attr, "short_description"):
|
||||||
label = attr.short_description
|
label = attr.short_description
|
||||||
|
@ -30,6 +30,7 @@ from django.core.exceptions import (
|
|||||||
)
|
)
|
||||||
from django.core.paginator import InvalidPage
|
from django.core.paginator import InvalidPage
|
||||||
from django.db.models import F, Field, ManyToOneRel, OrderBy
|
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.db.models.expressions import Combinable
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.deprecation import RemovedInDjango60Warning
|
from django.utils.deprecation import RemovedInDjango60Warning
|
||||||
@ -356,9 +357,9 @@ class ChangeList:
|
|||||||
"""
|
"""
|
||||||
Return the proper model field name corresponding to the given
|
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
|
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
|
proper model field, possibly across relations, or the name of a method
|
||||||
callable with the 'admin_order_field' attribute. Return None if no
|
(on the admin or model) or a callable with the 'admin_order_field'
|
||||||
proper model field name can be matched.
|
attribute. Return None if no proper model field name can be matched.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
field = self.lookup_opts.get_field(field_name)
|
field = self.lookup_opts.get_field(field_name)
|
||||||
@ -371,7 +372,12 @@ class ChangeList:
|
|||||||
elif hasattr(self.model_admin, field_name):
|
elif hasattr(self.model_admin, field_name):
|
||||||
attr = getattr(self.model_admin, field_name)
|
attr = getattr(self.model_admin, field_name)
|
||||||
else:
|
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"):
|
if isinstance(attr, property) and hasattr(attr, "fget"):
|
||||||
attr = attr.fget
|
attr = attr.fget
|
||||||
return getattr(attr, "admin_order_field", None)
|
return getattr(attr, "admin_order_field", None)
|
||||||
@ -612,7 +618,7 @@ class ChangeList:
|
|||||||
else:
|
else:
|
||||||
if isinstance(field.remote_field, ManyToOneRel):
|
if isinstance(field.remote_field, ManyToOneRel):
|
||||||
# <FK>_id field names don't require a join.
|
# <FK>_id field names don't require a join.
|
||||||
if field_name != field.get_attname():
|
if field_name != field.attname:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -272,6 +272,8 @@ class RelatedFieldWidgetWrapper(forms.Widget):
|
|||||||
self.can_add_related = can_add_related
|
self.can_add_related = can_add_related
|
||||||
# XXX: The UX does not support multiple selected values.
|
# XXX: The UX does not support multiple selected values.
|
||||||
multiple = getattr(widget, "allow_multiple_selected", False)
|
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
|
self.can_change_related = not multiple and can_change_related
|
||||||
# XXX: The deletion UX can be confusing when dealing with cascading deletion.
|
# XXX: The deletion UX can be confusing when dealing with cascading deletion.
|
||||||
cascade = getattr(rel, "on_delete", None) is CASCADE
|
cascade = getattr(rel, "on_delete", None) is CASCADE
|
||||||
@ -329,6 +331,7 @@ class RelatedFieldWidgetWrapper(forms.Widget):
|
|||||||
"name": name,
|
"name": name,
|
||||||
"url_params": url_params,
|
"url_params": url_params,
|
||||||
"model": rel_opts.verbose_name,
|
"model": rel_opts.verbose_name,
|
||||||
|
"model_name": rel_opts.model_name,
|
||||||
"can_add_related": self.can_add_related,
|
"can_add_related": self.can_add_related,
|
||||||
"can_change_related": self.can_change_related,
|
"can_change_related": self.can_change_related,
|
||||||
"can_delete_related": self.can_delete_related,
|
"can_delete_related": self.can_delete_related,
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{% block extrahead %}
|
{% block extrahead %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<style type="text/css">
|
<style>
|
||||||
.module table { width:100%; }
|
.module table { width:100%; }
|
||||||
.module table p { padding: 0; margin: 0; }
|
.module table p { padding: 0; margin: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
@ -359,7 +359,7 @@ class ModelDetailView(BaseAdminDocsView):
|
|||||||
"app_label": rel.related_model._meta.app_label,
|
"app_label": rel.related_model._meta.app_label,
|
||||||
"object_name": rel.related_model._meta.object_name,
|
"object_name": rel.related_model._meta.object_name,
|
||||||
}
|
}
|
||||||
accessor = rel.get_accessor_name()
|
accessor = rel.accessor_name
|
||||||
fields.append(
|
fields.append(
|
||||||
{
|
{
|
||||||
"name": "%s.all" % accessor,
|
"name": "%s.all" % accessor,
|
||||||
|
@ -269,4 +269,6 @@ def update_session_auth_hash(request, user):
|
|||||||
|
|
||||||
async def aupdate_session_auth_hash(request, user):
|
async def aupdate_session_auth_hash(request, user):
|
||||||
"""See update_session_auth_hash()."""
|
"""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,
|
None,
|
||||||
{
|
{
|
||||||
"classes": ("wide",),
|
"classes": ("wide",),
|
||||||
"fields": ("username", "password1", "password2"),
|
"fields": ("username", "usable_password", "password1", "password2"),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -164,10 +164,27 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = self.change_password_form(user, request.POST)
|
form = self.change_password_form(user, request.POST)
|
||||||
if form.is_valid():
|
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)
|
change_message = self.construct_change_message(request, form, None)
|
||||||
self.log_change(request, user, change_message)
|
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)
|
messages.success(request, msg)
|
||||||
update_session_auth_hash(request, form.user)
|
update_session_auth_hash(request, form.user)
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
@ -187,8 +204,12 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = [(None, {"fields": list(form.base_fields)})]
|
fieldsets = [(None, {"fields": list(form.base_fields)})]
|
||||||
admin_form = admin.helpers.AdminForm(form, fieldsets, {})
|
admin_form = admin.helpers.AdminForm(form, fieldsets, {})
|
||||||
|
|
||||||
|
if user.has_usable_password():
|
||||||
|
title = _("Change password: %s")
|
||||||
|
else:
|
||||||
|
title = _("Set password: %s")
|
||||||
context = {
|
context = {
|
||||||
"title": _("Change password: %s") % escape(user.get_username()),
|
"title": title % escape(user.get_username()),
|
||||||
"adminForm": admin_form,
|
"adminForm": admin_form,
|
||||||
"form_url": form_url,
|
"form_url": form_url,
|
||||||
"form": form,
|
"form": form,
|
||||||
|
@ -5,7 +5,7 @@ from django.db.models.signals import post_migrate
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from . import get_user_model
|
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 .management import create_permissions
|
||||||
from .signals import user_logged_in
|
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")
|
user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
|
||||||
checks.register(check_user_model, checks.Tags.models)
|
checks.register(check_user_model, checks.Tags.models)
|
||||||
checks.register(check_models_permissions, 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.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import checks
|
from django.core import checks
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
from .management import _get_builtin_permissions
|
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):
|
def check_user_model(app_configs=None, **kwargs):
|
||||||
if app_configs is None:
|
if app_configs is None:
|
||||||
cls = apps.get_model(settings.AUTH_USER_MODEL)
|
cls = apps.get_model(settings.AUTH_USER_MODEL)
|
||||||
@ -218,3 +235,28 @@ def check_models_permissions(app_configs=None, **kwargs):
|
|||||||
codenames.add(codename)
|
codenames.add(codename)
|
||||||
|
|
||||||
return errors
|
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 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.conf import settings
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||||
@ -17,16 +20,13 @@ def user_passes_test(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
def _redirect_to_login(request):
|
||||||
def _wrapper_view(request, *args, **kwargs):
|
|
||||||
if test_func(request.user):
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
path = request.build_absolute_uri()
|
path = request.build_absolute_uri()
|
||||||
resolved_login_url = resolve_url(login_url or settings.LOGIN_URL)
|
resolved_login_url = resolve_url(login_url or settings.LOGIN_URL)
|
||||||
# If the login url is the same scheme and net location then just
|
# If the login url is the same scheme and net location then just
|
||||||
# use the path as the "next" url.
|
# use the path as the "next" url.
|
||||||
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
|
login_scheme, login_netloc = urlsplit(resolved_login_url)[:2]
|
||||||
current_scheme, current_netloc = urlparse(path)[:2]
|
current_scheme, current_netloc = urlsplit(path)[:2]
|
||||||
if (not login_scheme or login_scheme == current_scheme) and (
|
if (not login_scheme or login_scheme == current_scheme) and (
|
||||||
not login_netloc or login_netloc == current_netloc
|
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 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
|
return decorator
|
||||||
|
|
||||||
@ -57,6 +86,14 @@ def login_required(
|
|||||||
return actual_decorator
|
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):
|
def permission_required(perm, login_url=None, raise_exception=False):
|
||||||
"""
|
"""
|
||||||
Decorator for views that checks whether a user has a particular permission
|
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
|
If the raise_exception parameter is given the PermissionDenied exception
|
||||||
is raised.
|
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:
|
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):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
|
usable_password = value and not value.startswith(UNUSABLE_PASSWORD_PREFIX)
|
||||||
summary = []
|
summary = []
|
||||||
if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX):
|
if usable_password:
|
||||||
summary.append({"label": gettext("No password set.")})
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
hasher = identify_hasher(value)
|
hasher = identify_hasher(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -53,7 +52,12 @@ class ReadOnlyPasswordHashWidget(forms.Widget):
|
|||||||
else:
|
else:
|
||||||
for key, value_ in hasher.safe_summary(value).items():
|
for key, value_ in hasher.safe_summary(value).items():
|
||||||
summary.append({"label": gettext(key), "value": value_})
|
summary.append({"label": gettext(key), "value": value_})
|
||||||
|
else:
|
||||||
|
summary.append({"label": gettext("No password set.")})
|
||||||
context["summary"] = summary
|
context["summary"] = summary
|
||||||
|
context["button_label"] = (
|
||||||
|
_("Reset password") if usable_password else _("Set password")
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def id_for_label(self, id_):
|
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
|
Form mixin that validates and sets a password for a user.
|
||||||
password.
|
|
||||||
|
This mixin also support setting an unusable password for a user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
"password_mismatch": _("The two password fields didn’t match."),
|
"password_mismatch": _("The two password fields didn’t match."),
|
||||||
}
|
}
|
||||||
password1 = forms.CharField(
|
usable_password_help_text = _(
|
||||||
label=_("Password"),
|
"Whether the user will be able to authenticate using a password or not. "
|
||||||
strip=False,
|
"If disabled, they may still be able to authenticate using other backends, "
|
||||||
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
|
"such as Single Sign-On or LDAP."
|
||||||
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."),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("username",)
|
fields = ("username",)
|
||||||
@ -123,34 +214,21 @@ class BaseUserCreationForm(forms.ModelForm):
|
|||||||
"autofocus"
|
"autofocus"
|
||||||
] = True
|
] = True
|
||||||
|
|
||||||
def clean_password2(self):
|
def clean(self):
|
||||||
password1 = self.cleaned_data.get("password1")
|
self.validate_passwords()
|
||||||
password2 = self.cleaned_data.get("password2")
|
return super().clean()
|
||||||
if password1 and password2 and password1 != password2:
|
|
||||||
raise ValidationError(
|
|
||||||
self.error_messages["password_mismatch"],
|
|
||||||
code="password_mismatch",
|
|
||||||
)
|
|
||||||
return password2
|
|
||||||
|
|
||||||
def _post_clean(self):
|
def _post_clean(self):
|
||||||
super()._post_clean()
|
super()._post_clean()
|
||||||
# Validate the password after self.instance is updated with form data
|
# Validate the password after self.instance is updated with form data
|
||||||
# by super().
|
# by super().
|
||||||
password = self.cleaned_data.get("password2")
|
self.validate_password_for_user(self.instance)
|
||||||
if password:
|
|
||||||
try:
|
|
||||||
password_validation.validate_password(password, self.instance)
|
|
||||||
except ValidationError as error:
|
|
||||||
self.add_error("password2", error)
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
user = super().save(commit=False)
|
user = super().save(commit=False)
|
||||||
user.set_password(self.cleaned_data["password1"])
|
user = self.set_password_and_save(user, commit=commit)
|
||||||
if commit:
|
if commit and hasattr(self, "save_m2m"):
|
||||||
user.save()
|
self.save_m2m()
|
||||||
if hasattr(self, "save_m2m"):
|
|
||||||
self.save_m2m()
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@ -179,9 +257,8 @@ class UserChangeForm(forms.ModelForm):
|
|||||||
password = ReadOnlyPasswordHashField(
|
password = ReadOnlyPasswordHashField(
|
||||||
label=_("Password"),
|
label=_("Password"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Raw passwords are not stored, so there is no way to see this "
|
"Raw passwords are not stored, so there is no way to see "
|
||||||
"user’s password, but you can change the password using "
|
"the user’s password."
|
||||||
'<a href="{}">this form</a>.'
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -194,9 +271,11 @@ class UserChangeForm(forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
password = self.fields.get("password")
|
password = self.fields.get("password")
|
||||||
if password:
|
if password:
|
||||||
password.help_text = password.help_text.format(
|
if self.instance and not self.instance.has_usable_password():
|
||||||
f"../../{self.instance.pk}/password/"
|
password.help_text = _(
|
||||||
)
|
"Enable password-based authentication for this user by setting a "
|
||||||
|
"password."
|
||||||
|
)
|
||||||
user_permissions = self.fields.get("user_permissions")
|
user_permissions = self.fields.get("user_permissions")
|
||||||
if user_permissions:
|
if user_permissions:
|
||||||
user_permissions.queryset = user_permissions.queryset.select_related(
|
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
|
A form that lets a user set their password without entering the old
|
||||||
password
|
password
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error_messages = {
|
new_password1, new_password2 = SetPasswordMixin.create_password_fields(
|
||||||
"password_mismatch": _("The two password fields didn’t match."),
|
label1=_("New password"), label2=_("New password confirmation")
|
||||||
}
|
|
||||||
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"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
self.user = user
|
self.user = user
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def clean_new_password2(self):
|
def clean(self):
|
||||||
password1 = self.cleaned_data.get("new_password1")
|
self.validate_passwords("new_password1", "new_password2")
|
||||||
password2 = self.cleaned_data.get("new_password2")
|
self.validate_password_for_user(self.user, "new_password2")
|
||||||
if password1 and password2 and password1 != password2:
|
return super().clean()
|
||||||
raise ValidationError(
|
|
||||||
self.error_messages["password_mismatch"],
|
|
||||||
code="password_mismatch",
|
|
||||||
)
|
|
||||||
password_validation.validate_password(password2, self.user)
|
|
||||||
return password2
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
password = self.cleaned_data["new_password1"]
|
return self.set_password_and_save(self.user, "new_password1", commit=commit)
|
||||||
self.user.set_password(password)
|
|
||||||
if commit:
|
|
||||||
self.user.save()
|
|
||||||
return self.user
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordChangeForm(SetPasswordForm):
|
class PasswordChangeForm(SetPasswordForm):
|
||||||
@ -462,57 +520,41 @@ class PasswordChangeForm(SetPasswordForm):
|
|||||||
return old_password
|
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.
|
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"
|
required_css_class = "required"
|
||||||
password1 = forms.CharField(
|
usable_password_help_text = SetPasswordMixin.usable_password_help_text + (
|
||||||
label=_("Password"),
|
'<ul id="id_unusable_warning" class="messagelist"><li class="warning">'
|
||||||
widget=forms.PasswordInput(
|
"If disabled, the current password for this user will be lost.</li></ul>"
|
||||||
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."),
|
|
||||||
)
|
)
|
||||||
|
password1, password2 = SetPasswordMixin.create_password_fields()
|
||||||
|
|
||||||
def __init__(self, user, *args, **kwargs):
|
def __init__(self, user, *args, **kwargs):
|
||||||
self.user = user
|
self.user = user
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["password1"].widget.attrs["autofocus"] = True
|
||||||
def clean_password2(self):
|
if self.user.has_usable_password():
|
||||||
password1 = self.cleaned_data.get("password1")
|
self.fields["usable_password"] = (
|
||||||
password2 = self.cleaned_data.get("password2")
|
SetPasswordMixin.create_usable_password_field(
|
||||||
if password1 and password2 and password1 != password2:
|
self.usable_password_help_text
|
||||||
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()
|
||||||
|
self.validate_password_for_user(self.user)
|
||||||
|
return super().clean()
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
"""Save the new password."""
|
"""Save the new password."""
|
||||||
password = self.cleaned_data["password1"]
|
return self.set_password_and_save(self.user, commit=commit)
|
||||||
self.user.set_password(password)
|
|
||||||
if commit:
|
|
||||||
self.user.save()
|
|
||||||
return self.user
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def changed_data(self):
|
def changed_data(self):
|
||||||
data = super().changed_data
|
data = super().changed_data
|
||||||
for name in self.fields:
|
if "set_usable_password" in data or "password1" in data and "password2" in data:
|
||||||
if name not in data:
|
return ["password"]
|
||||||
return []
|
return []
|
||||||
return ["password"]
|
|
||||||
|
@ -312,7 +312,7 @@ class PBKDF2PasswordHasher(BasePasswordHasher):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
algorithm = "pbkdf2_sha256"
|
algorithm = "pbkdf2_sha256"
|
||||||
iterations = 870000
|
iterations = 1_000_000
|
||||||
digest = hashlib.sha256
|
digest = hashlib.sha256
|
||||||
|
|
||||||
def encode(self, password, salt, iterations=None):
|
def encode(self, password, salt, iterations=None):
|
||||||
@ -570,7 +570,7 @@ class ScryptPasswordHasher(BasePasswordHasher):
|
|||||||
algorithm = "scrypt"
|
algorithm = "scrypt"
|
||||||
block_size = 8
|
block_size = 8
|
||||||
maxmem = 0
|
maxmem = 0
|
||||||
parallelism = 1
|
parallelism = 5
|
||||||
work_factor = 2**14
|
work_factor = 2**14
|
||||||
|
|
||||||
def encode(self, password, salt, n=None, r=None, p=None):
|
def encode(self, password, salt, n=None, r=None, p=None):
|
||||||
|
@ -4,7 +4,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Django\n"
|
"Project-Id-Version: Django\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: 2010-05-13 15:35+0200\n"
|
||||||
"Last-Translator: Django team\n"
|
"Last-Translator: Django team\n"
|
||||||
"Language-Team: English <en@li.org>\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."
|
msgid "%(name)s object with primary key %(key)r does not exist."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: contrib/auth/admin.py:177
|
||||||
|
msgid "Conflicting form data submitted. Please try again."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: contrib/auth/admin.py:168
|
#: contrib/auth/admin.py:168
|
||||||
msgid "Password changed successfully."
|
msgid "Password changed successfully."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: contrib/auth/admin.py:187
|
||||||
|
msgid "Password-based authentication was disabled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: contrib/auth/admin.py:189
|
#: contrib/auth/admin.py:189
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Change password: %s"
|
msgid "Change password: %s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: contrib/auth/admin.py:210
|
||||||
|
#, python-format
|
||||||
|
msgid "Set password: %s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: contrib/auth/apps.py:16
|
#: contrib/auth/apps.py:16
|
||||||
msgid "Authentication and Authorization"
|
msgid "Authentication and Authorization"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -60,10 +73,25 @@ msgstr ""
|
|||||||
msgid "Invalid password format or unknown hashing algorithm."
|
msgid "Invalid password format or unknown hashing algorithm."
|
||||||
msgstr ""
|
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
|
#: contrib/auth/forms.py:91 contrib/auth/forms.py:379 contrib/auth/forms.py:457
|
||||||
msgid "The two password fields didn’t match."
|
msgid "The two password fields didn’t match."
|
||||||
msgstr ""
|
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:94 contrib/auth/forms.py:166 contrib/auth/forms.py:201
|
||||||
#: contrib/auth/forms.py:461
|
#: contrib/auth/forms.py:461
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
@ -77,10 +105,26 @@ msgstr ""
|
|||||||
msgid "Enter the same password as before, for verification."
|
msgid "Enter the same password as before, for verification."
|
||||||
msgstr ""
|
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 ""
|
msgid ""
|
||||||
"Raw passwords are not stored, so there is no way to see this user’s "
|
"Raw passwords are not stored, so there is no way to see the user’s password."
|
||||||
"password, but you can change the password using <a href=\"{}\">this form</a>."
|
msgstr ""
|
||||||
|
|
||||||
|
#: contrib/auth/forms.py:276
|
||||||
|
msgid ""
|
||||||
|
"Enable password-based authentication for this user by setting a password."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: contrib/auth/forms.py:208
|
#: contrib/auth/forms.py:208
|
||||||
@ -114,10 +158,6 @@ msgstr ""
|
|||||||
msgid "Old password"
|
msgid "Old password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: contrib/auth/forms.py:469
|
|
||||||
msgid "Password (again)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: contrib/auth/hashers.py:327 contrib/auth/hashers.py:420
|
#: contrib/auth/hashers.py:327 contrib/auth/hashers.py:420
|
||||||
#: contrib/auth/hashers.py:510 contrib/auth/hashers.py:605
|
#: contrib/auth/hashers.py:510 contrib/auth/hashers.py:605
|
||||||
#: contrib/auth/hashers.py:665 contrib/auth/hashers.py:707
|
#: contrib/auth/hashers.py:665 contrib/auth/hashers.py:707
|
||||||
|
@ -46,6 +46,13 @@ def create_permissions(
|
|||||||
if not app_config.models_module:
|
if not app_config.models_module:
|
||||||
return
|
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
|
# Ensure that contenttypes are created for this app. Needed if
|
||||||
# 'django.contrib.auth' is in INSTALLED_APPS before
|
# 'django.contrib.auth' is in INSTALLED_APPS before
|
||||||
# 'django.contrib.contenttypes'.
|
# 'django.contrib.contenttypes'.
|
||||||
@ -62,28 +69,15 @@ def create_permissions(
|
|||||||
try:
|
try:
|
||||||
app_config = apps.get_app_config(app_label)
|
app_config = apps.get_app_config(app_label)
|
||||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||||
Permission = apps.get_model("auth", "Permission")
|
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not router.allow_migrate_model(using, Permission):
|
models = list(app_config.get_models())
|
||||||
return
|
|
||||||
|
|
||||||
# This will hold the permissions we're looking for as
|
# Grab all the ContentTypes.
|
||||||
# (content_type, (codename, name))
|
ctypes = ContentType.objects.db_manager(using).get_for_models(
|
||||||
searched_perms = []
|
*models, for_concrete_models=False
|
||||||
# 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))
|
|
||||||
|
|
||||||
# Find all the Permissions that have a content_type for a model we're
|
# 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
|
# looking for. We don't need to check for codenames since we already have
|
||||||
@ -91,20 +85,22 @@ def create_permissions(
|
|||||||
all_perms = set(
|
all_perms = set(
|
||||||
Permission.objects.using(using)
|
Permission.objects.using(using)
|
||||||
.filter(
|
.filter(
|
||||||
content_type__in=ctypes,
|
content_type__in=set(ctypes.values()),
|
||||||
)
|
)
|
||||||
.values_list("content_type", "codename")
|
.values_list("content_type", "codename")
|
||||||
)
|
)
|
||||||
|
|
||||||
perms = []
|
perms = []
|
||||||
for ct, (codename, name) in searched_perms:
|
for model in models:
|
||||||
if (ct.pk, codename) not in all_perms:
|
ctype = ctypes[model]
|
||||||
permission = Permission()
|
for codename, name in _get_all_permissions(model._meta):
|
||||||
permission._state.db = using
|
if (ctype.pk, codename) not in all_perms:
|
||||||
permission.codename = codename
|
permission = Permission()
|
||||||
permission.name = name
|
permission._state.db = using
|
||||||
permission.content_type = ct
|
permission.codename = codename
|
||||||
perms.append(permission)
|
permission.name = name
|
||||||
|
permission.content_type = ctype
|
||||||
|
perms.append(permission)
|
||||||
|
|
||||||
Permission.objects.using(using).bulk_create(perms)
|
Permission.objects.using(using).bulk_create(perms)
|
||||||
if verbosity >= 2:
|
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.contrib.auth.password_validation import validate_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
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()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--database",
|
"--database",
|
||||||
default=DEFAULT_DB_ALIAS,
|
default=DEFAULT_DB_ALIAS,
|
||||||
|
choices=tuple(connections),
|
||||||
help='Specifies the database to use. Default is "default".',
|
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.contrib.auth.password_validation import validate_password
|
||||||
from django.core import exceptions
|
from django.core import exceptions
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
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.functional import cached_property
|
||||||
from django.utils.text import capfirst
|
from django.utils.text import capfirst
|
||||||
|
|
||||||
@ -56,6 +56,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--database",
|
"--database",
|
||||||
default=DEFAULT_DB_ALIAS,
|
default=DEFAULT_DB_ALIAS,
|
||||||
|
choices=tuple(connections),
|
||||||
help='Specifies the database to use. Default is "default".',
|
help='Specifies the database to use. Default is "default".',
|
||||||
)
|
)
|
||||||
for field_name in self.UserModel.REQUIRED_FIELDS:
|
for field_name in self.UserModel.REQUIRED_FIELDS:
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import auth
|
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.backends import RemoteUserBackend
|
||||||
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.shortcuts import resolve_url
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
from django.utils.functional import SimpleLazyObject
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
|
||||||
@ -34,6 +38,56 @@ class AuthenticationMiddleware(MiddlewareMixin):
|
|||||||
request.auser = partial(auser, request)
|
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):
|
class RemoteUserMiddleware(MiddlewareMixin):
|
||||||
"""
|
"""
|
||||||
Middleware for utilizing web-server-provided authentication.
|
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.conf import settings
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
from django.contrib.auth import REDIRECT_FIELD_NAME
|
||||||
@ -51,8 +51,8 @@ class AccessMixin:
|
|||||||
resolved_login_url = resolve_url(self.get_login_url())
|
resolved_login_url = resolve_url(self.get_login_url())
|
||||||
# If the login url is the same scheme and net location then use the
|
# If the login url is the same scheme and net location then use the
|
||||||
# path as the "next" url.
|
# path as the "next" url.
|
||||||
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
|
login_scheme, login_netloc = urlsplit(resolved_login_url)[:2]
|
||||||
current_scheme, current_netloc = urlparse(path)[:2]
|
current_scheme, current_netloc = urlsplit(path)[:2]
|
||||||
if (not login_scheme or login_scheme == current_scheme) and (
|
if (not login_scheme or login_scheme == current_scheme) and (
|
||||||
not login_netloc or login_netloc == current_netloc
|
not login_netloc or login_netloc == current_netloc
|
||||||
):
|
):
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
<div{% include 'django/forms/widgets/attrs.html' %}>
|
<div{% include 'django/forms/widgets/attrs.html' %}>
|
||||||
{% for entry in summary %}
|
<p>
|
||||||
<strong>{{ entry.label }}</strong>{% if entry.value %}: <bdi>{{ entry.value }}</bdi>{% endif %}
|
{% for entry in summary %}
|
||||||
{% endfor %}
|
<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>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
from django.conf import settings
|
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 login as auth_login
|
||||||
from django.contrib.auth import logout as auth_logout
|
from django.contrib.auth import logout as auth_logout
|
||||||
from django.contrib.auth import update_session_auth_hash
|
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 (
|
from django.contrib.auth.forms import (
|
||||||
AuthenticationForm,
|
AuthenticationForm,
|
||||||
PasswordChangeForm,
|
PasswordChangeForm,
|
||||||
@ -62,6 +62,7 @@ class RedirectURLMixin:
|
|||||||
raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
|
raise ImproperlyConfigured("No URL to redirect to. Provide a next_page.")
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class LoginView(RedirectURLMixin, FormView):
|
class LoginView(RedirectURLMixin, FormView):
|
||||||
"""
|
"""
|
||||||
Display the login form and handle the login action.
|
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)
|
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:
|
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
|
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
|
# Class-based password reset views
|
||||||
@ -210,6 +211,7 @@ class PasswordContextMixin:
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class PasswordResetView(PasswordContextMixin, FormView):
|
class PasswordResetView(PasswordContextMixin, FormView):
|
||||||
email_template_name = "registration/password_reset_email.html"
|
email_template_name = "registration/password_reset_email.html"
|
||||||
extra_email_context = None
|
extra_email_context = None
|
||||||
@ -244,11 +246,13 @@ class PasswordResetView(PasswordContextMixin, FormView):
|
|||||||
INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
|
INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class PasswordResetDoneView(PasswordContextMixin, TemplateView):
|
class PasswordResetDoneView(PasswordContextMixin, TemplateView):
|
||||||
template_name = "registration/password_reset_done.html"
|
template_name = "registration/password_reset_done.html"
|
||||||
title = _("Password reset sent")
|
title = _("Password reset sent")
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class PasswordResetConfirmView(PasswordContextMixin, FormView):
|
class PasswordResetConfirmView(PasswordContextMixin, FormView):
|
||||||
form_class = SetPasswordForm
|
form_class = SetPasswordForm
|
||||||
post_reset_login = False
|
post_reset_login = False
|
||||||
@ -335,6 +339,7 @@ class PasswordResetConfirmView(PasswordContextMixin, FormView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
|
class PasswordResetCompleteView(PasswordContextMixin, TemplateView):
|
||||||
template_name = "registration/password_reset_complete.html"
|
template_name = "registration/password_reset_complete.html"
|
||||||
title = _("Password reset complete")
|
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 import DEFAULT_DB_ALIAS, models, router, transaction
|
||||||
from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel
|
from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel
|
||||||
from django.db.models.base import ModelBase, make_foreign_order_accessors
|
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.mixins import FieldCacheMixin
|
||||||
from django.db.models.fields.related import (
|
from django.db.models.fields.related import (
|
||||||
ReverseManyToOneDescriptor,
|
ReverseManyToOneDescriptor,
|
||||||
@ -24,7 +25,7 @@ from django.utils.deprecation import RemovedInDjango60Warning
|
|||||||
from django.utils.functional import cached_property
|
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
|
Provide a generic many-to-one relation through the ``content_type`` and
|
||||||
``object_id`` fields.
|
``object_id`` fields.
|
||||||
@ -33,35 +34,28 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
ForwardManyToOneDescriptor) by adding itself as a model attribute.
|
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_many = False
|
||||||
many_to_one = True
|
many_to_one = True
|
||||||
one_to_many = False
|
one_to_many = False
|
||||||
one_to_one = False
|
one_to_one = False
|
||||||
related_model = None
|
|
||||||
remote_field = None
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, ct_field="content_type", fk_field="object_id", for_concrete_model=True
|
self, ct_field="content_type", fk_field="object_id", for_concrete_model=True
|
||||||
):
|
):
|
||||||
|
super().__init__(editable=False)
|
||||||
self.ct_field = ct_field
|
self.ct_field = ct_field
|
||||||
self.fk_field = fk_field
|
self.fk_field = fk_field
|
||||||
self.for_concrete_model = for_concrete_model
|
self.for_concrete_model = for_concrete_model
|
||||||
self.editable = False
|
self.is_relation = True
|
||||||
self.rel = None
|
|
||||||
self.column = None
|
|
||||||
|
|
||||||
def contribute_to_class(self, cls, name, **kwargs):
|
def contribute_to_class(self, cls, name, **kwargs):
|
||||||
self.name = name
|
super().contribute_to_class(cls, name, private_only=True, **kwargs)
|
||||||
self.model = cls
|
# GenericForeignKey is its own descriptor.
|
||||||
cls._meta.add_field(self, private=True)
|
setattr(cls, self.attname, self)
|
||||||
setattr(cls, name, self)
|
|
||||||
|
def get_attname_column(self):
|
||||||
|
attname, column = super().get_attname_column()
|
||||||
|
return attname, None
|
||||||
|
|
||||||
def get_filter_kwargs_for_object(self, obj):
|
def get_filter_kwargs_for_object(self, obj):
|
||||||
"""See corresponding method on Field"""
|
"""See corresponding method on Field"""
|
||||||
@ -77,10 +71,6 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
self.ct_field: ContentType.objects.get_for_model(obj).pk,
|
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):
|
def check(self, **kwargs):
|
||||||
return [
|
return [
|
||||||
*self._check_field_name(),
|
*self._check_field_name(),
|
||||||
@ -88,18 +78,6 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
*self._check_content_type_field(),
|
*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):
|
def _check_object_id_field(self):
|
||||||
try:
|
try:
|
||||||
self.model._meta.get_field(self.fk_field)
|
self.model._meta.get_field(self.fk_field)
|
||||||
@ -162,7 +140,8 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_cache_name(self):
|
@cached_property
|
||||||
|
def cache_name(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_content_type(self, obj=None, id=None, using=None, model=None):
|
def get_content_type(self, obj=None, id=None, using=None, model=None):
|
||||||
@ -209,7 +188,7 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
fk_dict = defaultdict(set)
|
fk_dict = defaultdict(set)
|
||||||
# We need one instance for each group in order to get the right db:
|
# We need one instance for each group in order to get the right db:
|
||||||
instance_dict = {}
|
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:
|
for instance in instances:
|
||||||
# We avoid looking for values if either ct_id or fkey value is None
|
# We avoid looking for values if either ct_id or fkey value is None
|
||||||
ct_id = getattr(instance, ct_attname)
|
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,
|
# content type ID here, and later when the actual instance is needed,
|
||||||
# use ContentType.objects.get_for_id(), which has a global cache.
|
# use ContentType.objects.get_for_id(), which has a global cache.
|
||||||
f = self.model._meta.get_field(self.ct_field)
|
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)
|
pk_val = getattr(instance, self.fk_field)
|
||||||
|
|
||||||
rel_obj = self.get_cached_value(instance, default=None)
|
rel_obj = self.get_cached_value(instance, default=None)
|
||||||
@ -280,7 +259,9 @@ class GenericForeignKey(FieldCacheMixin):
|
|||||||
if ct_id is not None:
|
if ct_id is not None:
|
||||||
ct = self.get_content_type(id=ct_id, using=instance._state.db)
|
ct = self.get_content_type(id=ct_id, using=instance._state.db)
|
||||||
try:
|
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:
|
except ObjectDoesNotExist:
|
||||||
pass
|
pass
|
||||||
self.set_cached_value(instance, rel_obj)
|
self.set_cached_value(instance, rel_obj)
|
||||||
|
@ -67,10 +67,10 @@ class BaseGenericInlineFormSet(BaseModelFormSet):
|
|||||||
def save_new(self, form, commit=True):
|
def save_new(self, form, commit=True):
|
||||||
setattr(
|
setattr(
|
||||||
form.instance,
|
form.instance,
|
||||||
self.ct_field.get_attname(),
|
self.ct_field.attname,
|
||||||
ContentType.objects.get_for_model(self.instance).pk,
|
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)
|
return form.save(commit=commit)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import itertools
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.management import BaseCommand
|
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
|
from django.db.models.deletion import Collector
|
||||||
|
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--database",
|
"--database",
|
||||||
default=DEFAULT_DB_ALIAS,
|
default=DEFAULT_DB_ALIAS,
|
||||||
|
choices=tuple(connections),
|
||||||
help='Nominates the database to use. Defaults to the "default" database.',
|
help='Nominates the database to use. Defaults to the "default" database.',
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
@ -174,20 +174,20 @@ class ContentType(models.Model):
|
|||||||
except LookupError:
|
except LookupError:
|
||||||
return None
|
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.
|
Return an object of this type for the keyword arguments given.
|
||||||
Basically, this is a proxy around this object_type's get_object() model
|
Basically, this is a proxy around this object_type's get_object() model
|
||||||
method. The ObjectNotExist exception, if thrown, will not be caught,
|
method. The ObjectNotExist exception, if thrown, will not be caught,
|
||||||
so code that calls this method should catch it.
|
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):
|
def get_all_objects_for_this_type(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Return all objects of this type for the keyword arguments given.
|
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):
|
def natural_key(self):
|
||||||
return (self.app_label, self.model)
|
return (self.app_label, self.model)
|
||||||
|
@ -3,7 +3,7 @@ from django.db.models.query import ModelIterable, RawQuerySet
|
|||||||
|
|
||||||
|
|
||||||
class GenericPrefetch(Prefetch):
|
class GenericPrefetch(Prefetch):
|
||||||
def __init__(self, lookup, querysets=None, to_attr=None):
|
def __init__(self, lookup, querysets, to_attr=None):
|
||||||
for queryset in querysets:
|
for queryset in querysets:
|
||||||
if queryset is not None and (
|
if queryset is not None and (
|
||||||
isinstance(queryset, RawQuerySet)
|
isinstance(queryset, RawQuerySet)
|
||||||
|
@ -54,7 +54,7 @@ class MySQLGISSchemaEditor(DatabaseSchemaEditor):
|
|||||||
self.create_spatial_indexes()
|
self.create_spatial_indexes()
|
||||||
|
|
||||||
def remove_field(self, model, field):
|
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)
|
index_name = self._create_spatial_index_name(model, field)
|
||||||
sql = self._delete_index_sql(model, index_name)
|
sql = self._delete_index_sql(model, index_name)
|
||||||
try:
|
try:
|
||||||
|
@ -203,7 +203,7 @@ class PostGISOperations(BaseSpatialOperations, DatabaseOperations):
|
|||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
'Cannot determine PostGIS version for database "%s" '
|
'Cannot determine PostGIS version for database "%s" '
|
||||||
'using command "SELECT postgis_lib_version()". '
|
'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 "
|
"Was the database created from a spatial database "
|
||||||
"template?" % self.connection.settings_dict["NAME"]
|
"template?" % self.connection.settings_dict["NAME"]
|
||||||
)
|
)
|
||||||
|
@ -34,17 +34,14 @@ class GeoAggregate(Aggregate):
|
|||||||
tolerance = self.extra.get("tolerance") or getattr(self, "tolerance", 0.05)
|
tolerance = self.extra.get("tolerance") or getattr(self, "tolerance", 0.05)
|
||||||
clone = self.copy()
|
clone = self.copy()
|
||||||
source_expressions = self.get_source_expressions()
|
source_expressions = self.get_source_expressions()
|
||||||
if self.filter:
|
source_expressions.pop() # Don't wrap filters with SDOAGGRTYPE().
|
||||||
source_expressions.pop()
|
|
||||||
spatial_type_expr = Func(
|
spatial_type_expr = Func(
|
||||||
*source_expressions,
|
*source_expressions,
|
||||||
Value(tolerance),
|
Value(tolerance),
|
||||||
function="SDOAGGRTYPE",
|
function="SDOAGGRTYPE",
|
||||||
output_field=self.output_field,
|
output_field=self.output_field,
|
||||||
)
|
)
|
||||||
source_expressions = [spatial_type_expr]
|
source_expressions = [spatial_type_expr, self.filter]
|
||||||
if self.filter:
|
|
||||||
source_expressions.append(self.filter)
|
|
||||||
clone.set_source_expressions(source_expressions)
|
clone.set_source_expressions(source_expressions)
|
||||||
return clone.as_sql(compiler, connection, **extra_context)
|
return clone.as_sql(compiler, connection, **extra_context)
|
||||||
return self.as_sql(compiler, connection, **extra_context)
|
return self.as_sql(compiler, connection, **extra_context)
|
||||||
|
@ -367,15 +367,28 @@ class ForcePolygonCW(GeomOutputGeoFunc):
|
|||||||
|
|
||||||
|
|
||||||
class FromWKB(GeoFunc):
|
class FromWKB(GeoFunc):
|
||||||
output_field = GeometryField(srid=0)
|
arity = 2
|
||||||
arity = 1
|
|
||||||
geom_param_pos = ()
|
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):
|
def as_oracle(self, compiler, connection, **extra_context):
|
||||||
output_field = GeometryField(srid=0)
|
# Oracle doesn't support the srid parameter.
|
||||||
arity = 1
|
source_expressions = self.get_source_expressions()
|
||||||
geom_param_pos = ()
|
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):
|
class GeoHash(GeoFunc):
|
||||||
|
@ -801,14 +801,22 @@ GEO_CLASSES = {
|
|||||||
2001: Point, # POINT M
|
2001: Point, # POINT M
|
||||||
2002: LineString, # LINESTRING M
|
2002: LineString, # LINESTRING M
|
||||||
2003: Polygon, # POLYGON 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
|
3001: Point, # POINT ZM
|
||||||
3002: LineString, # LINESTRING ZM
|
3002: LineString, # LINESTRING ZM
|
||||||
3003: Polygon, # POLYGON 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
|
1 + OGRGeomType.wkb25bit: Point, # POINT Z
|
||||||
2 + OGRGeomType.wkb25bit: LineString, # LINESTRING Z
|
2 + OGRGeomType.wkb25bit: LineString, # LINESTRING Z
|
||||||
3 + OGRGeomType.wkb25bit: Polygon, # POLYGON Z
|
3 + OGRGeomType.wkb25bit: Polygon, # POLYGON Z
|
||||||
4 + OGRGeomType.wkb25bit: MultiPoint,
|
4 + OGRGeomType.wkb25bit: MultiPoint, # MULTIPOINT Z
|
||||||
5 + OGRGeomType.wkb25bit: MultiLineString,
|
5 + OGRGeomType.wkb25bit: MultiLineString, # MULTILINESTRING Z
|
||||||
6 + OGRGeomType.wkb25bit: MultiPolygon,
|
6 + OGRGeomType.wkb25bit: MultiPolygon, # MULTIPOLYGON Z
|
||||||
7 + OGRGeomType.wkb25bit: GeometryCollection,
|
7 + OGRGeomType.wkb25bit: GeometryCollection, # GEOMETRYCOLLECTION Z
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ class MapWidget {
|
|||||||
|
|
||||||
// Altering using user-provided options
|
// Altering using user-provided options
|
||||||
for (const property in options) {
|
for (const property in options) {
|
||||||
if (options.hasOwnProperty(property)) {
|
if (Object.hasOwn(options, property)) {
|
||||||
this.options[property] = options[property];
|
this.options[property] = options[property];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,12 +24,14 @@ register = template.Library()
|
|||||||
def ordinal(value):
|
def ordinal(value):
|
||||||
"""
|
"""
|
||||||
Convert an integer to its ordinal as a string. 1 is '1st', 2 is '2nd',
|
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:
|
try:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return value
|
return value
|
||||||
|
if value < 0:
|
||||||
|
return str(value)
|
||||||
if value % 100 in (11, 12, 13):
|
if value % 100 in (11, 12, 13):
|
||||||
# Translators: Ordinal format for 11 (11th), 12 (12th), and 13 (13th).
|
# Translators: Ordinal format for 11 (11th), 12 (12th), and 13 (13th).
|
||||||
value = pgettext("ordinal 11, 12, 13", "{}th").format(value)
|
value = pgettext("ordinal 11, 12, 13", "{}th").format(value)
|
||||||
@ -75,12 +77,15 @@ def intcomma(value, use_l10n=True):
|
|||||||
return intcomma(value, False)
|
return intcomma(value, False)
|
||||||
else:
|
else:
|
||||||
return number_format(value, use_l10n=True, force_grouping=True)
|
return number_format(value, use_l10n=True, force_grouping=True)
|
||||||
orig = str(value)
|
result = str(value)
|
||||||
new = re.sub(r"^(-?\d+)(\d{3})", r"\g<1>,\g<2>", orig)
|
match = re.match(r"-?\d+", result)
|
||||||
if orig == new:
|
if match:
|
||||||
return new
|
prefix = match[0]
|
||||||
else:
|
prefix_with_commas = re.sub(r"\d{3}", r"\g<0>,", prefix[::-1])[::-1]
|
||||||
return intcomma(new, use_l10n)
|
# 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
|
# 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
|
from django.db.models.expressions import OrderByList
|
||||||
|
|
||||||
|
|
||||||
@ -17,19 +18,30 @@ class OrderableAggMixin:
|
|||||||
return super().resolve_expression(*args, **kwargs)
|
return super().resolve_expression(*args, **kwargs)
|
||||||
|
|
||||||
def get_source_expressions(self):
|
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() + [self.order_by]
|
|
||||||
return super().get_source_expressions()
|
|
||||||
|
|
||||||
def set_source_expressions(self, exprs):
|
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)
|
return super().set_source_expressions(exprs)
|
||||||
|
|
||||||
def as_sql(self, compiler, connection):
|
def as_sql(self, compiler, connection):
|
||||||
if self.order_by is not None:
|
*source_exprs, filtering_expr, ordering_expr = self.get_source_expressions()
|
||||||
order_by_sql, order_by_params = compiler.compile(self.order_by)
|
|
||||||
else:
|
order_by_sql = ""
|
||||||
order_by_sql, order_by_params = "", ()
|
order_by_params = []
|
||||||
sql, sql_params = super().as_sql(compiler, connection, ordering=order_by_sql)
|
if ordering_expr is not None:
|
||||||
return sql, (*sql_params, *order_by_params)
|
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 types import NoneType
|
||||||
|
|
||||||
from django.contrib.postgres.indexes import OpClass
|
|
||||||
from django.core.exceptions import ValidationError
|
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.backends.ddl_references import Expressions, Statement, Table
|
||||||
from django.db.models import BaseConstraint, Deferrable, F, Q
|
from django.db.models import BaseConstraint, Deferrable, F, Q
|
||||||
from django.db.models.expressions import Exists, ExpressionList
|
from django.db.models.expressions import Exists, ExpressionList
|
||||||
@ -77,6 +76,14 @@ class ExclusionConstraint(BaseConstraint):
|
|||||||
expressions.append(expression)
|
expressions.append(expression)
|
||||||
return ExpressionList(*expressions).resolve_expression(query)
|
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):
|
def _get_condition_sql(self, compiler, schema_editor, query):
|
||||||
if self.condition is None:
|
if self.condition is None:
|
||||||
return None
|
return None
|
||||||
@ -107,7 +114,6 @@ class ExclusionConstraint(BaseConstraint):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def create_sql(self, model, schema_editor):
|
def create_sql(self, model, schema_editor):
|
||||||
self.check_supported(schema_editor)
|
|
||||||
return Statement(
|
return Statement(
|
||||||
"ALTER TABLE %(table)s ADD %(constraint)s",
|
"ALTER TABLE %(table)s ADD %(constraint)s",
|
||||||
table=Table(model._meta.db_table, schema_editor.quote_name),
|
table=Table(model._meta.db_table, schema_editor.quote_name),
|
||||||
@ -121,17 +127,6 @@ class ExclusionConstraint(BaseConstraint):
|
|||||||
schema_editor.quote_name(self.name),
|
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):
|
def deconstruct(self):
|
||||||
path, args, kwargs = super().deconstruct()
|
path, args, kwargs = super().deconstruct()
|
||||||
kwargs["expressions"] = self.expressions
|
kwargs["expressions"] = self.expressions
|
||||||
@ -200,12 +195,10 @@ class ExclusionConstraint(BaseConstraint):
|
|||||||
if isinstance(expr, F) and expr.name in exclude:
|
if isinstance(expr, F) and expr.name in exclude:
|
||||||
return
|
return
|
||||||
rhs_expression = expression.replace_expressions(replacements)
|
rhs_expression = expression.replace_expressions(replacements)
|
||||||
# Remove OpClass because it only has sense during the constraint
|
if hasattr(expression, "get_expression_for_validation"):
|
||||||
# creation.
|
expression = expression.get_expression_for_validation()
|
||||||
if isinstance(expression, OpClass):
|
if hasattr(rhs_expression, "get_expression_for_validation"):
|
||||||
expression = expression.get_source_expressions()[0]
|
rhs_expression = rhs_expression.get_expression_for_validation()
|
||||||
if isinstance(rhs_expression, OpClass):
|
|
||||||
rhs_expression = rhs_expression.get_source_expressions()[0]
|
|
||||||
lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression)
|
lookup = PostgresOperatorLookup(lhs=expression, rhs=rhs_expression)
|
||||||
lookup.postgres_operator = operator
|
lookup.postgres_operator = operator
|
||||||
lookups.append(lookup)
|
lookups.append(lookup)
|
||||||
|
@ -20,7 +20,7 @@ class HStoreField(forms.CharField):
|
|||||||
|
|
||||||
def prepare_value(self, value):
|
def prepare_value(self, value):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return json.dumps(value)
|
return json.dumps(value, ensure_ascii=False)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from django.db import NotSupportedError
|
|
||||||
from django.db.models import Func, Index
|
from django.db.models import Func, Index
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
@ -234,16 +233,10 @@ class SpGistIndex(PostgresIndex):
|
|||||||
with_params.append("fillfactor = %d" % self.fillfactor)
|
with_params.append("fillfactor = %d" % self.fillfactor)
|
||||||
return with_params
|
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):
|
class OpClass(Func):
|
||||||
template = "%(expressions)s %(name)s"
|
template = "%(expressions)s %(name)s"
|
||||||
|
constraint_validation_compatible = False
|
||||||
|
|
||||||
def __init__(self, expression, name):
|
def __init__(self, expression, name):
|
||||||
super().__init__(expression, name=name)
|
super().__init__(expression, name=name)
|
||||||
|
@ -2,6 +2,8 @@ import logging
|
|||||||
import string
|
import string
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -56,6 +58,10 @@ class SessionBase:
|
|||||||
self._session[key] = value
|
self._session[key] = value
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
async def aset(self, key, value):
|
||||||
|
(await self._aget_session())[key] = value
|
||||||
|
self.modified = True
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key):
|
||||||
del self._session[key]
|
del self._session[key]
|
||||||
self.modified = True
|
self.modified = True
|
||||||
@ -67,28 +73,52 @@ class SessionBase:
|
|||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return self._session.get(key, default)
|
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):
|
def pop(self, key, default=__not_given):
|
||||||
self.modified = self.modified or key in self._session
|
self.modified = self.modified or key in self._session
|
||||||
args = () if default is self.__not_given else (default,)
|
args = () if default is self.__not_given else (default,)
|
||||||
return self._session.pop(key, *args)
|
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):
|
def setdefault(self, key, value):
|
||||||
if key in self._session:
|
if key in self._session:
|
||||||
return self._session[key]
|
return self._session[key]
|
||||||
else:
|
else:
|
||||||
self.modified = True
|
self[key] = value
|
||||||
self._session[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
|
return value
|
||||||
|
|
||||||
def set_test_cookie(self):
|
def set_test_cookie(self):
|
||||||
self[self.TEST_COOKIE_NAME] = self.TEST_COOKIE_VALUE
|
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):
|
def test_cookie_worked(self):
|
||||||
return self.get(self.TEST_COOKIE_NAME) == self.TEST_COOKIE_VALUE
|
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):
|
def delete_test_cookie(self):
|
||||||
del self[self.TEST_COOKIE_NAME]
|
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):
|
def encode(self, session_dict):
|
||||||
"Return the given session dictionary serialized and encoded as a string."
|
"Return the given session dictionary serialized and encoded as a string."
|
||||||
return signing.dumps(
|
return signing.dumps(
|
||||||
@ -116,18 +146,34 @@ class SessionBase:
|
|||||||
self._session.update(dict_)
|
self._session.update(dict_)
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
async def aupdate(self, dict_):
|
||||||
|
(await self._aget_session()).update(dict_)
|
||||||
|
self.modified = True
|
||||||
|
|
||||||
def has_key(self, key):
|
def has_key(self, key):
|
||||||
return key in self._session
|
return key in self._session
|
||||||
|
|
||||||
|
async def ahas_key(self, key):
|
||||||
|
return key in (await self._aget_session())
|
||||||
|
|
||||||
def keys(self):
|
def keys(self):
|
||||||
return self._session.keys()
|
return self._session.keys()
|
||||||
|
|
||||||
|
async def akeys(self):
|
||||||
|
return (await self._aget_session()).keys()
|
||||||
|
|
||||||
def values(self):
|
def values(self):
|
||||||
return self._session.values()
|
return self._session.values()
|
||||||
|
|
||||||
|
async def avalues(self):
|
||||||
|
return (await self._aget_session()).values()
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return self._session.items()
|
return self._session.items()
|
||||||
|
|
||||||
|
async def aitems(self):
|
||||||
|
return (await self._aget_session()).items()
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
# To avoid unnecessary persistent storage accesses, we set up the
|
# To avoid unnecessary persistent storage accesses, we set up the
|
||||||
# internals directly (loading data wastes time, since we are going to
|
# internals directly (loading data wastes time, since we are going to
|
||||||
@ -150,11 +196,22 @@ class SessionBase:
|
|||||||
if not self.exists(session_key):
|
if not self.exists(session_key):
|
||||||
return 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):
|
def _get_or_create_session_key(self):
|
||||||
if self._session_key is None:
|
if self._session_key is None:
|
||||||
self._session_key = self._get_new_session_key()
|
self._session_key = self._get_new_session_key()
|
||||||
return self._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):
|
def _validate_session_key(self, key):
|
||||||
"""
|
"""
|
||||||
Key must be truthy and at least 8 characters long. 8 characters is an
|
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()
|
self._session_cache = self.load()
|
||||||
return self._session_cache
|
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)
|
_session = property(_get_session)
|
||||||
|
|
||||||
def get_session_cookie_age(self):
|
def get_session_cookie_age(self):
|
||||||
@ -224,6 +292,25 @@ class SessionBase:
|
|||||||
delta = expiry - modification
|
delta = expiry - modification
|
||||||
return delta.days * 86400 + delta.seconds
|
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):
|
def get_expiry_date(self, **kwargs):
|
||||||
"""Get session the expiry date (as a datetime object).
|
"""Get session the expiry date (as a datetime object).
|
||||||
|
|
||||||
@ -247,6 +334,23 @@ class SessionBase:
|
|||||||
expiry = expiry or self.get_session_cookie_age()
|
expiry = expiry or self.get_session_cookie_age()
|
||||||
return modification + timedelta(seconds=expiry)
|
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):
|
def set_expiry(self, value):
|
||||||
"""
|
"""
|
||||||
Set a custom expiration for the session. ``value`` can be an integer,
|
Set a custom expiration for the session. ``value`` can be an integer,
|
||||||
@ -275,6 +379,20 @@ class SessionBase:
|
|||||||
value = value.isoformat()
|
value = value.isoformat()
|
||||||
self["_session_expiry"] = value
|
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):
|
def get_expire_at_browser_close(self):
|
||||||
"""
|
"""
|
||||||
Return ``True`` if the session is set to expire when the browser
|
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 settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
|
||||||
return expiry == 0
|
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):
|
def flush(self):
|
||||||
"""
|
"""
|
||||||
Remove the current session data from the database and regenerate the
|
Remove the current session data from the database and regenerate the
|
||||||
@ -295,6 +418,11 @@ class SessionBase:
|
|||||||
self.delete()
|
self.delete()
|
||||||
self._session_key = None
|
self._session_key = None
|
||||||
|
|
||||||
|
async def aflush(self):
|
||||||
|
self.clear()
|
||||||
|
await self.adelete()
|
||||||
|
self._session_key = None
|
||||||
|
|
||||||
def cycle_key(self):
|
def cycle_key(self):
|
||||||
"""
|
"""
|
||||||
Create a new session key, while retaining the current session data.
|
Create a new session key, while retaining the current session data.
|
||||||
@ -306,6 +434,17 @@ class SessionBase:
|
|||||||
if key:
|
if key:
|
||||||
self.delete(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.
|
# Methods that child classes must implement.
|
||||||
|
|
||||||
def exists(self, session_key):
|
def exists(self, session_key):
|
||||||
@ -316,6 +455,9 @@ class SessionBase:
|
|||||||
"subclasses of SessionBase must provide an exists() method"
|
"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):
|
def create(self):
|
||||||
"""
|
"""
|
||||||
Create a new session instance. Guaranteed to create a new object with
|
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"
|
"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):
|
def save(self, must_create=False):
|
||||||
"""
|
"""
|
||||||
Save the session data. If 'must_create' is True, create a new session
|
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"
|
"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):
|
def delete(self, session_key=None):
|
||||||
"""
|
"""
|
||||||
Delete the session data under this key. If the key is None, use the
|
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"
|
"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):
|
def load(self):
|
||||||
"""
|
"""
|
||||||
Load the session data and return a dictionary.
|
Load the session data and return a dictionary.
|
||||||
@ -353,6 +504,9 @@ class SessionBase:
|
|||||||
"subclasses of SessionBase must provide a load() method"
|
"subclasses of SessionBase must provide a load() method"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def aload(self):
|
||||||
|
return await sync_to_async(self.load)()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_expired(cls):
|
def clear_expired(cls):
|
||||||
"""
|
"""
|
||||||
@ -363,3 +517,7 @@ class SessionBase:
|
|||||||
a built-in expiration mechanism, it should be a no-op.
|
a built-in expiration mechanism, it should be a no-op.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("This backend does not support clear_expired().")
|
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):
|
def cache_key(self):
|
||||||
return self.cache_key_prefix + self._get_or_create_session_key()
|
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):
|
def load(self):
|
||||||
try:
|
try:
|
||||||
session_data = self._cache.get(self.cache_key)
|
session_data = self._cache.get(self.cache_key)
|
||||||
@ -32,6 +35,16 @@ class SessionStore(SessionBase):
|
|||||||
self._session_key = None
|
self._session_key = None
|
||||||
return {}
|
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):
|
def create(self):
|
||||||
# Because a cache can fail silently (e.g. memcache), we don't know if
|
# 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
|
# 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."
|
"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):
|
def save(self, must_create=False):
|
||||||
if self.session_key is None:
|
if self.session_key is None:
|
||||||
return self.create()
|
return self.create()
|
||||||
@ -68,11 +95,33 @@ class SessionStore(SessionBase):
|
|||||||
if must_create and not result:
|
if must_create and not result:
|
||||||
raise CreateError
|
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):
|
def exists(self, session_key):
|
||||||
return (
|
return (
|
||||||
bool(session_key) and (self.cache_key_prefix + session_key) in self._cache
|
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):
|
def delete(self, session_key=None):
|
||||||
if session_key is None:
|
if session_key is None:
|
||||||
if self.session_key is None:
|
if self.session_key is None:
|
||||||
@ -80,6 +129,17 @@ class SessionStore(SessionBase):
|
|||||||
session_key = self.session_key
|
session_key = self.session_key
|
||||||
self._cache.delete(self.cache_key_prefix + 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
|
@classmethod
|
||||||
def clear_expired(cls):
|
def clear_expired(cls):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def aclear_expired(cls):
|
||||||
|
pass
|
||||||
|
@ -2,12 +2,16 @@
|
|||||||
Cached, database-backed sessions.
|
Cached, database-backed sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.sessions.backends.db import SessionStore as DBStore
|
from django.contrib.sessions.backends.db import SessionStore as DBStore
|
||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
|
|
||||||
KEY_PREFIX = "django.contrib.sessions.cached_db"
|
KEY_PREFIX = "django.contrib.sessions.cached_db"
|
||||||
|
|
||||||
|
logger = logging.getLogger("django.contrib.sessions")
|
||||||
|
|
||||||
|
|
||||||
class SessionStore(DBStore):
|
class SessionStore(DBStore):
|
||||||
"""
|
"""
|
||||||
@ -24,6 +28,9 @@ class SessionStore(DBStore):
|
|||||||
def cache_key(self):
|
def cache_key(self):
|
||||||
return self.cache_key_prefix + self._get_or_create_session_key()
|
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):
|
def load(self):
|
||||||
try:
|
try:
|
||||||
data = self._cache.get(self.cache_key)
|
data = self._cache.get(self.cache_key)
|
||||||
@ -43,6 +50,27 @@ class SessionStore(DBStore):
|
|||||||
data = {}
|
data = {}
|
||||||
return 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):
|
def exists(self, session_key):
|
||||||
return (
|
return (
|
||||||
session_key
|
session_key
|
||||||
@ -50,9 +78,30 @@ class SessionStore(DBStore):
|
|||||||
or super().exists(session_key)
|
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):
|
def save(self, must_create=False):
|
||||||
super().save(must_create)
|
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):
|
def delete(self, session_key=None):
|
||||||
super().delete(session_key)
|
super().delete(session_key)
|
||||||
@ -62,6 +111,14 @@ class SessionStore(DBStore):
|
|||||||
session_key = self.session_key
|
session_key = self.session_key
|
||||||
self._cache.delete(self.cache_key_prefix + 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):
|
def flush(self):
|
||||||
"""
|
"""
|
||||||
Remove the current session data from the database and regenerate the
|
Remove the current session data from the database and regenerate the
|
||||||
@ -70,3 +127,9 @@ class SessionStore(DBStore):
|
|||||||
self.clear()
|
self.clear()
|
||||||
self.delete(self.session_key)
|
self.delete(self.session_key)
|
||||||
self._session_key = None
|
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
|
import logging
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
from django.contrib.sessions.backends.base import CreateError, SessionBase, UpdateError
|
from django.contrib.sessions.backends.base import CreateError, SessionBase, UpdateError
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.db import DatabaseError, IntegrityError, router, transaction
|
from django.db import DatabaseError, IntegrityError, router, transaction
|
||||||
@ -38,13 +40,31 @@ class SessionStore(SessionBase):
|
|||||||
logger.warning(str(e))
|
logger.warning(str(e))
|
||||||
self._session_key = None
|
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):
|
def load(self):
|
||||||
s = self._get_session_from_db()
|
s = self._get_session_from_db()
|
||||||
return self.decode(s.session_data) if s else {}
|
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):
|
def exists(self, session_key):
|
||||||
return self.model.objects.filter(session_key=session_key).exists()
|
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):
|
def create(self):
|
||||||
while True:
|
while True:
|
||||||
self._session_key = self._get_new_session_key()
|
self._session_key = self._get_new_session_key()
|
||||||
@ -58,6 +78,19 @@ class SessionStore(SessionBase):
|
|||||||
self.modified = True
|
self.modified = True
|
||||||
return
|
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):
|
def create_model_instance(self, data):
|
||||||
"""
|
"""
|
||||||
Return a new instance of the session model object, which represents the
|
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(),
|
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):
|
def save(self, must_create=False):
|
||||||
"""
|
"""
|
||||||
Save the current session data to the database. If 'must_create' is
|
Save the current session data to the database. If 'must_create' is
|
||||||
@ -95,6 +136,36 @@ class SessionStore(SessionBase):
|
|||||||
raise UpdateError
|
raise UpdateError
|
||||||
raise
|
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):
|
def delete(self, session_key=None):
|
||||||
if session_key is None:
|
if session_key is None:
|
||||||
if self.session_key is None:
|
if self.session_key is None:
|
||||||
@ -105,6 +176,23 @@ class SessionStore(SessionBase):
|
|||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
pass
|
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
|
@classmethod
|
||||||
def clear_expired(cls):
|
def clear_expired(cls):
|
||||||
cls.get_model_class().objects.filter(expire_date__lt=timezone.now()).delete()
|
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
|
self._session_key = None
|
||||||
return session_data
|
return session_data
|
||||||
|
|
||||||
|
async def aload(self):
|
||||||
|
return self.load()
|
||||||
|
|
||||||
def create(self):
|
def create(self):
|
||||||
while True:
|
while True:
|
||||||
self._session_key = self._get_new_session_key()
|
self._session_key = self._get_new_session_key()
|
||||||
@ -114,6 +117,9 @@ class SessionStore(SessionBase):
|
|||||||
self.modified = True
|
self.modified = True
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async def acreate(self):
|
||||||
|
return self.create()
|
||||||
|
|
||||||
def save(self, must_create=False):
|
def save(self, must_create=False):
|
||||||
if self.session_key is None:
|
if self.session_key is None:
|
||||||
return self.create()
|
return self.create()
|
||||||
@ -177,9 +183,15 @@ class SessionStore(SessionBase):
|
|||||||
except (EOFError, OSError):
|
except (EOFError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def asave(self, must_create=False):
|
||||||
|
return self.save(must_create=must_create)
|
||||||
|
|
||||||
def exists(self, session_key):
|
def exists(self, session_key):
|
||||||
return os.path.exists(self._key_to_file(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):
|
def delete(self, session_key=None):
|
||||||
if session_key is None:
|
if session_key is None:
|
||||||
if self.session_key is None:
|
if self.session_key is None:
|
||||||
@ -190,8 +202,8 @@ class SessionStore(SessionBase):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def clean(self):
|
async def adelete(self, session_key=None):
|
||||||
pass
|
return self.delete(session_key=session_key)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_expired(cls):
|
def clear_expired(cls):
|
||||||
@ -208,3 +220,7 @@ class SessionStore(SessionBase):
|
|||||||
# the create() method.
|
# the create() method.
|
||||||
session.create = lambda: None
|
session.create = lambda: None
|
||||||
session.load()
|
session.load()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def aclear_expired(cls):
|
||||||
|
cls.clear_expired()
|
||||||
|
@ -23,6 +23,9 @@ class SessionStore(SessionBase):
|
|||||||
self.create()
|
self.create()
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def aload(self):
|
||||||
|
return self.load()
|
||||||
|
|
||||||
def create(self):
|
def create(self):
|
||||||
"""
|
"""
|
||||||
To create a new key, set the modified flag so that the cookie is set
|
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
|
self.modified = True
|
||||||
|
|
||||||
|
async def acreate(self):
|
||||||
|
return self.create()
|
||||||
|
|
||||||
def save(self, must_create=False):
|
def save(self, must_create=False):
|
||||||
"""
|
"""
|
||||||
To save, get the session key as a securely signed string and then set
|
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._session_key = self._get_session_key()
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
async def asave(self, must_create=False):
|
||||||
|
return self.save(must_create=must_create)
|
||||||
|
|
||||||
def exists(self, session_key=None):
|
def exists(self, session_key=None):
|
||||||
"""
|
"""
|
||||||
This method makes sense when you're talking to a shared resource, but
|
This method makes sense when you're talking to a shared resource, but
|
||||||
@ -47,6 +56,9 @@ class SessionStore(SessionBase):
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def aexists(self, session_key=None):
|
||||||
|
return self.exists(session_key=session_key)
|
||||||
|
|
||||||
def delete(self, session_key=None):
|
def delete(self, session_key=None):
|
||||||
"""
|
"""
|
||||||
To delete, clear the session key and the underlying data structure
|
To delete, clear the session key and the underlying data structure
|
||||||
@ -57,6 +69,9 @@ class SessionStore(SessionBase):
|
|||||||
self._session_cache = {}
|
self._session_cache = {}
|
||||||
self.modified = True
|
self.modified = True
|
||||||
|
|
||||||
|
async def adelete(self, session_key=None):
|
||||||
|
return self.delete(session_key=session_key)
|
||||||
|
|
||||||
def cycle_key(self):
|
def cycle_key(self):
|
||||||
"""
|
"""
|
||||||
Keep the same data but with a new key. Call save() and it will
|
Keep the same data but with a new key. Call save() and it will
|
||||||
@ -64,6 +79,9 @@ class SessionStore(SessionBase):
|
|||||||
"""
|
"""
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
async def acycle_key(self):
|
||||||
|
return self.cycle_key()
|
||||||
|
|
||||||
def _get_session_key(self):
|
def _get_session_key(self):
|
||||||
"""
|
"""
|
||||||
Instead of generating a random string, generate a secure url-safe
|
Instead of generating a random string, generate a secure url-safe
|
||||||
@ -79,3 +97,7 @@ class SessionStore(SessionBase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def clear_expired(cls):
|
def clear_expired(cls):
|
||||||
pass
|
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 host is provided as part of the base_url
|
||||||
* the request's path isn't under the media path (or equal)
|
* 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):
|
def file_path(self, url):
|
||||||
"""
|
"""
|
||||||
Return the relative path to the media file on disk for the given 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)
|
return url2pathname(relative_url)
|
||||||
|
|
||||||
def serve(self, request):
|
def serve(self, request):
|
||||||
|
@ -221,7 +221,7 @@ class HashedFilesMixin:
|
|||||||
url = matches["url"]
|
url = matches["url"]
|
||||||
|
|
||||||
# Ignore absolute/protocol-relative and data-uri URLs.
|
# 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
|
return matched
|
||||||
|
|
||||||
# Ignore absolute URLs that don't point to a static file (dynamic
|
# 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_copyright=self._get_dynamic_attr("feed_copyright", obj),
|
||||||
feed_guid=self._get_dynamic_attr("feed_guid", obj),
|
feed_guid=self._get_dynamic_attr("feed_guid", obj),
|
||||||
ttl=self._get_dynamic_attr("ttl", obj),
|
ttl=self._get_dynamic_attr("ttl", obj),
|
||||||
|
stylesheets=self._get_dynamic_attr("stylesheets", obj),
|
||||||
**self.feed_extra_kwargs(obj),
|
**self.feed_extra_kwargs(obj),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,75 +1,12 @@
|
|||||||
import copy
|
from . import Tags, register
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.templates)
|
@register(Tags.templates)
|
||||||
def check_setting_app_dirs_loaders(app_configs, **kwargs):
|
def check_templates(app_configs, **kwargs):
|
||||||
return (
|
"""Check all registered template engines."""
|
||||||
[E001]
|
from django.template import engines
|
||||||
if any(
|
|
||||||
conf.get("APP_DIRS") and "loaders" in conf.get("OPTIONS", {})
|
|
||||||
for conf in settings.TEMPLATES
|
|
||||||
)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@register(Tags.templates)
|
|
||||||
def check_string_if_invalid_is_string(app_configs, **kwargs):
|
|
||||||
errors = []
|
errors = []
|
||||||
for conf in settings.TEMPLATES:
|
for engine in engines.all():
|
||||||
string_if_invalid = conf.get("OPTIONS", {}).get("string_if_invalid", "")
|
errors.extend(engine.check())
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import inspect
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ViewDoesNotExist
|
||||||
|
|
||||||
from . import Error, Tags, Warning, register
|
from . import Error, Tags, Warning, register
|
||||||
|
|
||||||
@ -115,3 +117,43 @@ def E006(name):
|
|||||||
"The {} setting must end with a slash.".format(name),
|
"The {} setting must end with a slash.".format(name),
|
||||||
id="urls.E006",
|
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"]
|
__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(
|
def file_move_safe(
|
||||||
old_file_name, new_file_name, chunk_size=1024 * 64, allow_overwrite=False
|
old_file_name, new_file_name, chunk_size=1024 * 64, allow_overwrite=False
|
||||||
):
|
):
|
||||||
@ -40,16 +26,18 @@ def file_move_safe(
|
|||||||
``FileExistsError``.
|
``FileExistsError``.
|
||||||
"""
|
"""
|
||||||
# There's no reason to move if we don't have to.
|
# There's no reason to move if we don't have to.
|
||||||
if _samefile(old_file_name, new_file_name):
|
try:
|
||||||
return
|
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:
|
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)
|
os.rename(old_file_name, new_file_name)
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
|
@ -69,7 +69,8 @@ class Storage:
|
|||||||
"Detected path traversal attempt in '%s'" % dir_name
|
"Detected path traversal attempt in '%s'" % dir_name
|
||||||
)
|
)
|
||||||
validate_file_name(file_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
|
# If the filename already exists, generate an alternative filename
|
||||||
# until it doesn't exist.
|
# until it doesn't exist.
|
||||||
# Truncate original name if required, so the new filename does not
|
# Truncate original name if required, so the new filename does not
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousFileOperation
|
||||||
from django.core.files import File, locks
|
from django.core.files import File, locks
|
||||||
from django.core.files.move import file_move_safe
|
from django.core.files.move import file_move_safe
|
||||||
from django.core.signals import setting_changed
|
from django.core.signals import setting_changed
|
||||||
from django.utils._os import safe_join
|
from django.utils._os import safe_join
|
||||||
from django.utils.deconstruct import deconstructible
|
from django.utils.deconstruct import deconstructible
|
||||||
|
from django.utils.deprecation import RemovedInDjango60Warning
|
||||||
from django.utils.encoding import filepath_to_uri
|
from django.utils.encoding import filepath_to_uri
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
@ -21,8 +24,7 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
|
|||||||
Standard filesystem storage
|
Standard filesystem storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The combination of O_CREAT and O_EXCL makes os.open() raise OSError if
|
# RemovedInDjango60Warning: remove OS_OPEN_FLAGS.
|
||||||
# the file already exists before it's opened.
|
|
||||||
OS_OPEN_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
OS_OPEN_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -31,12 +33,23 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
|
|||||||
base_url=None,
|
base_url=None,
|
||||||
file_permissions_mode=None,
|
file_permissions_mode=None,
|
||||||
directory_permissions_mode=None,
|
directory_permissions_mode=None,
|
||||||
|
allow_overwrite=False,
|
||||||
):
|
):
|
||||||
self._location = location
|
self._location = location
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._file_permissions_mode = file_permissions_mode
|
self._file_permissions_mode = file_permissions_mode
|
||||||
self._directory_permissions_mode = directory_permissions_mode
|
self._directory_permissions_mode = directory_permissions_mode
|
||||||
|
self._allow_overwrite = allow_overwrite
|
||||||
setting_changed.connect(self._clear_cached_properties)
|
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
|
@cached_property
|
||||||
def base_location(self):
|
def base_location(self):
|
||||||
@ -98,12 +111,30 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
|
|||||||
try:
|
try:
|
||||||
# This file has a file path that we can move.
|
# This file has a file path that we can move.
|
||||||
if hasattr(content, "temporary_file_path"):
|
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.
|
# This is a normal uploadedfile that we can stream.
|
||||||
else:
|
else:
|
||||||
# The current umask value is masked out by os.open!
|
# The combination of O_CREAT and O_EXCL makes os.open() raises an
|
||||||
fd = os.open(full_path, self.OS_OPEN_FLAGS, 0o666)
|
# 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
|
_file = None
|
||||||
try:
|
try:
|
||||||
locks.lock(fd, locks.LOCK_EX)
|
locks.lock(fd, locks.LOCK_EX)
|
||||||
@ -162,7 +193,13 @@ class FileSystemStorage(Storage, StorageSettingsMixin):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def exists(self, name):
|
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):
|
def listdir(self, path):
|
||||||
path = self.path(path)
|
path = self.path(path)
|
||||||
|
@ -50,21 +50,13 @@ class ASGIRequest(HttpRequest):
|
|||||||
self._post_parse_error = False
|
self._post_parse_error = False
|
||||||
self._read_started = False
|
self._read_started = False
|
||||||
self.resolver_match = None
|
self.resolver_match = None
|
||||||
|
self.path = scope["path"]
|
||||||
self.script_name = get_script_prefix(scope)
|
self.script_name = get_script_prefix(scope)
|
||||||
if self.script_name:
|
if self.script_name:
|
||||||
# TODO: Better is-prefix checking, slash handling?
|
# TODO: Better is-prefix checking, slash handling?
|
||||||
self.path_info = scope["path"].removeprefix(self.script_name)
|
self.path_info = scope["path"].removeprefix(self.script_name)
|
||||||
else:
|
else:
|
||||||
self.path_info = scope["path"]
|
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.
|
# HTTP basics.
|
||||||
self.method = self.scope["method"].upper()
|
self.method = self.scope["method"].upper()
|
||||||
# Ensure query string is encoded correctly.
|
# Ensure query string is encoded correctly.
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
|
from collections import namedtuple
|
||||||
from email import charset as Charset
|
from email import charset as Charset
|
||||||
from email import encoders as Encoders
|
from email import encoders as Encoders
|
||||||
from email import generator, message_from_string
|
from email import generator, message_from_string
|
||||||
@ -168,7 +169,8 @@ class SafeMIMEText(MIMEMixin, MIMEText):
|
|||||||
def set_payload(self, payload, charset=None):
|
def set_payload(self, payload, charset=None):
|
||||||
if charset == "utf-8" and not isinstance(charset, Charset.Charset):
|
if charset == "utf-8" and not isinstance(charset, Charset.Charset):
|
||||||
has_long_lines = any(
|
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()
|
for line in payload.splitlines()
|
||||||
)
|
)
|
||||||
# Quoted-Printable encoding has the side effect of shortening long
|
# Quoted-Printable encoding has the side effect of shortening long
|
||||||
@ -189,6 +191,10 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
|
|||||||
MIMEMultipart.__setitem__(self, name, val)
|
MIMEMultipart.__setitem__(self, name, val)
|
||||||
|
|
||||||
|
|
||||||
|
Alternative = namedtuple("Alternative", ["content", "mimetype"])
|
||||||
|
EmailAttachment = namedtuple("Attachment", ["filename", "content", "mimetype"])
|
||||||
|
|
||||||
|
|
||||||
class EmailMessage:
|
class EmailMessage:
|
||||||
"""A container for email information."""
|
"""A container for email information."""
|
||||||
|
|
||||||
@ -337,7 +343,7 @@ class EmailMessage:
|
|||||||
# actually binary, read() raises a UnicodeDecodeError.
|
# actually binary, read() raises a UnicodeDecodeError.
|
||||||
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
|
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):
|
def attach_file(self, path, mimetype=None):
|
||||||
"""
|
"""
|
||||||
@ -470,13 +476,15 @@ class EmailMultiAlternatives(EmailMessage):
|
|||||||
cc,
|
cc,
|
||||||
reply_to,
|
reply_to,
|
||||||
)
|
)
|
||||||
self.alternatives = alternatives or []
|
self.alternatives = [
|
||||||
|
Alternative(*alternative) for alternative in (alternatives or [])
|
||||||
|
]
|
||||||
|
|
||||||
def attach_alternative(self, content, mimetype):
|
def attach_alternative(self, content, mimetype):
|
||||||
"""Attach an alternative content representation."""
|
"""Attach an alternative content representation."""
|
||||||
if content is None or mimetype is None:
|
if content is None or mimetype is None:
|
||||||
raise ValueError("Both content and mimetype must be provided.")
|
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):
|
def _create_message(self, msg):
|
||||||
return self._create_attachments(self._create_alternatives(msg))
|
return self._create_attachments(self._create_alternatives(msg))
|
||||||
@ -491,5 +499,22 @@ class EmailMultiAlternatives(EmailMessage):
|
|||||||
if self.body:
|
if self.body:
|
||||||
msg.attach(body_msg)
|
msg.attach(body_msg)
|
||||||
for alternative in self.alternatives:
|
for alternative in self.alternatives:
|
||||||
msg.attach(self._create_mime_attachment(*alternative))
|
msg.attach(
|
||||||
|
self._create_mime_attachment(
|
||||||
|
alternative.content, alternative.mimetype
|
||||||
|
)
|
||||||
|
)
|
||||||
return msg
|
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,
|
parser,
|
||||||
"--traceback",
|
"--traceback",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Raise on CommandError exceptions.",
|
help="Display a full stack trace on CommandError exceptions.",
|
||||||
)
|
)
|
||||||
self.add_base_argument(
|
self.add_base_argument(
|
||||||
parser,
|
parser,
|
||||||
|
@ -2,6 +2,7 @@ from django.apps import apps
|
|||||||
from django.core import checks
|
from django.core import checks
|
||||||
from django.core.checks.registry import registry
|
from django.core.checks.registry import registry
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import connections
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -43,6 +44,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--database",
|
"--database",
|
||||||
action="append",
|
action="append",
|
||||||
|
choices=tuple(connections),
|
||||||
dest="databases",
|
dest="databases",
|
||||||
help="Run database related checks against these aliases.",
|
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