mirror of
https://github.com/jazzband/django-downloadview.git
synced 2026-05-10 16:44:48 +00:00
Compare commits
No commits in common. "master" and "1.3" have entirely different histories.
131 changed files with 1714 additions and 3968 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -1,8 +0,0 @@
|
||||||
version: 2
|
|
||||||
|
|
||||||
updates:
|
|
||||||
# Maintain dependencies for GitHub Actions
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "monthly"
|
|
||||||
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
|
|
@ -1,40 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
if: github.repository == 'jazzband/django-downloadview'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: 3.8
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install -U pip
|
|
||||||
python -m pip install -U setuptools twine wheel
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: |
|
|
||||||
python setup.py --version
|
|
||||||
python setup.py sdist --format=gztar bdist_wheel
|
|
||||||
twine check dist/*
|
|
||||||
|
|
||||||
- name: Upload packages to Jazzband
|
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
|
||||||
uses: pypa/gh-action-pypi-publish@master
|
|
||||||
with:
|
|
||||||
user: jazzband
|
|
||||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
|
||||||
repository_url: https://jazzband.co/projects/django-downloadview/upload
|
|
||||||
61
.github/workflows/test.yml
vendored
61
.github/workflows/test.yml
vendored
|
|
@ -1,61 +0,0 @@
|
||||||
name: Test
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
|
|
||||||
django-version: ['4.2', '5.0', '5.1', '5.2', 'main']
|
|
||||||
exclude:
|
|
||||||
# Django 5.0 dropped support for Python <3.10
|
|
||||||
- django-version: '5.0'
|
|
||||||
python-version: '3.8'
|
|
||||||
- django-version: '5.0'
|
|
||||||
python-version: '3.9'
|
|
||||||
- django-version: 'main'
|
|
||||||
python-version: '3.8'
|
|
||||||
- django-version: 'main'
|
|
||||||
python-version: '3.9'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
- name: Get pip cache dir
|
|
||||||
id: pip-cache
|
|
||||||
run: |
|
|
||||||
echo "::set-output name=dir::$(pip cache dir)"
|
|
||||||
|
|
||||||
- name: Cache
|
|
||||||
uses: actions/cache@v5
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
|
||||||
key:
|
|
||||||
${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ matrix.python-version }}-v1-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install --upgrade tox tox-gh-actions
|
|
||||||
|
|
||||||
- name: Tox tests
|
|
||||||
run: |
|
|
||||||
tox -v
|
|
||||||
env:
|
|
||||||
DJANGO: ${{ matrix.django-version }}
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: codecov/codecov-action@v6
|
|
||||||
with:
|
|
||||||
name: Python ${{ matrix.python-version }}
|
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -6,27 +6,12 @@
|
||||||
|
|
||||||
# Data files.
|
# Data files.
|
||||||
/var/
|
/var/
|
||||||
coverage.xml
|
|
||||||
.coverage/
|
|
||||||
|
|
||||||
# Python files.
|
# Python files.
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
# Tox files.
|
|
||||||
/.tox/
|
|
||||||
.eggs
|
|
||||||
*.egg-info
|
|
||||||
|
|
||||||
# Virtualenv files (created by tox).
|
|
||||||
/build/
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# Virtual environments (created by user).
|
|
||||||
/venv/
|
|
||||||
|
|
||||||
# Editors' temporary buffers.
|
# Editors' temporary buffers.
|
||||||
.*.swp
|
.*.swp
|
||||||
*~
|
*~
|
||||||
.idea
|
|
||||||
|
|
|
||||||
16
.isort.cfg
16
.isort.cfg
|
|
@ -1,16 +0,0 @@
|
||||||
[settings]
|
|
||||||
# # Needed for black compatibility
|
|
||||||
multi_line_output=3
|
|
||||||
include_trailing_comma=True
|
|
||||||
force_grid_wrap=0
|
|
||||||
line_length=88
|
|
||||||
combine_as_imports=True
|
|
||||||
|
|
||||||
# List sections with django and
|
|
||||||
known_django=django
|
|
||||||
known_downloadview=django_downloadview
|
|
||||||
|
|
||||||
sections=FUTURE,STDLIB,DJANGO,DOWNLOADVIEW,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
|
|
||||||
|
|
||||||
# If set, imports will be sorted within their section independent to the import_type.
|
|
||||||
force_sort_within_sections=True
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v6.0.0
|
|
||||||
hooks:
|
|
||||||
- id: check-toml
|
|
||||||
- id: check-yaml
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: mixed-line-ending
|
|
||||||
- id: file-contents-sorter
|
|
||||||
files: docs/spelling_wordlist.txt
|
|
||||||
- repo: https://github.com/pycqa/doc8
|
|
||||||
rev: v2.0.0
|
|
||||||
hooks:
|
|
||||||
- id: doc8
|
|
||||||
- repo: https://github.com/adamchainz/django-upgrade
|
|
||||||
rev: 1.30.0
|
|
||||||
hooks:
|
|
||||||
- id: django-upgrade
|
|
||||||
args: [--target-version, "4.2"]
|
|
||||||
- repo: https://github.com/pre-commit/pygrep-hooks
|
|
||||||
rev: v1.10.0
|
|
||||||
hooks:
|
|
||||||
- id: rst-backticks
|
|
||||||
- id: rst-directive-colons
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v4.0.0-alpha.8
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
entry: env PRETTIER_LEGACY_CLI=1 prettier
|
|
||||||
types_or: [javascript, css]
|
|
||||||
args:
|
|
||||||
- --trailing-comma=es5
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
|
||||||
rev: v10.3.0
|
|
||||||
hooks:
|
|
||||||
- id: eslint
|
|
||||||
additional_dependencies:
|
|
||||||
- "eslint@v9.0.0-beta.1"
|
|
||||||
- "@eslint/js@v9.0.0-beta.1"
|
|
||||||
- "globals"
|
|
||||||
files: \.js?$
|
|
||||||
types: [file]
|
|
||||||
args:
|
|
||||||
- --fix
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: 'v0.15.12'
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix, --exit-non-zero-on-fix]
|
|
||||||
- id: ruff-format
|
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
||||||
rev: v2.21.1
|
|
||||||
hooks:
|
|
||||||
- id: pyproject-fmt
|
|
||||||
- repo: https://github.com/abravalheri/validate-pyproject
|
|
||||||
rev: v0.25
|
|
||||||
hooks:
|
|
||||||
- id: validate-pyproject
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# .readthedocs.yaml
|
|
||||||
# Read the Docs configuration file
|
|
||||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
|
|
||||||
build:
|
|
||||||
os: ubuntu-22.04
|
|
||||||
tools:
|
|
||||||
python: "3.10"
|
|
||||||
|
|
||||||
sphinx:
|
|
||||||
configuration: docs/conf.py
|
|
||||||
|
|
||||||
python:
|
|
||||||
install:
|
|
||||||
- method: pip
|
|
||||||
path: .
|
|
||||||
5
.travis.yml
Normal file
5
.travis.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
language: python
|
||||||
|
python:
|
||||||
|
- "2.7"
|
||||||
|
install: make configure develop
|
||||||
|
script: make test
|
||||||
27
AUTHORS
27
AUTHORS
|
|
@ -4,25 +4,12 @@ Authors & contributors
|
||||||
|
|
||||||
Maintainer: Benoît Bryon <benoit@marmelune.net>
|
Maintainer: Benoît Bryon <benoit@marmelune.net>
|
||||||
|
|
||||||
Original code by `PeopleDoc <https://www.people-doc.com/>`_ team:
|
Original code by `Novapost <http://www.novapost.fr>`_ team:
|
||||||
|
|
||||||
* Adam Chainz <adam@adamj.eu>
|
* Nicolas Tobo <https://github.com/nicolastobo>
|
||||||
* Aleksi Häkli <aleksi.hakli@iki.fi>
|
* Lauréline Guérin <https://github.com/zebuline>
|
||||||
|
* Gregory Tappero <https://github.com/coulix>
|
||||||
|
* Rémy Hubscher <remy.hubscher@novapost.fr>
|
||||||
* Benoît Bryon <benoit@marmelune.net>
|
* Benoît Bryon <benoit@marmelune.net>
|
||||||
* CJ <cjdreiss@users.noreply.github.com>
|
|
||||||
* David Wolf <68775926+devidw@users.noreply.github.com>
|
Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors
|
||||||
* Davide Setti <setti.davide89@gmail.com>
|
|
||||||
* Erik Dykema <dykema@gmail.com>
|
|
||||||
* Fabre Florian <ffabre@hybird.org>
|
|
||||||
* Hasan Ramezani <hasan.r67@gmail.com>
|
|
||||||
* Jannis Leidel <jannis@leidel.info>
|
|
||||||
* John Hagen <johnthagen@gmail.com>
|
|
||||||
* Mariusz Felisiak <felisiak.mariusz@gmail.com>
|
|
||||||
* Martin Bächtold <martin@baechtold.me>
|
|
||||||
* Nikhil Benesch <nikhil.benesch@gmail.com>
|
|
||||||
* Omer Katz <omer.drow@gmail.com>
|
|
||||||
* Peter Marheine <peter@taricorp.net>
|
|
||||||
* René Leonhardt <rene.leonhardt@gmail.com>
|
|
||||||
* Rémy HUBSCHER <hubscher.remy@gmail.com>
|
|
||||||
* Tim Gates <tim.gates@iress.com>
|
|
||||||
* zero13cool <zero13cool@yandex.ru>
|
|
||||||
|
|
|
||||||
154
CHANGELOG
154
CHANGELOG
|
|
@ -4,158 +4,6 @@ Changelog
|
||||||
This document describes changes between past releases. For information about
|
This document describes changes between past releases. For information about
|
||||||
future releases, check `milestones`_ and :doc:`/about/vision`.
|
future releases, check `milestones`_ and :doc:`/about/vision`.
|
||||||
|
|
||||||
2.6.0 (unreleased)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- No changes yet
|
|
||||||
|
|
||||||
|
|
||||||
2.5.0 (2025-10-28)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- Upgrade to Django 5.2 and Python 3.14
|
|
||||||
|
|
||||||
|
|
||||||
2.4.0 (2024-08-05)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Drop support for Python 3.6
|
|
||||||
- Escape malicious filenames
|
|
||||||
- Handle headers in XAccel responses
|
|
||||||
|
|
||||||
|
|
||||||
2.3.0 (2022-01-11)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Drop Django 3.0 support
|
|
||||||
- Add Django 3.2 support
|
|
||||||
- Add support for Python 3.10
|
|
||||||
- Add support for Django 4.0
|
|
||||||
- Remove support for Python 3.5 and Django 1.11
|
|
||||||
- Add support for Python 3.9 and Django 3.1
|
|
||||||
- Remove old urls syntax and adopt the new one
|
|
||||||
- Move the project to the jazzband organization
|
|
||||||
- Adopt black automatic formatting rules
|
|
||||||
|
|
||||||
|
|
||||||
2.1.1 (2020-01-14)
|
|
||||||
------------------
|
|
||||||
|
|
||||||
- Fix missing function parameter. (#152)
|
|
||||||
|
|
||||||
|
|
||||||
2.1 (2020-01-13)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- Add a SignedFileSystemStorage that signs file URLs for clients. (#151)
|
|
||||||
|
|
||||||
|
|
||||||
2.0 (2020-01-07)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- Drop support for Python 2.7.
|
|
||||||
- Add black and isort.
|
|
||||||
|
|
||||||
|
|
||||||
1.10 (2020-01-07)
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
- Introduced support from Django 1.11, 2.2 and 3.0.
|
|
||||||
- Drop support of Django 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 2.0 and 2.1
|
|
||||||
|
|
||||||
|
|
||||||
1.9 (2016-03-15)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
- Feature #112 - Introduced support of Django 1.9.
|
|
||||||
|
|
||||||
- Feature #113 - Introduced support of Python 3.5.
|
|
||||||
|
|
||||||
- Feature #116 - ``HTTPFile`` has ``content_type`` property. It makes
|
|
||||||
``HTTPDownloadView`` proxy ``Content-Type`` header from remote location.
|
|
||||||
|
|
||||||
|
|
||||||
1.8 (2015-07-20)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Bugfixes.
|
|
||||||
|
|
||||||
- Bugfix #103 - ``PathDownloadView.get_file()`` makes a single call to
|
|
||||||
``PathDownloadView.get_file()`` (was doing it twice).
|
|
||||||
|
|
||||||
- Bugfix #104 - Pass numeric timestamp to Django's ``was_modified_since()``
|
|
||||||
(was passing a datetime).
|
|
||||||
|
|
||||||
|
|
||||||
1.7 (2015-06-13)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Bugfixes.
|
|
||||||
|
|
||||||
- Bugfix #87 - Filenames with commas are now supported. In download responses,
|
|
||||||
filename is now surrounded by double quotes.
|
|
||||||
|
|
||||||
- Bugfix #97 - ``HTTPFile`` proxies bytes as ``BytesIteratorIO`` (was undecoded
|
|
||||||
urllib3 file object). ``StringIteratorIO`` has been split into
|
|
||||||
``TextIteratorIO`` and ``BytesIteratorIO``. ``StringIteratorIO`` is
|
|
||||||
deprecated but kept for backward compatibility as an alias for
|
|
||||||
``TextIteratorIO``.
|
|
||||||
|
|
||||||
- Bugfix #92 - Run demo using ``make demo runserver`` (was broken).
|
|
||||||
|
|
||||||
- Feature #99 - Tox runs project's tests with Python 2.7, 3.3 and 3.4, and with
|
|
||||||
Django 1.5 to 1.8.
|
|
||||||
|
|
||||||
- Refactoring #98 - Refreshed development environment: packaging, Tox and
|
|
||||||
Sphinx.
|
|
||||||
|
|
||||||
|
|
||||||
1.6 (2014-03-03)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Python 3 support, development environment refactoring.
|
|
||||||
|
|
||||||
- Feature #46: introduced support for Python>=3.3.
|
|
||||||
|
|
||||||
- Feature #80: added documentation about "how to serve a file inline VS how to
|
|
||||||
serve a file as attachment". Improved documentation of views' base options
|
|
||||||
inherited from ``DownloadMixin``.
|
|
||||||
|
|
||||||
- Feature #74: the Makefile in project's repository no longer creates a
|
|
||||||
virtualenv. Developers setup the environment as they like, i.e. using
|
|
||||||
virtualenv, virtualenvwrapper or whatever. Tests are run with tox.
|
|
||||||
|
|
||||||
|
|
||||||
1.5 (2013-11-29)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
X-Sendfile support and helpers to migrate for `django-sendfile`.
|
|
||||||
|
|
||||||
- Feature #2 - Introduced support of Lighttpd's x-Sendfile.
|
|
||||||
|
|
||||||
- Feature #36 - Introduced support of Apache's mod_xsendfile.
|
|
||||||
|
|
||||||
- Feature #41 - ``django_downloadview.sendfile`` is a port of
|
|
||||||
`django-sendfile`'s ``sendfile`` function. The documentation contains notes
|
|
||||||
about migrating from `django-sendfile` to `django-downloadview`.
|
|
||||||
|
|
||||||
|
|
||||||
1.4 (2013-11-24)
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Bugfixes and documentation features.
|
|
||||||
|
|
||||||
- Bugfix #43 - ``ObjectDownloadView`` returns HTTP 404 if model instance's file
|
|
||||||
field is empty (was HTTP 500).
|
|
||||||
|
|
||||||
- Bugfix #7 - Special characters in file names (``Content-Disposition`` header)
|
|
||||||
are urlencoded. An US-ASCII fallback is also provided.
|
|
||||||
|
|
||||||
- Feature #10 - `django-downloadview` is registered on djangopackages.com.
|
|
||||||
|
|
||||||
- Feature #65 - INSTALL documentation shows "known good set" (KGS) of versions,
|
|
||||||
i.e. versions that have been used in test environment.
|
|
||||||
|
|
||||||
|
|
||||||
1.3 (2013-11-08)
|
1.3 (2013-11-08)
|
||||||
----------------
|
----------------
|
||||||
|
|
@ -266,4 +114,4 @@ Contains **backward incompatible changes.**
|
||||||
|
|
||||||
.. target-notes::
|
.. target-notes::
|
||||||
|
|
||||||
.. _`milestones`: https://github.com/jazzband/django-downloadview/milestones
|
.. _`milestones`: https://github.com/benoitbryon/django-downloadview/issues/milestones
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# Code of Conduct
|
|
||||||
|
|
||||||
As contributors and maintainers of the Jazzband projects, and in the interest of
|
|
||||||
fostering an open and welcoming community, we pledge to respect all people who
|
|
||||||
contribute through reporting issues, posting feature requests, updating documentation,
|
|
||||||
submitting pull requests or patches, and other activities.
|
|
||||||
|
|
||||||
We are committed to making participation in the Jazzband a harassment-free experience
|
|
||||||
for everyone, regardless of the level of experience, gender, gender identity and
|
|
||||||
expression, sexual orientation, disability, personal appearance, body size, race,
|
|
||||||
ethnicity, age, religion, or nationality.
|
|
||||||
|
|
||||||
Examples of unacceptable behavior by participants include:
|
|
||||||
|
|
||||||
- The use of sexualized language or imagery
|
|
||||||
- Personal attacks
|
|
||||||
- Trolling or insulting/derogatory comments
|
|
||||||
- Public or private harassment
|
|
||||||
- Publishing other's private information, such as physical or electronic addresses,
|
|
||||||
without explicit permission
|
|
||||||
- Other unethical or unprofessional conduct
|
|
||||||
|
|
||||||
The Jazzband roadies have the right and responsibility to remove, edit, or reject
|
|
||||||
comments, commits, code, wiki edits, issues, and other contributions that are not
|
|
||||||
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
|
|
||||||
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
|
||||||
|
|
||||||
By adopting this Code of Conduct, the roadies commit themselves to fairly and
|
|
||||||
consistently applying these principles to every aspect of managing the jazzband
|
|
||||||
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
|
|
||||||
removed from the Jazzband roadies.
|
|
||||||
|
|
||||||
This code of conduct applies both within project spaces and in public spaces when an
|
|
||||||
individual is representing the project or its community.
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
|
|
||||||
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
|
|
||||||
investigated and will result in a response that is deemed necessary and appropriate to
|
|
||||||
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
|
|
||||||
reporter of an incident.
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
|
|
||||||
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
|
|
||||||
|
|
||||||
[homepage]: https://contributor-covenant.org
|
|
||||||
[version]: https://contributor-covenant.org/version/1/3/0/
|
|
||||||
102
CONTRIBUTING.rst
102
CONTRIBUTING.rst
|
|
@ -1,102 +0,0 @@
|
||||||
############
|
|
||||||
Contributing
|
|
||||||
############
|
|
||||||
|
|
||||||
|
|
||||||
.. image:: https://jazzband.co/static/img/jazzband.svg
|
|
||||||
:target: https://jazzband.co/
|
|
||||||
:alt: Jazzband
|
|
||||||
|
|
||||||
This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
|
|
||||||
|
|
||||||
|
|
||||||
This document provides guidelines for people who want to contribute to
|
|
||||||
``django-downloadview``.
|
|
||||||
|
|
||||||
|
|
||||||
**************
|
|
||||||
Create tickets
|
|
||||||
**************
|
|
||||||
|
|
||||||
Please use the `bugtracker`_ **before** starting some work:
|
|
||||||
|
|
||||||
* check if the bug or feature request has already been filed. It may have been
|
|
||||||
answered too!
|
|
||||||
|
|
||||||
* else create a new ticket.
|
|
||||||
|
|
||||||
* if you plan to contribute, tell us, so that we are given an opportunity to
|
|
||||||
give feedback as soon as possible.
|
|
||||||
|
|
||||||
* Then, in your commit messages, reference the ticket with some
|
|
||||||
``refs #TICKET-ID`` syntax.
|
|
||||||
|
|
||||||
|
|
||||||
******************
|
|
||||||
Use topic branches
|
|
||||||
******************
|
|
||||||
|
|
||||||
* Work in branches.
|
|
||||||
|
|
||||||
* Prefix your branch with the ticket ID corresponding to the issue. As an
|
|
||||||
example, if you are working on ticket #23 which is about contribute
|
|
||||||
documentation, name your branch like ``23-contribute-doc``.
|
|
||||||
|
|
||||||
* If you work in a development branch and want to refresh it with changes from
|
|
||||||
master, please `rebase`_ or `merge-based rebase`_, i.e. do not merge master.
|
|
||||||
|
|
||||||
|
|
||||||
***********
|
|
||||||
Fork, clone
|
|
||||||
***********
|
|
||||||
|
|
||||||
Clone ``django-downloadview`` repository (adapt to use your own fork):
|
|
||||||
|
|
||||||
.. code:: sh
|
|
||||||
|
|
||||||
git clone git@github.com:jazzband/django-downloadview.git
|
|
||||||
cd django-downloadview/
|
|
||||||
|
|
||||||
|
|
||||||
*************
|
|
||||||
Usual actions
|
|
||||||
*************
|
|
||||||
|
|
||||||
The ``Makefile`` is the reference card for usual actions in development
|
|
||||||
environment:
|
|
||||||
|
|
||||||
* Install development toolkit with `pip`_: ``make develop``.
|
|
||||||
|
|
||||||
* Run tests with `tox`_: ``make test``.
|
|
||||||
|
|
||||||
* Build documentation: ``make documentation``. It builds `Sphinx`_
|
|
||||||
documentation in ``var/docs/html/index.html``.
|
|
||||||
|
|
||||||
* Release project with `zest.releaser`_: ``make release``.
|
|
||||||
|
|
||||||
* Cleanup local repository: ``make clean``, ``make distclean`` and
|
|
||||||
``make maintainer-clean``.
|
|
||||||
|
|
||||||
See also ``make help``.
|
|
||||||
|
|
||||||
|
|
||||||
*********************
|
|
||||||
Demo project included
|
|
||||||
*********************
|
|
||||||
|
|
||||||
The ``demo`` included in project's repository is part of the tests and
|
|
||||||
documentation. Maintain it along with code and documentation.
|
|
||||||
|
|
||||||
|
|
||||||
.. rubric:: Notes & references
|
|
||||||
|
|
||||||
.. target-notes::
|
|
||||||
|
|
||||||
.. _`bugtracker`:
|
|
||||||
https://github.com/jazzband/django-downloadview/issues
|
|
||||||
.. _`rebase`: http://git-scm.com/book/en/Git-Branching-Rebasing
|
|
||||||
.. _`merge-based rebase`: https://tech.people-doc.com/psycho-rebasing.html
|
|
||||||
.. _`pip`: https://pypi.python.org/pypi/pip/
|
|
||||||
.. _`tox`: https://tox.readthedocs.io/
|
|
||||||
.. _`Sphinx`: https://pypi.python.org/pypi/Sphinx/
|
|
||||||
.. _`zest.releaser`: https://pypi.python.org/pypi/zest.releaser/
|
|
||||||
72
INSTALL
72
INSTALL
|
|
@ -4,74 +4,23 @@ Install
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
If you want to install a development environment, please see
|
If you want to install a development environment, please see :doc:`/dev`.
|
||||||
:doc:`/contributing`.
|
|
||||||
|
|
||||||
|
System requirements:
|
||||||
|
|
||||||
************
|
* Python 2.7
|
||||||
Requirements
|
|
||||||
************
|
|
||||||
|
|
||||||
`django-downloadview` has been tested with `Python`_ 3.7, 3.8, 3.9 and 3.10.
|
Install the package with your favorite Python installer. As an example, with
|
||||||
Other versions may work, but they are not part of the test suite at the moment.
|
pip:
|
||||||
|
|
||||||
Installing `django-downloadview` will automatically trigger the installation of
|
|
||||||
the following requirements:
|
|
||||||
|
|
||||||
.. literalinclude:: /../setup.py
|
|
||||||
:language: python
|
|
||||||
:start-after: BEGIN requirements
|
|
||||||
:end-before: END requirements
|
|
||||||
|
|
||||||
|
|
||||||
************
|
|
||||||
As a library
|
|
||||||
************
|
|
||||||
|
|
||||||
In most cases, you will use `django-downloadview` as a dependency of another
|
|
||||||
project. In such a case, you should add `django-downloadview` in your main
|
|
||||||
project's requirements. Typically in :file:`setup.py`:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
setup(
|
|
||||||
install_requires=[
|
|
||||||
'django-downloadview',
|
|
||||||
#...
|
|
||||||
]
|
|
||||||
# ...
|
|
||||||
)
|
|
||||||
|
|
||||||
Then when you install your main project with your favorite package manager
|
|
||||||
(like `pip`_), `django-downloadview` and its recursive dependencies will
|
|
||||||
automatically be installed.
|
|
||||||
|
|
||||||
|
|
||||||
**********
|
|
||||||
Standalone
|
|
||||||
**********
|
|
||||||
|
|
||||||
You can install `django-downloadview` with your favorite Python package
|
|
||||||
manager. As an example with `pip`_:
|
|
||||||
|
|
||||||
.. code:: sh
|
.. code:: sh
|
||||||
|
|
||||||
pip install django-downloadview
|
pip install django-downloadview
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
*****
|
Since version 1.1, django-downloadview requires Django>=1.5, which provides
|
||||||
Check
|
:py:class:`~django.http.StreamingHttpResponse`.
|
||||||
*****
|
|
||||||
|
|
||||||
Check `django-downloadview` has been installed:
|
|
||||||
|
|
||||||
.. code:: sh
|
|
||||||
|
|
||||||
python -c "import django_downloadview;print(django_downloadview.__version__)"
|
|
||||||
|
|
||||||
You should get installed `django-downloadview`'s version.
|
|
||||||
|
|
||||||
|
|
||||||
.. rubric:: Notes & references
|
.. rubric:: Notes & references
|
||||||
|
|
@ -81,8 +30,3 @@ You should get installed `django-downloadview`'s version.
|
||||||
* :doc:`/settings`
|
* :doc:`/settings`
|
||||||
* :doc:`/about/changelog`
|
* :doc:`/about/changelog`
|
||||||
* :doc:`/about/license`
|
* :doc:`/about/license`
|
||||||
|
|
||||||
.. target-notes::
|
|
||||||
|
|
||||||
.. _`Python`: https://www.python.org/
|
|
||||||
.. _`pip`: https://pip.pypa.io/
|
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -2,7 +2,7 @@
|
||||||
License
|
License
|
||||||
#######
|
#######
|
||||||
|
|
||||||
Copyright (c) 2012-2014, Benoît Bryon.
|
Copyright (c) 2012-2013, Benoît Bryon.
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
|
|
||||||
10
MANIFEST.in
10
MANIFEST.in
|
|
@ -1,8 +1,4 @@
|
||||||
recursive-include django_downloadview *
|
recursive-include django_downloadview *
|
||||||
global-exclude *.pyc
|
global-exclude *.pyc .*.swp
|
||||||
include AUTHORS
|
include *.txt
|
||||||
include CHANGELOG
|
include AUTHORS CHANGELOG INSTALL LICENSE README VERSION
|
||||||
include CONTRIBUTING.rst
|
|
||||||
include INSTALL
|
|
||||||
include LICENSE
|
|
||||||
include README.rst
|
|
||||||
|
|
|
||||||
136
Makefile
136
Makefile
|
|
@ -1,101 +1,89 @@
|
||||||
# Reference card for usual actions in development environment.
|
# Makefile for development.
|
||||||
#
|
# See INSTALL and docs/dev.txt for details.
|
||||||
# For standard installation of django-downloadview as a library, see INSTALL.
|
SHELL = /bin/bash
|
||||||
#
|
ROOT_DIR = $(shell pwd)
|
||||||
# For details about django-downloadview's development environment, see
|
BIN_DIR = $(ROOT_DIR)/bin
|
||||||
# CONTRIBUTING.rst.
|
DATA_DIR = $(ROOT_DIR)/var
|
||||||
#
|
WGET = wget
|
||||||
PIP = pip
|
PYTHON = $(shell which python)
|
||||||
TOX = tox
|
PROJECT = $(shell $(PYTHON) -c "import setup; print setup.NAME")
|
||||||
BLACK = black
|
PACKAGE = $(shell $(PYTHON) -c "import setup; print setup.PACKAGES[0]")
|
||||||
ISORT = isort
|
BUILDOUT_CFG = $(ROOT_DIR)/etc/buildout.cfg
|
||||||
|
BUILDOUT_DIR = $(ROOT_DIR)/lib/buildout
|
||||||
#: help - Display callable targets.
|
BUILDOUT_VERSION = 1.7.0
|
||||||
.PHONY: help
|
BUILDOUT_BOOTSTRAP_URL = https://raw.github.com/buildout/buildout/$(BUILDOUT_VERSION)/bootstrap/bootstrap.py
|
||||||
help:
|
BUILDOUT_BOOTSTRAP = $(BUILDOUT_DIR)/bootstrap.py
|
||||||
@echo "Reference card for usual actions in development environment."
|
BUILDOUT_BOOTSTRAP_ARGS = -c $(BUILDOUT_CFG) --version=$(BUILDOUT_VERSION) --distribute buildout:directory=$(ROOT_DIR)
|
||||||
@echo "Here are available targets:"
|
BUILDOUT = $(BIN_DIR)/buildout
|
||||||
@egrep -o "^#: (.+)" [Mm]akefile | sed 's/#: /* /'
|
BUILDOUT_ARGS = -N -c $(BUILDOUT_CFG) buildout:directory=$(ROOT_DIR)
|
||||||
|
NOSE = $(BIN_DIR)/nosetests
|
||||||
|
|
||||||
|
|
||||||
#: develop - Install minimal development utilities.
|
configure:
|
||||||
.PHONY: develop
|
# Configuration is stored in etc/ folder. Not generated yet.
|
||||||
develop:
|
|
||||||
$(PIP) install -e .
|
|
||||||
|
develop: buildout
|
||||||
|
|
||||||
|
|
||||||
|
buildout:
|
||||||
|
if [ ! -d $(BUILDOUT_DIR) ]; then mkdir -p $(BUILDOUT_DIR); fi
|
||||||
|
if [ ! -f $(BUILDOUT_BOOTSTRAP) ]; then wget -O $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_URL); fi
|
||||||
|
if [ ! -x $(BUILDOUT) ]; then $(PYTHON) $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_ARGS); fi
|
||||||
|
$(BUILDOUT) $(BUILDOUT_ARGS)
|
||||||
|
|
||||||
|
|
||||||
#: clean - Basic cleanup, mostly temporary files.
|
|
||||||
.PHONY: clean
|
|
||||||
clean:
|
clean:
|
||||||
find . -name "*.pyc" -delete
|
find $(ROOT_DIR)/ -name "*.pyc" -delete
|
||||||
find . -name '*.pyo' -delete
|
find $(ROOT_DIR)/ -name ".noseids" -delete
|
||||||
find . -name "__pycache__" -delete
|
|
||||||
|
|
||||||
|
|
||||||
#: distclean - Remove local builds, such as *.egg-info.
|
|
||||||
.PHONY: distclean
|
|
||||||
distclean: clean
|
distclean: clean
|
||||||
rm -rf *.egg
|
rm -rf $(ROOT_DIR)/*.egg-info
|
||||||
rm -rf *.egg-info
|
rm -rf $(ROOT_DIR)/demo/*.egg-info
|
||||||
rm -rf demo/*.egg-info
|
|
||||||
|
|
||||||
|
|
||||||
#: maintainer-clean - Remove almost everything that can be re-generated.
|
|
||||||
.PHONY: maintainer-clean
|
|
||||||
maintainer-clean: distclean
|
maintainer-clean: distclean
|
||||||
rm -rf build/
|
rm -rf $(BIN_DIR)/
|
||||||
rm -rf dist/
|
rm -rf $(ROOT_DIR)/lib/
|
||||||
rm -rf .tox/
|
|
||||||
|
|
||||||
|
|
||||||
#: test - Run test suites.
|
test: test-app test-demo test-documentation
|
||||||
.PHONY: test
|
|
||||||
test:
|
|
||||||
mkdir -p var
|
|
||||||
$(PIP) install -e .[test]
|
|
||||||
$(TOX)
|
|
||||||
|
|
||||||
|
|
||||||
#: documentation - Build documentation (Sphinx, README, ...)
|
test-app:
|
||||||
.PHONY: documentation
|
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg -c $(ROOT_DIR)/etc/nose/$(PACKAGE).cfg
|
||||||
documentation: sphinx readme
|
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/app.coverage
|
||||||
|
|
||||||
|
|
||||||
|
test-demo:
|
||||||
|
$(BIN_DIR)/demo test --nose-verbosity=2
|
||||||
|
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/demo.coverage
|
||||||
|
|
||||||
|
|
||||||
|
test-documentation:
|
||||||
|
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg sphinxcontrib.testbuild.tests
|
||||||
|
|
||||||
|
|
||||||
#: sphinx - Build Sphinx documentation (docs).
|
|
||||||
.PHONY: sphinx
|
|
||||||
sphinx:
|
sphinx:
|
||||||
$(TOX) -e sphinx
|
make --directory=docs clean html doctest
|
||||||
|
|
||||||
|
|
||||||
#: readme - Build standalone documentation files (README, CONTRIBUTING...).
|
documentation: sphinx
|
||||||
.PHONY: readme
|
|
||||||
readme:
|
|
||||||
$(TOX) -e readme
|
|
||||||
|
|
||||||
|
|
||||||
#: demo - Setup demo project.
|
demo: develop
|
||||||
.PHONY: demo
|
$(BIN_DIR)/demo syncdb --noinput
|
||||||
demo:
|
|
||||||
pip install -e .
|
|
||||||
pip install -e demo
|
|
||||||
demo migrate --noinput
|
|
||||||
# Install fixtures.
|
# Install fixtures.
|
||||||
mkdir -p var/media/object var/media/object-other/ var/media/nginx
|
mkdir -p var/media
|
||||||
cp -r demo/demoproject/fixtures/* var/media/object/
|
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object
|
||||||
cp -r demo/demoproject/fixtures/* var/media/object-other/
|
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object-other
|
||||||
cp -r demo/demoproject/fixtures/* var/media/nginx/
|
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/nginx
|
||||||
demo loaddata demo.json
|
$(BIN_DIR)/demo loaddata demo.json
|
||||||
|
|
||||||
|
|
||||||
#: runserver - Run demo server.
|
|
||||||
.PHONY: runserver
|
|
||||||
runserver: demo
|
runserver: demo
|
||||||
demo runserver
|
$(BIN_DIR)/demo runserver
|
||||||
|
|
||||||
.PHONY: black
|
|
||||||
black:
|
|
||||||
$(BLACK) demo tests django_downloadview
|
|
||||||
|
|
||||||
.PHONY: isort
|
release:
|
||||||
isort:
|
$(BIN_DIR)/fullrelease
|
||||||
$(ISORT) --recursive django_downloadview tests demo
|
|
||||||
|
|
|
||||||
50
README.rst
50
README.rst
|
|
@ -2,33 +2,9 @@
|
||||||
django-downloadview
|
django-downloadview
|
||||||
###################
|
###################
|
||||||
|
|
||||||
.. image:: https://jazzband.co/static/img/badge.svg
|
``django-downloadview`` makes it easy to serve files with Django:
|
||||||
:target: https://jazzband.co/
|
|
||||||
:alt: Jazzband
|
|
||||||
|
|
||||||
.. image:: https://img.shields.io/pypi/v/django-downloadview.svg
|
* you manage files with Django (permissions, search, generation, ...);
|
||||||
:target: https://pypi.python.org/pypi/django-downloadview
|
|
||||||
|
|
||||||
.. image:: https://img.shields.io/pypi/pyversions/django-downloadview.svg
|
|
||||||
:target: https://pypi.python.org/pypi/django-downloadview
|
|
||||||
|
|
||||||
.. image:: https://img.shields.io/pypi/djversions/django-downloadview.svg
|
|
||||||
:target: https://pypi.python.org/pypi/django-downloadview
|
|
||||||
|
|
||||||
.. image:: https://img.shields.io/pypi/dm/django-downloadview.svg
|
|
||||||
:target: https://pypi.python.org/pypi/django-downloadview
|
|
||||||
|
|
||||||
.. image:: https://github.com/jazzband/django-downloadview/workflows/Test/badge.svg
|
|
||||||
:target: https://github.com/jazzband/django-downloadview/actions
|
|
||||||
:alt: GitHub Actions
|
|
||||||
|
|
||||||
.. image:: https://codecov.io/gh/jazzband/django-downloadview/branch/master/graph/badge.svg
|
|
||||||
:target: https://codecov.io/gh/jazzband/django-downloadview
|
|
||||||
:alt: Coverage
|
|
||||||
|
|
||||||
``django-downloadview`` makes it easy to serve files with `Django`_:
|
|
||||||
|
|
||||||
* you manage files with Django (permissions, filters, generation, ...);
|
|
||||||
|
|
||||||
* files are stored somewhere or generated somehow (local filesystem, remote
|
* files are stored somewhere or generated somehow (local filesystem, remote
|
||||||
storage, memory...);
|
storage, memory...);
|
||||||
|
|
@ -36,14 +12,14 @@ django-downloadview
|
||||||
* ``django-downloadview`` helps you stream the files with very little code;
|
* ``django-downloadview`` helps you stream the files with very little code;
|
||||||
|
|
||||||
* ``django-downloadview`` helps you improve performances with reverse proxies,
|
* ``django-downloadview`` helps you improve performances with reverse proxies,
|
||||||
via mechanisms such as Nginx's X-Accel or Apache's X-Sendfile.
|
via mechanisms such as Nginx's X-Accel.
|
||||||
|
|
||||||
|
|
||||||
*******
|
*******
|
||||||
Example
|
Example
|
||||||
*******
|
*******
|
||||||
|
|
||||||
Let's serve a file stored in a file field of some model:
|
Let's serve a file stored in a FileField of some model:
|
||||||
|
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
|
|
@ -59,15 +35,13 @@ Let's serve a file stored in a file field of some model:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
*********
|
**********
|
||||||
Resources
|
Ressources
|
||||||
*********
|
**********
|
||||||
|
|
||||||
* Documentation: https://django-downloadview.readthedocs.io
|
* Documentation: http://django-downloadview.readthedocs.org
|
||||||
* PyPI page: http://pypi.python.org/pypi/django-downloadview
|
* PyPI page: http://pypi.python.org/pypi/django-downloadview
|
||||||
* Code repository: https://github.com/jazzband/django-downloadview
|
* Code repository: https://github.com/benoitbryon/django-downloadview
|
||||||
* Bugtracker: https://github.com/jazzband/django-downloadview/issues
|
* Bugtracker: https://github.com/benoitbryon/django-downloadview/issues
|
||||||
* Continuous integration: https://github.com/jazzband/django-downloadview/actions
|
* Continuous integration: https://travis-ci.org/benoitbryon/django-downloadview
|
||||||
* Roadmap: https://github.com/jazzband/django-downloadview/milestones
|
* Roadmap: https://github.com/benoitbryon/django-downloadview/issues/milestones
|
||||||
|
|
||||||
.. _`Django`: https://djangoproject.com
|
|
||||||
|
|
|
||||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1.3
|
||||||
|
|
@ -3,7 +3,7 @@ Demo project
|
||||||
############
|
############
|
||||||
|
|
||||||
`Demo folder in project's repository`_ contains a Django project to illustrate
|
`Demo folder in project's repository`_ contains a Django project to illustrate
|
||||||
``django-downloadview`` usage.
|
`django-downloadview` usage.
|
||||||
|
|
||||||
|
|
||||||
*****************************************
|
*****************************************
|
||||||
|
|
@ -31,8 +31,8 @@ Deploy the demo
|
||||||
|
|
||||||
System requirements:
|
System requirements:
|
||||||
|
|
||||||
* `Python`_ version 3.7+, available as ``python`` command.
|
* `Python`_ version 2.7, available as ``python`` command.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
You may use `Virtualenv`_ to make sure the active ``python`` is the right
|
You may use `Virtualenv`_ to make sure the active ``python`` is the right
|
||||||
|
|
@ -44,12 +44,12 @@ Execute:
|
||||||
|
|
||||||
.. code-block:: sh
|
.. code-block:: sh
|
||||||
|
|
||||||
git clone git@github.com:jazzband/django-downloadview.git
|
git clone git@github.com:benoitbryon/django-downloadview.git
|
||||||
cd django-downloadview/
|
cd django-downloadview/
|
||||||
make runserver
|
make runserver
|
||||||
|
|
||||||
It installs and runs the demo server on localhost, port 8000. So have a look
|
It installs and runs the demo server on localhost, port 8000. So have a look
|
||||||
at ``http://localhost:8000/``.
|
at http://localhost:8000/
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ References
|
||||||
.. target-notes::
|
.. target-notes::
|
||||||
|
|
||||||
.. _`demo folder in project's repository`:
|
.. _`demo folder in project's repository`:
|
||||||
https://github.com/jazzband/django-downloadview/tree/master/demo/demoproject/
|
https://github.com/benoitbryon/django-downloadview/tree/master/demo/demoproject/
|
||||||
|
|
||||||
.. _`Python`: http://python.org
|
.. _`Python`: http://python.org
|
||||||
.. _`Virtualenv`: http://virtualenv.org
|
.. _`Virtualenv`: http://virtualenv.org
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Apache optimizations."""
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Required to make a Django application."""
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
import django.test
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview.apache import assert_x_sendfile
|
|
||||||
|
|
||||||
from demoproject.apache.views import storage, storage_dir
|
|
||||||
|
|
||||||
|
|
||||||
def setup_file():
|
|
||||||
if not os.path.exists(storage_dir):
|
|
||||||
os.makedirs(storage_dir)
|
|
||||||
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByMiddlewareTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'apache:optimized_by_middleware' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("apache:optimized_by_middleware")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/apache-optimized-by-middleware/hello-world.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByDecoratorTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'apache:optimized_by_decorator' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("apache:optimized_by_decorator")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/apache-optimized-by-decorator/hello-world.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModifiedHeadersTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'apache:modified_headers' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("apache:modified_headers")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/apache-modified-headers/hello-world.txt",
|
|
||||||
)
|
|
||||||
self.assertEqual(response["X-Test"], "header")
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"""URL mapping."""
|
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from demoproject.apache import views
|
|
||||||
|
|
||||||
app_name = "apache"
|
|
||||||
urlpatterns = [
|
|
||||||
path(
|
|
||||||
"optimized-by-middleware/",
|
|
||||||
views.optimized_by_middleware,
|
|
||||||
name="optimized_by_middleware",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"optimized-by-decorator/",
|
|
||||||
views.optimized_by_decorator,
|
|
||||||
name="optimized_by_decorator",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"modified_headers/",
|
|
||||||
views.modified_headers,
|
|
||||||
name="modified_headers",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.files.storage import FileSystemStorage
|
|
||||||
|
|
||||||
from django_downloadview import StorageDownloadView
|
|
||||||
from django_downloadview.apache import x_sendfile
|
|
||||||
|
|
||||||
storage_dir = os.path.join(settings.MEDIA_ROOT, "apache")
|
|
||||||
storage = FileSystemStorage(
|
|
||||||
location=storage_dir, base_url="".join([settings.MEDIA_URL, "apache/"])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
optimized_by_middleware = StorageDownloadView.as_view(
|
|
||||||
storage=storage, path="hello-world.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
optimized_by_decorator = x_sendfile(
|
|
||||||
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
|
|
||||||
source_url=storage.base_url,
|
|
||||||
destination_dir="/apache-optimized-by-decorator/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _modified_headers(request):
|
|
||||||
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
|
|
||||||
response = view(request)
|
|
||||||
response["X-Test"] = "header"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
modified_headers = x_sendfile(
|
|
||||||
_modified_headers,
|
|
||||||
source_url=storage.base_url,
|
|
||||||
destination_dir="/apache-modified-headers/",
|
|
||||||
)
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
"model": "object.document",
|
"model": "object.document",
|
||||||
"fields": {
|
"fields": {
|
||||||
"slug": "hello-world",
|
"slug": "hello-world",
|
||||||
"file": "object/hello-world.txt"
|
"file": "document/hello-world.txt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview import assert_download_response
|
from django_downloadview import assert_download_response
|
||||||
|
|
||||||
|
|
@ -7,20 +7,10 @@ from django_downloadview import assert_download_response
|
||||||
class SimpleURLTestCase(django.test.TestCase):
|
class SimpleURLTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'simple_url' serves 'hello-world.txt' from Github."""
|
"""'simple_url' serves 'hello-world.txt' from Github."""
|
||||||
url = reverse("http:simple_url")
|
url = reverse('http:simple_url')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarTestCase(django.test.TestCase):
|
|
||||||
def test_download_response(self):
|
|
||||||
"""HTTPDownloadView proxies Content-Type header."""
|
|
||||||
url = reverse("http:avatar_url")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_download_response(self, response, mime_type="image/png")
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from django.urls import path
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from demoproject.http import views
|
from demoproject.http import views
|
||||||
|
|
||||||
app_name = "http"
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
path("simple_url/", views.simple_url, name="simple_url"),
|
'',
|
||||||
path("avatar_url/", views.avatar_url, name="avatar_url"),
|
url(r'^simple_url/$',
|
||||||
]
|
views.simple_url,
|
||||||
|
name='simple_url'),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,9 @@ from django_downloadview import HTTPDownloadView
|
||||||
class SimpleURLDownloadView(HTTPDownloadView):
|
class SimpleURLDownloadView(HTTPDownloadView):
|
||||||
def get_url(self):
|
def get_url(self):
|
||||||
"""Return URL of hello-world.txt file on GitHub."""
|
"""Return URL of hello-world.txt file on GitHub."""
|
||||||
return (
|
return 'https://raw.github.com/benoitbryon/django-downloadview' \
|
||||||
"https://raw.githubusercontent.com"
|
'/b7f660c5e3f37d918b106b02c5af7a887acc0111' \
|
||||||
"/jazzband/django-downloadview"
|
'/demo/demoproject/download/fixtures/hello-world.txt'
|
||||||
"/b7f660c5e3f37d918b106b02c5af7a887acc0111"
|
|
||||||
"/demo/demoproject/download/fixtures/hello-world.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GithubAvatarDownloadView(HTTPDownloadView):
|
|
||||||
def get_url(self):
|
|
||||||
return "https://avatars0.githubusercontent.com/u/235204"
|
|
||||||
|
|
||||||
|
|
||||||
simple_url = SimpleURLDownloadView.as_view()
|
simple_url = SimpleURLDownloadView.as_view()
|
||||||
avatar_url = GithubAvatarDownloadView.as_view()
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Lighttpd optimizations."""
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Required to make a Django application."""
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
import django.test
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview.lighttpd import assert_x_sendfile
|
|
||||||
|
|
||||||
from demoproject.lighttpd.views import storage, storage_dir
|
|
||||||
|
|
||||||
|
|
||||||
def setup_file():
|
|
||||||
if not os.path.exists(storage_dir):
|
|
||||||
os.makedirs(storage_dir)
|
|
||||||
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByMiddlewareTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'lighttpd:optimized_by_middleware' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("lighttpd:optimized_by_middleware")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/lighttpd-optimized-by-middleware/hello-world.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByDecoratorTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'lighttpd:optimized_by_decorator' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("lighttpd:optimized_by_decorator")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/lighttpd-optimized-by-decorator/hello-world.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModifiedHeadersTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'lighttpd:modified_headers' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("lighttpd:modified_headers")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_sendfile(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
file_path="/lighttpd-modified-headers/hello-world.txt",
|
|
||||||
)
|
|
||||||
self.assertEqual(response["X-Test"], "header")
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"""URL mapping."""
|
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from demoproject.lighttpd import views
|
|
||||||
|
|
||||||
app_name = "lighttpd"
|
|
||||||
urlpatterns = [
|
|
||||||
path(
|
|
||||||
"optimized-by-middleware/",
|
|
||||||
views.optimized_by_middleware,
|
|
||||||
name="optimized_by_middleware",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"optimized-by-decorator/",
|
|
||||||
views.optimized_by_decorator,
|
|
||||||
name="optimized_by_decorator",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"modified_headers/",
|
|
||||||
views.modified_headers,
|
|
||||||
name="modified_headers",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import os
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.files.storage import FileSystemStorage
|
|
||||||
|
|
||||||
from django_downloadview import StorageDownloadView
|
|
||||||
from django_downloadview.lighttpd import x_sendfile
|
|
||||||
|
|
||||||
storage_dir = os.path.join(settings.MEDIA_ROOT, "lighttpd")
|
|
||||||
storage = FileSystemStorage(
|
|
||||||
location=storage_dir, base_url="".join([settings.MEDIA_URL, "lighttpd/"])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
optimized_by_middleware = StorageDownloadView.as_view(
|
|
||||||
storage=storage, path="hello-world.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
optimized_by_decorator = x_sendfile(
|
|
||||||
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
|
|
||||||
source_url=storage.base_url,
|
|
||||||
destination_dir="/lighttpd-optimized-by-decorator/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _modified_headers(request):
|
|
||||||
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
|
|
||||||
response = view(request)
|
|
||||||
response["X-Test"] = "header"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
modified_headers = x_sendfile(
|
|
||||||
_modified_headers,
|
|
||||||
source_url=storage.base_url,
|
|
||||||
destination_dir="/lighttpd-modified-headers/",
|
|
||||||
)
|
|
||||||
|
|
@ -4,9 +4,8 @@ import sys
|
||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"{__package__}.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview.nginx import assert_x_accel_redirect
|
from django_downloadview.nginx import assert_x_accel_redirect
|
||||||
|
|
||||||
|
|
@ -12,14 +12,14 @@ from demoproject.nginx.views import storage, storage_dir
|
||||||
def setup_file():
|
def setup_file():
|
||||||
if not os.path.exists(storage_dir):
|
if not os.path.exists(storage_dir):
|
||||||
os.makedirs(storage_dir)
|
os.makedirs(storage_dir)
|
||||||
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
|
storage.save('hello-world.txt', ContentFile(u'Hello world!\n'))
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByMiddlewareTestCase(django.test.TestCase):
|
class OptimizedByMiddlewareTestCase(django.test.TestCase):
|
||||||
def test_response(self):
|
def test_response(self):
|
||||||
"""'nginx:optimized_by_middleware' returns X-Accel response."""
|
"""'nginx:optimized_by_middleware' returns X-Accel response."""
|
||||||
setup_file()
|
setup_file()
|
||||||
url = reverse("nginx:optimized_by_middleware")
|
url = reverse('nginx:optimized_by_middleware')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_x_accel_redirect(
|
assert_x_accel_redirect(
|
||||||
self,
|
self,
|
||||||
|
|
@ -30,15 +30,14 @@ class OptimizedByMiddlewareTestCase(django.test.TestCase):
|
||||||
redirect_url="/nginx-optimized-by-middleware/hello-world.txt",
|
redirect_url="/nginx-optimized-by-middleware/hello-world.txt",
|
||||||
expires=None,
|
expires=None,
|
||||||
with_buffering=None,
|
with_buffering=None,
|
||||||
limit_rate=None,
|
limit_rate=None)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizedByDecoratorTestCase(django.test.TestCase):
|
class OptimizedByDecoratorTestCase(django.test.TestCase):
|
||||||
def test_response(self):
|
def test_response(self):
|
||||||
"""'nginx:optimized_by_decorator' returns X-Accel response."""
|
"""'nginx:optimized_by_decorator' returns X-Accel response."""
|
||||||
setup_file()
|
setup_file()
|
||||||
url = reverse("nginx:optimized_by_decorator")
|
url = reverse('nginx:optimized_by_decorator')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_x_accel_redirect(
|
assert_x_accel_redirect(
|
||||||
self,
|
self,
|
||||||
|
|
@ -49,25 +48,4 @@ class OptimizedByDecoratorTestCase(django.test.TestCase):
|
||||||
redirect_url="/nginx-optimized-by-decorator/hello-world.txt",
|
redirect_url="/nginx-optimized-by-decorator/hello-world.txt",
|
||||||
expires=None,
|
expires=None,
|
||||||
with_buffering=None,
|
with_buffering=None,
|
||||||
limit_rate=None,
|
limit_rate=None)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ModifiedHeadersTestCase(django.test.TestCase):
|
|
||||||
def test_response(self):
|
|
||||||
"""'nginx:modified_headers' returns X-Sendfile response."""
|
|
||||||
setup_file()
|
|
||||||
url = reverse("nginx:modified_headers")
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_x_accel_redirect(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content_type="text/plain; charset=utf-8",
|
|
||||||
charset="utf-8",
|
|
||||||
basename="hello-world.txt",
|
|
||||||
redirect_url="/nginx-modified-headers/hello-world.txt",
|
|
||||||
expires=None,
|
|
||||||
with_buffering=None,
|
|
||||||
limit_rate=None,
|
|
||||||
)
|
|
||||||
self.assertEqual(response["X-Test"], "header")
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,13 @@
|
||||||
"""URL mapping."""
|
"""URL mapping."""
|
||||||
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from demoproject.nginx import views
|
urlpatterns = patterns(
|
||||||
|
'demoproject.nginx.views',
|
||||||
app_name = "nginx"
|
url(r'^optimized-by-middleware/$',
|
||||||
urlpatterns = [
|
'optimized_by_middleware',
|
||||||
path(
|
name='optimized_by_middleware'),
|
||||||
"optimized-by-middleware/",
|
url(r'^optimized-by-decorator/$',
|
||||||
views.optimized_by_middleware,
|
'optimized_by_decorator',
|
||||||
name="optimized_by_middleware",
|
name='optimized_by_decorator'),
|
||||||
),
|
)
|
||||||
path(
|
|
||||||
"optimized-by-decorator/",
|
|
||||||
views.optimized_by_decorator,
|
|
||||||
name="optimized_by_decorator",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"modified_headers/",
|
|
||||||
views.modified_headers,
|
|
||||||
name="modified_headers",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -6,33 +6,17 @@ from django.core.files.storage import FileSystemStorage
|
||||||
from django_downloadview import StorageDownloadView
|
from django_downloadview import StorageDownloadView
|
||||||
from django_downloadview.nginx import x_accel_redirect
|
from django_downloadview.nginx import x_accel_redirect
|
||||||
|
|
||||||
storage_dir = os.path.join(settings.MEDIA_ROOT, "nginx")
|
|
||||||
storage = FileSystemStorage(
|
storage_dir = os.path.join(settings.MEDIA_ROOT, 'nginx')
|
||||||
location=storage_dir, base_url="".join([settings.MEDIA_URL, "nginx/"])
|
storage = FileSystemStorage(location=storage_dir,
|
||||||
)
|
base_url=''.join([settings.MEDIA_URL, 'nginx/']))
|
||||||
|
|
||||||
|
|
||||||
optimized_by_middleware = StorageDownloadView.as_view(
|
optimized_by_middleware = StorageDownloadView.as_view(storage=storage,
|
||||||
storage=storage, path="hello-world.txt"
|
path='hello-world.txt')
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
optimized_by_decorator = x_accel_redirect(
|
optimized_by_decorator = x_accel_redirect(
|
||||||
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
|
StorageDownloadView.as_view(storage=storage, path='hello-world.txt'),
|
||||||
source_url=storage.base_url,
|
source_url=storage.base_url,
|
||||||
destination_url="/nginx-optimized-by-decorator/",
|
destination_url='/nginx-optimized-by-decorator/')
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _modified_headers(request):
|
|
||||||
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
|
|
||||||
response = view(request)
|
|
||||||
response["X-Test"] = "header"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
modified_headers = x_accel_redirect(
|
|
||||||
_modified_headers,
|
|
||||||
source_url=storage.base_url,
|
|
||||||
destination_url="/nginx-modified-headers/",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ from django.db import models
|
||||||
|
|
||||||
class Document(models.Model):
|
class Document(models.Model):
|
||||||
slug = models.SlugField()
|
slug = models.SlugField()
|
||||||
file = models.FileField(upload_to="object")
|
file = models.FileField(upload_to='object')
|
||||||
another_file = models.FileField(upload_to="object-other")
|
another_file = models.FileField(upload_to='object-other')
|
||||||
basename = models.CharField(max_length=100)
|
basename = models.CharField(max_length=100)
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview import assert_download_response, temporary_media_root
|
from django_downloadview import temporary_media_root, assert_download_response
|
||||||
|
|
||||||
from demoproject.object.models import Document
|
from demoproject.object.models import Document
|
||||||
|
|
||||||
|
|
||||||
# Fixtures.
|
# Fixtures.
|
||||||
slug = "hello-world"
|
slug = 'hello-world'
|
||||||
basename = "hello-world.txt"
|
basename = 'hello-world.txt'
|
||||||
file_name = "file.txt"
|
file_name = 'file.txt'
|
||||||
another_name = "another_file.txt"
|
another_name = 'another_file.txt'
|
||||||
file_content = "Hello world!\n"
|
file_content = 'Hello world!\n'
|
||||||
another_content = "Goodbye world!\n"
|
another_content = 'Goodbye world!\n'
|
||||||
|
|
||||||
|
|
||||||
def setup_document():
|
def setup_document():
|
||||||
document = Document(slug=slug, basename=basename)
|
document = Document(slug=slug, basename=basename)
|
||||||
document.file.save(file_name, ContentFile(file_content), save=False)
|
document.file.save(file_name,
|
||||||
document.another_file.save(another_name, ContentFile(another_content), save=False)
|
ContentFile(file_content),
|
||||||
|
save=False)
|
||||||
|
document.another_file.save(another_name,
|
||||||
|
ContentFile(another_content),
|
||||||
|
save=False)
|
||||||
document.save()
|
document.save()
|
||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
@ -28,15 +33,13 @@ class DefaultFileTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'default_file' streams Document.file."""
|
"""'default_file' streams Document.file."""
|
||||||
setup_document()
|
setup_document()
|
||||||
url = reverse("object:default_file", kwargs={"slug": slug})
|
url = reverse('object:default_file', kwargs={'slug': slug})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content=file_content,
|
||||||
content=file_content,
|
basename=file_name,
|
||||||
basename=file_name,
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AnotherFileTestCase(django.test.TestCase):
|
class AnotherFileTestCase(django.test.TestCase):
|
||||||
|
|
@ -44,15 +47,13 @@ class AnotherFileTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'another_file' streams Document.another_file."""
|
"""'another_file' streams Document.another_file."""
|
||||||
setup_document()
|
setup_document()
|
||||||
url = reverse("object:another_file", kwargs={"slug": slug})
|
url = reverse('object:another_file', kwargs={'slug': slug})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content=another_content,
|
||||||
content=another_content,
|
basename=another_name,
|
||||||
basename=another_name,
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DeserializedBasenameTestCase(django.test.TestCase):
|
class DeserializedBasenameTestCase(django.test.TestCase):
|
||||||
|
|
@ -60,28 +61,10 @@ class DeserializedBasenameTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"'deserialized_basename' streams Document.file with custom basename."
|
"'deserialized_basename' streams Document.file with custom basename."
|
||||||
setup_document()
|
setup_document()
|
||||||
url = reverse("object:deserialized_basename", kwargs={"slug": slug})
|
url = reverse('object:deserialized_basename', kwargs={'slug': slug})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content=file_content,
|
||||||
content=file_content,
|
basename=basename,
|
||||||
basename=basename,
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InlineFileTestCase(django.test.TestCase):
|
|
||||||
@temporary_media_root()
|
|
||||||
def test_download_response(self):
|
|
||||||
"'inline_file_view' streams Document.file inline."
|
|
||||||
setup_document()
|
|
||||||
url = reverse("object:inline_file", kwargs={"slug": slug})
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert_download_response(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content=file_content,
|
|
||||||
mime_type="text/plain",
|
|
||||||
attachment=False,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,17 @@
|
||||||
from django.urls import re_path
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from demoproject.object import views
|
from demoproject.object import views
|
||||||
|
|
||||||
app_name = "object"
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
re_path(
|
'',
|
||||||
r"^default-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
|
url(r'^default-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
|
||||||
views.default_file_view,
|
views.default_file_view,
|
||||||
name="default_file",
|
name='default_file'),
|
||||||
),
|
url(r'^another-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
|
||||||
re_path(
|
|
||||||
r"^another-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
|
|
||||||
views.another_file_view,
|
views.another_file_view,
|
||||||
name="another_file",
|
name='another_file'),
|
||||||
),
|
url(r'^deserialized_basename/(?P<slug>[a-zA-Z0-9_-]+)/$',
|
||||||
re_path(
|
|
||||||
r"^deserialized_basename/(?P<slug>[a-zA-Z0-9_-]+)/$",
|
|
||||||
views.deserialized_basename_view,
|
views.deserialized_basename_view,
|
||||||
name="deserialized_basename",
|
name='deserialized_basename'),
|
||||||
),
|
)
|
||||||
re_path(
|
|
||||||
r"^inline-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
|
|
||||||
views.inline_file_view,
|
|
||||||
name="inline_file",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,17 @@ from django_downloadview import ObjectDownloadView
|
||||||
|
|
||||||
from demoproject.object.models import Document
|
from demoproject.object.models import Document
|
||||||
|
|
||||||
|
|
||||||
#: Serve ``file`` attribute of ``Document`` model.
|
#: Serve ``file`` attribute of ``Document`` model.
|
||||||
default_file_view = ObjectDownloadView.as_view(model=Document)
|
default_file_view = ObjectDownloadView.as_view(model=Document)
|
||||||
|
|
||||||
#: Serve ``another_file`` attribute of ``Document`` model.
|
#: Serve ``another_file`` attribute of ``Document`` model.
|
||||||
another_file_view = ObjectDownloadView.as_view(
|
another_file_view = ObjectDownloadView.as_view(
|
||||||
model=Document, file_field="another_file"
|
model=Document,
|
||||||
)
|
file_field='another_file')
|
||||||
|
|
||||||
#: Serve ``file`` attribute of ``Document`` model, using client-side filename
|
#: Serve ``file`` attribute of ``Document`` model, using client-side filename
|
||||||
#: from model.
|
#: from model.
|
||||||
deserialized_basename_view = ObjectDownloadView.as_view(
|
deserialized_basename_view = ObjectDownloadView.as_view(
|
||||||
model=Document, basename_field="basename"
|
model=Document,
|
||||||
)
|
basename_field='basename')
|
||||||
|
|
||||||
#: Serve ``file`` attribute of ``Document`` model, inline (not as attachment).
|
|
||||||
inline_file_view = ObjectDownloadView.as_view(model=Document, attachment=False)
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview import assert_download_response
|
from django_downloadview import assert_download_response
|
||||||
|
|
||||||
|
|
@ -7,26 +7,22 @@ from django_downloadview import assert_download_response
|
||||||
class StaticPathTestCase(django.test.TestCase):
|
class StaticPathTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'static_path' serves 'fixtures/hello-world.txt'."""
|
"""'static_path' serves 'fixtures/hello-world.txt'."""
|
||||||
url = reverse("path:static_path")
|
url = reverse('path:static_path')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicPathTestCase(django.test.TestCase):
|
class DynamicPathTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'dynamic_path' serves 'fixtures/{path}'."""
|
"""'dynamic_path' serves 'fixtures/{path}'."""
|
||||||
url = reverse("path:dynamic_path", kwargs={"path": "hello-world.txt"})
|
url = reverse('path:dynamic_path', kwargs={'path': 'hello-world.txt'})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
from django.urls import path, re_path
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from demoproject.path import views
|
from demoproject.path import views
|
||||||
|
|
||||||
app_name = "path"
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
path("static-path/", views.static_path, name="static_path"),
|
'',
|
||||||
re_path(
|
url(r'^static-path/$',
|
||||||
r"^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
|
views.static_path,
|
||||||
|
name='static_path'),
|
||||||
|
url(r'^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
|
||||||
views.dynamic_path,
|
views.dynamic_path,
|
||||||
name="dynamic_path",
|
name='dynamic_path'),
|
||||||
),
|
)
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import os
|
||||||
|
|
||||||
from django_downloadview import PathDownloadView
|
from django_downloadview import PathDownloadView
|
||||||
|
|
||||||
|
|
||||||
# Let's initialize some fixtures.
|
# Let's initialize some fixtures.
|
||||||
app_dir = os.path.dirname(os.path.abspath(__file__))
|
app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_dir = os.path.dirname(app_dir)
|
project_dir = os.path.dirname(app_dir)
|
||||||
fixtures_dir = os.path.join(project_dir, "fixtures")
|
fixtures_dir = os.path.join(project_dir, 'fixtures')
|
||||||
#: Path to a text file that says 'Hello world!'.
|
#: Path to a text file that says 'Hello world!'.
|
||||||
hello_world_path = os.path.join(fixtures_dir, "hello-world.txt")
|
hello_world_path = os.path.join(fixtures_dir, 'hello-world.txt')
|
||||||
|
|
||||||
#: Serve ``fixtures/hello-world.txt`` file.
|
#: Serve ``fixtures/hello-world.txt`` file.
|
||||||
static_path = PathDownloadView.as_view(path=hello_world_path)
|
static_path = PathDownloadView.as_view(path=hello_world_path)
|
||||||
|
|
@ -26,11 +27,10 @@ class DynamicPathDownloadView(PathDownloadView):
|
||||||
:class:`StorageDownloadView`
|
:class:`StorageDownloadView`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_path(self):
|
def get_path(self):
|
||||||
"""Return path inside fixtures directory."""
|
"""Return path inside fixtures directory."""
|
||||||
# Get path from URL resolvers or as_view kwarg.
|
# Get path from URL resolvers or as_view kwarg.
|
||||||
relative_path = super().get_path()
|
relative_path = super(DynamicPathDownloadView, self).get_path()
|
||||||
# Make it absolute.
|
# Make it absolute.
|
||||||
absolute_path = os.path.join(fixtures_dir, relative_path)
|
absolute_path = os.path.join(fixtures_dir, relative_path)
|
||||||
return absolute_path
|
return absolute_path
|
||||||
|
|
|
||||||
149
demo/demoproject/settings.py
Normal file → Executable file
149
demo/demoproject/settings.py
Normal file → Executable file
|
|
@ -1,26 +1,26 @@
|
||||||
"""Django settings for django-downloadview demo project."""
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Django settings for Django-DownloadView demo project."""
|
||||||
import os
|
from os.path import abspath, dirname, join
|
||||||
|
|
||||||
|
|
||||||
# Configure some relative directories.
|
# Configure some relative directories.
|
||||||
demoproject_dir = os.path.dirname(os.path.abspath(__file__))
|
demoproject_dir = dirname(abspath(__file__))
|
||||||
demo_dir = os.path.dirname(demoproject_dir)
|
demo_dir = dirname(demoproject_dir)
|
||||||
root_dir = os.path.dirname(demo_dir)
|
root_dir = dirname(demo_dir)
|
||||||
data_dir = os.path.join(root_dir, "var")
|
data_dir = join(root_dir, 'var')
|
||||||
cfg_dir = os.path.join(root_dir, "etc")
|
cfg_dir = join(root_dir, 'etc')
|
||||||
|
|
||||||
|
|
||||||
# Mandatory settings.
|
# Mandatory settings.
|
||||||
ROOT_URLCONF = "demoproject.urls"
|
ROOT_URLCONF = 'demoproject.urls'
|
||||||
WSGI_APPLICATION = "demoproject.wsgi.application"
|
WSGI_APPLICATION = 'demoproject.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
# Database.
|
# Database.
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
'default': {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
"NAME": os.path.join(data_dir, "db.sqlite"),
|
'NAME': join(data_dir, 'db.sqlite'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,106 +29,59 @@ DATABASES = {
|
||||||
SECRET_KEY = "This is a secret made public on project's repository."
|
SECRET_KEY = "This is a secret made public on project's repository."
|
||||||
|
|
||||||
# Media and static files.
|
# Media and static files.
|
||||||
MEDIA_ROOT = os.path.join(data_dir, "media")
|
MEDIA_ROOT = join(data_dir, 'media')
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = '/media/'
|
||||||
STATIC_ROOT = os.path.join(data_dir, "static")
|
STATIC_ROOT = join(data_dir, 'static')
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
|
|
||||||
# Applications.
|
# Applications.
|
||||||
INSTALLED_APPS = (
|
INSTALLED_APPS = (
|
||||||
# The actual django-downloadview demo.
|
|
||||||
"demoproject",
|
|
||||||
"demoproject.object", # Demo around ObjectDownloadView
|
|
||||||
"demoproject.storage", # Demo around StorageDownloadView
|
|
||||||
"demoproject.path", # Demo around PathDownloadView
|
|
||||||
"demoproject.http", # Demo around HTTPDownloadView
|
|
||||||
"demoproject.virtual", # Demo around VirtualDownloadView
|
|
||||||
"demoproject.nginx", # Sample optimizations for Nginx X-Accel.
|
|
||||||
"demoproject.apache", # Sample optimizations for Apache X-Sendfile.
|
|
||||||
"demoproject.lighttpd", # Sample optimizations for Lighttpd X-Sendfile.
|
|
||||||
# Standard Django applications.
|
# Standard Django applications.
|
||||||
"django.contrib.auth",
|
'django.contrib.auth',
|
||||||
"django.contrib.contenttypes",
|
'django.contrib.contenttypes',
|
||||||
"django.contrib.sessions",
|
'django.contrib.sessions',
|
||||||
"django.contrib.sites",
|
'django.contrib.sites',
|
||||||
"django.contrib.messages",
|
'django.contrib.messages',
|
||||||
"django.contrib.staticfiles",
|
'django.contrib.staticfiles',
|
||||||
|
# The actual django-downloadview demo.
|
||||||
|
'demoproject',
|
||||||
|
'demoproject.object', # Demo around ObjectDownloadView
|
||||||
|
'demoproject.storage', # Demo around StorageDownloadView
|
||||||
|
'demoproject.path', # Demo around PathDownloadView
|
||||||
|
'demoproject.http', # Demo around HTTPDownloadView
|
||||||
|
'demoproject.virtual', # Demo around VirtualDownloadView
|
||||||
|
'demoproject.nginx', # Sample optimizations for Nginx X-Accel.
|
||||||
|
# For test purposes. The demo project is part of django-downloadview
|
||||||
|
# test suite.
|
||||||
|
'django_nose',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# BEGIN middlewares
|
# Middlewares.
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE_CLASSES = [
|
||||||
"django.middleware.common.CommonMiddleware",
|
'django.middleware.common.CommonMiddleware',
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
"django_downloadview.SmartDownloadMiddleware",
|
'django_downloadview.SmartDownloadMiddleware'
|
||||||
]
|
]
|
||||||
# END middlewares
|
|
||||||
|
|
||||||
|
|
||||||
# Specific configuration for django_downloadview.SmartDownloadMiddleware.
|
# Specific configuration for django_downloadview.SmartDownloadMiddleware.
|
||||||
# BEGIN backend
|
DOWNLOADVIEW_BACKEND = 'django_downloadview.nginx.XAccelRedirectMiddleware'
|
||||||
DOWNLOADVIEW_BACKEND = "django_downloadview.nginx.XAccelRedirectMiddleware"
|
|
||||||
# END backend
|
|
||||||
"""Could also be:
|
|
||||||
DOWNLOADVIEW_BACKEND = 'django_downloadview.apache.XSendfileMiddleware'
|
|
||||||
DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware'
|
|
||||||
"""
|
|
||||||
|
|
||||||
# BEGIN rules
|
|
||||||
DOWNLOADVIEW_RULES = [
|
DOWNLOADVIEW_RULES = [
|
||||||
{
|
{'source_url': '/media/nginx/',
|
||||||
"source_url": "/media/nginx/",
|
'destination_url': '/nginx-optimized-by-middleware/'},
|
||||||
"destination_url": "/nginx-optimized-by-middleware/",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
# END rules
|
|
||||||
DOWNLOADVIEW_RULES += [
|
|
||||||
{
|
|
||||||
"source_url": "/media/apache/",
|
|
||||||
"destination_dir": "/apache-optimized-by-middleware/",
|
|
||||||
# Bypass global default backend with additional argument "backend".
|
|
||||||
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
|
|
||||||
# enough. Here, the django_downloadview demo project needs to
|
|
||||||
# demonstrate usage of several backends.
|
|
||||||
"backend": "django_downloadview.apache.XSendfileMiddleware",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_url": "/media/lighttpd/",
|
|
||||||
"destination_dir": "/lighttpd-optimized-by-middleware/",
|
|
||||||
# Bypass global default backend with additional argument "backend".
|
|
||||||
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
|
|
||||||
# enough. Here, the django_downloadview demo project needs to
|
|
||||||
# demonstrate usage of several backends.
|
|
||||||
"backend": "django_downloadview.lighttpd.XSendfileMiddleware",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Test/development settings.
|
# Test/development settings.
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
TEMPLATE_DEBUG = DEBUG
|
||||||
|
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||||
TEMPLATES = [
|
nose_cfg_dir = join(cfg_dir, 'nose')
|
||||||
{
|
NOSE_ARGS = ['--config={etc}/base.cfg'.format(etc=nose_cfg_dir),
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
'--config={etc}/{package}.cfg'.format(etc=nose_cfg_dir,
|
||||||
"DIRS": [os.path.join(os.path.dirname(__file__), "templates")],
|
package=__package__)]
|
||||||
"OPTIONS": {
|
|
||||||
"debug": DEBUG,
|
|
||||||
"context_processors": [
|
|
||||||
# Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this
|
|
||||||
# list if you haven't customized them:
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.i18n",
|
|
||||||
"django.template.context_processors.media",
|
|
||||||
"django.template.context_processors.static",
|
|
||||||
"django.template.context_processors.tz",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
|
||||||
storage = FileSystemStorage()
|
storage = FileSystemStorage()
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import datetime
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.http.response import HttpResponseNotModified
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview import (
|
from django_downloadview import assert_download_response, temporary_media_root
|
||||||
assert_download_response,
|
from django_downloadview import setup_view
|
||||||
setup_view,
|
|
||||||
temporary_media_root,
|
|
||||||
)
|
|
||||||
|
|
||||||
from demoproject.storage import views
|
from demoproject.storage import views
|
||||||
|
|
||||||
|
|
||||||
# Fixtures.
|
# Fixtures.
|
||||||
file_content = "Hello world!\n"
|
file_content = 'Hello world!\n'
|
||||||
|
|
||||||
|
|
||||||
def setup_file(path):
|
def setup_file(path):
|
||||||
|
|
@ -26,48 +22,18 @@ class StaticPathTestCase(django.test.TestCase):
|
||||||
@temporary_media_root()
|
@temporary_media_root()
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'storage:static_path' streams file by path."""
|
"""'storage:static_path' streams file by path."""
|
||||||
setup_file("1.txt")
|
setup_file('1.txt')
|
||||||
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
|
url = reverse('storage:static_path', kwargs={'path': '1.txt'})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content=file_content,
|
||||||
content=file_content,
|
basename='1.txt',
|
||||||
basename="1.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
@temporary_media_root()
|
|
||||||
def test_not_modified_download_response(self):
|
|
||||||
"""'storage:static_path' sends not modified response if unmodified."""
|
|
||||||
setup_file("1.txt")
|
|
||||||
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
|
|
||||||
year = datetime.date.today().year + 4
|
|
||||||
response = self.client.get(
|
|
||||||
url, headers={"if-modified-since": f"Sat, 29 Oct {year} 19:43:31 GMT"}
|
|
||||||
)
|
|
||||||
self.assertTrue(isinstance(response, HttpResponseNotModified))
|
|
||||||
|
|
||||||
@temporary_media_root()
|
|
||||||
def test_modified_since_download_response(self):
|
|
||||||
"""'storage:static_path' streams file if modified."""
|
|
||||||
setup_file("1.txt")
|
|
||||||
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
|
|
||||||
response = self.client.get(
|
|
||||||
url, headers={"if-modified-since": "Sat, 29 Oct 1980 19:43:31 GMT"}
|
|
||||||
)
|
|
||||||
assert_download_response(
|
|
||||||
self,
|
|
||||||
response,
|
|
||||||
content=file_content,
|
|
||||||
basename="1.txt",
|
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicPathIntegrationTestCase(django.test.TestCase):
|
class DynamicPathIntegrationTestCase(django.test.TestCase):
|
||||||
"""Integration tests around ``storage:dynamic_path`` URL."""
|
"""Integration tests around ``storage:dynamic_path`` URL."""
|
||||||
|
|
||||||
@temporary_media_root()
|
@temporary_media_root()
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'dynamic_path' streams file by generated path.
|
"""'dynamic_path' streams file by generated path.
|
||||||
|
|
@ -80,21 +46,18 @@ class DynamicPathIntegrationTestCase(django.test.TestCase):
|
||||||
file in storage.
|
file in storage.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
setup_file("1.TXT")
|
setup_file('1.TXT')
|
||||||
url = reverse("storage:dynamic_path", kwargs={"path": "1.txt"})
|
url = reverse('storage:dynamic_path', kwargs={'path': '1.txt'})
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content=file_content,
|
||||||
content=file_content,
|
basename='1.TXT',
|
||||||
basename="1.TXT",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicPathUnitTestCase(unittest.TestCase):
|
class DynamicPathUnitTestCase(unittest.TestCase):
|
||||||
"""Unit tests around ``views.DynamicStorageDownloadView``."""
|
"""Unit tests around ``views.DynamicStorageDownloadView``."""
|
||||||
|
|
||||||
def test_get_path(self):
|
def test_get_path(self):
|
||||||
"""DynamicStorageDownloadView.get_path() returns uppercase path.
|
"""DynamicStorageDownloadView.get_path() returns uppercase path.
|
||||||
|
|
||||||
|
|
@ -106,10 +69,8 @@ class DynamicPathUnitTestCase(unittest.TestCase):
|
||||||
URL works. It targets only custom ``DynamicStorageDownloadView`` class.
|
URL works. It targets only custom ``DynamicStorageDownloadView`` class.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
view = setup_view(
|
view = setup_view(views.DynamicStorageDownloadView(),
|
||||||
views.DynamicStorageDownloadView(),
|
django.test.RequestFactory().get('/fake-url'),
|
||||||
django.test.RequestFactory().get("/fake-url"),
|
path='dummy path')
|
||||||
path="dummy path",
|
|
||||||
)
|
|
||||||
path = view.get_path()
|
path = view.get_path()
|
||||||
self.assertEqual(path, "DUMMY PATH")
|
self.assertEqual(path, 'DUMMY PATH')
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
from django.urls import re_path
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from demoproject.storage import views
|
from demoproject.storage import views
|
||||||
|
|
||||||
app_name = "storage"
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
re_path(
|
'',
|
||||||
r"^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
|
url(r'^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
|
||||||
views.static_path,
|
views.static_path,
|
||||||
name="static_path",
|
name='static_path'),
|
||||||
),
|
url(r'^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
|
||||||
re_path(
|
|
||||||
r"^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
|
|
||||||
views.dynamic_path,
|
views.dynamic_path,
|
||||||
name="dynamic_path",
|
name='dynamic_path'),
|
||||||
),
|
)
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
from django_downloadview import StorageDownloadView
|
from django_downloadview import StorageDownloadView
|
||||||
|
|
||||||
|
|
||||||
storage = FileSystemStorage()
|
storage = FileSystemStorage()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,10 +12,9 @@ static_path = StorageDownloadView.as_view(storage=storage)
|
||||||
|
|
||||||
class DynamicStorageDownloadView(StorageDownloadView):
|
class DynamicStorageDownloadView(StorageDownloadView):
|
||||||
"""Serve file of storage by path.upper()."""
|
"""Serve file of storage by path.upper()."""
|
||||||
|
|
||||||
def get_path(self):
|
def get_path(self):
|
||||||
"""Return uppercase path."""
|
"""Return uppercase path."""
|
||||||
return super().get_path().upper()
|
return super(DynamicStorageDownloadView, self).get_path().upper()
|
||||||
|
|
||||||
|
|
||||||
dynamic_path = DynamicStorageDownloadView.as_view(storage=storage)
|
dynamic_path = DynamicStorageDownloadView.as_view(storage=storage)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% load url from future %}
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>django-downloadview demo</title>
|
<title>django-downloadview demo</title>
|
||||||
|
|
@ -10,9 +11,6 @@
|
||||||
<p>In the following views, Django streams the files, no optimization
|
<p>In the following views, Django streams the files, no optimization
|
||||||
has been setup.</p>
|
has been setup.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
|
||||||
<a href="{% url 'object:default_file' 'hello-world' %}">object:default_file</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Optimized downloads</h2>
|
<h2>Optimized downloads</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
|
# coding=utf8
|
||||||
"""Test suite for demoproject.download."""
|
"""Test suite for demoproject.download."""
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
|
|
||||||
class HomeViewTestCase(TestCase):
|
class HomeViewTestCase(TestCase):
|
||||||
"""Test homepage."""
|
"""Test homepage."""
|
||||||
|
|
||||||
def test_get(self):
|
def test_get(self):
|
||||||
"""Homepage returns HTTP 200."""
|
"""Homepage returns HTTP 200."""
|
||||||
home_url = reverse("home")
|
home_url = reverse('home')
|
||||||
response = self.client.get(home_url)
|
response = self.client.get(home_url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
|
||||||
58
demo/demoproject/urls.py
Normal file → Executable file
58
demo/demoproject/urls.py
Normal file → Executable file
|
|
@ -1,44 +1,36 @@
|
||||||
from django.urls import include, path
|
from django.conf.urls import patterns, include, url
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
home = TemplateView.as_view(template_name="home.html")
|
|
||||||
|
home = TemplateView.as_view(template_name='home.html')
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
|
'',
|
||||||
# ObjectDownloadView.
|
# ObjectDownloadView.
|
||||||
path(
|
url(r'^object/', include('demoproject.object.urls',
|
||||||
"object/",
|
app_name='object',
|
||||||
include("demoproject.object.urls", namespace="object"),
|
namespace='object')),
|
||||||
),
|
|
||||||
# StorageDownloadView.
|
# StorageDownloadView.
|
||||||
path(
|
url(r'^storage/', include('demoproject.storage.urls',
|
||||||
"storage/",
|
app_name='storage',
|
||||||
include("demoproject.storage.urls", namespace="storage"),
|
namespace='storage')),
|
||||||
),
|
|
||||||
# PathDownloadView.
|
# PathDownloadView.
|
||||||
path("path/", include("demoproject.path.urls", namespace="path")),
|
url(r'^path/', include('demoproject.path.urls',
|
||||||
|
app_name='path',
|
||||||
|
namespace='path')),
|
||||||
# HTTPDownloadView.
|
# HTTPDownloadView.
|
||||||
path("http/", include("demoproject.http.urls", namespace="http")),
|
url(r'^http/', include('demoproject.http.urls',
|
||||||
|
app_name='http',
|
||||||
|
namespace='http')),
|
||||||
# VirtualDownloadView.
|
# VirtualDownloadView.
|
||||||
path(
|
url(r'^virtual/', include('demoproject.virtual.urls',
|
||||||
"virtual/",
|
app_name='virtual',
|
||||||
include("demoproject.virtual.urls", namespace="virtual"),
|
namespace='virtual')),
|
||||||
),
|
|
||||||
# Nginx optimizations.
|
# Nginx optimizations.
|
||||||
path(
|
url(r'^nginx/', include('demoproject.nginx.urls',
|
||||||
"nginx/",
|
app_name='nginx',
|
||||||
include("demoproject.nginx.urls", namespace="nginx"),
|
namespace='nginx')),
|
||||||
),
|
|
||||||
# Apache optimizations.
|
|
||||||
path(
|
|
||||||
"apache/",
|
|
||||||
include("demoproject.apache.urls", namespace="apache"),
|
|
||||||
),
|
|
||||||
# Lighttpd optimizations.
|
|
||||||
path(
|
|
||||||
"lighttpd/",
|
|
||||||
include("demoproject.lighttpd.urls", namespace="lighttpd"),
|
|
||||||
),
|
|
||||||
# An informative homepage.
|
# An informative homepage.
|
||||||
path("", home, name="home"),
|
url(r'', home, name='home')
|
||||||
]
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
import django.test
|
import django.test
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from django_downloadview import assert_download_response
|
from django_downloadview import assert_download_response
|
||||||
|
|
||||||
|
|
@ -7,40 +7,34 @@ from django_downloadview import assert_download_response
|
||||||
class TextTestCase(django.test.TestCase):
|
class TextTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'virtual:text' serves 'hello-world.txt' from unicode."""
|
"""'virtual:text' serves 'hello-world.txt' from unicode."""
|
||||||
url = reverse("virtual:text")
|
url = reverse('virtual:text')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StringIOTestCase(django.test.TestCase):
|
class StringIOTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'virtual:stringio' serves 'hello-world.txt' from stringio."""
|
"""'virtual:stringio' serves 'hello-world.txt' from stringio."""
|
||||||
url = reverse("virtual:stringio")
|
url = reverse('virtual:stringio')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GeneratedTestCase(django.test.TestCase):
|
class GeneratedTestCase(django.test.TestCase):
|
||||||
def test_download_response(self):
|
def test_download_response(self):
|
||||||
"""'virtual:generated' serves 'hello-world.txt' from generator."""
|
"""'virtual:generated' serves 'hello-world.txt' from generator."""
|
||||||
url = reverse("virtual:generated")
|
url = reverse('virtual:generated')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
assert_download_response(
|
assert_download_response(self,
|
||||||
self,
|
response,
|
||||||
response,
|
content='Hello world!\n',
|
||||||
content="Hello world!\n",
|
basename='hello-world.txt',
|
||||||
basename="hello-world.txt",
|
mime_type='text/plain')
|
||||||
mime_type="text/plain",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
from django.urls import path
|
from django.conf.urls import patterns, url
|
||||||
|
|
||||||
from demoproject.virtual import views
|
from demoproject.virtual import views
|
||||||
|
|
||||||
app_name = "virtual"
|
|
||||||
urlpatterns = [
|
urlpatterns = patterns(
|
||||||
path("text/", views.TextDownloadView.as_view(), name="text"),
|
'',
|
||||||
path("stringio/", views.StringIODownloadView.as_view(), name="stringio"),
|
url(r'^text/$',
|
||||||
path("gerenated/", views.GeneratedDownloadView.as_view(), name="generated"),
|
views.TextDownloadView.as_view(),
|
||||||
]
|
name='text'),
|
||||||
|
url(r'^stringio/$',
|
||||||
|
views.StringIODownloadView.as_view(),
|
||||||
|
name='stringio'),
|
||||||
|
url(r'^gerenated/$',
|
||||||
|
views.GeneratedDownloadView.as_view(),
|
||||||
|
name='generated'),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,33 @@
|
||||||
from io import StringIO
|
from StringIO import StringIO
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
from django_downloadview import TextIteratorIO, VirtualDownloadView, VirtualFile
|
from django_downloadview import VirtualDownloadView
|
||||||
|
from django_downloadview import VirtualFile
|
||||||
|
from django_downloadview import StringIteratorIO
|
||||||
|
|
||||||
|
|
||||||
class TextDownloadView(VirtualDownloadView):
|
class TextDownloadView(VirtualDownloadView):
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return :class:`django.core.files.base.ContentFile` object."""
|
"""Return :class:`django.core.files.base.ContentFile` object."""
|
||||||
return ContentFile(b"Hello world!\n", name="hello-world.txt")
|
return ContentFile(u"Hello world!\n", name='hello-world.txt')
|
||||||
|
|
||||||
|
|
||||||
class StringIODownloadView(VirtualDownloadView):
|
class StringIODownloadView(VirtualDownloadView):
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return wrapper on ``six.StringIO`` object."""
|
"""Return wrapper on ``StringIO`` object."""
|
||||||
file_obj = StringIO("Hello world!\n")
|
file_obj = StringIO(u"Hello world!\n")
|
||||||
return VirtualFile(file_obj, name="hello-world.txt")
|
return VirtualFile(file_obj, name='hello-world.txt')
|
||||||
|
|
||||||
|
|
||||||
def generate_hello():
|
def generate_hello():
|
||||||
yield "Hello "
|
yield u'Hello '
|
||||||
yield "world!"
|
yield u'world!'
|
||||||
yield "\n"
|
yield u'\n'
|
||||||
|
|
||||||
|
|
||||||
class GeneratedDownloadView(VirtualDownloadView):
|
class GeneratedDownloadView(VirtualDownloadView):
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return wrapper on ``StringIteratorIO`` object."""
|
"""Return wrapper on ``StringIteratorIO`` object."""
|
||||||
file_obj = TextIteratorIO(generate_hello())
|
file_obj = StringIteratorIO(generate_hello())
|
||||||
return VirtualFile(file_obj, name="hello-world.txt")
|
return VirtualFile(file_obj, name='hello-world.txt')
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,14 @@ middleware here, or combine a Django application with an application of another
|
||||||
framework.
|
framework.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
|
||||||
|
|
||||||
# This application object is used by any WSGI server configured to use this
|
# This application object is used by any WSGI server configured to use this
|
||||||
# file. This includes Django's development server, if the WSGI_APPLICATION
|
# file. This includes Django's development server, if the WSGI_APPLICATION
|
||||||
# setting points here.
|
# setting points here.
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|
||||||
# Apply WSGI middleware here.
|
# Apply WSGI middleware here.
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,47 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Python packaging."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
here = os.path.abspath(os.path.dirname(__file__))
|
|
||||||
|
|
||||||
setup(
|
here = os.path.abspath(os.path.dirname(__file__))
|
||||||
name="django-downloadview-demo",
|
project_root = os.path.dirname(here)
|
||||||
version="1.0",
|
|
||||||
description="Serve files with Django and reverse-proxies.",
|
|
||||||
long_description=open(os.path.join(here, "README.rst")).read(),
|
NAME = 'django-downloadview-demo'
|
||||||
classifiers=[
|
DESCRIPTION = 'Serve files with Django and reverse-proxies.'
|
||||||
"Development Status :: 5 - Production/Stable",
|
README = open(os.path.join(here, 'README.rst')).read()
|
||||||
"License :: OSI Approved :: BSD License",
|
VERSION = open(os.path.join(project_root, 'VERSION')).read().strip()
|
||||||
"Programming Language :: Python :: 3",
|
AUTHOR = u'Benoît Bryon'
|
||||||
"Framework :: Django",
|
EMAIL = u'benoit@marmelune.net'
|
||||||
],
|
URL = 'https://{name}.readthedocs.org/'.format(name=NAME)
|
||||||
author="Benoît Bryon",
|
CLASSIFIERS = ['Development Status :: 4 - Beta',
|
||||||
author_email="benoit@marmelune.net",
|
'License :: OSI Approved :: BSD License',
|
||||||
url="https://django-downloadview.readthedocs.io/",
|
'Programming Language :: Python :: 2.7',
|
||||||
license="BSD",
|
'Programming Language :: Python :: 2.6',
|
||||||
packages=["demoproject"],
|
'Framework :: Django']
|
||||||
include_package_data=True,
|
KEYWORDS = []
|
||||||
zip_safe=False,
|
PACKAGES = ['demoproject']
|
||||||
install_requires=["django-downloadview", "pytest-django"],
|
REQUIREMENTS = ['django-downloadview', 'django-nose']
|
||||||
entry_points={"console_scripts": ["demo = demoproject.manage:main"]},
|
ENTRY_POINTS = {
|
||||||
)
|
'console_scripts': ['demo = demoproject.manage:main']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': # Don't run setup() when we import this module.
|
||||||
|
setup(name=NAME,
|
||||||
|
version=VERSION,
|
||||||
|
description=DESCRIPTION,
|
||||||
|
long_description=README,
|
||||||
|
classifiers=CLASSIFIERS,
|
||||||
|
keywords=' '.join(KEYWORDS),
|
||||||
|
author=AUTHOR,
|
||||||
|
author_email=EMAIL,
|
||||||
|
url=URL,
|
||||||
|
license='BSD',
|
||||||
|
packages=PACKAGES,
|
||||||
|
include_package_data=True,
|
||||||
|
zip_safe=False,
|
||||||
|
install_requires=REQUIREMENTS,
|
||||||
|
entry_points=ENTRY_POINTS)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Serve files with Django and reverse proxies."""
|
"""Serve files with Django and reverse proxies."""
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
from django_downloadview.api import * # NoQA
|
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
|
|
||||||
#: Module version, as defined in PEP-0396.
|
#: Module version, as defined in PEP-0396.
|
||||||
__version__ = importlib.metadata.version(__package__.replace("-", "_"))
|
__version__ = pkg_resources.get_distribution(__package__.replace('-', '_')) \
|
||||||
|
.version
|
||||||
|
|
||||||
|
|
||||||
|
# API shortcuts.
|
||||||
|
from django_downloadview.api import * # NoQA
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
"""Optimizations for Apache.
|
|
||||||
|
|
||||||
See also `documentation of mod_xsendfile for Apache
|
|
||||||
<https://tn123.org/mod_xsendfile/>`_ and :doc:`narrative documentation about
|
|
||||||
Apache optimizations </optimizations/apache>`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# API shortcuts.
|
|
||||||
from django_downloadview.apache.decorators import x_sendfile # NoQA
|
|
||||||
from django_downloadview.apache.middlewares import XSendfileMiddleware # NoQA
|
|
||||||
from django_downloadview.apache.response import XSendfileResponse # NoQA
|
|
||||||
from django_downloadview.apache.tests import assert_x_sendfile # NoQA
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"""Decorators to apply Apache X-Sendfile on a specific view."""
|
|
||||||
|
|
||||||
from django_downloadview.apache.middlewares import XSendfileMiddleware
|
|
||||||
from django_downloadview.decorators import DownloadDecorator
|
|
||||||
|
|
||||||
|
|
||||||
def x_sendfile(view_func, *args, **kwargs):
|
|
||||||
"""Apply
|
|
||||||
:class:`~django_downloadview.apache.middlewares.XSendfileMiddleware` to
|
|
||||||
``view_func``.
|
|
||||||
|
|
||||||
Proxies (``*args``, ``**kwargs``) to middleware constructor.
|
|
||||||
|
|
||||||
"""
|
|
||||||
decorator = DownloadDecorator(XSendfileMiddleware)
|
|
||||||
return decorator(view_func, *args, **kwargs)
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
from django_downloadview.apache.response import XSendfileResponse
|
|
||||||
from django_downloadview.middlewares import (
|
|
||||||
NoRedirectionMatch,
|
|
||||||
ProxiedDownloadMiddleware,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileMiddleware(ProxiedDownloadMiddleware):
|
|
||||||
"""Configurable middleware, for use in decorators or in global middlewares.
|
|
||||||
|
|
||||||
Standard Django middlewares are configured globally via settings. Instances
|
|
||||||
of this class are to be configured individually. It makes it possible to
|
|
||||||
use this class as the factory in
|
|
||||||
:py:class:`django_downloadview.decorators.DownloadDecorator`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
|
|
||||||
):
|
|
||||||
"""Constructor."""
|
|
||||||
super().__init__(get_response, source_dir, source_url, destination_dir)
|
|
||||||
|
|
||||||
def process_download_response(self, request, response):
|
|
||||||
"""Replace DownloadResponse instances by XSendfileResponse ones."""
|
|
||||||
try:
|
|
||||||
redirect_url = self.get_redirect_url(response)
|
|
||||||
except NoRedirectionMatch:
|
|
||||||
return response
|
|
||||||
return XSendfileResponse(
|
|
||||||
file_path=redirect_url,
|
|
||||||
content_type=response["Content-Type"],
|
|
||||||
basename=response.basename,
|
|
||||||
attachment=response.attachment,
|
|
||||||
headers=response.headers,
|
|
||||||
)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
"""Apache's specific responses."""
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
|
|
||||||
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileResponse(ProxiedDownloadResponse):
|
|
||||||
"Delegates serving file to Apache via X-Sendfile header."
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, file_path, content_type, basename=None, attachment=True, headers=None
|
|
||||||
):
|
|
||||||
"""Return a HttpResponse with headers for Apache X-Sendfile."""
|
|
||||||
# content-type must be provided only as keyword argument to response
|
|
||||||
if headers and content_type:
|
|
||||||
headers.pop("Content-Type", None)
|
|
||||||
super().__init__(content_type=content_type, headers=headers)
|
|
||||||
if attachment:
|
|
||||||
self.basename = basename or os.path.basename(file_path)
|
|
||||||
self["Content-Disposition"] = content_disposition(self.basename)
|
|
||||||
self["X-Sendfile"] = file_path
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
from django_downloadview.apache.response import XSendfileResponse
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileValidator:
|
|
||||||
"""Utility class to validate XSendfileResponse instances.
|
|
||||||
|
|
||||||
See also :py:func:`assert_x_sendfile` shortcut function.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __call__(self, test_case, response, **assertions):
|
|
||||||
"""Assert that ``response`` is a valid X-Sendfile response.
|
|
||||||
|
|
||||||
Optional ``assertions`` dictionary can be used to check additional
|
|
||||||
items:
|
|
||||||
|
|
||||||
* ``basename``: the basename of the file in the response.
|
|
||||||
|
|
||||||
* ``content_type``: the value of "Content-Type" header.
|
|
||||||
|
|
||||||
* ``file_path``: the value of "X-Sendfile" header.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.assert_x_sendfile_response(test_case, response)
|
|
||||||
for key, value in assertions.items():
|
|
||||||
assert_func = getattr(self, "assert_%s" % key)
|
|
||||||
assert_func(test_case, response, value)
|
|
||||||
|
|
||||||
def assert_x_sendfile_response(self, test_case, response):
|
|
||||||
test_case.assertTrue(isinstance(response, XSendfileResponse))
|
|
||||||
|
|
||||||
def assert_basename(self, test_case, response, value):
|
|
||||||
test_case.assertEqual(response.basename, value)
|
|
||||||
|
|
||||||
def assert_content_type(self, test_case, response, value):
|
|
||||||
test_case.assertEqual(response["Content-Type"], value)
|
|
||||||
|
|
||||||
def assert_file_path(self, test_case, response, value):
|
|
||||||
test_case.assertEqual(response["X-Sendfile"], value)
|
|
||||||
|
|
||||||
def assert_attachment(self, test_case, response, value):
|
|
||||||
header = "Content-Disposition"
|
|
||||||
if value:
|
|
||||||
test_case.assertTrue(response[header].startswith("attachment"))
|
|
||||||
else:
|
|
||||||
test_case.assertFalse(header in response)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_x_sendfile(test_case, response, **assertions):
|
|
||||||
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
|
|
||||||
|
|
||||||
Optional ``assertions`` dictionary can be used to check additional items:
|
|
||||||
|
|
||||||
* ``basename``: the basename of the file in the response.
|
|
||||||
|
|
||||||
* ``content_type``: the value of "Content-Type" header.
|
|
||||||
|
|
||||||
* ``file_path``: the value of "X-Sendfile" header.
|
|
||||||
|
|
||||||
"""
|
|
||||||
validator = XSendfileValidator()
|
|
||||||
return validator(test_case, response, **assertions)
|
|
||||||
|
|
@ -1,29 +1,22 @@
|
||||||
# flake8: noqa
|
# -*- coding: utf-8 -*-
|
||||||
"""Declaration of API shortcuts."""
|
"""Declaration of API shortcuts."""
|
||||||
|
from django_downloadview.io import StringIteratorIO # NoQA
|
||||||
from django_downloadview.files import HTTPFile, StorageFile, VirtualFile
|
from django_downloadview.files import (StorageFile, # NoQA
|
||||||
from django_downloadview.io import BytesIteratorIO, TextIteratorIO
|
VirtualFile,
|
||||||
from django_downloadview.middlewares import (
|
HTTPFile)
|
||||||
BaseDownloadMiddleware,
|
from django_downloadview.response import (DownloadResponse, # NoQA
|
||||||
DownloadDispatcherMiddleware,
|
ProxiedDownloadResponse)
|
||||||
SmartDownloadMiddleware,
|
from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA
|
||||||
)
|
DownloadDispatcherMiddleware,
|
||||||
from django_downloadview.response import DownloadResponse, ProxiedDownloadResponse
|
SmartDownloadMiddleware)
|
||||||
from django_downloadview.shortcuts import sendfile
|
from django_downloadview.views import (PathDownloadView, # NoQA
|
||||||
from django_downloadview.test import (
|
ObjectDownloadView,
|
||||||
assert_download_response,
|
StorageDownloadView,
|
||||||
setup_view,
|
HTTPDownloadView,
|
||||||
temporary_media_root,
|
VirtualDownloadView,
|
||||||
)
|
BaseDownloadView,
|
||||||
from django_downloadview.views import (
|
DownloadMixin)
|
||||||
BaseDownloadView,
|
from django_downloadview.sendfile import sendfile # NoQA
|
||||||
DownloadMixin,
|
from django_downloadview.test import (assert_download_response, # NoQA
|
||||||
HTTPDownloadView,
|
setup_view,
|
||||||
ObjectDownloadView,
|
temporary_media_root)
|
||||||
PathDownloadView,
|
|
||||||
StorageDownloadView,
|
|
||||||
VirtualDownloadView,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Backward compatibility.
|
|
||||||
StringIteratorIO = TextIteratorIO
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,6 @@ See also decorators provided by server-specific modules, such as
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadDecorator(object):
|
class DownloadDecorator(object):
|
||||||
"""View decorator factory to apply middleware to ``view_func``'s response.
|
"""View decorator factory to apply middleware to ``view_func``'s response.
|
||||||
|
|
@ -23,54 +17,16 @@ class DownloadDecorator(object):
|
||||||
method is applied on response.
|
method is applied on response.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, middleware_factory):
|
def __init__(self, middleware_factory):
|
||||||
"""Create a download view decorator."""
|
"""Create a download view decorator."""
|
||||||
self.middleware_factory = middleware_factory
|
self.middleware_factory = middleware_factory
|
||||||
|
|
||||||
def __call__(self, view_func, *middleware_args, **middleware_kwargs):
|
def __call__(self, view_func, *middleware_args, **middleware_kwargs):
|
||||||
"""Return ``view_func`` decorated with response middleware."""
|
"""Return ``view_func`` decorated with response middleware."""
|
||||||
|
|
||||||
def decorated(request, *view_args, **view_kwargs):
|
def decorated(request, *view_args, **view_kwargs):
|
||||||
"""Return view's response modified by middleware."""
|
"""Return view's response modified by middleware."""
|
||||||
response = view_func(request, *view_args, **view_kwargs)
|
response = view_func(request, *view_args, **view_kwargs)
|
||||||
middleware = self.middleware_factory(*middleware_args, **middleware_kwargs)
|
middleware = self.middleware_factory(*middleware_args,
|
||||||
|
**middleware_kwargs)
|
||||||
return middleware.process_response(request, response)
|
return middleware.process_response(request, response)
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
def _signature_is_valid(request):
|
|
||||||
"""
|
|
||||||
Validator that raises a PermissionDenied error on invalid and
|
|
||||||
mismatching signatures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
signer = TimestampSigner()
|
|
||||||
signature = request.GET.get("X-Signature")
|
|
||||||
expiration = getattr(settings, "DOWNLOADVIEW_URL_EXPIRATION", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
signature_path = signer.unsign(signature, max_age=expiration)
|
|
||||||
except SignatureExpired as e:
|
|
||||||
raise PermissionDenied("Signature expired") from e
|
|
||||||
except BadSignature as e:
|
|
||||||
raise PermissionDenied("Signature invalid") from e
|
|
||||||
except Exception as e:
|
|
||||||
raise PermissionDenied("Signature error") from e
|
|
||||||
|
|
||||||
if request.path != signature_path:
|
|
||||||
raise PermissionDenied("Signature mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
def signature_required(function):
|
|
||||||
"""
|
|
||||||
Decorator that checks for X-Signature query parameter to authorize access to views.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(function)
|
|
||||||
def decorator(request, *args, **kwargs):
|
|
||||||
_signature_is_valid(request)
|
|
||||||
return function(request, *args, **kwargs)
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
"""Custom exceptions."""
|
|
||||||
|
|
||||||
|
|
||||||
class FileNotFound(IOError):
|
|
||||||
"""Requested file does not exist.
|
|
||||||
|
|
||||||
This exception is to be raised when operations (such as read) fail because
|
|
||||||
file does not exist (whatever the storage or location).
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""File wrappers for use as exchange data between views and responses."""
|
"""File wrappers for use as exchange data between views and responses."""
|
||||||
|
from __future__ import absolute_import
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from urllib.parse import urlparse
|
from urlparse import urlparse
|
||||||
|
|
||||||
from django.core.files.base import File
|
from django.core.files.base import File
|
||||||
from django.utils.encoding import force_bytes
|
from django.utils.encoding import force_bytes
|
||||||
|
|
||||||
from django_downloadview.io import BytesIteratorIO
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,7 +17,6 @@ class StorageFile(File):
|
||||||
but unrelated to model instance.
|
but unrelated to model instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, storage, name, file=None):
|
def __init__(self, storage, name, file=None):
|
||||||
"""Constructor.
|
"""Constructor.
|
||||||
|
|
||||||
|
|
@ -35,8 +33,8 @@ class StorageFile(File):
|
||||||
|
|
||||||
def _get_file(self):
|
def _get_file(self):
|
||||||
"""Getter for :py:attr:``file`` property."""
|
"""Getter for :py:attr:``file`` property."""
|
||||||
if not hasattr(self, "_file") or self._file is None:
|
if not hasattr(self, '_file') or self._file is None:
|
||||||
self._file = self.storage.open(self.name, "rb")
|
self._file = self.storage.open(self.name, 'rb')
|
||||||
return self._file
|
return self._file
|
||||||
|
|
||||||
def _set_file(self, file):
|
def _set_file(self, file):
|
||||||
|
|
@ -50,7 +48,7 @@ class StorageFile(File):
|
||||||
#: Required by django.core.files.utils.FileProxy.
|
#: Required by django.core.files.utils.FileProxy.
|
||||||
file = property(_get_file, _set_file, _del_file)
|
file = property(_get_file, _set_file, _del_file)
|
||||||
|
|
||||||
def open(self, mode="rb"):
|
def open(self, mode='rb'):
|
||||||
"""Retrieves the specified file from storage and return open() result.
|
"""Retrieves the specified file from storage and return open() result.
|
||||||
|
|
||||||
Proxy to self.storage.open(self.name, mode).
|
Proxy to self.storage.open(self.name, mode).
|
||||||
|
|
@ -122,10 +120,7 @@ class StorageFile(File):
|
||||||
Proxy to self.storage.accessed_time(self.name).
|
Proxy to self.storage.accessed_time(self.name).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
return self.storage.accessed(self.name)
|
||||||
return self.storage.get_accessed_time(self.name)
|
|
||||||
except AttributeError:
|
|
||||||
return self.storage.accessed_time(self.name)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created_time(self):
|
def created_time(self):
|
||||||
|
|
@ -134,10 +129,7 @@ class StorageFile(File):
|
||||||
Proxy to self.storage.created_time(self.name).
|
Proxy to self.storage.created_time(self.name).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
return self.storage.created_time(self.name)
|
||||||
return self.storage.get_created_time(self.name)
|
|
||||||
except AttributeError:
|
|
||||||
return self.storage.created_time(self.name)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def modified_time(self):
|
def modified_time(self):
|
||||||
|
|
@ -146,16 +138,12 @@ class StorageFile(File):
|
||||||
Proxy to self.storage.modified_time(self.name).
|
Proxy to self.storage.modified_time(self.name).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
return self.storage.modified_time(self.name)
|
||||||
return self.storage.get_modified_time(self.name)
|
|
||||||
except AttributeError:
|
|
||||||
return self.storage.modified_time(self.name)
|
|
||||||
|
|
||||||
|
|
||||||
class VirtualFile(File):
|
class VirtualFile(File):
|
||||||
"""Wrapper for files that live in memory."""
|
"""Wrapper for files that live in memory."""
|
||||||
|
def __init__(self, file=None, name=u'', url='', size=None):
|
||||||
def __init__(self, file=None, name="", url="", size=None):
|
|
||||||
"""Constructor.
|
"""Constructor.
|
||||||
|
|
||||||
file:
|
file:
|
||||||
|
|
@ -168,7 +156,7 @@ class VirtualFile(File):
|
||||||
File URL.
|
File URL.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
super().__init__(file, name)
|
super(VirtualFile, self).__init__(file, name)
|
||||||
self.url = url
|
self.url = url
|
||||||
if size is not None:
|
if size is not None:
|
||||||
self._size = size
|
self._size = size
|
||||||
|
|
@ -184,7 +172,7 @@ class VirtualFile(File):
|
||||||
return self._size
|
return self._size
|
||||||
|
|
||||||
def _set_size(self, value):
|
def _set_size(self, value):
|
||||||
return super()._set_size(value)
|
return super(VirtualFile, self)._set_size(value)
|
||||||
|
|
||||||
size = property(_get_size, _set_size)
|
size = property(_get_size, _set_size)
|
||||||
|
|
||||||
|
|
@ -206,7 +194,7 @@ class VirtualFile(File):
|
||||||
|
|
||||||
# If this is the end of a line, yield
|
# If this is the end of a line, yield
|
||||||
# otherwise, wait for the next round
|
# otherwise, wait for the next round
|
||||||
if line[-1] in ("\n", "\r"):
|
if line[-1] in ('\n', '\r'):
|
||||||
yield line
|
yield line
|
||||||
else:
|
else:
|
||||||
buffer_ = line
|
buffer_ = line
|
||||||
|
|
@ -215,6 +203,7 @@ class VirtualFile(File):
|
||||||
yield buffer_
|
yield buffer_
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPFile(File):
|
class HTTPFile(File):
|
||||||
"""Wrapper for files that live on remote HTTP servers.
|
"""Wrapper for files that live on remote HTTP servers.
|
||||||
|
|
||||||
|
|
@ -225,19 +214,19 @@ class HTTPFile(File):
|
||||||
Always sets "stream=True" in requests kwargs.
|
Always sets "stream=True" in requests kwargs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
def __init__(self, request_factory=requests.get, url='', name=u'',
|
||||||
def __init__(self, request_factory=requests.get, url="", name="", **kwargs):
|
**kwargs):
|
||||||
self.request_factory = request_factory
|
self.request_factory = request_factory
|
||||||
self.url = url
|
self.url = url
|
||||||
if name is None:
|
if name is None:
|
||||||
parts = urlparse(url)
|
parts = urlparse(url)
|
||||||
if parts.path: # Name from path.
|
if parts.path: # Name from path.
|
||||||
self.name = parts.path.strip("/").rsplit("/", 1)[-1]
|
self.name = parts.path.strip('/').rsplit('/', 1)[-1]
|
||||||
else: # Name from domain.
|
else: # Name from domain.
|
||||||
self.name = parts.netloc
|
self.name = parts.netloc
|
||||||
else:
|
else:
|
||||||
self.name = name
|
self.name = name
|
||||||
kwargs["stream"] = True
|
kwargs['stream'] = True
|
||||||
self.request_kwargs = kwargs
|
self.request_kwargs = kwargs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -245,17 +234,13 @@ class HTTPFile(File):
|
||||||
try:
|
try:
|
||||||
return self._request
|
return self._request
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
self._request = self.request_factory(self.url, **self.request_kwargs)
|
self._request = self.request_factory(self.url,
|
||||||
|
**self.request_kwargs)
|
||||||
return self._request
|
return self._request
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file(self):
|
def file(self):
|
||||||
try:
|
return self.request.raw
|
||||||
return self._file
|
|
||||||
except AttributeError:
|
|
||||||
content = self.request.iter_content(decode_unicode=False)
|
|
||||||
self._file = BytesIteratorIO(content)
|
|
||||||
return self._file
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self):
|
def size(self):
|
||||||
|
|
@ -264,9 +249,4 @@ class HTTPFile(File):
|
||||||
Reads response's "content-length" header.
|
Reads response's "content-length" header.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self.request.headers["Content-Length"]
|
return self.request.headers['Content-Length']
|
||||||
|
|
||||||
@property
|
|
||||||
def content_type(self):
|
|
||||||
"""Return content type of the file (from original response)."""
|
|
||||||
return self.request.headers["Content-Type"]
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,21 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Low-level IO operations, for use with file wrappers."""
|
"""Low-level IO operations, for use with file wrappers."""
|
||||||
|
from __future__ import absolute_import
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from django.utils.encoding import force_bytes, force_str
|
|
||||||
|
|
||||||
|
class StringIteratorIO(io.TextIOBase):
|
||||||
class TextIteratorIO(io.TextIOBase):
|
"""A dynamically generated StringIO-like object.
|
||||||
"""A dynamically generated TextIO-like object.
|
|
||||||
|
|
||||||
Original code by Matt Joiner <anacrolix@gmail.com> from:
|
Original code by Matt Joiner <anacrolix@gmail.com> from:
|
||||||
|
|
||||||
* http://stackoverflow.com/questions/12593576/
|
* http://stackoverflow.com/questions/12593576/adapt-an-iterator-to-behave-like-a-file-like-object-in-python
|
||||||
* https://gist.github.com/anacrolix/3788413
|
* https://gist.github.com/anacrolix/3788413
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, iterator):
|
def __init__(self, iterator):
|
||||||
#: Iterator/generator for content.
|
|
||||||
self._iter = iterator
|
self._iter = iterator
|
||||||
|
self._left = ''
|
||||||
#: Internal buffer.
|
|
||||||
self._left = ""
|
|
||||||
|
|
||||||
def readable(self):
|
def readable(self):
|
||||||
return True
|
return True
|
||||||
|
|
@ -31,113 +26,40 @@ class TextIteratorIO(io.TextIOBase):
|
||||||
self._left = next(self._iter)
|
self._left = next(self._iter)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
# Make sure we handle text.
|
|
||||||
self._left = force_str(self._left)
|
|
||||||
ret = self._left[:n]
|
ret = self._left[:n]
|
||||||
self._left = self._left[len(ret) :]
|
self._left = self._left[len(ret):]
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def read(self, n=None):
|
def read(self, n=None):
|
||||||
"""Return content up to ``n`` length."""
|
l = []
|
||||||
chunks = []
|
|
||||||
if n is None or n < 0:
|
if n is None or n < 0:
|
||||||
while True:
|
while True:
|
||||||
m = self._read1()
|
m = self._read1()
|
||||||
if not m:
|
if not m:
|
||||||
break
|
break
|
||||||
chunks.append(m)
|
l.append(m)
|
||||||
else:
|
else:
|
||||||
while n > 0:
|
while n > 0:
|
||||||
m = self._read1(n)
|
m = self._read1(n)
|
||||||
if not m:
|
if not m:
|
||||||
break
|
break
|
||||||
n -= len(m)
|
n -= len(m)
|
||||||
chunks.append(m)
|
l.append(m)
|
||||||
return "".join(chunks)
|
return ''.join(l)
|
||||||
|
|
||||||
def readline(self):
|
def readline(self):
|
||||||
chunks = []
|
l = []
|
||||||
while True:
|
while True:
|
||||||
i = self._left.find("\n")
|
i = self._left.find('\n')
|
||||||
if i == -1:
|
if i == -1:
|
||||||
chunks.append(self._left)
|
l.append(self._left)
|
||||||
try:
|
try:
|
||||||
self._left = next(self._iter)
|
self._left = next(self._iter)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
self._left = ""
|
self._left = ''
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
chunks.append(self._left[: i + 1])
|
l.append(self._left[:i + 1])
|
||||||
self._left = self._left[i + 1 :]
|
self._left = self._left[i + 1:]
|
||||||
break
|
break
|
||||||
return "".join(chunks)
|
return ''.join(l)
|
||||||
|
|
||||||
|
|
||||||
class BytesIteratorIO(io.BytesIO):
|
|
||||||
"""A dynamically generated BytesIO-like object.
|
|
||||||
|
|
||||||
Original code by Matt Joiner <anacrolix@gmail.com> from:
|
|
||||||
|
|
||||||
* http://stackoverflow.com/questions/12593576/
|
|
||||||
* https://gist.github.com/anacrolix/3788413
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, iterator):
|
|
||||||
#: Iterator/generator for content.
|
|
||||||
self._iter = iterator
|
|
||||||
|
|
||||||
#: Internal buffer.
|
|
||||||
self._left = b""
|
|
||||||
|
|
||||||
def readable(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _read1(self, n=None):
|
|
||||||
while not self._left:
|
|
||||||
try:
|
|
||||||
self._left = next(self._iter)
|
|
||||||
except StopIteration:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Make sure we handle text.
|
|
||||||
self._left = force_bytes(self._left)
|
|
||||||
ret = self._left[:n]
|
|
||||||
self._left = self._left[len(ret) :]
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def read(self, n=None):
|
|
||||||
"""Return content up to ``n`` length."""
|
|
||||||
chunks = []
|
|
||||||
if n is None or n < 0:
|
|
||||||
while True:
|
|
||||||
m = self._read1()
|
|
||||||
if not m:
|
|
||||||
break
|
|
||||||
chunks.append(m)
|
|
||||||
else:
|
|
||||||
while n > 0:
|
|
||||||
m = self._read1(n)
|
|
||||||
if not m:
|
|
||||||
break
|
|
||||||
n -= len(m)
|
|
||||||
chunks.append(m)
|
|
||||||
return b"".join(chunks)
|
|
||||||
|
|
||||||
def readline(self):
|
|
||||||
chunks = []
|
|
||||||
while True:
|
|
||||||
i = self._left.find(b"\n")
|
|
||||||
if i == -1:
|
|
||||||
chunks.append(self._left)
|
|
||||||
try:
|
|
||||||
self._left = next(self._iter)
|
|
||||||
except StopIteration:
|
|
||||||
self._left = b""
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
chunks.append(self._left[: i + 1])
|
|
||||||
self._left = self._left[i + 1 :]
|
|
||||||
break
|
|
||||||
return b"".join(chunks)
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
"""Optimizations for Lighttpd.
|
|
||||||
|
|
||||||
See also `documentation of X-Sendfile for Lighttpd
|
|
||||||
<http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file>`_ and
|
|
||||||
:doc:`narrative documentation about Lighttpd optimizations
|
|
||||||
</optimizations/lighttpd>`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# API shortcuts.
|
|
||||||
from django_downloadview.lighttpd.decorators import x_sendfile # NoQA
|
|
||||||
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA
|
|
||||||
from django_downloadview.lighttpd.response import XSendfileResponse # NoQA
|
|
||||||
from django_downloadview.lighttpd.tests import assert_x_sendfile # NoQA
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"""Decorators to apply Lighttpd X-Sendfile on a specific view."""
|
|
||||||
|
|
||||||
from django_downloadview.decorators import DownloadDecorator
|
|
||||||
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware
|
|
||||||
|
|
||||||
|
|
||||||
def x_sendfile(view_func, *args, **kwargs):
|
|
||||||
"""Apply
|
|
||||||
:class:`~django_downloadview.lighttpd.middlewares.XSendfileMiddleware` to
|
|
||||||
``view_func``.
|
|
||||||
|
|
||||||
Proxies (``*args``, ``**kwargs``) to middleware constructor.
|
|
||||||
|
|
||||||
"""
|
|
||||||
decorator = DownloadDecorator(XSendfileMiddleware)
|
|
||||||
return decorator(view_func, *args, **kwargs)
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
from django_downloadview.lighttpd.response import XSendfileResponse
|
|
||||||
from django_downloadview.middlewares import (
|
|
||||||
NoRedirectionMatch,
|
|
||||||
ProxiedDownloadMiddleware,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileMiddleware(ProxiedDownloadMiddleware):
|
|
||||||
"""Configurable middleware, for use in decorators or in global middlewares.
|
|
||||||
|
|
||||||
Standard Django middlewares are configured globally via settings. Instances
|
|
||||||
of this class are to be configured individually. It makes it possible to
|
|
||||||
use this class as the factory in
|
|
||||||
:py:class:`django_downloadview.decorators.DownloadDecorator`.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
|
|
||||||
):
|
|
||||||
"""Constructor."""
|
|
||||||
super().__init__(get_response, source_dir, source_url, destination_dir)
|
|
||||||
|
|
||||||
def process_download_response(self, request, response):
|
|
||||||
"""Replace DownloadResponse instances by XSendfileResponse ones."""
|
|
||||||
try:
|
|
||||||
redirect_url = self.get_redirect_url(response)
|
|
||||||
except NoRedirectionMatch:
|
|
||||||
return response
|
|
||||||
return XSendfileResponse(
|
|
||||||
file_path=redirect_url,
|
|
||||||
content_type=response["Content-Type"],
|
|
||||||
basename=response.basename,
|
|
||||||
attachment=response.attachment,
|
|
||||||
headers=response.headers,
|
|
||||||
)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
"""Lighttpd's specific responses."""
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
|
|
||||||
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileResponse(ProxiedDownloadResponse):
|
|
||||||
"Delegates serving file to Lighttpd via X-Sendfile header."
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, file_path, content_type, basename=None, attachment=True, headers=None
|
|
||||||
):
|
|
||||||
"""Return a HttpResponse with headers for Lighttpd X-Sendfile."""
|
|
||||||
# content-type must be porvided only as keyword argument to response
|
|
||||||
if headers and content_type:
|
|
||||||
headers.pop("Content-Type", None)
|
|
||||||
super().__init__(content_type=content_type, headers=headers)
|
|
||||||
if attachment:
|
|
||||||
self.basename = basename or os.path.basename(file_path)
|
|
||||||
self["Content-Disposition"] = content_disposition(self.basename)
|
|
||||||
self["X-Sendfile"] = file_path
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import django_downloadview.apache.tests
|
|
||||||
from django_downloadview.lighttpd.response import XSendfileResponse
|
|
||||||
|
|
||||||
|
|
||||||
class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator):
|
|
||||||
"""Utility class to validate XSendfileResponse instances.
|
|
||||||
|
|
||||||
See also :py:func:`assert_x_sendfile` shortcut function.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def assert_x_sendfile_response(self, test_case, response):
|
|
||||||
test_case.assertTrue(isinstance(response, XSendfileResponse))
|
|
||||||
|
|
||||||
|
|
||||||
def assert_x_sendfile(test_case, response, **assertions):
|
|
||||||
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
|
|
||||||
|
|
||||||
Optional ``assertions`` dictionary can be used to check additional items:
|
|
||||||
|
|
||||||
* ``basename``: the basename of the file in the response.
|
|
||||||
|
|
||||||
* ``content_type``: the value of "Content-Type" header.
|
|
||||||
|
|
||||||
* ``file_path``: the value of "X-Sendfile" header.
|
|
||||||
|
|
||||||
"""
|
|
||||||
validator = XSendfileValidator()
|
|
||||||
return validator(test_case, response, **assertions)
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Base material for download middlewares.
|
"""Base material for download middlewares.
|
||||||
|
|
||||||
Download middlewares capture :py:class:`django_downloadview.DownloadResponse`
|
Download middlewares capture :py:class:`django_downloadview.DownloadResponse`
|
||||||
responses and may replace them with optimized download responses.
|
responses and may replace them with optimized download responses.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import collections
|
||||||
import collections.abc
|
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -31,20 +30,12 @@ def is_download_response(response):
|
||||||
return isinstance(response, DownloadResponse)
|
return isinstance(response, DownloadResponse)
|
||||||
|
|
||||||
|
|
||||||
class BaseDownloadMiddleware:
|
class BaseDownloadMiddleware(object):
|
||||||
"""Base (abstract) Django middleware that handles download responses.
|
"""Base (abstract) Django middleware that handles download responses.
|
||||||
|
|
||||||
Subclasses **must** implement :py:meth:`process_download_response` method.
|
Subclasses **must** implement :py:meth:`process_download_response` method.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, get_response):
|
|
||||||
self.get_response = get_response
|
|
||||||
|
|
||||||
def __call__(self, request):
|
|
||||||
response = self.get_response(request)
|
|
||||||
return self.process_response(request, response)
|
|
||||||
|
|
||||||
def is_download_response(self, response):
|
def is_download_response(self, response):
|
||||||
"""Return True if ``response`` can be considered as a file download.
|
"""Return True if ``response`` can be considered as a file download.
|
||||||
|
|
||||||
|
|
@ -68,7 +59,6 @@ class BaseDownloadMiddleware:
|
||||||
|
|
||||||
class RealDownloadMiddleware(BaseDownloadMiddleware):
|
class RealDownloadMiddleware(BaseDownloadMiddleware):
|
||||||
"""Download middleware that cannot handle virtual files."""
|
"""Download middleware that cannot handle virtual files."""
|
||||||
|
|
||||||
def is_download_response(self, response):
|
def is_download_response(self, response):
|
||||||
"""Return True for DownloadResponse, except for "virtual" files.
|
"""Return True for DownloadResponse, except for "virtual" files.
|
||||||
|
|
||||||
|
|
@ -77,12 +67,18 @@ class RealDownloadMiddleware(BaseDownloadMiddleware):
|
||||||
whose file attribute have either an URL or a file name.
|
whose file attribute have either an URL or a file name.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return super().is_download_response(response) and bool(
|
if super(RealDownloadMiddleware, self).is_download_response(response):
|
||||||
getattr(response.file, "url", None) or getattr(response.file, "name", None)
|
try:
|
||||||
)
|
return response.file.url or response.file.name
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class DownloadDispatcher:
|
class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
|
||||||
|
"Download middleware that dispatches job to several middleware instances."
|
||||||
def __init__(self, middlewares=AUTO_CONFIGURE):
|
def __init__(self, middlewares=AUTO_CONFIGURE):
|
||||||
#: List of children middlewares.
|
#: List of children middlewares.
|
||||||
self.middlewares = middlewares
|
self.middlewares = middlewares
|
||||||
|
|
@ -92,48 +88,35 @@ class DownloadDispatcher:
|
||||||
def auto_configure_middlewares(self):
|
def auto_configure_middlewares(self):
|
||||||
"""Populate :attr:`middlewares` from
|
"""Populate :attr:`middlewares` from
|
||||||
``settings.DOWNLOADVIEW_MIDDLEWARES``."""
|
``settings.DOWNLOADVIEW_MIDDLEWARES``."""
|
||||||
for key, import_string, kwargs in getattr(
|
for (key, import_string, kwargs) in getattr(settings,
|
||||||
settings, "DOWNLOADVIEW_MIDDLEWARES", []
|
'DOWNLOADVIEW_MIDDLEWARES',
|
||||||
):
|
[]):
|
||||||
factory = import_member(import_string)
|
factory = import_member(import_string)
|
||||||
middleware = factory(**kwargs)
|
middleware = factory(**kwargs)
|
||||||
self.middlewares.append((key, middleware))
|
self.middlewares.append((key, middleware))
|
||||||
|
|
||||||
def dispatch(self, request, response):
|
def process_download_response(self, request, response):
|
||||||
"""Dispatches job to children middlewares."""
|
"""Dispatches job to children middlewares."""
|
||||||
for key, middleware in self.middlewares:
|
for (key, middleware) in self.middlewares:
|
||||||
response = middleware.process_response(request, response)
|
response = middleware.process_response(request, response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
|
class SmartDownloadMiddleware(BaseDownloadMiddleware):
|
||||||
"Download middleware that dispatches job to several middleware instances."
|
|
||||||
|
|
||||||
def __init__(self, get_response, middlewares=AUTO_CONFIGURE):
|
|
||||||
super().__init__(get_response)
|
|
||||||
self.dispatcher = DownloadDispatcher(middlewares)
|
|
||||||
|
|
||||||
def process_download_response(self, request, response):
|
|
||||||
return self.dispatcher.dispatch(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
|
|
||||||
"""Easy to configure download middleware."""
|
"""Easy to configure download middleware."""
|
||||||
|
def __init__(self,
|
||||||
def __init__(
|
backend_factory=AUTO_CONFIGURE,
|
||||||
self,
|
backend_options=AUTO_CONFIGURE):
|
||||||
get_response,
|
|
||||||
backend_factory=AUTO_CONFIGURE,
|
|
||||||
backend_options=AUTO_CONFIGURE,
|
|
||||||
):
|
|
||||||
"""Constructor."""
|
"""Constructor."""
|
||||||
super().__init__(get_response, middlewares=[])
|
#: :class:`DownloadDispatcher` instance that can hold multiple
|
||||||
#: Callable (typically a class) to instantiate backend (typically a
|
#: backend instances.
|
||||||
|
self.dispatcher = DownloadDispatcherMiddleware(middlewares=[])
|
||||||
|
#: Callable (typically a class) to instanciate backend (typically a
|
||||||
#: :class:`DownloadMiddleware` subclass).
|
#: :class:`DownloadMiddleware` subclass).
|
||||||
self.backend_factory = backend_factory
|
self.backend_factory = backend_factory
|
||||||
if self.backend_factory is AUTO_CONFIGURE:
|
if self.backend_factory is AUTO_CONFIGURE:
|
||||||
self.auto_configure_backend_factory()
|
self.auto_configure_backend_factory()
|
||||||
#: List of positional or keyword arguments to instantiate backend
|
#: List of positional or keyword arguments to instanciate backend
|
||||||
#: instances.
|
#: instances.
|
||||||
self.backend_options = backend_options
|
self.backend_options = backend_options
|
||||||
if self.backend_options is AUTO_CONFIGURE:
|
if self.backend_options is AUTO_CONFIGURE:
|
||||||
|
|
@ -144,34 +127,31 @@ class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
|
||||||
try:
|
try:
|
||||||
self.backend_factory = import_member(settings.DOWNLOADVIEW_BACKEND)
|
self.backend_factory = import_member(settings.DOWNLOADVIEW_BACKEND)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured('SmartDownloadMiddleware requires '
|
||||||
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_BACKEND"
|
'settings.DOWNLOADVIEW_BACKEND')
|
||||||
)
|
|
||||||
|
|
||||||
def auto_configure_backend_options(self):
|
def auto_configure_backend_options(self):
|
||||||
"""Populate :attr:`dispatcher` using :attr:`factory` and
|
"""Populate :attr:`dispatcher` using :attr:`factory` and
|
||||||
``settings.DOWNLOADVIEW_RULES``."""
|
``settings.DOWNLOADVIEW_RULES``."""
|
||||||
try:
|
try:
|
||||||
options_list = copy.deepcopy(settings.DOWNLOADVIEW_RULES)
|
options_list = settings.DOWNLOADVIEW_RULES
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured('SmartDownloadMiddleware requires '
|
||||||
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_RULES"
|
'settings.DOWNLOADVIEW_RULES')
|
||||||
)
|
|
||||||
for key, options in enumerate(options_list):
|
for key, options in enumerate(options_list):
|
||||||
args = []
|
args = []
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if isinstance(options, collections.abc.Mapping): # Using kwargs.
|
if isinstance(options, collections.Mapping): # Using kwargs.
|
||||||
kwargs = options
|
kwargs = options
|
||||||
else:
|
else:
|
||||||
args = options
|
args = options
|
||||||
if "backend" in kwargs: # Specific backend for this rule.
|
middleware_instance = self.backend_factory(*args, **kwargs)
|
||||||
factory = import_member(kwargs["backend"])
|
|
||||||
del kwargs["backend"]
|
|
||||||
else: # Fallback to global backend.
|
|
||||||
factory = self.backend_factory
|
|
||||||
middleware_instance = factory(*args, **kwargs)
|
|
||||||
self.dispatcher.middlewares.append((key, middleware_instance))
|
self.dispatcher.middlewares.append((key, middleware_instance))
|
||||||
|
|
||||||
|
def process_download_response(self, request, response):
|
||||||
|
"""Use :attr:`dispatcher` to process download response."""
|
||||||
|
return self.dispatcher.process_download_response(request, response)
|
||||||
|
|
||||||
|
|
||||||
class NoRedirectionMatch(Exception):
|
class NoRedirectionMatch(Exception):
|
||||||
"""Response object does not match redirection rules."""
|
"""Response object does not match redirection rules."""
|
||||||
|
|
@ -179,13 +159,8 @@ class NoRedirectionMatch(Exception):
|
||||||
|
|
||||||
class ProxiedDownloadMiddleware(RealDownloadMiddleware):
|
class ProxiedDownloadMiddleware(RealDownloadMiddleware):
|
||||||
"""Base class for middlewares that use optimizations of reverse proxies."""
|
"""Base class for middlewares that use optimizations of reverse proxies."""
|
||||||
|
def __init__(self, source_dir=None, source_url=None, destination_url=None):
|
||||||
def __init__(
|
|
||||||
self, get_response, source_dir=None, source_url=None, destination_url=None
|
|
||||||
):
|
|
||||||
"""Constructor."""
|
"""Constructor."""
|
||||||
super().__init__(get_response)
|
|
||||||
|
|
||||||
self.source_dir = source_dir
|
self.source_dir = source_dir
|
||||||
self.source_url = source_url
|
self.source_url = source_url
|
||||||
self.destination_url = destination_url
|
self.destination_url = destination_url
|
||||||
|
|
@ -193,7 +168,7 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
|
||||||
def get_redirect_url(self, response):
|
def get_redirect_url(self, response):
|
||||||
"""Return redirect URL for file wrapped into response."""
|
"""Return redirect URL for file wrapped into response."""
|
||||||
url = None
|
url = None
|
||||||
file_url = ""
|
file_url = ''
|
||||||
if self.source_url:
|
if self.source_url:
|
||||||
try:
|
try:
|
||||||
file_url = response.file.url
|
file_url = response.file.url
|
||||||
|
|
@ -201,9 +176,9 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
if file_url.startswith(self.source_url):
|
if file_url.startswith(self.source_url):
|
||||||
file_url = file_url[len(self.source_url) :]
|
file_url = file_url[len(self.source_url):]
|
||||||
url = file_url
|
url = file_url
|
||||||
file_name = ""
|
file_name = ''
|
||||||
if url is None and self.source_dir:
|
if url is None and self.source_dir:
|
||||||
try:
|
try:
|
||||||
file_name = response.file.name
|
file_name = response.file.name
|
||||||
|
|
@ -212,21 +187,17 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
|
||||||
else:
|
else:
|
||||||
if file_name.startswith(self.source_dir):
|
if file_name.startswith(self.source_dir):
|
||||||
file_name = os.path.relpath(file_name, self.source_dir)
|
file_name = os.path.relpath(file_name, self.source_dir)
|
||||||
url = file_name.replace(os.path.sep, "/")
|
url = file_name.replace(os.path.sep, '/')
|
||||||
if url is None:
|
if url is None:
|
||||||
message = (
|
message = ("""Couldn't capture/convert file attributes into a """
|
||||||
"""Couldn't capture/convert file attributes into a """
|
"""redirection. """
|
||||||
"""redirection. """
|
"""``source_url`` is "%(source_url)s", """
|
||||||
"""``source_url`` is "%(source_url)s", """
|
"""file's URL is "%(file_url)s". """
|
||||||
"""file's URL is "%(file_url)s". """
|
"""``source_dir`` is "%(source_dir)s", """
|
||||||
"""``source_dir`` is "%(source_dir)s", """
|
"""file's name is "%(file_name)s". """
|
||||||
"""file's name is "%(file_name)s". """
|
% {'source_url': self.source_url,
|
||||||
% {
|
'file_url': file_url,
|
||||||
"source_url": self.source_url,
|
'source_dir': self.source_dir,
|
||||||
"file_url": file_url,
|
'file_name': file_name})
|
||||||
"source_dir": self.source_dir,
|
|
||||||
"file_name": file_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
raise NoRedirectionMatch(message)
|
raise NoRedirectionMatch(message)
|
||||||
return "/".join((self.destination_url.rstrip("/"), url.lstrip("/")))
|
return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/')))
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Optimizations for Nginx.
|
"""Optimizations for Nginx.
|
||||||
|
|
||||||
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
|
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
|
||||||
|
|
@ -5,9 +6,9 @@ See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
|
||||||
</optimizations/nginx>`.
|
</optimizations/nginx>`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# API shortcuts.
|
# API shortcuts.
|
||||||
from django_downloadview.nginx.decorators import x_accel_redirect # NoQA
|
from django_downloadview.nginx.decorators import x_accel_redirect # NoQA
|
||||||
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware # NoQA
|
|
||||||
from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA
|
from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA
|
||||||
from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA
|
from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA
|
||||||
|
from django_downloadview.nginx.middlewares import ( # NoQA
|
||||||
|
XAccelRedirectMiddleware)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Decorators to apply Nginx X-Accel on a specific view."""
|
"""Decorators to apply Nginx X-Accel on a specific view."""
|
||||||
|
|
||||||
from django_downloadview.decorators import DownloadDecorator
|
from django_downloadview.decorators import DownloadDecorator
|
||||||
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware
|
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@ import warnings
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
from django_downloadview.middlewares import (
|
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
|
||||||
NoRedirectionMatch,
|
NoRedirectionMatch)
|
||||||
ProxiedDownloadMiddleware,
|
|
||||||
)
|
|
||||||
from django_downloadview.nginx.response import XAccelRedirectResponse
|
from django_downloadview.nginx.response import XAccelRedirectResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,26 +17,15 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
|
||||||
:py:class:`django_downloadview.decorators.DownloadDecorator`.
|
:py:class:`django_downloadview.decorators.DownloadDecorator`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
def __init__(self, source_dir=None, source_url=None, destination_url=None,
|
||||||
def __init__(
|
expires=None, with_buffering=None, limit_rate=None,
|
||||||
self,
|
media_root=None, media_url=None):
|
||||||
get_response=None,
|
|
||||||
source_dir=None,
|
|
||||||
source_url=None,
|
|
||||||
destination_url=None,
|
|
||||||
expires=None,
|
|
||||||
with_buffering=None,
|
|
||||||
limit_rate=None,
|
|
||||||
media_root=None,
|
|
||||||
media_url=None,
|
|
||||||
):
|
|
||||||
"""Constructor."""
|
"""Constructor."""
|
||||||
if media_url is not None:
|
if media_url is not None:
|
||||||
warnings.warn(
|
warnings.warn("%s ``media_url`` is deprecated. Use "
|
||||||
"%s ``media_url`` is deprecated. Use "
|
"``destination_url`` instead."
|
||||||
"``destination_url`` instead." % self.__class__.__name__,
|
% self.__class__.__name__,
|
||||||
DeprecationWarning,
|
DeprecationWarning)
|
||||||
)
|
|
||||||
if destination_url is None:
|
if destination_url is None:
|
||||||
destination_url = media_url
|
destination_url = media_url
|
||||||
else:
|
else:
|
||||||
|
|
@ -46,20 +33,18 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
|
||||||
else:
|
else:
|
||||||
destination_url = destination_url
|
destination_url = destination_url
|
||||||
if media_root is not None:
|
if media_root is not None:
|
||||||
warnings.warn(
|
warnings.warn("%s ``media_root`` is deprecated. Use "
|
||||||
"%s ``media_root`` is deprecated. Use "
|
"``source_dir`` instead." % self.__class__.__name__,
|
||||||
"``source_dir`` instead." % self.__class__.__name__,
|
DeprecationWarning)
|
||||||
DeprecationWarning,
|
|
||||||
)
|
|
||||||
if source_dir is None:
|
if source_dir is None:
|
||||||
source_dir = media_root
|
source_dir = media_root
|
||||||
else:
|
else:
|
||||||
source_dir = source_dir
|
source_dir = source_dir
|
||||||
else:
|
else:
|
||||||
source_dir = source_dir
|
source_dir = source_dir
|
||||||
|
super(XAccelRedirectMiddleware, self).__init__(source_dir,
|
||||||
super().__init__(get_response, source_dir, source_url, destination_url)
|
source_url,
|
||||||
|
destination_url)
|
||||||
self.expires = expires
|
self.expires = expires
|
||||||
self.with_buffering = with_buffering
|
self.with_buffering = with_buffering
|
||||||
self.limit_rate = limit_rate
|
self.limit_rate = limit_rate
|
||||||
|
|
@ -77,16 +62,13 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
|
||||||
expires = response.expires
|
expires = response.expires
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
expires = None
|
expires = None
|
||||||
return XAccelRedirectResponse(
|
return XAccelRedirectResponse(redirect_url=redirect_url,
|
||||||
redirect_url=redirect_url,
|
content_type=response['Content-Type'],
|
||||||
content_type=response["Content-Type"],
|
basename=response.basename,
|
||||||
basename=response.basename,
|
expires=expires,
|
||||||
expires=expires,
|
with_buffering=self.with_buffering,
|
||||||
with_buffering=self.with_buffering,
|
limit_rate=self.limit_rate,
|
||||||
limit_rate=self.limit_rate,
|
attachment=response.attachment)
|
||||||
attachment=response.attachment,
|
|
||||||
headers=response.headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
|
class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
|
||||||
|
|
@ -123,20 +105,16 @@ class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
|
||||||
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
|
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
def __init__(self):
|
||||||
def __init__(self, get_response=None):
|
|
||||||
"""Use Django settings as configuration."""
|
"""Use Django settings as configuration."""
|
||||||
if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None:
|
if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is "
|
'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is '
|
||||||
"required by %s middleware" % self.__class__.__name__
|
'required by %s middleware' % self.__class__.__name__)
|
||||||
)
|
super(SingleXAccelRedirectMiddleware, self).__init__(
|
||||||
super().__init__(
|
|
||||||
get_response=get_response,
|
|
||||||
source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR,
|
source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR,
|
||||||
source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL,
|
source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL,
|
||||||
destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL,
|
destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL,
|
||||||
expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES,
|
expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES,
|
||||||
with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING,
|
with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING,
|
||||||
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE,
|
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE)
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,34 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Nginx's specific responses."""
|
"""Nginx's specific responses."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
|
from django_downloadview.response import ProxiedDownloadResponse
|
||||||
from django_downloadview.utils import content_type_to_charset, url_basename
|
from django_downloadview.utils import content_type_to_charset, url_basename
|
||||||
|
|
||||||
|
|
||||||
class XAccelRedirectResponse(ProxiedDownloadResponse):
|
class XAccelRedirectResponse(ProxiedDownloadResponse):
|
||||||
"Http response that delegates serving file to Nginx via X-Accel headers."
|
"Http response that delegates serving file to Nginx via X-Accel headers."
|
||||||
|
def __init__(self, redirect_url, content_type, basename=None, expires=None,
|
||||||
def __init__(
|
with_buffering=None, limit_rate=None, attachment=True):
|
||||||
self,
|
|
||||||
redirect_url,
|
|
||||||
content_type,
|
|
||||||
basename=None,
|
|
||||||
expires=None,
|
|
||||||
with_buffering=None,
|
|
||||||
limit_rate=None,
|
|
||||||
attachment=True,
|
|
||||||
headers=None,
|
|
||||||
):
|
|
||||||
"""Return a HttpResponse with headers for Nginx X-Accel-Redirect."""
|
"""Return a HttpResponse with headers for Nginx X-Accel-Redirect."""
|
||||||
# content-type must be porvided only as keyword argument to response
|
super(XAccelRedirectResponse, self).__init__(content_type=content_type)
|
||||||
if headers and content_type:
|
|
||||||
headers.pop("Content-Type", None)
|
|
||||||
super().__init__(content_type=content_type, headers=headers)
|
|
||||||
if attachment:
|
if attachment:
|
||||||
self.basename = basename or url_basename(redirect_url, content_type)
|
self.basename = basename or url_basename(redirect_url,
|
||||||
self["Content-Disposition"] = content_disposition(self.basename)
|
content_type)
|
||||||
self["X-Accel-Redirect"] = redirect_url
|
self['Content-Disposition'] = 'attachment; filename={name}'.format(
|
||||||
self["X-Accel-Charset"] = content_type_to_charset(content_type)
|
name=self.basename)
|
||||||
|
self['X-Accel-Redirect'] = redirect_url
|
||||||
|
self['X-Accel-Charset'] = content_type_to_charset(content_type)
|
||||||
if with_buffering is not None:
|
if with_buffering is not None:
|
||||||
self["X-Accel-Buffering"] = with_buffering and "yes" or "no"
|
self['X-Accel-Buffering'] = with_buffering and 'yes' or 'no'
|
||||||
if expires:
|
if expires:
|
||||||
expire_seconds = timedelta(expires - now()).seconds
|
expire_seconds = timedelta(expires - now()).seconds
|
||||||
self["X-Accel-Expires"] = expire_seconds
|
self['X-Accel-Expires'] = expire_seconds
|
||||||
elif expires is not None: # We explicitely want it off.
|
elif expires is not None: # We explicitely want it off.
|
||||||
self["X-Accel-Expires"] = "off"
|
self['X-Accel-Expires'] = 'off'
|
||||||
if limit_rate is not None:
|
if limit_rate is not None:
|
||||||
self["X-Accel-Limit-Rate"] = limit_rate and "%d" % limit_rate or "off"
|
self['X-Accel-Limit-Rate'] = (limit_rate
|
||||||
|
and '%d' % limit_rate
|
||||||
|
or 'off')
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Django settings around Nginx X-Accel.
|
"""Django settings around Nginx X-Accel.
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
@ -7,31 +8,26 @@
|
||||||
for details.
|
for details.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
|
||||||
# In version 1.3, former XAccelRedirectMiddleware has been renamed to
|
# In version 1.3, former XAccelRedirectMiddleware has been renamed to
|
||||||
# SingleXAccelRedirectMiddleware. So tell the users.
|
# SingleXAccelRedirectMiddleware. So tell the users.
|
||||||
deprecated_middleware = "django_downloadview.nginx.XAccelRedirectMiddleware"
|
middleware = 'django_downloadview.nginx.XAccelRedirectMiddleware'
|
||||||
|
if middleware in settings.MIDDLEWARE_CLASSES:
|
||||||
|
|
||||||
if deprecated_middleware in settings.MIDDLEWARE:
|
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"{deprecated_middleware} middleware has been renamed as of "
|
'{middleware} middleware has been renamed as of django-downloadview '
|
||||||
"django-downloadview version 1.3. You may use "
|
'version 1.3. You may use '
|
||||||
'"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, '
|
'"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, '
|
||||||
'or upgrade to "django_downloadview.SmartDownloadDispatcher". '
|
'or upgrade to "django_downloadview.SmartDownloadDispatcher". ')
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
deprecated_msg = (
|
deprecated_msg = 'settings.{deprecated} is deprecated. You should combine ' \
|
||||||
"settings.{deprecated} is deprecated. You should combine "
|
'"django_downloadview.SmartDownloadDispatcher" with ' \
|
||||||
'"django_downloadview.SmartDownloadDispatcher" with '
|
'with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead.'
|
||||||
"with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#: Default value for X-Accel-Buffering header.
|
#: Default value for X-Accel-Buffering header.
|
||||||
|
|
@ -46,9 +42,10 @@ deprecated_msg = (
|
||||||
#: If set to ``False``, Nginx buffering is disabled.
|
#: If set to ``False``, Nginx buffering is disabled.
|
||||||
#: If set to ``True``, Nginx buffering is enabled.
|
#: If set to ``True``, Nginx buffering is enabled.
|
||||||
DEFAULT_WITH_BUFFERING = None
|
DEFAULT_WITH_BUFFERING = None
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_WITH_BUFFERING)
|
setattr(settings, setting_name, DEFAULT_WITH_BUFFERING)
|
||||||
|
|
||||||
|
|
@ -64,9 +61,10 @@ if not hasattr(settings, setting_name):
|
||||||
#: If set to ``False``, Nginx limit rate is disabled.
|
#: If set to ``False``, Nginx limit rate is disabled.
|
||||||
#: Else, it indicates the limit rate in bytes.
|
#: Else, it indicates the limit rate in bytes.
|
||||||
DEFAULT_LIMIT_RATE = None
|
DEFAULT_LIMIT_RATE = None
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_LIMIT_RATE)
|
setattr(settings, setting_name, DEFAULT_LIMIT_RATE)
|
||||||
|
|
||||||
|
|
@ -82,43 +80,49 @@ if not hasattr(settings, setting_name):
|
||||||
#: If set to ``False``, Nginx buffering is disabled.
|
#: If set to ``False``, Nginx buffering is disabled.
|
||||||
#: Else, it indicates the expiration delay, in seconds.
|
#: Else, it indicates the expiration delay, in seconds.
|
||||||
DEFAULT_EXPIRES = None
|
DEFAULT_EXPIRES = None
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_EXPIRES)
|
setattr(settings, setting_name, DEFAULT_EXPIRES)
|
||||||
|
|
||||||
|
|
||||||
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR.
|
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR.
|
||||||
DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT
|
DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT
|
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_SOURCE_DIR)
|
setattr(settings, setting_name, DEFAULT_SOURCE_DIR)
|
||||||
|
|
||||||
|
|
||||||
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL.
|
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL.
|
||||||
DEFAULT_SOURCE_URL = settings.MEDIA_URL
|
DEFAULT_SOURCE_URL = settings.MEDIA_URL
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_SOURCE_URL)
|
setattr(settings, setting_name, DEFAULT_SOURCE_URL)
|
||||||
|
|
||||||
|
|
||||||
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL.
|
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL.
|
||||||
DEFAULT_DESTINATION_URL = None
|
DEFAULT_DESTINATION_URL = None
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL
|
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL
|
||||||
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL"
|
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL'
|
||||||
if hasattr(settings, setting_name):
|
if hasattr(settings, setting_name):
|
||||||
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
|
warnings.warn(deprecated_msg.format(deprecated=setting_name),
|
||||||
|
DeprecationWarning)
|
||||||
if not hasattr(settings, setting_name):
|
if not hasattr(settings, setting_name):
|
||||||
setattr(settings, setting_name, DEFAULT_DESTINATION_URL)
|
setattr(settings, setting_name, DEFAULT_DESTINATION_URL)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
from django_downloadview.nginx.response import XAccelRedirectResponse
|
from django_downloadview.nginx.response import XAccelRedirectResponse
|
||||||
|
|
||||||
|
|
||||||
class XAccelRedirectValidator:
|
class XAccelRedirectValidator(object):
|
||||||
"""Utility class to validate XAccelRedirectResponse instances.
|
"""Utility class to validate XAccelRedirectResponse instances.
|
||||||
|
|
||||||
See also :py:func:`assert_x_accel_redirect` shortcut function.
|
See also :py:func:`assert_x_accel_redirect` shortcut function.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __call__(self, test_case, response, **assertions):
|
def __call__(self, test_case, response, **assertions):
|
||||||
"""Assert that ``response`` is a valid X-Accel-Redirect response.
|
"""Assert that ``response`` is a valid X-Accel-Redirect response.
|
||||||
|
|
||||||
|
|
@ -36,8 +35,8 @@ class XAccelRedirectValidator:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.assert_x_accel_redirect_response(test_case, response)
|
self.assert_x_accel_redirect_response(test_case, response)
|
||||||
for key, value in assertions.items():
|
for key, value in assertions.iteritems():
|
||||||
assert_func = getattr(self, "assert_%s" % key)
|
assert_func = getattr(self, 'assert_%s' % key)
|
||||||
assert_func(test_case, response, value)
|
assert_func(test_case, response, value)
|
||||||
|
|
||||||
def assert_x_accel_redirect_response(self, test_case, response):
|
def assert_x_accel_redirect_response(self, test_case, response):
|
||||||
|
|
@ -47,45 +46,45 @@ class XAccelRedirectValidator:
|
||||||
test_case.assertEqual(response.basename, value)
|
test_case.assertEqual(response.basename, value)
|
||||||
|
|
||||||
def assert_content_type(self, test_case, response, value):
|
def assert_content_type(self, test_case, response, value):
|
||||||
test_case.assertEqual(response["Content-Type"], value)
|
test_case.assertEqual(response['Content-Type'], value)
|
||||||
|
|
||||||
def assert_redirect_url(self, test_case, response, value):
|
def assert_redirect_url(self, test_case, response, value):
|
||||||
test_case.assertEqual(response["X-Accel-Redirect"], value)
|
test_case.assertEqual(response['X-Accel-Redirect'], value)
|
||||||
|
|
||||||
def assert_charset(self, test_case, response, value):
|
def assert_charset(self, test_case, response, value):
|
||||||
test_case.assertEqual(response["X-Accel-Charset"], value)
|
test_case.assertEqual(response['X-Accel-Charset'], value)
|
||||||
|
|
||||||
def assert_with_buffering(self, test_case, response, value):
|
def assert_with_buffering(self, test_case, response, value):
|
||||||
header = "X-Accel-Buffering"
|
header = 'X-Accel-Buffering'
|
||||||
if value is None:
|
if value is None:
|
||||||
test_case.assertFalse(header in response)
|
test_case.assertFalse(header in response)
|
||||||
elif value:
|
elif value:
|
||||||
test_case.assertEqual(header, "yes")
|
test_case.assertEqual(header, 'yes')
|
||||||
else:
|
else:
|
||||||
test_case.assertEqual(header, "no")
|
test_case.assertEqual(header, 'no')
|
||||||
|
|
||||||
def assert_expires(self, test_case, response, value):
|
def assert_expires(self, test_case, response, value):
|
||||||
header = "X-Accel-Expires"
|
header = 'X-Accel-Expires'
|
||||||
if value is None:
|
if value is None:
|
||||||
test_case.assertFalse(header in response)
|
test_case.assertFalse(header in response)
|
||||||
elif not value:
|
elif not value:
|
||||||
test_case.assertEqual(header, "off")
|
test_case.assertEqual(header, 'off')
|
||||||
else:
|
else:
|
||||||
test_case.assertEqual(header, value)
|
test_case.assertEqual(header, value)
|
||||||
|
|
||||||
def assert_limit_rate(self, test_case, response, value):
|
def assert_limit_rate(self, test_case, response, value):
|
||||||
header = "X-Accel-Limit-Rate"
|
header = 'X-Accel-Limit-Rate'
|
||||||
if value is None:
|
if value is None:
|
||||||
test_case.assertFalse(header in response)
|
test_case.assertFalse(header in response)
|
||||||
elif not value:
|
elif not value:
|
||||||
test_case.assertEqual(header, "off")
|
test_case.assertEqual(header, 'off')
|
||||||
else:
|
else:
|
||||||
test_case.assertEqual(header, value)
|
test_case.assertEqual(header, value)
|
||||||
|
|
||||||
def assert_attachment(self, test_case, response, value):
|
def assert_attachment(self, test_case, response, value):
|
||||||
header = "Content-Disposition"
|
header = 'Content-Disposition'
|
||||||
if value:
|
if value:
|
||||||
test_case.assertTrue(response[header].startswith("attachment"))
|
test_case.assertTrue(response[header].startswith('attachment'))
|
||||||
else:
|
else:
|
||||||
test_case.assertFalse(header in response)
|
test_case.assertFalse(header in response)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,10 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
""":py:class:`django.http.HttpResponse` subclasses."""
|
""":py:class:`django.http.HttpResponse` subclasses."""
|
||||||
|
|
||||||
import mimetypes
|
|
||||||
import os
|
import os
|
||||||
import re
|
import mimetypes
|
||||||
import unicodedata
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponse, StreamingHttpResponse
|
from django.http import HttpResponse, StreamingHttpResponse
|
||||||
from django.utils.encoding import force_str
|
|
||||||
|
|
||||||
|
|
||||||
def encode_basename_ascii(value):
|
|
||||||
"""Return US-ASCII encoded ``value`` for Content-Disposition header.
|
|
||||||
|
|
||||||
>>> print(encode_basename_ascii(u'éà'))
|
|
||||||
ea
|
|
||||||
|
|
||||||
Spaces are converted to underscores.
|
|
||||||
|
|
||||||
>>> print(encode_basename_ascii(' '))
|
|
||||||
_
|
|
||||||
|
|
||||||
Of course, ASCII values are not modified.
|
|
||||||
|
|
||||||
>>> print(encode_basename_ascii('ea'))
|
|
||||||
ea
|
|
||||||
>>> print(encode_basename_ascii(b'ea'))
|
|
||||||
ea
|
|
||||||
|
|
||||||
"""
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
value = value.decode("utf-8")
|
|
||||||
ascii_basename = str(value)
|
|
||||||
ascii_basename = unicodedata.normalize("NFKD", ascii_basename)
|
|
||||||
ascii_basename = ascii_basename.encode("ascii", "ignore")
|
|
||||||
ascii_basename = ascii_basename.decode("ascii")
|
|
||||||
ascii_basename = re.sub(r"[\s]", "_", ascii_basename)
|
|
||||||
return ascii_basename
|
|
||||||
|
|
||||||
|
|
||||||
def encode_basename_utf8(value):
|
|
||||||
"""Return UTF-8 encoded ``value`` for use in Content-Disposition header.
|
|
||||||
|
|
||||||
>>> print(encode_basename_utf8(u' .txt'))
|
|
||||||
%20.txt
|
|
||||||
|
|
||||||
>>> print(encode_basename_utf8(u'éà'))
|
|
||||||
%C3%A9%C3%A0
|
|
||||||
|
|
||||||
"""
|
|
||||||
return quote(force_str(value))
|
|
||||||
|
|
||||||
|
|
||||||
def content_disposition(filename):
|
|
||||||
"""Return value of ``Content-Disposition`` header with 'attachment'.
|
|
||||||
|
|
||||||
>>> print(content_disposition('demo.txt'))
|
|
||||||
attachment; filename="demo.txt"
|
|
||||||
|
|
||||||
If filename is empty, only "attachment" is returned.
|
|
||||||
|
|
||||||
>>> print(content_disposition(''))
|
|
||||||
attachment
|
|
||||||
|
|
||||||
If filename contains non US-ASCII characters, the returned value contains
|
|
||||||
UTF-8 encoded filename and US-ASCII fallback.
|
|
||||||
|
|
||||||
>>> print(content_disposition(u'é.txt'))
|
|
||||||
attachment; filename="e.txt"; filename*=UTF-8''%C3%A9.txt
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not filename:
|
|
||||||
return "attachment"
|
|
||||||
# ASCII filenames are quoted and must ensure escape sequences
|
|
||||||
# in the filename won't break out of the quoted header value
|
|
||||||
# which can permit a reflected file download attack. The UTF-8
|
|
||||||
# version is immune because it's not quoted.
|
|
||||||
ascii_filename = (
|
|
||||||
encode_basename_ascii(filename).replace("\\", "\\\\").replace('"', r"\"")
|
|
||||||
)
|
|
||||||
utf8_filename = encode_basename_utf8(filename)
|
|
||||||
if ascii_filename == utf8_filename: # ASCII only.
|
|
||||||
return f'attachment; filename="{ascii_filename}"'
|
|
||||||
else:
|
|
||||||
return (
|
|
||||||
f'attachment; filename="{ascii_filename}"; '
|
|
||||||
f"filename*=UTF-8''{utf8_filename}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadResponse(StreamingHttpResponse):
|
class DownloadResponse(StreamingHttpResponse):
|
||||||
|
|
@ -97,7 +14,30 @@ class DownloadResponse(StreamingHttpResponse):
|
||||||
where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a
|
where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a
|
||||||
file wrapper.
|
file wrapper.
|
||||||
|
|
||||||
Constructor differs a bit from :class:`~django.http.response.HttpResponse`.
|
Constructor differs a bit from :class:`~django.http.response.HttpResponse`:
|
||||||
|
|
||||||
|
``file_instance``
|
||||||
|
A :doc:`file wrapper instance </files>`, such as
|
||||||
|
:class:`~django.core.files.base.File`.
|
||||||
|
|
||||||
|
``attachement``
|
||||||
|
Boolean. Whether to return the file as attachment or not.
|
||||||
|
Affects ``Content-Disposition`` header.
|
||||||
|
|
||||||
|
``basename``
|
||||||
|
Unicode. Client-side name of the file to stream.
|
||||||
|
Only used if ``attachment`` is ``True``.
|
||||||
|
Affects ``Content-Disposition`` header.
|
||||||
|
|
||||||
|
``status``
|
||||||
|
HTTP status code.
|
||||||
|
|
||||||
|
``content_type``
|
||||||
|
Value for ``Content-Type`` header.
|
||||||
|
If ``None``, then mime-type and encoding will be populated by the
|
||||||
|
response (default implementation uses mimetypes, based on file
|
||||||
|
name).
|
||||||
|
|
||||||
|
|
||||||
Here are some highlights to understand internal mechanisms and motivations:
|
Here are some highlights to understand internal mechanisms and motivations:
|
||||||
|
|
||||||
|
|
@ -108,8 +48,8 @@ class DownloadResponse(StreamingHttpResponse):
|
||||||
generator-iterator) that produces the output in a block-by-block
|
generator-iterator) that produces the output in a block-by-block
|
||||||
fashion.
|
fashion.
|
||||||
|
|
||||||
* Django WSGI handler (application implementation) returns response object
|
* `Django WSGI handler (application implementation) return response object
|
||||||
(see :mod:`django.core.handlers.wsgi`).
|
<https://github.com/django/django/blob/fd1279a44df3b9a837453cd79fd0fbcf81bae39d/django/core/handlers/wsgi.py#L268>`_.
|
||||||
|
|
||||||
* :class:`django.http.HttpResponse` and subclasses are iterators.
|
* :class:`django.http.HttpResponse` and subclasses are iterators.
|
||||||
|
|
||||||
|
|
@ -123,56 +63,20 @@ class DownloadResponse(StreamingHttpResponse):
|
||||||
attributes (size, name, ...).
|
attributes (size, name, ...).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
def __init__(self, file_instance, attachment=True, basename=None,
|
||||||
def __init__(
|
status=200, content_type=None):
|
||||||
self,
|
"""Constructor."""
|
||||||
file_instance,
|
|
||||||
attachment=True,
|
|
||||||
basename=None,
|
|
||||||
status=200,
|
|
||||||
content_type=None,
|
|
||||||
file_mimetype=None,
|
|
||||||
file_encoding=None,
|
|
||||||
):
|
|
||||||
"""Constructor.
|
|
||||||
|
|
||||||
:param content_type: Value for ``Content-Type`` header.
|
|
||||||
If ``None``, then mime-type and encoding will be
|
|
||||||
populated by the response (default implementation
|
|
||||||
uses :mod:`mimetypes`, based on file name).
|
|
||||||
|
|
||||||
"""
|
|
||||||
#: A :doc:`file wrapper instance </files>`, such as
|
|
||||||
#: :class:`~django.core.files.base.File`.
|
|
||||||
self.file = file_instance
|
self.file = file_instance
|
||||||
super().__init__(
|
super(DownloadResponse, self).__init__(streaming_content=self.file,
|
||||||
streaming_content=self.file, status=status, content_type=content_type
|
status=status,
|
||||||
)
|
content_type=content_type)
|
||||||
|
|
||||||
#: Client-side name of the file to stream.
|
|
||||||
#: Only used if ``attachment`` is ``True``.
|
|
||||||
#: Affects ``Content-Disposition`` header.
|
|
||||||
self.basename = basename
|
self.basename = basename
|
||||||
|
|
||||||
#: Whether to return the file as attachment or not.
|
|
||||||
#: Affects ``Content-Disposition`` header.
|
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
if not content_type:
|
if not content_type:
|
||||||
del self["Content-Type"] # Will be set later.
|
del self['Content-Type'] # Will be set later.
|
||||||
|
|
||||||
#: Value for file's mimetype.
|
|
||||||
#: If ``None`` (the default), then the file's mimetype will be guessed
|
|
||||||
#: via Python's :mod:`mimetypes`. See :meth:`get_mime_type`.
|
|
||||||
self.file_mimetype = file_mimetype
|
|
||||||
|
|
||||||
#: Value for file's encoding. If ``None`` (the default), then the
|
|
||||||
#: file's encoding will be guessed via Python's :mod:`mimetypes`. See
|
|
||||||
#: :meth:`get_encoding`.
|
|
||||||
self.file_encoding = file_encoding
|
|
||||||
|
|
||||||
# Apply default headers.
|
# Apply default headers.
|
||||||
for header, value in self.default_headers.items():
|
for header, value in self.default_headers.items():
|
||||||
if header not in self:
|
if not header in self:
|
||||||
self[header] = value # Does self support setdefault?
|
self[header] = value # Does self support setdefault?
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -182,26 +86,32 @@ class DownloadResponse(StreamingHttpResponse):
|
||||||
Uses an internal ``_default_headers`` cache.
|
Uses an internal ``_default_headers`` cache.
|
||||||
Default values are computed if only cache hasn't been set.
|
Default values are computed if only cache hasn't been set.
|
||||||
|
|
||||||
``Content-Disposition`` header is encoded according to `RFC 5987
|
|
||||||
<http://tools.ietf.org/html/rfc5987>`_. See also
|
|
||||||
http://stackoverflow.com/questions/93551/.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return self._default_headers
|
return self._default_headers
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
headers = {}
|
headers = {}
|
||||||
headers["Content-Type"] = self.get_content_type()
|
headers['Content-Type'] = self.get_content_type()
|
||||||
try:
|
try:
|
||||||
headers["Content-Length"] = self.file.size
|
headers['Content-Length'] = self.file.size
|
||||||
except (AttributeError, NotImplementedError):
|
except (AttributeError, NotImplementedError):
|
||||||
pass # Generated files.
|
pass # Generated files.
|
||||||
if self.attachment:
|
if self.attachment:
|
||||||
basename = self.get_basename()
|
headers['Content-Disposition'] = 'attachment; filename=%s' \
|
||||||
headers["Content-Disposition"] = content_disposition(basename)
|
% self.get_basename()
|
||||||
self._default_headers = headers
|
self._default_headers = headers
|
||||||
return self._default_headers
|
return self._default_headers
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
"""Return iterable of (header, value).
|
||||||
|
|
||||||
|
This method is called by http handlers just before WSGI's
|
||||||
|
start_response() is called... but it is not called by
|
||||||
|
django.test.ClientHandler! :'(
|
||||||
|
|
||||||
|
"""
|
||||||
|
return super(DownloadResponse, self).items()
|
||||||
|
|
||||||
def get_basename(self):
|
def get_basename(self):
|
||||||
"""Return basename."""
|
"""Return basename."""
|
||||||
if self.basename:
|
if self.basename:
|
||||||
|
|
@ -214,21 +124,19 @@ class DownloadResponse(StreamingHttpResponse):
|
||||||
try:
|
try:
|
||||||
return self.file.content_type
|
return self.file.content_type
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return f"{self.get_mime_type()}; charset={self.get_charset()}"
|
content_type_template = '{mime_type}; charset={charset}'
|
||||||
|
return content_type_template.format(mime_type=self.get_mime_type(),
|
||||||
|
charset=self.get_charset())
|
||||||
|
|
||||||
def get_mime_type(self):
|
def get_mime_type(self):
|
||||||
"""Return mime-type of the file."""
|
"""Return mime-type of the file."""
|
||||||
if self.file_mimetype is not None:
|
default_mime_type = 'application/octet-stream'
|
||||||
return self.file_mimetype
|
|
||||||
default_mime_type = "application/octet-stream"
|
|
||||||
basename = self.get_basename()
|
basename = self.get_basename()
|
||||||
mime_type, encoding = mimetypes.guess_type(basename)
|
mime_type, encoding = mimetypes.guess_type(basename)
|
||||||
return mime_type or default_mime_type
|
return mime_type or default_mime_type
|
||||||
|
|
||||||
def get_encoding(self):
|
def get_encoding(self):
|
||||||
"""Return encoding of the file to serve."""
|
"""Return encoding of the file to serve."""
|
||||||
if self.file_encoding is not None:
|
|
||||||
return self.file_encoding
|
|
||||||
basename = self.get_basename()
|
basename = self.get_basename()
|
||||||
mime_type, encoding = mimetypes.guess_type(basename)
|
mime_type, encoding = mimetypes.guess_type(basename)
|
||||||
return encoding
|
return encoding
|
||||||
|
|
|
||||||
23
django_downloadview/sendfile.py
Normal file
23
django_downloadview/sendfile.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Port of django-sendfile in django-downloadview."""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
from django_downloadview.views.storage import StorageDownloadView
|
||||||
|
|
||||||
|
|
||||||
|
def sendfile(request, filename, attachment=False, attachment_filename=None,
|
||||||
|
mimetype=None, encoding=None):
|
||||||
|
"""Port of django-sendfile's API in django-downloadview.
|
||||||
|
|
||||||
|
Instantiates a :class:`~django.core.files.storage.FileSystemStorage` with
|
||||||
|
``settings.SENDFILE_ROOT`` as root folder. Then uses
|
||||||
|
:class:`StorageDownloadView` to stream the file by ``filename``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
storage = FileSystemStorage(location=settings.SENDFILE_ROOT)
|
||||||
|
view = StorageDownloadView().as_view(storage=storage,
|
||||||
|
path=filename,
|
||||||
|
attachment=attachment,
|
||||||
|
basename=attachment_filename)
|
||||||
|
return view(request)
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Port of django-sendfile in django-downloadview."""
|
|
||||||
|
|
||||||
from django_downloadview.views.path import PathDownloadView
|
|
||||||
|
|
||||||
|
|
||||||
def sendfile(
|
|
||||||
request,
|
|
||||||
filename,
|
|
||||||
attachment=False,
|
|
||||||
attachment_filename=None,
|
|
||||||
mimetype=None,
|
|
||||||
encoding=None,
|
|
||||||
):
|
|
||||||
"""Port of django-sendfile's API in django-downloadview.
|
|
||||||
|
|
||||||
Instantiates a :class:`~django_downloadview.views.path.PathDownloadView` to
|
|
||||||
stream the file by ``filename``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
view = PathDownloadView.as_view(
|
|
||||||
path=filename,
|
|
||||||
attachment=attachment,
|
|
||||||
basename=attachment_filename,
|
|
||||||
mimetype=mimetype,
|
|
||||||
encoding=encoding,
|
|
||||||
)
|
|
||||||
return view(request)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
from django.core.files.storage import FileSystemStorage
|
|
||||||
from django.core.signing import TimestampSigner
|
|
||||||
|
|
||||||
|
|
||||||
class SignedURLMixin:
|
|
||||||
"""
|
|
||||||
Mixin for generating signed file URLs with compatible storage backends.
|
|
||||||
|
|
||||||
Adds X-Signature query parameters to the normal URLs generated by the storage class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def url(self, name):
|
|
||||||
path = super().url(name)
|
|
||||||
signer = TimestampSigner()
|
|
||||||
signature = signer.sign(path)
|
|
||||||
return "{}?X-Signature={}".format(path, signature)
|
|
||||||
|
|
||||||
|
|
||||||
class SignedFileSystemStorage(SignedURLMixin, FileSystemStorage):
|
|
||||||
"""
|
|
||||||
Specialized filesystem storage that signs file URLs for clients.
|
|
||||||
"""
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
|
"""Testing utilities."""
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils.encoding import force_bytes
|
|
||||||
|
|
||||||
from django_downloadview.middlewares import is_download_response
|
from django_downloadview.middlewares import is_download_response
|
||||||
from django_downloadview.response import encode_basename_ascii, encode_basename_utf8
|
|
||||||
|
|
||||||
|
|
||||||
def setup_view(view, request, *args, **kwargs):
|
def setup_view(view, request, *args, **kwargs):
|
||||||
|
|
@ -45,7 +44,7 @@ class temporary_media_root(override_settings):
|
||||||
Use this function as a context manager:
|
Use this function as a context manager:
|
||||||
|
|
||||||
>>> from django_downloadview.test import temporary_media_root
|
>>> from django_downloadview.test import temporary_media_root
|
||||||
>>> from django.conf import settings # NoQA
|
>>> from django.conf import settings
|
||||||
>>> global_media_root = settings.MEDIA_ROOT
|
>>> global_media_root = settings.MEDIA_ROOT
|
||||||
>>> with temporary_media_root():
|
>>> with temporary_media_root():
|
||||||
... global_media_root == settings.MEDIA_ROOT
|
... global_media_root == settings.MEDIA_ROOT
|
||||||
|
|
@ -65,24 +64,22 @@ class temporary_media_root(override_settings):
|
||||||
True
|
True
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def enable(self):
|
def enable(self):
|
||||||
"""Create a temporary directory and use it to override
|
"""Create a temporary directory and use it to override
|
||||||
settings.MEDIA_ROOT."""
|
settings.MEDIA_ROOT."""
|
||||||
tmp_dir = tempfile.mkdtemp()
|
tmp_dir = tempfile.mkdtemp()
|
||||||
self.options["MEDIA_ROOT"] = tmp_dir
|
self.options['MEDIA_ROOT'] = tmp_dir
|
||||||
super().enable()
|
super(temporary_media_root, self).enable()
|
||||||
|
|
||||||
def disable(self):
|
def disable(self):
|
||||||
"""Remove directory settings.MEDIA_ROOT then restore original
|
"""Remove directory settings.MEDIA_ROOT then restore original
|
||||||
setting."""
|
setting."""
|
||||||
shutil.rmtree(settings.MEDIA_ROOT)
|
shutil.rmtree(settings.MEDIA_ROOT)
|
||||||
super().disable()
|
super(temporary_media_root, self).disable()
|
||||||
|
|
||||||
|
|
||||||
class DownloadResponseValidator(object):
|
class DownloadResponseValidator(object):
|
||||||
"""Utility class to validate DownloadResponse instances."""
|
"""Utility class to validate DownloadResponse instances."""
|
||||||
|
|
||||||
def __call__(self, test_case, response, **assertions):
|
def __call__(self, test_case, response, **assertions):
|
||||||
"""Assert that ``response`` is a valid DownloadResponse instance.
|
"""Assert that ``response`` is a valid DownloadResponse instance.
|
||||||
|
|
||||||
|
|
@ -102,8 +99,8 @@ class DownloadResponseValidator(object):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.assert_download_response(test_case, response)
|
self.assert_download_response(test_case, response)
|
||||||
for key, value in assertions.items():
|
for key, value in assertions.iteritems():
|
||||||
assert_func = getattr(self, "assert_%s" % key)
|
assert_func = getattr(self, 'assert_%s' % key)
|
||||||
assert_func(test_case, response, value)
|
assert_func(test_case, response, value)
|
||||||
|
|
||||||
def assert_download_response(self, test_case, response):
|
def assert_download_response(self, test_case, response):
|
||||||
|
|
@ -111,48 +108,22 @@ class DownloadResponseValidator(object):
|
||||||
|
|
||||||
def assert_basename(self, test_case, response, value):
|
def assert_basename(self, test_case, response, value):
|
||||||
"""Implies ``attachement is True``."""
|
"""Implies ``attachement is True``."""
|
||||||
ascii_name = encode_basename_ascii(value)
|
test_case.assertTrue(
|
||||||
utf8_name = encode_basename_utf8(value)
|
response['Content-Disposition'].endswith(
|
||||||
check_utf8 = False
|
'filename={name}'.format(name=value)))
|
||||||
check_ascii = False
|
|
||||||
if ascii_name == utf8_name: # Only ASCII characters.
|
|
||||||
check_ascii = True
|
|
||||||
if "filename*=" in response["Content-Disposition"]:
|
|
||||||
check_utf8 = True
|
|
||||||
else:
|
|
||||||
check_utf8 = True
|
|
||||||
if "filename=" in response["Content-Disposition"]:
|
|
||||||
check_ascii = True
|
|
||||||
if check_ascii:
|
|
||||||
test_case.assertIn(
|
|
||||||
f'filename="{ascii_name}"',
|
|
||||||
response["Content-Disposition"],
|
|
||||||
)
|
|
||||||
if check_utf8:
|
|
||||||
test_case.assertIn(
|
|
||||||
f"filename*=UTF-8''{utf8_name}",
|
|
||||||
response["Content-Disposition"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_content_type(self, test_case, response, value):
|
def assert_content_type(self, test_case, response, value):
|
||||||
test_case.assertEqual(response["Content-Type"], value)
|
test_case.assertEqual(response['Content-Type'], value)
|
||||||
|
|
||||||
def assert_mime_type(self, test_case, response, value):
|
def assert_mime_type(self, test_case, response, value):
|
||||||
test_case.assertTrue(response["Content-Type"].startswith(value))
|
test_case.assertTrue(response['Content-Type'].startswith(value))
|
||||||
|
|
||||||
def assert_content(self, test_case, response, value):
|
def assert_content(self, test_case, response, value):
|
||||||
"""Assert value equals response's content (byte comparison)."""
|
test_case.assertEqual(''.join(response.streaming_content), value)
|
||||||
parts = [force_bytes(s) for s in response.streaming_content]
|
|
||||||
test_case.assertEqual(b"".join(parts), force_bytes(value))
|
|
||||||
|
|
||||||
def assert_attachment(self, test_case, response, value):
|
def assert_attachment(self, test_case, response, value):
|
||||||
if value:
|
test_case.assertEqual('attachment;' in response['Content-Disposition'],
|
||||||
test_case.assertTrue("attachment;" in response["Content-Disposition"])
|
value)
|
||||||
else:
|
|
||||||
test_case.assertTrue(
|
|
||||||
"Content-Disposition" not in response
|
|
||||||
or "attachment;" not in response["Content-Disposition"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_download_response(test_case, response, **assertions):
|
def assert_download_response(test_case, response, **assertions):
|
||||||
|
|
|
||||||
2
django_downloadview/tests/__init__.py
Normal file
2
django_downloadview/tests/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Unit tests."""
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Test suite around :mod:`django_downloadview.api` and deprecation plan."""
|
"""Test suite around :mod:`django_downloadview.api` and deprecation plan."""
|
||||||
|
|
||||||
from importlib import import_module, reload
|
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
import django.test
|
import django.test
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
from django.utils.importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
class APITestCase(unittest.TestCase):
|
class APITestCase(unittest.TestCase):
|
||||||
"""Make sure django_downloadview exposes API."""
|
"""Make sure django_downloadview exposes API."""
|
||||||
|
|
||||||
def assert_module_attributes(self, module_path, attribute_names):
|
def assert_module_attributes(self, module_path, attribute_names):
|
||||||
"""Assert imported ``module_path`` has ``attribute_names``."""
|
"""Assert imported ``module_path`` has ``attribute_names``."""
|
||||||
module = import_module(module_path)
|
module = import_module(module_path)
|
||||||
|
|
@ -20,9 +19,8 @@ class APITestCase(unittest.TestCase):
|
||||||
if not hasattr(module, attribute_name):
|
if not hasattr(module, attribute_name):
|
||||||
missing_attributes.append(attribute_name)
|
missing_attributes.append(attribute_name)
|
||||||
if missing_attributes:
|
if missing_attributes:
|
||||||
self.fail(
|
self.fail('Missing attributes in "{module}": {attributes}'.format(
|
||||||
'Missing attributes in "{module_path}": {", ".join(missing_attributes)}'
|
module=module_path, attributes=', '.join(missing_attributes)))
|
||||||
)
|
|
||||||
|
|
||||||
def test_root_attributes(self):
|
def test_root_attributes(self):
|
||||||
"""API is exposed in django_downloadview root package.
|
"""API is exposed in django_downloadview root package.
|
||||||
|
|
@ -36,92 +34,67 @@ class APITestCase(unittest.TestCase):
|
||||||
"""
|
"""
|
||||||
api = [
|
api = [
|
||||||
# Views:
|
# Views:
|
||||||
"ObjectDownloadView",
|
'ObjectDownloadView',
|
||||||
"StorageDownloadView",
|
'StorageDownloadView',
|
||||||
"PathDownloadView",
|
'PathDownloadView',
|
||||||
"HTTPDownloadView",
|
'HTTPDownloadView',
|
||||||
"VirtualDownloadView",
|
'VirtualDownloadView',
|
||||||
"BaseDownloadView",
|
'BaseDownloadView',
|
||||||
"DownloadMixin",
|
'DownloadMixin',
|
||||||
# File wrappers:
|
# File wrappers:
|
||||||
"StorageFile",
|
'StorageFile',
|
||||||
"HTTPFile",
|
'HTTPFile',
|
||||||
"VirtualFile",
|
'VirtualFile',
|
||||||
# Responses:
|
# Responses:
|
||||||
"DownloadResponse",
|
'DownloadResponse',
|
||||||
"ProxiedDownloadResponse",
|
'ProxiedDownloadResponse',
|
||||||
# Middlewares:
|
# Middlewares:
|
||||||
"BaseDownloadMiddleware",
|
'BaseDownloadMiddleware',
|
||||||
"DownloadDispatcherMiddleware",
|
'DownloadDispatcherMiddleware',
|
||||||
"SmartDownloadMiddleware",
|
'SmartDownloadMiddleware',
|
||||||
# Testing:
|
# Testing:
|
||||||
"assert_download_response",
|
'assert_download_response',
|
||||||
"setup_view",
|
'setup_view',
|
||||||
"temporary_media_root",
|
'temporary_media_root',
|
||||||
# Utilities:
|
# Utilities:
|
||||||
"StringIteratorIO",
|
'StringIteratorIO',
|
||||||
"sendfile",
|
'sendfile']
|
||||||
]
|
self.assert_module_attributes('django_downloadview', api)
|
||||||
self.assert_module_attributes("django_downloadview", api)
|
|
||||||
|
|
||||||
def test_nginx_attributes(self):
|
def test_nginx_attributes(self):
|
||||||
"""Nginx-related API is exposed in django_downloadview.nginx."""
|
"""Nginx-related API is exposed in django_downloadview.nginx."""
|
||||||
api = [
|
api = [
|
||||||
"XAccelRedirectResponse",
|
'XAccelRedirectResponse',
|
||||||
"XAccelRedirectMiddleware",
|
'XAccelRedirectMiddleware',
|
||||||
"x_accel_redirect",
|
'x_accel_redirect',
|
||||||
"assert_x_accel_redirect",
|
'assert_x_accel_redirect']
|
||||||
]
|
self.assert_module_attributes('django_downloadview.nginx', api)
|
||||||
self.assert_module_attributes("django_downloadview.nginx", api)
|
|
||||||
|
|
||||||
def test_apache_attributes(self):
|
|
||||||
"""Apache-related API is exposed in django_downloadview.apache."""
|
|
||||||
api = [
|
|
||||||
"XSendfileResponse",
|
|
||||||
"XSendfileMiddleware",
|
|
||||||
"x_sendfile",
|
|
||||||
"assert_x_sendfile",
|
|
||||||
]
|
|
||||||
self.assert_module_attributes("django_downloadview.apache", api)
|
|
||||||
|
|
||||||
def test_lighttpd_attributes(self):
|
|
||||||
"""Lighttpd-related API is exposed in django_downloadview.lighttpd."""
|
|
||||||
api = [
|
|
||||||
"XSendfileResponse",
|
|
||||||
"XSendfileMiddleware",
|
|
||||||
"x_sendfile",
|
|
||||||
"assert_x_sendfile",
|
|
||||||
]
|
|
||||||
self.assert_module_attributes("django_downloadview.lighttpd", api)
|
|
||||||
|
|
||||||
|
|
||||||
class DeprecatedAPITestCase(django.test.SimpleTestCase):
|
class DeprecatedAPITestCase(django.test.SimpleTestCase):
|
||||||
"""Make sure using deprecated items raise DeprecationWarning."""
|
"""Make sure using deprecated items raise DeprecationWarning."""
|
||||||
|
|
||||||
def test_nginx_x_accel_redirect_middleware(self):
|
def test_nginx_x_accel_redirect_middleware(self):
|
||||||
"XAccelRedirectMiddleware in settings triggers ImproperlyConfigured."
|
"XAccelRedirectMiddleware in settings triggers ImproperlyConfigured."
|
||||||
with override_settings(
|
with override_settings(
|
||||||
MIDDLEWARE=["django_downloadview.nginx.XAccelRedirectMiddleware"],
|
MIDDLEWARE_CLASSES=[
|
||||||
):
|
'django_downloadview.nginx.XAccelRedirectMiddleware']):
|
||||||
with self.assertRaises(ImproperlyConfigured):
|
with self.assertRaises(ImproperlyConfigured):
|
||||||
import django_downloadview.nginx.settings
|
import django_downloadview.nginx.settings
|
||||||
|
|
||||||
reload(django_downloadview.nginx.settings)
|
reload(django_downloadview.nginx.settings)
|
||||||
|
|
||||||
def test_nginx_x_accel_redirect_global_settings(self):
|
def test_nginx_x_accel_redirect_global_settings(self):
|
||||||
"""Global settings for Nginx middleware are deprecated."""
|
"""Global settings for Nginx middleware are deprecated."""
|
||||||
settings_overrides = {
|
settings_overrides = {
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING": True,
|
'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING': True,
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE": 32,
|
'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE': 32,
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES": 3600,
|
'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES': 3600,
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT": "/",
|
'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT': '/',
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR": "/",
|
'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR': '/',
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL": "/",
|
'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL': '/',
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL": "/",
|
'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL': '/',
|
||||||
"NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL": "/",
|
'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL': '/',
|
||||||
}
|
}
|
||||||
import django_downloadview.nginx.settings
|
import django_downloadview.nginx.settings
|
||||||
|
|
||||||
missed_warnings = []
|
missed_warnings = []
|
||||||
for setting_name, setting_value in settings_overrides.items():
|
for setting_name, setting_value in settings_overrides.items():
|
||||||
warnings.resetwarnings()
|
warnings.resetwarnings()
|
||||||
|
|
@ -131,8 +104,8 @@ class DeprecatedAPITestCase(django.test.SimpleTestCase):
|
||||||
reload(django_downloadview.nginx.settings)
|
reload(django_downloadview.nginx.settings)
|
||||||
caught = False
|
caught = False
|
||||||
for warning_item in warning_list:
|
for warning_item in warning_list:
|
||||||
if warning_item.category is DeprecationWarning:
|
if warning_item.category == DeprecationWarning:
|
||||||
if "deprecated" in str(warning_item.message):
|
if 'deprecated' in str(warning_item.message):
|
||||||
if setting_name in str(warning_item.message):
|
if setting_name in str(warning_item.message):
|
||||||
caught = True
|
caught = True
|
||||||
break
|
break
|
||||||
|
|
@ -140,6 +113,5 @@ class DeprecatedAPITestCase(django.test.SimpleTestCase):
|
||||||
missed_warnings.append(setting_name)
|
missed_warnings.append(setting_name)
|
||||||
if missed_warnings:
|
if missed_warnings:
|
||||||
self.fail(
|
self.fail(
|
||||||
f"No DeprecationWarning raised about following settings: "
|
'No DeprecationWarning raised about following settings: '
|
||||||
f"{', '.join(missed_warnings)}."
|
'{settings}.'.format(settings=', '.join(missed_warnings)))
|
||||||
)
|
|
||||||
179
django_downloadview/tests/views.py
Normal file
179
django_downloadview/tests/views.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Unit tests around views."""
|
||||||
|
import unittest
|
||||||
|
try:
|
||||||
|
from unittest import mock
|
||||||
|
except ImportError:
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from django.http.response import HttpResponseNotModified
|
||||||
|
import django.test
|
||||||
|
|
||||||
|
from django_downloadview.test import setup_view
|
||||||
|
from django_downloadview.views import base
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadMixinTestCase(unittest.TestCase):
|
||||||
|
"""Tests around :class:`django_downloadviews.views.base.DownloadMixin`."""
|
||||||
|
def test_get_file(self):
|
||||||
|
"""DownloadMixin.get_file() raise NotImplementedError.
|
||||||
|
|
||||||
|
Subclasses must implement it!
|
||||||
|
|
||||||
|
"""
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
mixin.get_file()
|
||||||
|
|
||||||
|
def test_get_basename(self):
|
||||||
|
"""DownloadMixin.get_basename() returns basename attribute."""
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
self.assertEqual(mixin.get_basename(), None)
|
||||||
|
mixin.basename = 'fake'
|
||||||
|
self.assertEqual(mixin.get_basename(), 'fake')
|
||||||
|
|
||||||
|
def test_was_modified_since_file(self):
|
||||||
|
"""DownloadMixin.was_modified_since() tries (1) file's implementation.
|
||||||
|
|
||||||
|
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
|
||||||
|
first tries to delegate computations to file wrapper's implementation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file_wrapper = mock.Mock()
|
||||||
|
file_wrapper.was_modified_since = mock.Mock(
|
||||||
|
return_value=mock.sentinel.was_modified)
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
self.assertIs(
|
||||||
|
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
|
||||||
|
mock.sentinel.was_modified)
|
||||||
|
file_wrapper.was_modified_since.assertCalledOnceWith(
|
||||||
|
mock.sentinel.since)
|
||||||
|
|
||||||
|
def test_was_modified_since_django(self):
|
||||||
|
"""DownloadMixin.was_modified_since() tries (2) files attributes.
|
||||||
|
|
||||||
|
When calling file wrapper's ``was_modified_since()`` raises
|
||||||
|
``NotImplementedError`` or ``AttributeError``,
|
||||||
|
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
|
||||||
|
tries to pass file wrapper's ``size`` and ``modified_time`` to
|
||||||
|
:func:`django.views.static import was_modified_since`.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file_wrapper = mock.Mock()
|
||||||
|
file_wrapper.was_modified_since = mock.Mock(
|
||||||
|
side_effect=AttributeError)
|
||||||
|
file_wrapper.size = mock.sentinel.size
|
||||||
|
file_wrapper.modified_time = mock.sentinel.modified_time
|
||||||
|
was_modified_since_mock = mock.Mock(
|
||||||
|
return_value=mock.sentinel.was_modified)
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
with mock.patch('django_downloadview.views.base.was_modified_since',
|
||||||
|
new=was_modified_since_mock):
|
||||||
|
self.assertIs(
|
||||||
|
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
|
||||||
|
mock.sentinel.was_modified)
|
||||||
|
was_modified_since_mock.assertCalledOnceWith(
|
||||||
|
mock.sentinel.size,
|
||||||
|
mock.sentinel.modified_time)
|
||||||
|
|
||||||
|
def test_was_modified_since_fallback(self):
|
||||||
|
"""DownloadMixin.was_modified_since() fallbacks to `True`.
|
||||||
|
|
||||||
|
When:
|
||||||
|
|
||||||
|
* calling file wrapper's ``was_modified_since()`` raises
|
||||||
|
``NotImplementedError`` or ``AttributeError``;
|
||||||
|
|
||||||
|
* and accessing ``size`` and ``modified_time`` from file wrapper raises
|
||||||
|
``NotImplementedError`` or ``AttributeError``...
|
||||||
|
|
||||||
|
... then
|
||||||
|
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
|
||||||
|
returns ``True``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file_wrapper = mock.Mock()
|
||||||
|
file_wrapper.was_modified_since = mock.Mock(
|
||||||
|
side_effect=NotImplementedError)
|
||||||
|
type(file_wrapper).modified_time = mock.PropertyMock(
|
||||||
|
side_effect=NotImplementedError)
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
self.assertIs(
|
||||||
|
mixin.was_modified_since(file_wrapper, 'fake since'),
|
||||||
|
True)
|
||||||
|
|
||||||
|
def test_not_modified_response(self):
|
||||||
|
"DownloadMixin.not_modified_response returns HttpResponseNotModified."
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
response = mixin.not_modified_response()
|
||||||
|
self.assertTrue(isinstance(response, HttpResponseNotModified))
|
||||||
|
|
||||||
|
def test_download_response(self):
|
||||||
|
"DownloadMixin.download_response() returns download response instance."
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
mixin.file_instance = mock.sentinel.file_wrapper
|
||||||
|
response_factory = mock.Mock(return_value=mock.sentinel.response)
|
||||||
|
mixin.response_class = response_factory
|
||||||
|
response_kwargs = {'dummy': 'value',
|
||||||
|
'file_instance': mock.sentinel.file_wrapper,
|
||||||
|
'attachment': True,
|
||||||
|
'basename': None}
|
||||||
|
response = mixin.download_response(**response_kwargs)
|
||||||
|
self.assertIs(response, mock.sentinel.response)
|
||||||
|
response_factory.assert_called_once_with(**response_kwargs) # Not args
|
||||||
|
|
||||||
|
def test_render_to_response_not_modified(self):
|
||||||
|
"""DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE
|
||||||
|
header (calls ``not_modified_response()``)."""
|
||||||
|
# Setup.
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
mixin.request = django.test.RequestFactory().get(
|
||||||
|
'/dummy-url',
|
||||||
|
HTTP_IF_MODIFIED_SINCE=mock.sentinel.http_if_modified_since)
|
||||||
|
mixin.was_modified_since = mock.Mock(return_value=False)
|
||||||
|
mixin.not_modified_response = mock.Mock(
|
||||||
|
return_value=mock.sentinel.http_not_modified_response)
|
||||||
|
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
|
||||||
|
# Run.
|
||||||
|
response = mixin.render_to_response()
|
||||||
|
# Check.
|
||||||
|
self.assertIs(response, mock.sentinel.http_not_modified_response)
|
||||||
|
mixin.get_file.assert_called_once_with()
|
||||||
|
mixin.was_modified_since.assert_called_once_with(
|
||||||
|
mock.sentinel.file_wrapper,
|
||||||
|
mock.sentinel.http_if_modified_since)
|
||||||
|
mixin.not_modified_response.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_render_to_response_modified(self):
|
||||||
|
"""DownloadMixin.render_to_response() calls download_response()."""
|
||||||
|
# Setup.
|
||||||
|
mixin = base.DownloadMixin()
|
||||||
|
mixin.request = django.test.RequestFactory().get(
|
||||||
|
'/dummy-url',
|
||||||
|
HTTP_IF_MODIFIED_SINCE=None)
|
||||||
|
mixin.was_modified_since = mock.Mock()
|
||||||
|
mixin.download_response = mock.Mock(
|
||||||
|
return_value=mock.sentinel.download_response)
|
||||||
|
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
|
||||||
|
# Run.
|
||||||
|
response = mixin.render_to_response()
|
||||||
|
# Check.
|
||||||
|
self.assertIs(response, mock.sentinel.download_response)
|
||||||
|
mixin.get_file.assert_called_once_with()
|
||||||
|
self.assertEqual(mixin.was_modified_since.call_count, 0)
|
||||||
|
mixin.download_response.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseDownloadViewTestCase(unittest.TestCase):
|
||||||
|
"Tests around :class:`django_downloadviews.views.base.BaseDownloadView`."
|
||||||
|
def test_get(self):
|
||||||
|
"""BaseDownloadView.get() calls render_to_response()."""
|
||||||
|
request = django.test.RequestFactory().get('/dummy-url')
|
||||||
|
args = ['dummy-arg']
|
||||||
|
kwargs = {'dummy': 'kwarg'}
|
||||||
|
view = setup_view(base.BaseDownloadView(), request, *args, **kwargs)
|
||||||
|
view.render_to_response = mock.Mock(
|
||||||
|
return_value=mock.sentinel.response)
|
||||||
|
response = view.get(request, *args, **kwargs)
|
||||||
|
self.assertIs(response, mock.sentinel.response)
|
||||||
|
view.render_to_response.assert_called_once_with()
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Utility functions that may be implemented in external packages."""
|
"""Utility functions that may be implemented in external packages."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
charset_pattern = re.compile(r"charset=(?P<charset>.+)$", re.I | re.U)
|
|
||||||
|
charset_pattern = re.compile(r'charset=(?P<charset>.+)$', re.I | re.U)
|
||||||
|
|
||||||
|
|
||||||
def content_type_to_charset(content_type):
|
def content_type_to_charset(content_type):
|
||||||
|
|
@ -15,7 +16,7 @@ def content_type_to_charset(content_type):
|
||||||
"""
|
"""
|
||||||
match = re.search(charset_pattern, content_type)
|
match = re.search(charset_pattern, content_type)
|
||||||
if match:
|
if match:
|
||||||
return match.group("charset")
|
return match.group('charset')
|
||||||
|
|
||||||
|
|
||||||
def url_basename(url, content_type):
|
def url_basename(url, content_type):
|
||||||
|
|
@ -25,11 +26,11 @@ def url_basename(url, content_type):
|
||||||
|
|
||||||
If URL contains extension, it is kept as-is.
|
If URL contains extension, it is kept as-is.
|
||||||
|
|
||||||
>>> print(url_basename(u'/path/to/somefile.rst', 'text/plain'))
|
>>> url_basename(u'/path/to/somefile.rst', 'text/plain')
|
||||||
somefile.rst
|
u'somefile.rst'
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return url.split("/")[-1]
|
return url.split('/')[-1]
|
||||||
|
|
||||||
|
|
||||||
def import_member(import_string):
|
def import_member(import_string):
|
||||||
|
|
@ -41,6 +42,6 @@ def import_member(import_string):
|
||||||
True
|
True
|
||||||
|
|
||||||
"""
|
"""
|
||||||
module_name, factory_name = str(import_string).rsplit(".", 1)
|
module_name, factory_name = str(import_string).rsplit('.', 1)
|
||||||
module = __import__(module_name, globals(), locals(), [factory_name], 0)
|
module = __import__(module_name, globals(), locals(), [factory_name], -1)
|
||||||
return getattr(module, factory_name)
|
return getattr(module, factory_name)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
# coding=utf-8
|
||||||
|
"""Views."""
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Views to stream files."""
|
"""Views to stream files."""
|
||||||
|
|
||||||
# API shortcuts.
|
# API shortcuts.
|
||||||
from django_downloadview.views.base import BaseDownloadView, DownloadMixin # NoQA
|
from django_downloadview.views.base import (DownloadMixin, # NoQA
|
||||||
from django_downloadview.views.http import HTTPDownloadView # NoQA
|
BaseDownloadView)
|
||||||
from django_downloadview.views.object import ObjectDownloadView # NoQA
|
|
||||||
from django_downloadview.views.path import PathDownloadView # NoQA
|
from django_downloadview.views.path import PathDownloadView # NoQA
|
||||||
from django_downloadview.views.storage import StorageDownloadView # NoQA
|
from django_downloadview.views.storage import StorageDownloadView # NoQA
|
||||||
|
from django_downloadview.views.object import ObjectDownloadView # NoQA
|
||||||
|
from django_downloadview.views.http import HTTPDownloadView # NoQA
|
||||||
from django_downloadview.views.virtual import VirtualDownloadView # NoQA
|
from django_downloadview.views.virtual import VirtualDownloadView # NoQA
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Base material for download views: :class:`DownloadMixin` and
|
"""Base material for download views: :class:`DownloadMixin` and
|
||||||
:class:`BaseDownloadView`"""
|
:class:`BaseDownloadView`"""
|
||||||
|
from django.http import HttpResponseNotModified
|
||||||
import calendar
|
|
||||||
|
|
||||||
from django.http import Http404, HttpResponseNotModified
|
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
from django.views.static import was_modified_since
|
from django.views.static import was_modified_since
|
||||||
|
|
||||||
from django_downloadview import exceptions
|
|
||||||
from django_downloadview.response import DownloadResponse
|
from django_downloadview.response import DownloadResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,74 +23,22 @@ class DownloadMixin(object):
|
||||||
returned by :py:meth:`get_file`.
|
returned by :py:meth:`get_file`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Response class, to be used in :py:meth:`render_to_response`.
|
#: Response class, to be used in :py:meth:`render_to_response`.
|
||||||
response_class = DownloadResponse
|
response_class = DownloadResponse
|
||||||
|
|
||||||
#: Whether to return the response as attachment or not.
|
#: Whether to return the response as attachment or not.
|
||||||
#:
|
|
||||||
#: When ``True`` (the default), the view returns file "as attachment",
|
|
||||||
#: which usually triggers a "Save the file as ..." prompt.
|
|
||||||
#:
|
|
||||||
#: When ``False``, the view returns file "inline", as if it was an element
|
|
||||||
#: of the current page.
|
|
||||||
#:
|
|
||||||
#: .. note::
|
|
||||||
#:
|
|
||||||
#: The actual behaviour client-side depends on the browser and its
|
|
||||||
#: configuration.
|
|
||||||
#:
|
|
||||||
#: In fact, affects the "Content-Disposition" header via :attr:`response's
|
|
||||||
#: attachment attribute
|
|
||||||
#: <django_downloadview.response.DownloadResponse.attachment>`.
|
|
||||||
attachment = True
|
attachment = True
|
||||||
|
|
||||||
#: Client-side filename, if only file is returned as attachment.
|
#: Client-side filename, if only file is returned as attachment.
|
||||||
basename = None
|
basename = None
|
||||||
|
|
||||||
#: File's mime type.
|
|
||||||
#: If ``None`` (the default), then the file's mime type will be guessed via
|
|
||||||
#: :mod:`mimetypes`.
|
|
||||||
mimetype = None
|
|
||||||
|
|
||||||
#: File's encoding.
|
|
||||||
#: If ``None`` (the default), then the file's encoding will be guessed via
|
|
||||||
#: :mod:`mimetypes`.
|
|
||||||
encoding = None
|
|
||||||
|
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return a file wrapper instance.
|
"""Return a file wrapper instance."""
|
||||||
|
|
||||||
Raises :class:`~django_downloadview.exceptions.FileNotFound` if file
|
|
||||||
does not exist.
|
|
||||||
|
|
||||||
"""
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def get_basename(self):
|
def get_basename(self):
|
||||||
"""Return :attr:`basename`.
|
|
||||||
|
|
||||||
Override this method if you need more dynamic basename.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.basename
|
return self.basename
|
||||||
|
|
||||||
def get_mimetype(self):
|
|
||||||
"""Return :attr:`mimetype`.
|
|
||||||
|
|
||||||
Override this method if you need more dynamic mime type.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.mimetype
|
|
||||||
|
|
||||||
def get_encoding(self):
|
|
||||||
"""Return :attr:`encoding`.
|
|
||||||
|
|
||||||
Override this method if you need more dynamic encoding.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.encoding
|
|
||||||
|
|
||||||
def was_modified_since(self, file_instance, since):
|
def was_modified_since(self, file_instance, since):
|
||||||
"""Return True if ``file_instance`` was modified after ``since``.
|
"""Return True if ``file_instance`` was modified after ``since``.
|
||||||
|
|
||||||
|
|
@ -103,9 +48,9 @@ class DownloadMixin(object):
|
||||||
Else, fallbacks to default implementation, which uses
|
Else, fallbacks to default implementation, which uses
|
||||||
:py:func:`django.views.static.was_modified_since`.
|
:py:func:`django.views.static.was_modified_since`.
|
||||||
|
|
||||||
Django's ``was_modified_since`` function needs a datetime.
|
Django's ``was_modified_since`` function needs a datetime and a size.
|
||||||
It is passed the ``modified_time`` attribute from file
|
It is passed ``modified_time`` and ``size`` attributes from file
|
||||||
wrapper. If file wrapper does not support this attribute
|
wrapper. If file wrapper does not support these attributes
|
||||||
(``AttributeError`` or ``NotImplementedError`` is raised), then
|
(``AttributeError`` or ``NotImplementedError`` is raised), then
|
||||||
the file is considered as modified and ``True`` is returned.
|
the file is considered as modified and ``True`` is returned.
|
||||||
|
|
||||||
|
|
@ -114,14 +59,12 @@ class DownloadMixin(object):
|
||||||
return file_instance.was_modified_since(since)
|
return file_instance.was_modified_since(since)
|
||||||
except (AttributeError, NotImplementedError):
|
except (AttributeError, NotImplementedError):
|
||||||
try:
|
try:
|
||||||
modification_time = calendar.timegm(
|
modification_time = file_instance.modified_time
|
||||||
file_instance.modified_time.utctimetuple()
|
size = file_instance.size
|
||||||
)
|
except (AttributeError, NotImplementedError):
|
||||||
except (AttributeError, NotImplementedError) as e:
|
|
||||||
print("!=======!", e)
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return was_modified_since(since, modification_time)
|
return was_modified_since(since, modification_time, size)
|
||||||
|
|
||||||
def not_modified_response(self, *response_args, **response_kwargs):
|
def not_modified_response(self, *response_args, **response_kwargs):
|
||||||
"""Return :class:`django.http.HttpResponseNotModified` instance."""
|
"""Return :class:`django.http.HttpResponseNotModified` instance."""
|
||||||
|
|
@ -129,22 +72,14 @@ class DownloadMixin(object):
|
||||||
|
|
||||||
def download_response(self, *response_args, **response_kwargs):
|
def download_response(self, *response_args, **response_kwargs):
|
||||||
"""Return :class:`~django_downloadview.response.DownloadResponse`."""
|
"""Return :class:`~django_downloadview.response.DownloadResponse`."""
|
||||||
response_kwargs.setdefault("file_instance", self.file_instance)
|
response_kwargs.setdefault('file_instance', self.file_instance)
|
||||||
response_kwargs.setdefault("attachment", self.attachment)
|
response_kwargs.setdefault('attachment', self.attachment)
|
||||||
response_kwargs.setdefault("basename", self.get_basename())
|
response_kwargs.setdefault('basename', self.get_basename())
|
||||||
response_kwargs.setdefault("file_mimetype", self.get_mimetype())
|
|
||||||
response_kwargs.setdefault("file_encoding", self.get_encoding())
|
|
||||||
response = self.response_class(*response_args, **response_kwargs)
|
response = self.response_class(*response_args, **response_kwargs)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def file_not_found_response(self):
|
|
||||||
"""Raise Http404."""
|
|
||||||
raise Http404()
|
|
||||||
|
|
||||||
def render_to_response(self, *response_args, **response_kwargs):
|
def render_to_response(self, *response_args, **response_kwargs):
|
||||||
"""Return "download" response (if everything is ok).
|
"""Return "download" response.
|
||||||
|
|
||||||
Return :meth:`file_not_found_response` if file does not exist.
|
|
||||||
|
|
||||||
Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses
|
Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses
|
||||||
:py:meth:`was_modified_since` and :py:meth:`not_modified_response`.
|
:py:meth:`was_modified_since` and :py:meth:`not_modified_response`.
|
||||||
|
|
@ -152,12 +87,9 @@ class DownloadMixin(object):
|
||||||
Else, uses :py:meth:`download_response` to return a download response.
|
Else, uses :py:meth:`download_response` to return a download response.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
try:
|
self.file_instance = self.get_file()
|
||||||
self.file_instance = self.get_file()
|
|
||||||
except exceptions.FileNotFound:
|
|
||||||
return self.file_not_found_response()
|
|
||||||
# Respect the If-Modified-Since header.
|
# Respect the If-Modified-Since header.
|
||||||
since = self.request.headers.get("if-modified-since", None)
|
since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None)
|
||||||
if since is not None:
|
if since is not None:
|
||||||
if not self.was_modified_since(self.file_instance, since):
|
if not self.was_modified_since(self.file_instance, since):
|
||||||
return self.not_modified_response(**response_kwargs)
|
return self.not_modified_response(**response_kwargs)
|
||||||
|
|
@ -167,7 +99,6 @@ class DownloadMixin(object):
|
||||||
|
|
||||||
class BaseDownloadView(DownloadMixin, View):
|
class BaseDownloadView(DownloadMixin, View):
|
||||||
"""A base :class:`DownloadMixin` that implements :meth:`get`."""
|
"""A base :class:`DownloadMixin` that implements :meth:`get`."""
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Handle GET requests: stream a file."""
|
"""Handle GET requests: stream a file."""
|
||||||
return self.render_to_response()
|
return self.render_to_response()
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Stream files given an URL, i.e. files you want to proxy."""
|
"""Stream files given an URL, i.e. files you want to proxy."""
|
||||||
|
import requests
|
||||||
|
|
||||||
from django_downloadview.files import HTTPFile
|
from django_downloadview.files import HTTPFile
|
||||||
from django_downloadview.views.base import BaseDownloadView
|
from django_downloadview.views.base import BaseDownloadView
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPDownloadView(BaseDownloadView):
|
class HTTPDownloadView(BaseDownloadView):
|
||||||
"""Proxy files that live on remote servers."""
|
"""Proxy files that live on remote servers."""
|
||||||
|
|
||||||
#: URL to download (the one we are proxying).
|
#: URL to download (the one we are proxying).
|
||||||
url = ""
|
url = u''
|
||||||
|
|
||||||
#: Additional keyword arguments for request handler.
|
#: Additional keyword arguments for request handler.
|
||||||
request_kwargs = {}
|
request_kwargs = {}
|
||||||
|
|
@ -41,9 +40,7 @@ class HTTPDownloadView(BaseDownloadView):
|
||||||
|
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return wrapper which has an ``url`` attribute."""
|
"""Return wrapper which has an ``url`` attribute."""
|
||||||
return HTTPFile(
|
return HTTPFile(request_factory=self.get_request_factory(),
|
||||||
request_factory=self.get_request_factory(),
|
name=self.get_basename(),
|
||||||
name=self.get_basename(),
|
url=self.get_url(),
|
||||||
url=self.get_url(),
|
**self.get_request_kwargs())
|
||||||
**self.get_request_kwargs(),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,31 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Stream files that live in models."""
|
"""Stream files that live in models."""
|
||||||
|
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
from django_downloadview.exceptions import FileNotFound
|
|
||||||
from django_downloadview.views.base import BaseDownloadView
|
from django_downloadview.views.base import BaseDownloadView
|
||||||
|
|
||||||
|
|
||||||
class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
|
class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
|
||||||
"""Serve file fields from models.
|
"""Serve file fields from models.
|
||||||
|
|
||||||
This class extends :class:`~django.views.generic.detail.SingleObjectMixin`,
|
This class extends BaseDetailView, so you can use its arguments to target
|
||||||
so you can use its arguments to target the instance to operate on:
|
the instance to operate on: slug, slug_kwarg, model, queryset...
|
||||||
``slug``, ``slug_kwarg``, ``model``, ``queryset``...
|
See Django's DetailView reference for details.
|
||||||
|
|
||||||
In addition to :class:`~django.views.generic.detail.SingleObjectMixin`
|
In addition to BaseDetailView arguments, you can set arguments related to
|
||||||
arguments, you can set arguments related to the file to be downloaded:
|
the file to be downloaded.
|
||||||
|
|
||||||
* :attr:`file_field`;
|
The main one is ``file_field``.
|
||||||
* :attr:`basename_field`;
|
|
||||||
* :attr:`encoding_field`;
|
|
||||||
* :attr:`mime_type_field`;
|
|
||||||
* :attr:`charset_field`;
|
|
||||||
* :attr:`modification_time_field`;
|
|
||||||
* :attr:`size_field`.
|
|
||||||
|
|
||||||
:attr:`file_field` is the main one. Other arguments are provided for
|
The other arguments are provided for convenience, in case your model holds
|
||||||
convenience, in case your model holds some (deserialized) metadata about
|
some (deserialized) metadata about the file, such as its basename, its
|
||||||
the file, such as its basename, its modification time, its MIME type...
|
modification time, its MIME type... These fields may be particularly handy
|
||||||
These fields may be particularly handy if your file storage is not the
|
if your file storage is not the local filesystem.
|
||||||
local filesystem.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Name of the model's attribute which contains the file to be streamed.
|
#: Name of the model's attribute which contains the file to be streamed.
|
||||||
#: Typically the name of a FileField.
|
#: Typically the name of a FileField.
|
||||||
file_field = "file"
|
file_field = 'file'
|
||||||
|
|
||||||
#: Optional name of the model's attribute which contains the basename.
|
#: Optional name of the model's attribute which contains the basename.
|
||||||
basename_field = None
|
basename_field = None
|
||||||
|
|
@ -62,21 +53,15 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
|
||||||
is typically a :class:`~django.db.models.fields.files.FieldFile` or
|
is typically a :class:`~django.db.models.fields.files.FieldFile` or
|
||||||
subclass.
|
subclass.
|
||||||
|
|
||||||
Raises :class:`~django_downloadview.exceptions.FileNotFound` if
|
|
||||||
instance's field is empty.
|
|
||||||
|
|
||||||
Additional attributes are set on the file wrapper if :attr:`encoding`,
|
Additional attributes are set on the file wrapper if :attr:`encoding`,
|
||||||
:attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or
|
:attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or
|
||||||
:attr:`size` are configured.
|
:attr:`size` are configured.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
file_instance = getattr(self.object, self.file_field)
|
file_instance = getattr(self.object, self.file_field)
|
||||||
if not file_instance:
|
for field in ('encoding', 'mime_type', 'charset', 'modification_time',
|
||||||
raise FileNotFound(
|
'size'):
|
||||||
f'Field="{self.file_field}" on object="{self.object}" is empty'
|
model_field = getattr(self, '%s_field' % field, False)
|
||||||
)
|
|
||||||
for field in ("encoding", "mime_type", "charset", "modification_time", "size"):
|
|
||||||
model_field = getattr(self, "%s_field" % field, False)
|
|
||||||
if model_field:
|
if model_field:
|
||||||
value = getattr(self.object, model_field)
|
value = getattr(self.object, model_field)
|
||||||
setattr(file_instance, field, value)
|
setattr(file_instance, field, value)
|
||||||
|
|
@ -84,14 +69,14 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
|
||||||
|
|
||||||
def get_basename(self):
|
def get_basename(self):
|
||||||
"""Return client-side filename."""
|
"""Return client-side filename."""
|
||||||
basename = super().get_basename()
|
basename = super(ObjectDownloadView, self).get_basename()
|
||||||
if basename is None:
|
if basename is None:
|
||||||
field = "basename"
|
field = 'basename'
|
||||||
model_field = getattr(self, "%s_field" % field, False)
|
model_field = getattr(self, '%s_field' % field, False)
|
||||||
if model_field:
|
if model_field:
|
||||||
basename = getattr(self.object, model_field)
|
basename = getattr(self.object, model_field)
|
||||||
return basename
|
return basename
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
return super().get(request, *args, **kwargs)
|
return super(ObjectDownloadView, self).get(request, *args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
""":class:`PathDownloadView`."""
|
""":class:`PathDownloadView`."""
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
|
||||||
from django_downloadview.exceptions import FileNotFound
|
|
||||||
from django_downloadview.views.base import BaseDownloadView
|
from django_downloadview.views.base import BaseDownloadView
|
||||||
|
|
||||||
|
|
||||||
class PathDownloadView(BaseDownloadView):
|
class PathDownloadView(BaseDownloadView):
|
||||||
"""Serve a file using filename."""
|
"""Serve a file using filename."""
|
||||||
|
|
||||||
#: Server-side name (including path) of the file to serve.
|
#: Server-side name (including path) of the file to serve.
|
||||||
#:
|
#:
|
||||||
#: Filename is supposed to be an absolute filename of a file located on the
|
#: Filename is supposed to be an absolute filename of a file located on the
|
||||||
|
|
@ -18,7 +14,7 @@ class PathDownloadView(BaseDownloadView):
|
||||||
path = None
|
path = None
|
||||||
|
|
||||||
#: Name of the URL argument that contains path.
|
#: Name of the URL argument that contains path.
|
||||||
path_url_kwarg = "path"
|
path_url_kwarg = 'path'
|
||||||
|
|
||||||
def get_path(self):
|
def get_path(self):
|
||||||
"""Return actual path of the file to serve.
|
"""Return actual path of the file to serve.
|
||||||
|
|
@ -34,7 +30,4 @@ class PathDownloadView(BaseDownloadView):
|
||||||
|
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Use path to return wrapper around file to serve."""
|
"""Use path to return wrapper around file to serve."""
|
||||||
filename = self.get_path()
|
return File(open(self.get_path(), 'rb'))
|
||||||
if not os.path.isfile(filename):
|
|
||||||
raise FileNotFound(f'File "{filename}" does not exists')
|
|
||||||
return File(open(filename, "rb"))
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Stream files from storage."""
|
"""Stream files from storage."""
|
||||||
|
|
||||||
from django.core.files.storage import DefaultStorage
|
from django.core.files.storage import DefaultStorage
|
||||||
|
|
||||||
from django_downloadview.files import StorageFile
|
from django_downloadview.files import StorageFile
|
||||||
|
|
@ -8,13 +8,22 @@ from django_downloadview.views.path import PathDownloadView
|
||||||
|
|
||||||
class StorageDownloadView(PathDownloadView):
|
class StorageDownloadView(PathDownloadView):
|
||||||
"""Serve a file using storage and filename."""
|
"""Serve a file using storage and filename."""
|
||||||
|
|
||||||
#: Storage the file to serve belongs to.
|
#: Storage the file to serve belongs to.
|
||||||
storage = DefaultStorage()
|
storage = DefaultStorage()
|
||||||
|
|
||||||
#: Path to the file to serve relative to storage.
|
#: Path to the file to serve relative to storage.
|
||||||
path = None # Override docstring.
|
path = None # Override docstring.
|
||||||
|
|
||||||
|
def get_path(self):
|
||||||
|
"""Return path of the file to serve, relative to storage.
|
||||||
|
|
||||||
|
Default implementation simply returns view's :py:attr:`path` attribute.
|
||||||
|
|
||||||
|
Override this method if you want custom implementation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return super(StorageDownloadView, self).get_path()
|
||||||
|
|
||||||
def get_file(self):
|
def get_file(self):
|
||||||
"""Return :class:`~django_downloadview.files.StorageFile` instance."""
|
"""Return :class:`~django_downloadview.files.StorageFile` instance."""
|
||||||
return StorageFile(self.storage, self.get_path())
|
return StorageFile(self.storage, self.get_path())
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""Stream files that you generate or that live in memory."""
|
"""Stream files that you generate or that live in memory."""
|
||||||
|
|
||||||
from django_downloadview.views.base import BaseDownloadView
|
from django_downloadview.views.base import BaseDownloadView
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -9,7 +9,6 @@ class VirtualDownloadView(BaseDownloadView):
|
||||||
Override the :py:meth:`get_file` method to customize file wrapper.
|
Override the :py:meth:`get_file` method to customize file wrapper.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def was_modified_since(self, file_instance, since):
|
def was_modified_since(self, file_instance, since):
|
||||||
"""Delegate to file wrapper's was_modified_since, or return True.
|
"""Delegate to file wrapper's was_modified_since, or return True.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
# You can set these variables from the command line.
|
# You can set these variables from the command line.
|
||||||
SPHINXOPTS =
|
SPHINXOPTS =
|
||||||
SPHINXBUILD = sphinx-build
|
SPHINXBUILD = ../bin/sphinx-build
|
||||||
PAPER =
|
PAPER =
|
||||||
BUILDDIR = ../var/docs
|
BUILDDIR = ../var/docs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,22 @@ Alternatives and related projects
|
||||||
This document presents other projects that provide similar or complementary
|
This document presents other projects that provide similar or complementary
|
||||||
functionalities. It focuses on differences with django-downloadview.
|
functionalities. It focuses on differences with django-downloadview.
|
||||||
|
|
||||||
There is a comparison grid on djangopackages.com:
|
|
||||||
https://www.djangopackages.com/grids/g/file-streaming/.
|
|
||||||
|
|
||||||
Here are additional highlights...
|
|
||||||
|
|
||||||
|
|
||||||
*************************
|
*************************
|
||||||
Django's static file view
|
Django's static file view
|
||||||
*************************
|
*************************
|
||||||
|
|
||||||
`django.contrib.staticfiles provides a view to serve files`_. It is simple and
|
`Django has a builtin static file view`_. It can stream files. As explained in
|
||||||
quite naive by design: it is meant for development, not for production.
|
Django documentation, it is designed for development purposes. For production,
|
||||||
See `Django ticket #2131`_: advanced file streaming is left to third-party
|
static files'd better be served by some optimized server.
|
||||||
applications.
|
|
||||||
|
|
||||||
`django-downloadview` is such a third-party application.
|
Django-downloadview can replace Django's builtin static file view:
|
||||||
|
|
||||||
|
* perform actions with Django when receiving download requests: check
|
||||||
|
permissions, generate files, gzip, logging, signals...
|
||||||
|
* delegate actual download to a reverse proxy for increased performance.
|
||||||
|
* disable optimization middlewares or decorators in development, if you want to
|
||||||
|
serve files with Django.
|
||||||
|
|
||||||
|
|
||||||
***************
|
***************
|
||||||
|
|
@ -30,11 +30,6 @@ django-sendfile
|
||||||
`django-sendfile`_ is a wrapper around web-server specific methods for sending
|
`django-sendfile`_ is a wrapper around web-server specific methods for sending
|
||||||
files to web clients.
|
files to web clients.
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
:func:`django_downloadview.shortcuts.sendfile` is a port of
|
|
||||||
`django-sendfile`'s main function. See :doc:`/django-sendfile` for details.
|
|
||||||
|
|
||||||
``django-senfile``'s main focus is simplicity: API is made of a single
|
``django-senfile``'s main focus is simplicity: API is made of a single
|
||||||
``sendfile()`` function you call inside your views:
|
``sendfile()`` function you call inside your views:
|
||||||
|
|
||||||
|
|
@ -69,12 +64,40 @@ Here are main differences between the two projects:
|
||||||
root folder. Whereas ``django-downloadview``'s
|
root folder. Whereas ``django-downloadview``'s
|
||||||
``DownloadDispatcherMiddleware`` supports multiple configurations.
|
``DownloadDispatcherMiddleware`` supports multiple configurations.
|
||||||
|
|
||||||
|
As of 2012-04-11, ``django-sendfile`` (version 0.3.2) seems quite popular and
|
||||||
|
may be a good alternative **provided you serve files that live in a single
|
||||||
|
directory of local filesystem**.
|
||||||
|
|
||||||
|
:func:`django_downloadview.sendfile` is a port of django-sendfile's main function.
|
||||||
|
|
||||||
|
|
||||||
|
********************
|
||||||
|
django-private-files
|
||||||
|
********************
|
||||||
|
|
||||||
|
`django-private-files`_ provides utilities for controlling access to static
|
||||||
|
files based on conditions you can specify within your Django application.
|
||||||
|
|
||||||
|
|
||||||
|
**********************
|
||||||
|
django-protected-files
|
||||||
|
**********************
|
||||||
|
|
||||||
|
`django-protected-files`_ is a Django application that lets you serve protected
|
||||||
|
static files via your frontend server after authorizing the user against
|
||||||
|
``django.contrib.auth``.
|
||||||
|
|
||||||
|
As of 2012-12-10, this project seems inactive.
|
||||||
|
|
||||||
|
|
||||||
.. rubric:: References
|
.. rubric:: References
|
||||||
|
|
||||||
.. target-notes::
|
.. target-notes::
|
||||||
|
|
||||||
.. _`django.contrib.staticfiles provides a view to serve files`:
|
.. _`Django has a builtin static file view`:
|
||||||
https://docs.djangoproject.com/en/3.0/ref/contrib/staticfiles/#static-file-development-view
|
https://docs.djangoproject.com/en/1.4/ref/contrib/staticfiles/#static-file-development-view
|
||||||
.. _`Django ticket #2131`: https://code.djangoproject.com/ticket/2131
|
|
||||||
.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile
|
.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile
|
||||||
|
.. _`requests`: https://pypi.python.org/pypi/requests
|
||||||
|
.. _`django-private-files`: http://pypi.python.org/pypi/django-private-files
|
||||||
|
.. _`django-protected-files`:
|
||||||
|
https://github.com/lincolnloop/django-protected-files
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ optimizations.
|
||||||
|
|
||||||
* :doc:`/about/alternatives`
|
* :doc:`/about/alternatives`
|
||||||
* `roadmap
|
* `roadmap
|
||||||
<https://github.com/jazzband/django-downloadview/milestones>`_
|
<https://github.com/benoitbryon/django-downloadview/issues/milestones>`_
|
||||||
|
|
||||||
.. target-notes::
|
.. target-notes::
|
||||||
|
|
||||||
.. _`Django`: https://www.djangoproject.com
|
.. _`Django`: https://django-project.com
|
||||||
|
|
|
||||||
259
docs/conf.py
259
docs/conf.py
|
|
@ -1,142 +1,275 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""django-downloadview documentation build configuration file."""
|
#
|
||||||
|
# django-downloadview documentation build configuration file, created by
|
||||||
|
# sphinx-quickstart on Mon Aug 27 11:37:23 2012.
|
||||||
|
#
|
||||||
|
# This file is execfile()d with the current directory set to its containing dir.
|
||||||
|
#
|
||||||
|
# Note that not all possible configuration values are present in this
|
||||||
|
# autogenerated file.
|
||||||
|
#
|
||||||
|
# All configuration values have a default; values that are commented out
|
||||||
|
# serve to show the default.
|
||||||
|
import os
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
|
|
||||||
# Minimal Django settings. Required to use sphinx.ext.autodoc, because
|
# Minimal Django settings. Required to use sphinx.ext.autodoc, because
|
||||||
# django-downloadview depends on Django...
|
# django-downloadview depends on Django...
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
settings.configure(
|
settings.configure(
|
||||||
DATABASES={}, # Required to load ``django.views.generic``.
|
DATABASES={}, # Required to load ``django.views.generic``.
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -- General configuration ----------------------------------------------------
|
doc_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_dir = os.path.dirname(doc_dir)
|
||||||
|
version_filename = os.path.join(project_dir, 'VERSION')
|
||||||
|
|
||||||
# Extensions.
|
|
||||||
extensions = [
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
"sphinx.ext.autodoc",
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
"sphinx.ext.autosummary",
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
"sphinx.ext.doctest",
|
#sys.path.insert(0, os.path.abspath('.'))
|
||||||
"sphinx.ext.coverage",
|
|
||||||
"sphinx.ext.intersphinx",
|
# -- General configuration -----------------------------------------------------
|
||||||
]
|
|
||||||
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
|
#needs_sphinx = '1.0'
|
||||||
|
|
||||||
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
|
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
|
extensions = ['sphinx.ext.autodoc',
|
||||||
|
'sphinx.ext.autosummary',
|
||||||
|
'sphinx.ext.doctest',
|
||||||
|
'sphinx.ext.coverage',
|
||||||
|
'sphinx.ext.intersphinx']
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ["_templates"]
|
templates_path = ['_templates']
|
||||||
|
|
||||||
# The suffix of source filenames.
|
# The suffix of source filenames.
|
||||||
source_suffix = ".txt"
|
source_suffix = '.txt'
|
||||||
|
|
||||||
# The encoding of source files.
|
# The encoding of source files.
|
||||||
source_encoding = "utf-8"
|
#source_encoding = 'utf-8-sig'
|
||||||
|
|
||||||
# The master toctree document.
|
# The master toctree document.
|
||||||
master_doc = "index"
|
master_doc = 'index'
|
||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = "django-downloadview"
|
project = u'django-downloadview'
|
||||||
project_slug = re.sub(r"([\w_.-]+)", "-", project)
|
copyright = u'2012, Benoît Bryon'
|
||||||
copyright = "2012-2015, Benoît Bryon"
|
|
||||||
author = "Benoît Bryon"
|
|
||||||
author_slug = re.sub(r"([\w_.-]+)", "-", author)
|
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
|
#
|
||||||
# The full version, including alpha/beta/rc tags.
|
|
||||||
release = importlib.metadata.version("django-downloadview")
|
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = ".".join(release.split(".")[:2])
|
version = open(version_filename).read().strip()
|
||||||
|
# The full version, including alpha/beta/rc tags.
|
||||||
|
release = version
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
language = "en"
|
language = 'en'
|
||||||
|
|
||||||
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
|
# non-false value, then it is used:
|
||||||
|
#today = ''
|
||||||
|
# Else, today_fmt is used as the format for a strftime call.
|
||||||
|
#today_fmt = '%B %d, %Y'
|
||||||
|
|
||||||
# List of patterns, relative to source directory, that match files and
|
# List of patterns, relative to source directory, that match files and
|
||||||
# directories to ignore when looking for source files.
|
# directories to ignore when looking for source files.
|
||||||
exclude_patterns = ["_build"]
|
exclude_patterns = ['_build']
|
||||||
|
|
||||||
|
# The reST default role (used for this markup: `text`) to use for all documents.
|
||||||
|
#default_role = None
|
||||||
|
|
||||||
|
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||||
|
#add_function_parentheses = True
|
||||||
|
|
||||||
|
# If true, the current module name will be prepended to all description
|
||||||
|
# unit titles (such as .. function::).
|
||||||
|
#add_module_names = True
|
||||||
|
|
||||||
|
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||||
|
# output. They are ignored by default.
|
||||||
|
#show_authors = False
|
||||||
|
|
||||||
# The name of the Pygments (syntax highlighting) style to use.
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
pygments_style = "sphinx"
|
pygments_style = 'sphinx'
|
||||||
|
|
||||||
|
# A list of ignored prefixes for module index sorting.
|
||||||
|
#modindex_common_prefix = []
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output --------------------------------------------------
|
# -- Options for HTML output ---------------------------------------------------
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
html_theme = "alabaster"
|
html_theme = 'default'
|
||||||
|
|
||||||
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
|
# further. For a list of options available for each theme, see the
|
||||||
|
# documentation.
|
||||||
|
#html_theme_options = {}
|
||||||
|
|
||||||
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
|
#html_theme_path = []
|
||||||
|
|
||||||
|
# The name for this set of Sphinx documents. If None, it defaults to
|
||||||
|
# "<project> v<release> documentation".
|
||||||
|
#html_title = None
|
||||||
|
|
||||||
|
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||||
|
#html_short_title = None
|
||||||
|
|
||||||
|
# The name of an image file (relative to this directory) to place at the top
|
||||||
|
# of the sidebar.
|
||||||
|
#html_logo = None
|
||||||
|
|
||||||
|
# The name of an image file (within the static path) to use as favicon of the
|
||||||
|
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||||
|
# pixels large.
|
||||||
|
#html_favicon = None
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = []
|
html_static_path = ['_static']
|
||||||
|
|
||||||
|
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||||
|
# using the given strftime format.
|
||||||
|
#html_last_updated_fmt = '%b %d, %Y'
|
||||||
|
|
||||||
|
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||||
|
# typographically correct entities.
|
||||||
|
#html_use_smartypants = True
|
||||||
|
|
||||||
# Custom sidebar templates, maps document names to template names.
|
# Custom sidebar templates, maps document names to template names.
|
||||||
html_sidebars = {
|
html_sidebars = {
|
||||||
"**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"],
|
'**': ['globaltoc.html',
|
||||||
|
'relations.html',
|
||||||
|
'sourcelink.html',
|
||||||
|
'searchbox.html'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Additional templates that should be rendered to pages, maps page names to
|
||||||
|
# template names.
|
||||||
|
#html_additional_pages = {}
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#html_domain_indices = True
|
||||||
|
|
||||||
|
# If false, no index is generated.
|
||||||
|
#html_use_index = True
|
||||||
|
|
||||||
|
# If true, the index is split into individual pages for each letter.
|
||||||
|
#html_split_index = False
|
||||||
|
|
||||||
|
# If true, links to the reST sources are added to the pages.
|
||||||
|
#html_show_sourcelink = True
|
||||||
|
|
||||||
|
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||||
|
#html_show_sphinx = True
|
||||||
|
|
||||||
|
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||||
|
#html_show_copyright = True
|
||||||
|
|
||||||
|
# If true, an OpenSearch description file will be output, and all pages will
|
||||||
|
# contain a <link> tag referring to it. The value of this option must be the
|
||||||
|
# base URL from which the finished HTML is served.
|
||||||
|
#html_use_opensearch = ''
|
||||||
|
|
||||||
|
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||||
|
#html_file_suffix = None
|
||||||
|
|
||||||
# Output file base name for HTML help builder.
|
# Output file base name for HTML help builder.
|
||||||
htmlhelp_basename = "{project}doc".format(project=project_slug)
|
htmlhelp_basename = 'django-downloadviewdoc'
|
||||||
|
|
||||||
|
|
||||||
# -- Options for sphinx.ext.intersphinx ---------------------------------------
|
# -- Options for sphinx.ext.intersphinx ---------------------------------------
|
||||||
|
|
||||||
intersphinx_mapping = {
|
intersphinx_mapping = {
|
||||||
"python": ("https://docs.python.org/3", None),
|
'python': ('http://docs.python.org/2.7', None),
|
||||||
"django": (
|
'django': ('http://docs.djangoproject.com/en/1.5/',
|
||||||
"https://docs.djangoproject.com/en/3.1/",
|
'http://docs.djangoproject.com/en/1.5/_objects/'),
|
||||||
"https://docs.djangoproject.com/en/3.1/_objects/",
|
'requests': ('http://docs.python-requests.org/en/latest/', None),
|
||||||
),
|
|
||||||
"requests": ("https://requests.readthedocs.io/en/master/", None),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -- Options for LaTeX output --------------------------------------------------
|
||||||
|
|
||||||
# -- Options for LaTeX output -------------------------------------------------
|
latex_elements = {
|
||||||
|
# The paper size ('letterpaper' or 'a4paper').
|
||||||
|
#'papersize': 'letterpaper',
|
||||||
|
|
||||||
latex_elements = {}
|
# The font size ('10pt', '11pt' or '12pt').
|
||||||
|
#'pointsize': '10pt',
|
||||||
|
|
||||||
|
# Additional stuff for the LaTeX preamble.
|
||||||
|
#'preamble': '',
|
||||||
|
}
|
||||||
|
|
||||||
# Grouping the document tree into LaTeX files. List of tuples
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
# (source start file, target name, title, author, documentclass
|
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||||
# [howto/manual]).
|
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
(
|
('index', 'django-downloadview.tex', u'django-downloadview Documentation',
|
||||||
"index",
|
u'Benoît Bryon', 'manual'),
|
||||||
"{project}.tex".format(project=project_slug),
|
|
||||||
"{project} Documentation".format(project=project),
|
|
||||||
author,
|
|
||||||
"manual",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# The name of an image file (relative to this directory) to place at the top of
|
||||||
|
# the title page.
|
||||||
|
#latex_logo = None
|
||||||
|
|
||||||
# -- Options for manual page output -------------------------------------------
|
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||||
|
# not chapters.
|
||||||
|
#latex_use_parts = False
|
||||||
|
|
||||||
|
# If true, show page references after internal links.
|
||||||
|
#latex_show_pagerefs = False
|
||||||
|
|
||||||
|
# If true, show URL addresses after external links.
|
||||||
|
#latex_show_urls = False
|
||||||
|
|
||||||
|
# Documents to append as an appendix to all manuals.
|
||||||
|
#latex_appendices = []
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#latex_domain_indices = True
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for manual page output --------------------------------------------
|
||||||
|
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
man_pages = [
|
man_pages = [
|
||||||
("index", project, "{project} Documentation".format(project=project), [author], 1)
|
('index', 'django-downloadview', u'django-downloadview Documentation',
|
||||||
|
[u'Benoît Bryon'], 1)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# If true, show URL addresses after external links.
|
||||||
|
#man_show_urls = False
|
||||||
|
|
||||||
# -- Options for Texinfo output -----------------------------------------------
|
|
||||||
|
# -- Options for Texinfo output ------------------------------------------------
|
||||||
|
|
||||||
# Grouping the document tree into Texinfo files. List of tuples
|
# Grouping the document tree into Texinfo files. List of tuples
|
||||||
# (source start file, target name, title, author,
|
# (source start file, target name, title, author,
|
||||||
# dir menu entry, description, category)
|
# dir menu entry, description, category)
|
||||||
texinfo_documents = [
|
texinfo_documents = [
|
||||||
(
|
('index', 'django-downloadview', u'django-downloadview Documentation',
|
||||||
"index",
|
u'Benoît Bryon', 'django-downloadview', 'One line description of project.',
|
||||||
project_slug,
|
'Miscellaneous'),
|
||||||
"{project} Documentation".format(project=project),
|
|
||||||
author,
|
|
||||||
project,
|
|
||||||
"One line description of project.",
|
|
||||||
"Miscellaneous",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Documents to append as an appendix to all manuals.
|
||||||
|
#texinfo_appendices = []
|
||||||
|
|
||||||
|
# If false, no module index is generated.
|
||||||
|
#texinfo_domain_indices = True
|
||||||
|
|
||||||
|
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||||
|
#texinfo_show_urls = 'footnote'
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue