Compare commits

..

No commits in common. "master" and "1.10" have entirely different histories.
master ... 1.10

112 changed files with 1401 additions and 2017 deletions

View file

@ -1,8 +0,0 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

View file

@ -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@v5
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

View file

@ -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@v5
- 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@v4
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@v5
with:
name: Python ${{ matrix.python-version }}

8
.gitignore vendored
View file

@ -6,8 +6,6 @@
# Data files.
/var/
coverage.xml
.coverage/
# Python files.
*.pyc
@ -16,17 +14,11 @@ coverage.xml
# Tox files.
/.tox/
.eggs
*.egg-info
# Virtualenv files (created by tox).
/build/
/dist/
# Virtual environments (created by user).
/venv/
# Editors' temporary buffers.
.*.swp
*~
.idea

View file

@ -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

View file

@ -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.29.1
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: v9.39.1
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.14.5'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.11.1
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject

View file

@ -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: .

12
.travis.yml Normal file
View file

@ -0,0 +1,12 @@
language: python
dist: bionic
python:
- 2.7
- 3.6
- 3.7
- 3.8
install:
- pip install tox
- pip install -q tox-travis
script:
- tox

27
AUTHORS
View file

@ -4,25 +4,12 @@ Authors & contributors
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>
* Aleksi Häkli <aleksi.hakli@iki.fi>
* Nicolas Tobo <https://github.com/nicolastobo>
* 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>
* CJ <cjdreiss@users.noreply.github.com>
* David Wolf <68775926+devidw@users.noreply.github.com>
* 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>
Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors

View file

@ -4,58 +4,6 @@ Changelog
This document describes changes between past releases. For information about
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)
-----------------
@ -266,4 +214,4 @@ Contains **backward incompatible changes.**
.. target-notes::
.. _`milestones`: https://github.com/jazzband/django-downloadview/milestones
.. _`milestones`: https://github.com/benoitbryon/django-downloadview/milestones

View file

@ -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/

View file

@ -2,16 +2,8 @@
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``.
`django-downloadview`.
**************
@ -50,11 +42,11 @@ Use topic branches
Fork, clone
***********
Clone ``django-downloadview`` repository (adapt to use your own fork):
Clone `django-downloadview` repository (adapt to use your own fork):
.. code:: sh
git clone git@github.com:jazzband/django-downloadview.git
git clone git@github.com:benoitbryon/django-downloadview.git
cd django-downloadview/
@ -62,7 +54,7 @@ Clone ``django-downloadview`` repository (adapt to use your own fork):
Usual actions
*************
The ``Makefile`` is the reference card for usual actions in development
The `Makefile` is the reference card for usual actions in development
environment:
* Install development toolkit with `pip`_: ``make develop``.
@ -70,7 +62,7 @@ environment:
* Run tests with `tox`_: ``make test``.
* Build documentation: ``make documentation``. It builds `Sphinx`_
documentation in ``var/docs/html/index.html``.
documentation in `var/docs/html/index.html`.
* Release project with `zest.releaser`_: ``make release``.
@ -84,7 +76,7 @@ See also ``make help``.
Demo project included
*********************
The ``demo`` included in project's repository is part of the tests and
The `demo` included in project's repository is part of the tests and
documentation. Maintain it along with code and documentation.
@ -92,8 +84,8 @@ documentation. Maintain it along with code and documentation.
.. target-notes::
.. _`bugtracker`:
https://github.com/jazzband/django-downloadview/issues
.. _`bugtracker`:
https://github.com/benoitbryon/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/

View file

@ -12,8 +12,8 @@ Install
Requirements
************
`django-downloadview` has been tested with `Python`_ 3.7, 3.8, 3.9 and 3.10.
Other versions may work, but they are not part of the test suite at the moment.
`django-downloadview` has been tested with `Python`_ 2.7, 3.3 and 3.4. Other
versions may work, but they are not part of the test suite at the moment.
Installing `django-downloadview` will automatically trigger the installation of
the following requirements:

View file

@ -6,3 +6,4 @@ include CONTRIBUTING.rst
include INSTALL
include LICENSE
include README.rst
include VERSION

View file

@ -7,8 +7,7 @@
#
PIP = pip
TOX = tox
BLACK = black
ISORT = isort
#: help - Display callable targets.
.PHONY: help
@ -92,10 +91,8 @@ demo:
runserver: demo
demo runserver
.PHONY: black
black:
$(BLACK) demo tests django_downloadview
.PHONY: isort
isort:
$(ISORT) --recursive django_downloadview tests demo
#: release - Tag and push to PyPI.
.PHONY: release
release:
$(TOX) -e release

View file

@ -2,40 +2,16 @@
django-downloadview
###################
.. image:: https://jazzband.co/static/img/badge.svg
:target: https://jazzband.co/
:alt: Jazzband
.. image:: https://img.shields.io/pypi/v/django-downloadview.svg
: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`_:
`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
storage, memory...);
* ``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.
@ -65,9 +41,10 @@ Resources
* Documentation: https://django-downloadview.readthedocs.io
* PyPI page: http://pypi.python.org/pypi/django-downloadview
* Code repository: https://github.com/jazzband/django-downloadview
* Bugtracker: https://github.com/jazzband/django-downloadview/issues
* Continuous integration: https://github.com/jazzband/django-downloadview/actions
* Roadmap: https://github.com/jazzband/django-downloadview/milestones
* Code repository: https://github.com/benoitbryon/django-downloadview
* Bugtracker: https://github.com/benoitbryon/django-downloadview/issues
* Continuous integration: https://travis-ci.org/benoitbryon/django-downloadview
* Roadmap: https://github.com/benoitbryon/django-downloadview/milestones
.. _`Django`: https://djangoproject.com

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.10

View file

@ -3,7 +3,7 @@ Demo project
############
`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:
* `Python`_ version 3.7+, available as ``python`` command.
* `Python`_ version 2.7, available as ``python`` command.
.. note::
You may use `Virtualenv`_ to make sure the active ``python`` is the right
@ -44,7 +44,7 @@ Execute:
.. code-block:: sh
git clone git@github.com:jazzband/django-downloadview.git
git clone git@github.com:benoitbryon/django-downloadview.git
cd django-downloadview/
make runserver
@ -66,7 +66,7 @@ References
.. target-notes::
.. _`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
.. _`Virtualenv`: http://virtualenv.org

View file

@ -2,60 +2,42 @@ 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.compat import reverse
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"))
storage.save('hello-world.txt', ContentFile(u'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")
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",
)
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")
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")
file_path="/apache-optimized-by-decorator/hello-world.txt")

View file

@ -1,24 +1,16 @@
"""URL mapping."""
from django.conf.urls import url
from django.urls import path
from demoproject.compat import patterns
from demoproject.apache import views
app_name = "apache"
urlpatterns = [
path(
"optimized-by-middleware/",
urlpatterns = patterns(
'demoproject.apache.views',
url(r'^optimized-by-middleware/$',
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
name='optimized_by_middleware'),
url(r'^optimized-by-decorator/$',
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]
name='optimized_by_decorator'),
)

View file

@ -6,33 +6,17 @@ 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/"])
)
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_middleware = StorageDownloadView.as_view(storage=storage,
path='hello-world.txt')
optimized_by_decorator = x_sendfile(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
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/",
)
destination_dir='/apache-optimized-by-decorator/')

View file

@ -0,0 +1,22 @@
from distutils.version import StrictVersion
from django.utils.version import get_version
try:
from django.conf.urls import patterns # noqa
except ImportError:
def patterns(prefix, *args):
return list(args)
try:
from django.urls import reverse # noqa
except ImportError:
from django.core.urlresolvers import reverse # noqa
if StrictVersion(get_version()) >= StrictVersion('2.0'):
from django.conf.urls import include as urlinclude # noqa
def include(arg, namespace=None, app_name=None):
return urlinclude((arg, app_name), namespace=namespace)
else:
from django.conf.urls import include # noqa

View file

@ -1,9 +1,9 @@
[
{
"pk": 1,
"model": "object.document",
"pk": 1,
"model": "object.document",
"fields": {
"slug": "hello-world",
"slug": "hello-world",
"file": "object/hello-world.txt"
}
}

View file

@ -1,26 +1,27 @@
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
from demoproject.compat import reverse
class SimpleURLTestCase(django.test.TestCase):
def test_download_response(self):
"""'simple_url' serves 'hello-world.txt' from Github."""
url = reverse("http:simple_url")
url = reverse('http:simple_url')
response = self.client.get(url)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')
class AvatarTestCase(django.test.TestCase):
def test_download_response(self):
"""HTTPDownloadView proxies Content-Type header."""
url = reverse("http:avatar_url")
url = reverse('http:avatar_url')
response = self.client.get(url)
assert_download_response(self, response, mime_type="image/png")
assert_download_response(self,
response,
mime_type='image/png')

View file

@ -1,9 +1,15 @@
from django.urls import path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.http import views
app_name = "http"
urlpatterns = [
path("simple_url/", views.simple_url, name="simple_url"),
path("avatar_url/", views.avatar_url, name="avatar_url"),
]
urlpatterns = patterns(
'',
url(r'^simple_url/$',
views.simple_url,
name='simple_url'),
url(r'^avatar_url/$',
views.avatar_url,
name='avatar_url'),
)

View file

@ -4,17 +4,15 @@ from django_downloadview import HTTPDownloadView
class SimpleURLDownloadView(HTTPDownloadView):
def get_url(self):
"""Return URL of hello-world.txt file on GitHub."""
return (
"https://raw.githubusercontent.com"
"/jazzband/django-downloadview"
"/b7f660c5e3f37d918b106b02c5af7a887acc0111"
"/demo/demoproject/download/fixtures/hello-world.txt"
)
return 'https://raw.githubusercontent.com' \
'/benoitbryon/django-downloadview' \
'/b7f660c5e3f37d918b106b02c5af7a887acc0111' \
'/demo/demoproject/download/fixtures/hello-world.txt'
class GithubAvatarDownloadView(HTTPDownloadView):
def get_url(self):
return "https://avatars0.githubusercontent.com/u/235204"
return 'https://avatars0.githubusercontent.com/u/235204'
simple_url = SimpleURLDownloadView.as_view()

View file

@ -2,60 +2,42 @@ 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.compat import reverse
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"))
storage.save('hello-world.txt', ContentFile(u'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")
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",
)
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")
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")
file_path="/lighttpd-optimized-by-decorator/hello-world.txt")

View file

@ -1,24 +1,16 @@
"""URL mapping."""
from django.conf.urls import url
from django.urls import path
from demoproject.compat import patterns
from demoproject.lighttpd import views
app_name = "lighttpd"
urlpatterns = [
path(
"optimized-by-middleware/",
urlpatterns = patterns(
'demoproject.lighttpd.views',
url(r'^optimized-by-middleware/$',
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
name='optimized_by_middleware'),
url(r'^optimized-by-decorator/$',
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]
name='optimized_by_decorator'),
)

View file

@ -6,33 +6,18 @@ 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_dir = os.path.join(settings.MEDIA_ROOT, 'lighttpd')
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "lighttpd/"])
)
location=storage_dir,
base_url=''.join([settings.MEDIA_URL, 'lighttpd/']))
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
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"),
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/",
)
destination_dir='/lighttpd-optimized-by-decorator/')

View file

@ -6,7 +6,8 @@ from django.core.management import execute_from_command_line
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"{__package__}.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE",
"{package}.settings".format(package=__package__))
execute_from_command_line(sys.argv)

View file

@ -2,24 +2,24 @@ import os
from django.core.files.base import ContentFile
import django.test
from django.urls import reverse
from django_downloadview.nginx import assert_x_accel_redirect
from demoproject.compat import reverse
from demoproject.nginx.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"))
storage.save('hello-world.txt', ContentFile(u'Hello world!\n'))
class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self):
"""'nginx:optimized_by_middleware' returns X-Accel response."""
setup_file()
url = reverse("nginx:optimized_by_middleware")
url = reverse('nginx:optimized_by_middleware')
response = self.client.get(url)
assert_x_accel_redirect(
self,
@ -30,15 +30,14 @@ class OptimizedByMiddlewareTestCase(django.test.TestCase):
redirect_url="/nginx-optimized-by-middleware/hello-world.txt",
expires=None,
with_buffering=None,
limit_rate=None,
)
limit_rate=None)
class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self):
"""'nginx:optimized_by_decorator' returns X-Accel response."""
setup_file()
url = reverse("nginx:optimized_by_decorator")
url = reverse('nginx:optimized_by_decorator')
response = self.client.get(url)
assert_x_accel_redirect(
self,
@ -49,25 +48,4 @@ class OptimizedByDecoratorTestCase(django.test.TestCase):
redirect_url="/nginx-optimized-by-decorator/hello-world.txt",
expires=None,
with_buffering=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")
limit_rate=None)

View file

@ -1,24 +1,17 @@
"""URL mapping."""
from django.urls import path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.nginx import views
app_name = "nginx"
urlpatterns = [
path(
"optimized-by-middleware/",
urlpatterns = patterns(
'demoproject.nginx.views',
url(r'^optimized-by-middleware/$',
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
name='optimized_by_middleware'),
url(r'^optimized-by-decorator/$',
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]
name='optimized_by_decorator'),
)

View file

@ -6,33 +6,17 @@ from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
from django_downloadview.nginx import x_accel_redirect
storage_dir = os.path.join(settings.MEDIA_ROOT, "nginx")
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "nginx/"])
)
storage_dir = os.path.join(settings.MEDIA_ROOT, 'nginx')
storage = FileSystemStorage(location=storage_dir,
base_url=''.join([settings.MEDIA_URL, 'nginx/']))
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
optimized_by_middleware = StorageDownloadView.as_view(storage=storage,
path='hello-world.txt')
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,
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/",
)
destination_url='/nginx-optimized-by-decorator/')

View file

@ -3,6 +3,6 @@ from django.db import models
class Document(models.Model):
slug = models.SlugField()
file = models.FileField(upload_to="object")
another_file = models.FileField(upload_to="object-other")
file = models.FileField(upload_to='object')
another_file = models.FileField(upload_to='object-other')
basename = models.CharField(max_length=100)

View file

@ -1,24 +1,29 @@
from django.core.files.base import ContentFile
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.compat import reverse
from demoproject.object.models import Document
# Fixtures.
slug = "hello-world"
basename = "hello-world.txt"
file_name = "file.txt"
another_name = "another_file.txt"
file_content = "Hello world!\n"
another_content = "Goodbye world!\n"
slug = 'hello-world'
basename = 'hello-world.txt'
file_name = 'file.txt'
another_name = 'another_file.txt'
file_content = 'Hello world!\n'
another_content = 'Goodbye world!\n'
def setup_document():
document = Document(slug=slug, basename=basename)
document.file.save(file_name, ContentFile(file_content), save=False)
document.another_file.save(another_name, ContentFile(another_content), save=False)
document.file.save(file_name,
ContentFile(file_content),
save=False)
document.another_file.save(another_name,
ContentFile(another_content),
save=False)
document.save()
return document
@ -28,15 +33,13 @@ class DefaultFileTestCase(django.test.TestCase):
def test_download_response(self):
"""'default_file' streams Document.file."""
setup_document()
url = reverse("object:default_file", kwargs={"slug": slug})
url = reverse('object:default_file', kwargs={'slug': slug})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
basename=file_name,
mime_type="text/plain",
)
assert_download_response(self,
response,
content=file_content,
basename=file_name,
mime_type='text/plain')
class AnotherFileTestCase(django.test.TestCase):
@ -44,15 +47,13 @@ class AnotherFileTestCase(django.test.TestCase):
def test_download_response(self):
"""'another_file' streams Document.another_file."""
setup_document()
url = reverse("object:another_file", kwargs={"slug": slug})
url = reverse('object:another_file', kwargs={'slug': slug})
response = self.client.get(url)
assert_download_response(
self,
response,
content=another_content,
basename=another_name,
mime_type="text/plain",
)
assert_download_response(self,
response,
content=another_content,
basename=another_name,
mime_type='text/plain')
class DeserializedBasenameTestCase(django.test.TestCase):
@ -60,15 +61,13 @@ class DeserializedBasenameTestCase(django.test.TestCase):
def test_download_response(self):
"'deserialized_basename' streams Document.file with custom basename."
setup_document()
url = reverse("object:deserialized_basename", kwargs={"slug": slug})
url = reverse('object:deserialized_basename', kwargs={'slug': slug})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
basename=basename,
mime_type="text/plain",
)
assert_download_response(self,
response,
content=file_content,
basename=basename,
mime_type='text/plain')
class InlineFileTestCase(django.test.TestCase):
@ -76,12 +75,10 @@ class InlineFileTestCase(django.test.TestCase):
def test_download_response(self):
"'inline_file_view' streams Document.file inline."
setup_document()
url = reverse("object:inline_file", kwargs={"slug": slug})
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,
)
assert_download_response(self,
response,
content=file_content,
mime_type='text/plain',
attachment=False)

View file

@ -1,27 +1,21 @@
from django.urls import re_path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.object import views
app_name = "object"
urlpatterns = [
re_path(
r"^default-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
urlpatterns = patterns(
'',
url(r'^default-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.default_file_view,
name="default_file",
),
re_path(
r"^another-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
name='default_file'),
url(r'^another-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.another_file_view,
name="another_file",
),
re_path(
r"^deserialized_basename/(?P<slug>[a-zA-Z0-9_-]+)/$",
name='another_file'),
url(r'^deserialized_basename/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.deserialized_basename_view,
name="deserialized_basename",
),
re_path(
r"^inline-file/(?P<slug>[a-zA-Z0-9_-]+)/$",
name='deserialized_basename'),
url(r'^inline-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.inline_file_view,
name="inline_file",
),
]
name='inline_file'),
)

View file

@ -2,19 +2,22 @@ from django_downloadview import ObjectDownloadView
from demoproject.object.models import Document
#: Serve ``file`` attribute of ``Document`` model.
default_file_view = ObjectDownloadView.as_view(model=Document)
#: Serve ``another_file`` attribute of ``Document`` model.
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
#: from model.
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)
inline_file_view = ObjectDownloadView.as_view(
model=Document,
attachment=False)

View file

@ -1,32 +1,29 @@
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
from demoproject.compat import reverse
class StaticPathTestCase(django.test.TestCase):
def test_download_response(self):
"""'static_path' serves 'fixtures/hello-world.txt'."""
url = reverse("path:static_path")
url = reverse('path:static_path')
response = self.client.get(url)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')
class DynamicPathTestCase(django.test.TestCase):
def test_download_response(self):
"""'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)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')

View file

@ -1,13 +1,15 @@
from django.urls import path, re_path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.path import views
app_name = "path"
urlpatterns = [
path("static-path/", views.static_path, name="static_path"),
re_path(
r"^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
urlpatterns = patterns(
'',
url(r'^static-path/$',
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,
name="dynamic_path",
),
]
name='dynamic_path'),
)

View file

@ -2,12 +2,13 @@ import os
from django_downloadview import PathDownloadView
# Let's initialize some fixtures.
app_dir = os.path.dirname(os.path.abspath(__file__))
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!'.
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.
static_path = PathDownloadView.as_view(path=hello_world_path)
@ -26,11 +27,10 @@ class DynamicPathDownloadView(PathDownloadView):
:class:`StorageDownloadView`
"""
def get_path(self):
"""Return path inside fixtures directory."""
# Get path from URL resolvers or as_view kwarg.
relative_path = super().get_path()
relative_path = super(DynamicPathDownloadView, self).get_path()
# Make it absolute.
absolute_path = os.path.join(fixtures_dir, relative_path)
return absolute_path

160
demo/demoproject/settings.py Normal file → Executable file
View file

@ -1,26 +1,30 @@
# -*- coding: utf-8 -*-
"""Django settings for django-downloadview demo project."""
from distutils.version import StrictVersion
import os
from django.utils.version import get_version
# Configure some relative directories.
demoproject_dir = os.path.dirname(os.path.abspath(__file__))
demo_dir = os.path.dirname(demoproject_dir)
root_dir = os.path.dirname(demo_dir)
data_dir = os.path.join(root_dir, "var")
cfg_dir = os.path.join(root_dir, "etc")
data_dir = os.path.join(root_dir, 'var')
cfg_dir = os.path.join(root_dir, 'etc')
# Mandatory settings.
ROOT_URLCONF = "demoproject.urls"
WSGI_APPLICATION = "demoproject.wsgi.application"
ROOT_URLCONF = 'demoproject.urls'
WSGI_APPLICATION = 'demoproject.wsgi.application'
# Database.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(data_dir, "db.sqlite"),
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(data_dir, 'db.sqlite'),
}
}
@ -29,49 +33,61 @@ DATABASES = {
SECRET_KEY = "This is a secret made public on project's repository."
# Media and static files.
MEDIA_ROOT = os.path.join(data_dir, "media")
MEDIA_URL = "/media/"
STATIC_ROOT = os.path.join(data_dir, "static")
STATIC_URL = "/static/"
MEDIA_ROOT = os.path.join(data_dir, 'media')
MEDIA_URL = '/media/'
STATIC_ROOT = os.path.join(data_dir, 'static')
STATIC_URL = '/static/'
# Applications.
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.
'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.
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
# Stuff that must be at the end.
'django_nose',
)
# BEGIN middlewares
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django_downloadview.SmartDownloadMiddleware",
]
if StrictVersion(get_version()) >= StrictVersion('1.10'):
MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django_downloadview.SmartDownloadMiddleware'
]
else:
MIDDLEWARE_CLASSES = [
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django_downloadview.SmartDownloadMiddleware'
]
# END middlewares
# 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'
@ -81,54 +97,70 @@ DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware'
# BEGIN rules
DOWNLOADVIEW_RULES = [
{
"source_url": "/media/nginx/",
"destination_url": "/nginx-optimized-by-middleware/",
'source_url': '/media/nginx/',
'destination_url': '/nginx-optimized-by-middleware/',
},
]
# END rules
DOWNLOADVIEW_RULES += [
{
"source_url": "/media/apache/",
"destination_dir": "/apache-optimized-by-middleware/",
'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",
'backend': 'django_downloadview.apache.XSendfileMiddleware',
},
{
"source_url": "/media/lighttpd/",
"destination_dir": "/lighttpd-optimized-by-middleware/",
'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",
'backend': 'django_downloadview.lighttpd.XSendfileMiddleware',
},
]
# Test/development settings.
DEBUG = True
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(os.path.dirname(__file__), "templates")],
"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",
],
},
},
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
NOSE_ARGS = [
'--verbosity=2',
'--no-path-adjustment',
'--nocapture',
'--all-modules',
'--with-coverage',
'--with-doctest',
]
if StrictVersion(get_version()) >= StrictVersion('1.8'):
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(os.path.dirname(__file__), "templates"),
],
'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',
],
},
},
]
else:
TEMPLATE_DEBUG = DEBUG
TEMPLATE_DIRS = (
os.path.join(os.path.dirname(__file__), "templates"),
)

View file

@ -1,3 +1,4 @@
from django.core.files.storage import FileSystemStorage
storage = FileSystemStorage()

View file

@ -4,18 +4,16 @@ import unittest
from django.core.files.base import ContentFile
from django.http.response import HttpResponseNotModified
import django.test
from django.urls import reverse
from django_downloadview import (
assert_download_response,
setup_view,
temporary_media_root,
)
from django_downloadview import assert_download_response, temporary_media_root
from django_downloadview import setup_view
from demoproject.compat import reverse
from demoproject.storage import views
# Fixtures.
file_content = "Hello world!\n"
file_content = 'Hello world!\n'
def setup_file(path):
@ -26,48 +24,44 @@ class StaticPathTestCase(django.test.TestCase):
@temporary_media_root()
def test_download_response(self):
"""'storage:static_path' streams file by path."""
setup_file("1.txt")
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
setup_file('1.txt')
url = reverse('storage:static_path', kwargs={'path': '1.txt'})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
basename="1.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content=file_content,
basename='1.txt',
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
setup_file('1.txt')
url = reverse('storage:static_path', kwargs={'path': '1.txt'})
response = self.client.get(
url, headers={"if-modified-since": f"Sat, 29 Oct {year} 19:43:31 GMT"}
url,
HTTP_IF_MODIFIED_SINCE='Sat, 29 Oct {year} 19:43:31 GMT'.format(
year=datetime.date.today().year + 4)
)
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"})
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",
)
url,
HTTP_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):
"""Integration tests around ``storage:dynamic_path`` URL."""
@temporary_media_root()
def test_download_response(self):
"""'dynamic_path' streams file by generated path.
@ -80,21 +74,18 @@ class DynamicPathIntegrationTestCase(django.test.TestCase):
file in storage.
"""
setup_file("1.TXT")
url = reverse("storage:dynamic_path", kwargs={"path": "1.txt"})
setup_file('1.TXT')
url = reverse('storage:dynamic_path', kwargs={'path': '1.txt'})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
basename="1.TXT",
mime_type="text/plain",
)
assert_download_response(self,
response,
content=file_content,
basename='1.TXT',
mime_type='text/plain')
class DynamicPathUnitTestCase(unittest.TestCase):
"""Unit tests around ``views.DynamicStorageDownloadView``."""
def test_get_path(self):
"""DynamicStorageDownloadView.get_path() returns uppercase path.
@ -106,10 +97,8 @@ class DynamicPathUnitTestCase(unittest.TestCase):
URL works. It targets only custom ``DynamicStorageDownloadView`` class.
"""
view = setup_view(
views.DynamicStorageDownloadView(),
django.test.RequestFactory().get("/fake-url"),
path="dummy path",
)
view = setup_view(views.DynamicStorageDownloadView(),
django.test.RequestFactory().get('/fake-url'),
path='dummy path')
path = view.get_path()
self.assertEqual(path, "DUMMY PATH")
self.assertEqual(path, 'DUMMY PATH')

View file

@ -1,17 +1,15 @@
from django.urls import re_path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.storage import views
app_name = "storage"
urlpatterns = [
re_path(
r"^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
urlpatterns = patterns(
'',
url(r'^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
views.static_path,
name="static_path",
),
re_path(
r"^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
name='static_path'),
url(r'^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
views.dynamic_path,
name="dynamic_path",
),
]
name='dynamic_path'),
)

View file

@ -2,6 +2,7 @@ from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
storage = FileSystemStorage()
@ -11,10 +12,9 @@ static_path = StorageDownloadView.as_view(storage=storage)
class DynamicStorageDownloadView(StorageDownloadView):
"""Serve file of storage by path.upper()."""
def get_path(self):
"""Return uppercase path."""
return super().get_path().upper()
return super(DynamicStorageDownloadView, self).get_path().upper()
dynamic_path = DynamicStorageDownloadView.as_view(storage=storage)

View file

@ -1,14 +1,14 @@
# coding=utf8
"""Test suite for demoproject.download."""
from demoproject.compat import reverse
from django.test import TestCase
from django.urls import reverse
class HomeViewTestCase(TestCase):
"""Test homepage."""
def test_get(self):
"""Homepage returns HTTP 200."""
home_url = reverse("home")
home_url = reverse('home')
response = self.client.get(home_url)
self.assertEqual(response.status_code, 200)

64
demo/demoproject/urls.py Normal file → Executable file
View file

@ -1,44 +1,46 @@
from django.urls import include, path
from django.conf.urls import url
from django.views.generic import TemplateView
home = TemplateView.as_view(template_name="home.html")
from demoproject.compat import patterns, include
urlpatterns = [
home = TemplateView.as_view(template_name='home.html')
urlpatterns = patterns(
'',
# ObjectDownloadView.
path(
"object/",
include("demoproject.object.urls", namespace="object"),
),
url(r'^object/', include('demoproject.object.urls',
app_name='object',
namespace='object')),
# StorageDownloadView.
path(
"storage/",
include("demoproject.storage.urls", namespace="storage"),
),
url(r'^storage/', include('demoproject.storage.urls',
app_name='storage',
namespace='storage')),
# PathDownloadView.
path("path/", include("demoproject.path.urls", namespace="path")),
url(r'^path/', include('demoproject.path.urls',
app_name='path',
namespace='path')),
# HTTPDownloadView.
path("http/", include("demoproject.http.urls", namespace="http")),
url(r'^http/', include('demoproject.http.urls',
app_name='http',
namespace='http')),
# VirtualDownloadView.
path(
"virtual/",
include("demoproject.virtual.urls", namespace="virtual"),
),
url(r'^virtual/', include('demoproject.virtual.urls',
app_name='virtual',
namespace='virtual')),
# Nginx optimizations.
path(
"nginx/",
include("demoproject.nginx.urls", namespace="nginx"),
),
url(r'^nginx/', include('demoproject.nginx.urls',
app_name='nginx',
namespace='nginx')),
# Apache optimizations.
path(
"apache/",
include("demoproject.apache.urls", namespace="apache"),
),
url(r'^apache/', include('demoproject.apache.urls',
app_name='apache',
namespace='apache')),
# Lighttpd optimizations.
path(
"lighttpd/",
include("demoproject.lighttpd.urls", namespace="lighttpd"),
),
url(r'^lighttpd/', include('demoproject.lighttpd.urls',
app_name='lighttpd',
namespace='lighttpd')),
# An informative homepage.
path("", home, name="home"),
]
url(r'$', home, name='home')
)

View file

@ -1,46 +1,41 @@
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
from demoproject.compat import reverse
class TextTestCase(django.test.TestCase):
def test_download_response(self):
"""'virtual:text' serves 'hello-world.txt' from unicode."""
url = reverse("virtual:text")
url = reverse('virtual:text')
response = self.client.get(url)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')
class StringIOTestCase(django.test.TestCase):
def test_download_response(self):
"""'virtual:stringio' serves 'hello-world.txt' from stringio."""
url = reverse("virtual:stringio")
url = reverse('virtual:stringio')
response = self.client.get(url)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')
class GeneratedTestCase(django.test.TestCase):
def test_download_response(self):
"""'virtual:generated' serves 'hello-world.txt' from generator."""
url = reverse("virtual:generated")
url = reverse('virtual:generated')
response = self.client.get(url)
assert_download_response(
self,
response,
content="Hello world!\n",
basename="hello-world.txt",
mime_type="text/plain",
)
assert_download_response(self,
response,
content='Hello world!\n',
basename='hello-world.txt',
mime_type='text/plain')

View file

@ -1,10 +1,18 @@
from django.urls import path
from django.conf.urls import url
from demoproject.compat import patterns
from demoproject.virtual import views
app_name = "virtual"
urlpatterns = [
path("text/", views.TextDownloadView.as_view(), name="text"),
path("stringio/", views.StringIODownloadView.as_view(), name="stringio"),
path("gerenated/", views.GeneratedDownloadView.as_view(), name="generated"),
]
urlpatterns = patterns(
'',
url(r'^text/$',
views.TextDownloadView.as_view(),
name='text'),
url(r'^stringio/$',
views.StringIODownloadView.as_view(),
name='stringio'),
url(r'^gerenated/$',
views.GeneratedDownloadView.as_view(),
name='generated'),
)

View file

@ -1,31 +1,33 @@
from io import StringIO
from six import StringIO
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 TextIteratorIO
class TextDownloadView(VirtualDownloadView):
def get_file(self):
"""Return :class:`django.core.files.base.ContentFile` object."""
return ContentFile(b"Hello world!\n", name="hello-world.txt")
return ContentFile(b"Hello world!\n", name='hello-world.txt')
class StringIODownloadView(VirtualDownloadView):
def get_file(self):
"""Return wrapper on ``six.StringIO`` object."""
file_obj = StringIO("Hello world!\n")
return VirtualFile(file_obj, name="hello-world.txt")
file_obj = StringIO(u"Hello world!\n")
return VirtualFile(file_obj, name='hello-world.txt')
def generate_hello():
yield "Hello "
yield "world!"
yield "\n"
yield u'Hello '
yield u'world!'
yield u'\n'
class GeneratedDownloadView(VirtualDownloadView):
def get_file(self):
"""Return wrapper on ``StringIteratorIO`` object."""
file_obj = TextIteratorIO(generate_hello())
return VirtualFile(file_obj, name="hello-world.txt")
return VirtualFile(file_obj, name='hello-world.txt')

View file

@ -12,11 +12,11 @@ middleware here, or combine a Django application with an application of another
framework.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
# This application object is used by any WSGI server configured to use this

View file

@ -1,26 +1,48 @@
# -*- coding: utf-8 -*-
"""Python packaging."""
import os
from setuptools import setup
here = os.path.abspath(os.path.dirname(__file__))
setup(
name="django-downloadview-demo",
version="1.0",
description="Serve files with Django and reverse-proxies.",
long_description=open(os.path.join(here, "README.rst")).read(),
classifiers=[
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Framework :: Django",
],
author="Benoît Bryon",
author_email="benoit@marmelune.net",
url="https://django-downloadview.readthedocs.io/",
license="BSD",
packages=["demoproject"],
include_package_data=True,
zip_safe=False,
install_requires=["django-downloadview", "pytest-django"],
entry_points={"console_scripts": ["demo = demoproject.manage:main"]},
)
here = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.dirname(here)
NAME = 'django-downloadview-demo'
DESCRIPTION = 'Serve files with Django and reverse-proxies.'
README = open(os.path.join(here, 'README.rst')).read()
VERSION = open(os.path.join(project_root, 'VERSION')).read().strip()
AUTHOR = u'Benoît Bryon'
EMAIL = u'benoit@marmelune.net'
URL = 'https://django-downloadview.readthedocs.io/'
CLASSIFIERS = ['Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: BSD License',
'Programming Language :: Python :: 2.7',
'Framework :: Django']
KEYWORDS = []
PACKAGES = ['demoproject']
REQUIREMENTS = [
'django-downloadview',
'django-nose']
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)

View file

@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
"""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.
__version__ = importlib.metadata.version(__package__.replace("-", "_"))
__version__ = pkg_resources.get_distribution(__package__.replace('-', '_')) \
.version
# API shortcuts.
from django_downloadview.api import * # NoQA

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Optimizations for Apache.
See also `documentation of mod_xsendfile for Apache
@ -5,9 +6,8 @@ See also `documentation of mod_xsendfile for Apache
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
from django_downloadview.apache.middlewares import XSendfileMiddleware # NoQA

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Decorators to apply Apache X-Sendfile on a specific view."""
from django_downloadview.apache.middlewares import XSendfileMiddleware
from django_downloadview.decorators import DownloadDecorator
from django_downloadview.apache.middlewares import XSendfileMiddleware
def x_sendfile(view_func, *args, **kwargs):

View file

@ -1,8 +1,6 @@
from django_downloadview.apache.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
@ -14,12 +12,13 @@ class XSendfileMiddleware(ProxiedDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
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)
super(XSendfileMiddleware, self).__init__(
get_response, source_dir, source_url, destination_dir
)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
@ -27,10 +26,7 @@ class XSendfileMiddleware(ProxiedDownloadMiddleware):
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,
)
return XSendfileResponse(file_path=redirect_url,
content_type=response['Content-Type'],
basename=response.basename,
attachment=response.attachment)

View file

@ -1,22 +1,18 @@
# -*- coding: utf-8 -*-
"""Apache's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
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
):
def __init__(self, file_path, content_type, basename=None,
attachment=True):
"""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)
super(XSendfileResponse, self).__init__(content_type=content_type)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path
self['Content-Disposition'] = content_disposition(self.basename)
self['X-Sendfile'] = file_path

View file

@ -1,13 +1,13 @@
from six import iteritems
from django_downloadview.apache.response import XSendfileResponse
class XSendfileValidator:
class XSendfileValidator(object):
"""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.
@ -22,8 +22,8 @@ class XSendfileValidator:
"""
self.assert_x_sendfile_response(test_case, response)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
for key, value in iteritems(assertions):
assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value)
def assert_x_sendfile_response(self, test_case, response):
@ -33,15 +33,15 @@ class XSendfileValidator:
test_case.assertEqual(response.basename, 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_file_path(self, test_case, response, value):
test_case.assertEqual(response["X-Sendfile"], value)
test_case.assertEqual(response['X-Sendfile'], value)
def assert_attachment(self, test_case, response, value):
header = "Content-Disposition"
header = 'Content-Disposition'
if value:
test_case.assertTrue(response[header].startswith("attachment"))
test_case.assertTrue(response[header].startswith('attachment'))
else:
test_case.assertFalse(header in response)

View file

@ -1,29 +1,27 @@
# flake8: noqa
# -*- coding: utf-8 -*-
"""Declaration of API shortcuts."""
from django_downloadview.io import (BytesIteratorIO, # NoQA
TextIteratorIO)
from django_downloadview.files import (StorageFile, # NoQA
VirtualFile,
HTTPFile)
from django_downloadview.response import (DownloadResponse, # NoQA
ProxiedDownloadResponse)
from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA
DownloadDispatcherMiddleware,
SmartDownloadMiddleware)
from django_downloadview.views import (PathDownloadView, # NoQA
ObjectDownloadView,
StorageDownloadView,
HTTPDownloadView,
VirtualDownloadView,
BaseDownloadView,
DownloadMixin)
from django_downloadview.shortcuts import sendfile # NoQA
from django_downloadview.test import (assert_download_response, # NoQA
setup_view,
temporary_media_root)
from django_downloadview.files import HTTPFile, StorageFile, VirtualFile
from django_downloadview.io import BytesIteratorIO, TextIteratorIO
from django_downloadview.middlewares import (
BaseDownloadMiddleware,
DownloadDispatcherMiddleware,
SmartDownloadMiddleware,
)
from django_downloadview.response import DownloadResponse, ProxiedDownloadResponse
from django_downloadview.shortcuts import sendfile
from django_downloadview.test import (
assert_download_response,
setup_view,
temporary_media_root,
)
from django_downloadview.views import (
BaseDownloadView,
DownloadMixin,
HTTPDownloadView,
ObjectDownloadView,
PathDownloadView,
StorageDownloadView,
VirtualDownloadView,
)
# Backward compatibility.
StringIteratorIO = TextIteratorIO

View file

@ -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):
"""View decorator factory to apply middleware to ``view_func``'s response.
@ -23,54 +17,16 @@ class DownloadDecorator(object):
method is applied on response.
"""
def __init__(self, middleware_factory):
"""Create a download view decorator."""
self.middleware_factory = middleware_factory
def __call__(self, view_func, *middleware_args, **middleware_kwargs):
"""Return ``view_func`` decorated with response middleware."""
def decorated(request, *view_args, **view_kwargs):
"""Return view's response modified by middleware."""
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 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

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Custom exceptions."""

View file

@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
"""File wrappers for use as exchange data between views and responses."""
from __future__ import absolute_import
from io import BytesIO
from urllib.parse import urlparse
from six.moves.urllib.parse import urlparse
from django.core.files.base import File
from django.utils.encoding import force_bytes
from django_downloadview.io import BytesIteratorIO
import requests
from django_downloadview.io import BytesIteratorIO
class StorageFile(File):
"""A file in a Django storage.
@ -18,7 +20,6 @@ class StorageFile(File):
but unrelated to model instance.
"""
def __init__(self, storage, name, file=None):
"""Constructor.
@ -35,8 +36,8 @@ class StorageFile(File):
def _get_file(self):
"""Getter for :py:attr:``file`` property."""
if not hasattr(self, "_file") or self._file is None:
self._file = self.storage.open(self.name, "rb")
if not hasattr(self, '_file') or self._file is None:
self._file = self.storage.open(self.name, 'rb')
return self._file
def _set_file(self, file):
@ -50,7 +51,7 @@ class StorageFile(File):
#: Required by django.core.files.utils.FileProxy.
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.
Proxy to self.storage.open(self.name, mode).
@ -154,8 +155,7 @@ class StorageFile(File):
class VirtualFile(File):
"""Wrapper for files that live in memory."""
def __init__(self, file=None, name="", url="", size=None):
def __init__(self, file=None, name=u'', url='', size=None):
"""Constructor.
file:
@ -168,7 +168,7 @@ class VirtualFile(File):
File URL.
"""
super().__init__(file, name)
super(VirtualFile, self).__init__(file, name)
self.url = url
if size is not None:
self._size = size
@ -184,7 +184,7 @@ class VirtualFile(File):
return self._size
def _set_size(self, value):
return super()._set_size(value)
return super(VirtualFile, self)._set_size(value)
size = property(_get_size, _set_size)
@ -206,7 +206,7 @@ class VirtualFile(File):
# If this is the end of a line, yield
# otherwise, wait for the next round
if line[-1] in ("\n", "\r"):
if line[-1] in ('\n', '\r'):
yield line
else:
buffer_ = line
@ -225,19 +225,19 @@ class HTTPFile(File):
Always sets "stream=True" in requests kwargs.
"""
def __init__(self, request_factory=requests.get, url="", name="", **kwargs):
def __init__(self, request_factory=requests.get, url='', name=u'',
**kwargs):
self.request_factory = request_factory
self.url = url
if name is None:
parts = urlparse(url)
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.
self.name = parts.netloc
else:
self.name = name
kwargs["stream"] = True
kwargs['stream'] = True
self.request_kwargs = kwargs
@property
@ -245,7 +245,8 @@ class HTTPFile(File):
try:
return self._request
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
@property
@ -264,9 +265,9 @@ class HTTPFile(File):
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"]
return self.request.headers['Content-Type']

View file

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""Low-level IO operations, for use with file wrappers."""
from __future__ import absolute_import
import io
from django.utils.encoding import force_bytes, force_str
from django.utils.encoding import force_text, force_bytes
class TextIteratorIO(io.TextIOBase):
@ -14,13 +15,12 @@ class TextIteratorIO(io.TextIOBase):
* https://gist.github.com/anacrolix/3788413
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
#: Internal buffer.
self._left = ""
self._left = u''
def readable(self):
return True
@ -33,9 +33,9 @@ class TextIteratorIO(io.TextIOBase):
break
else:
# Make sure we handle text.
self._left = force_str(self._left)
self._left = force_text(self._left)
ret = self._left[:n]
self._left = self._left[len(ret) :]
self._left = self._left[len(ret):]
return ret
def read(self, n=None):
@ -54,24 +54,24 @@ class TextIteratorIO(io.TextIOBase):
break
n -= len(m)
chunks.append(m)
return "".join(chunks)
return u''.join(chunks)
def readline(self):
chunks = []
while True:
i = self._left.find("\n")
i = self._left.find(u'\n')
if i == -1:
chunks.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = ""
self._left = u''
break
else:
chunks.append(self._left[: i + 1])
self._left = self._left[i + 1 :]
chunks.append(self._left[:i + 1])
self._left = self._left[i + 1:]
break
return "".join(chunks)
return u''.join(chunks)
class BytesIteratorIO(io.BytesIO):
@ -83,13 +83,12 @@ class BytesIteratorIO(io.BytesIO):
* https://gist.github.com/anacrolix/3788413
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
#: Internal buffer.
self._left = b""
self._left = b''
def readable(self):
return True
@ -104,7 +103,7 @@ class BytesIteratorIO(io.BytesIO):
# Make sure we handle text.
self._left = force_bytes(self._left)
ret = self._left[:n]
self._left = self._left[len(ret) :]
self._left = self._left[len(ret):]
return ret
def read(self, n=None):
@ -123,21 +122,21 @@ class BytesIteratorIO(io.BytesIO):
break
n -= len(m)
chunks.append(m)
return b"".join(chunks)
return b''.join(chunks)
def readline(self):
chunks = []
while True:
i = self._left.find(b"\n")
i = self._left.find(b'\n')
if i == -1:
chunks.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = b""
self._left = b''
break
else:
chunks.append(self._left[: i + 1])
self._left = self._left[i + 1 :]
chunks.append(self._left[:i + 1])
self._left = self._left[i + 1:]
break
return b"".join(chunks)
return b''.join(chunks)

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Optimizations for Lighttpd.
See also `documentation of X-Sendfile for Lighttpd
@ -6,9 +7,8 @@ See also `documentation of X-Sendfile for Lighttpd
</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
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Decorators to apply Lighttpd X-Sendfile on a specific view."""
from django_downloadview.decorators import DownloadDecorator
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware

View file

@ -1,8 +1,6 @@
from django_downloadview.lighttpd.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
@ -14,12 +12,13 @@ class XSendfileMiddleware(ProxiedDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
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)
super(XSendfileMiddleware, self).__init__(
get_response, source_dir, source_url, destination_dir
)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
@ -27,10 +26,7 @@ class XSendfileMiddleware(ProxiedDownloadMiddleware):
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,
)
return XSendfileResponse(file_path=redirect_url,
content_type=response['Content-Type'],
basename=response.basename,
attachment=response.attachment)

View file

@ -1,22 +1,18 @@
# -*- coding: utf-8 -*-
"""Lighttpd's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
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
):
def __init__(self, file_path, content_type, basename=None,
attachment=True):
"""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)
super(XSendfileResponse, self).__init__(content_type=content_type)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path
self['Content-Disposition'] = content_disposition(self.basename)
self['X-Sendfile'] = file_path

View file

@ -8,7 +8,6 @@ class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator):
See also :py:func:`assert_x_sendfile` shortcut function.
"""
def assert_x_sendfile_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XSendfileResponse))

View file

@ -1,17 +1,24 @@
# -*- coding: utf-8 -*-
"""Base material for download middlewares.
Download middlewares capture :py:class:`django_downloadview.DownloadResponse`
responses and may replace them with optimized download responses.
"""
import collections.abc
import copy
import collections
import os
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
try:
from django.utils.deprecation import MiddlewareMixin
except ImportError:
class MiddlewareMixin(object):
def __init__(self, get_response=None):
super(MiddlewareMixin, self).__init__()
from django_downloadview.response import DownloadResponse
from django_downloadview.utils import import_member
@ -31,20 +38,12 @@ def is_download_response(response):
return isinstance(response, DownloadResponse)
class BaseDownloadMiddleware:
class BaseDownloadMiddleware(MiddlewareMixin):
"""Base (abstract) Django middleware that handles download responses.
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):
"""Return True if ``response`` can be considered as a file download.
@ -68,7 +67,6 @@ class BaseDownloadMiddleware:
class RealDownloadMiddleware(BaseDownloadMiddleware):
"""Download middleware that cannot handle virtual files."""
def is_download_response(self, response):
"""Return True for DownloadResponse, except for "virtual" files.
@ -77,13 +75,20 @@ class RealDownloadMiddleware(BaseDownloadMiddleware):
whose file attribute have either an URL or a file name.
"""
return super().is_download_response(response) and bool(
getattr(response.file, "url", None) or getattr(response.file, "name", None)
)
if super(RealDownloadMiddleware, self).is_download_response(response):
try:
return response.file.url or response.file.name
except AttributeError:
return False
else:
return True
return False
class DownloadDispatcher:
def __init__(self, middlewares=AUTO_CONFIGURE):
class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
"Download middleware that dispatches job to several middleware instances."
def __init__(self, get_response=None, middlewares=AUTO_CONFIGURE):
super(DownloadDispatcherMiddleware, self).__init__(get_response)
#: List of children middlewares.
self.middlewares = middlewares
if self.middlewares is AUTO_CONFIGURE:
@ -92,48 +97,37 @@ class DownloadDispatcher:
def auto_configure_middlewares(self):
"""Populate :attr:`middlewares` from
``settings.DOWNLOADVIEW_MIDDLEWARES``."""
for key, import_string, kwargs in getattr(
settings, "DOWNLOADVIEW_MIDDLEWARES", []
):
for (key, import_string, kwargs) in getattr(settings,
'DOWNLOADVIEW_MIDDLEWARES',
[]):
factory = import_member(import_string)
middleware = factory(**kwargs)
self.middlewares.append((key, middleware))
def dispatch(self, request, response):
def process_download_response(self, request, response):
"""Dispatches job to children middlewares."""
for key, middleware in self.middlewares:
for (key, middleware) in self.middlewares:
response = middleware.process_response(request, response)
return response
class DownloadDispatcherMiddleware(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):
class SmartDownloadMiddleware(BaseDownloadMiddleware):
"""Easy to configure download middleware."""
def __init__(
self,
get_response,
backend_factory=AUTO_CONFIGURE,
backend_options=AUTO_CONFIGURE,
):
def __init__(self,
get_response=None,
backend_factory=AUTO_CONFIGURE,
backend_options=AUTO_CONFIGURE):
"""Constructor."""
super().__init__(get_response, middlewares=[])
#: Callable (typically a class) to instantiate backend (typically a
super(SmartDownloadMiddleware, self).__init__(get_response)
#: :class:`DownloadDispatcher` instance that can hold multiple
#: backend instances.
self.dispatcher = DownloadDispatcherMiddleware(middlewares=[])
#: Callable (typically a class) to instanciate backend (typically a
#: :class:`DownloadMiddleware` subclass).
self.backend_factory = backend_factory
if self.backend_factory is AUTO_CONFIGURE:
self.auto_configure_backend_factory()
#: List of positional or keyword arguments to instantiate backend
#: List of positional or keyword arguments to instanciate backend
#: instances.
self.backend_options = backend_options
if self.backend_options is AUTO_CONFIGURE:
@ -144,9 +138,8 @@ class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
try:
self.backend_factory = import_member(settings.DOWNLOADVIEW_BACKEND)
except AttributeError:
raise ImproperlyConfigured(
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_BACKEND"
)
raise ImproperlyConfigured('SmartDownloadMiddleware requires '
'settings.DOWNLOADVIEW_BACKEND')
def auto_configure_backend_options(self):
"""Populate :attr:`dispatcher` using :attr:`factory` and
@ -154,24 +147,27 @@ class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
try:
options_list = copy.deepcopy(settings.DOWNLOADVIEW_RULES)
except AttributeError:
raise ImproperlyConfigured(
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_RULES"
)
raise ImproperlyConfigured('SmartDownloadMiddleware requires '
'settings.DOWNLOADVIEW_RULES')
for key, options in enumerate(options_list):
args = []
kwargs = {}
if isinstance(options, collections.abc.Mapping): # Using kwargs.
if isinstance(options, collections.Mapping): # Using kwargs.
kwargs = options
else:
args = options
if "backend" in kwargs: # Specific backend for this rule.
factory = import_member(kwargs["backend"])
del kwargs["backend"]
if 'backend' in kwargs: # Specific backend for this rule.
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))
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):
"""Response object does not match redirection rules."""
@ -179,12 +175,13 @@ class NoRedirectionMatch(Exception):
class ProxiedDownloadMiddleware(RealDownloadMiddleware):
"""Base class for middlewares that use optimizations of reverse proxies."""
def __init__(
self, get_response, source_dir=None, source_url=None, destination_url=None
):
def __init__(self,
get_response=None,
source_dir=None,
source_url=None,
destination_url=None):
"""Constructor."""
super().__init__(get_response)
super(ProxiedDownloadMiddleware, self).__init__(get_response)
self.source_dir = source_dir
self.source_url = source_url
@ -193,7 +190,7 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
def get_redirect_url(self, response):
"""Return redirect URL for file wrapped into response."""
url = None
file_url = ""
file_url = ''
if self.source_url:
try:
file_url = response.file.url
@ -201,9 +198,9 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
pass
else:
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
file_name = ""
file_name = ''
if url is None and self.source_dir:
try:
file_name = response.file.name
@ -212,21 +209,17 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
else:
if file_name.startswith(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:
message = (
"""Couldn't capture/convert file attributes into a """
"""redirection. """
"""``source_url`` is "%(source_url)s", """
"""file's URL is "%(file_url)s". """
"""``source_dir`` is "%(source_dir)s", """
"""file's name is "%(file_name)s". """
% {
"source_url": self.source_url,
"file_url": file_url,
"source_dir": self.source_dir,
"file_name": file_name,
}
)
message = ("""Couldn't capture/convert file attributes into a """
"""redirection. """
"""``source_url`` is "%(source_url)s", """
"""file's URL is "%(file_url)s". """
"""``source_dir`` is "%(source_dir)s", """
"""file's name is "%(file_name)s". """
% {'source_url': self.source_url,
'file_url': file_url,
'source_dir': self.source_dir,
'file_name': file_name})
raise NoRedirectionMatch(message)
return "/".join((self.destination_url.rstrip("/"), url.lstrip("/")))
return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/')))

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Optimizations for Nginx.
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>`.
"""
# API shortcuts.
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.tests import assert_x_accel_redirect # NoQA
from django_downloadview.nginx.middlewares import ( # NoQA
XAccelRedirectMiddleware)

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Decorators to apply Nginx X-Accel on a specific view."""
from django_downloadview.decorators import DownloadDecorator
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware

View file

@ -3,10 +3,8 @@ import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch)
from django_downloadview.nginx.response import XAccelRedirectResponse
@ -19,26 +17,17 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self,
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,
):
def __init__(self,
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."""
if media_url is not None:
warnings.warn(
"%s ``media_url`` is deprecated. Use "
"``destination_url`` instead." % self.__class__.__name__,
DeprecationWarning,
)
warnings.warn("%s ``media_url`` is deprecated. Use "
"``destination_url`` instead."
% self.__class__.__name__,
DeprecationWarning)
if destination_url is None:
destination_url = media_url
else:
@ -46,11 +35,9 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
else:
destination_url = destination_url
if media_root is not None:
warnings.warn(
"%s ``media_root`` is deprecated. Use "
"``source_dir`` instead." % self.__class__.__name__,
DeprecationWarning,
)
warnings.warn("%s ``media_root`` is deprecated. Use "
"``source_dir`` instead." % self.__class__.__name__,
DeprecationWarning)
if source_dir is None:
source_dir = media_root
else:
@ -58,7 +45,8 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
else:
source_dir = source_dir
super().__init__(get_response, source_dir, source_url, destination_url)
super(XAccelRedirectMiddleware, self).__init__(
get_response, source_dir, source_url, destination_url)
self.expires = expires
self.with_buffering = with_buffering
@ -77,16 +65,13 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
expires = response.expires
except AttributeError:
expires = None
return XAccelRedirectResponse(
redirect_url=redirect_url,
content_type=response["Content-Type"],
basename=response.basename,
expires=expires,
with_buffering=self.with_buffering,
limit_rate=self.limit_rate,
attachment=response.attachment,
headers=response.headers,
)
return XAccelRedirectResponse(redirect_url=redirect_url,
content_type=response['Content-Type'],
basename=response.basename,
expires=expires,
with_buffering=self.with_buffering,
limit_rate=self.limit_rate,
attachment=response.attachment)
class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
@ -123,20 +108,17 @@ class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
"""
def __init__(self, get_response=None):
"""Use Django settings as configuration."""
if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None:
raise ImproperlyConfigured(
"settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is "
"required by %s middleware" % self.__class__.__name__
)
super().__init__(
'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is '
'required by %s middleware' % self.__class__.__name__)
super(SingleXAccelRedirectMiddleware, self).__init__(
get_response=get_response,
source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR,
source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL,
destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL,
expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES,
with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING,
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE,
)
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE)

View file

@ -1,43 +1,33 @@
# -*- coding: utf-8 -*-
"""Nginx's specific responses."""
from datetime import timedelta
from django.utils.timezone import now
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
from django_downloadview.response import (ProxiedDownloadResponse,
content_disposition)
from django_downloadview.utils import content_type_to_charset, url_basename
class XAccelRedirectResponse(ProxiedDownloadResponse):
"Http response that delegates serving file to Nginx via X-Accel headers."
def __init__(
self,
redirect_url,
content_type,
basename=None,
expires=None,
with_buffering=None,
limit_rate=None,
attachment=True,
headers=None,
):
def __init__(self, redirect_url, content_type, basename=None, expires=None,
with_buffering=None, limit_rate=None, attachment=True):
"""Return a HttpResponse with headers for Nginx X-Accel-Redirect."""
# 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)
super(XAccelRedirectResponse, self).__init__(content_type=content_type)
if attachment:
self.basename = basename or url_basename(redirect_url, content_type)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Accel-Redirect"] = redirect_url
self["X-Accel-Charset"] = content_type_to_charset(content_type)
self.basename = basename or url_basename(redirect_url,
content_type)
self['Content-Disposition'] = content_disposition(self.basename)
self['X-Accel-Redirect'] = redirect_url
self['X-Accel-Charset'] = content_type_to_charset(content_type)
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:
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.
self["X-Accel-Expires"] = "off"
self['X-Accel-Expires'] = 'off'
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'

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Django settings around Nginx X-Accel.
.. warning::
@ -7,31 +8,35 @@
for details.
"""
import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
# In version 1.3, former XAccelRedirectMiddleware has been renamed to
# SingleXAccelRedirectMiddleware. So tell the users.
deprecated_middleware = "django_downloadview.nginx.XAccelRedirectMiddleware"
deprecated_middleware = 'django_downloadview.nginx.XAccelRedirectMiddleware'
if deprecated_middleware in settings.MIDDLEWARE:
def get_middlewares():
try:
return settings.MIDDLEWARE
except AttributeError:
return settings.MIDDLEWARE_CLASSES
if deprecated_middleware in get_middlewares():
raise ImproperlyConfigured(
"{deprecated_middleware} middleware has been renamed as of "
"django-downloadview version 1.3. You may use "
'{deprecated_middleware} middleware has been renamed as of '
'django-downloadview version 1.3. You may use '
'"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, '
'or upgrade to "django_downloadview.SmartDownloadDispatcher". '
)
'or upgrade to "django_downloadview.SmartDownloadDispatcher". ')
deprecated_msg = (
"settings.{deprecated} is deprecated. You should combine "
'"django_downloadview.SmartDownloadDispatcher" with '
"with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead."
)
deprecated_msg = 'settings.{deprecated} is deprecated. You should combine ' \
'"django_downloadview.SmartDownloadDispatcher" with ' \
'with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead.'
#: Default value for X-Accel-Buffering header.
@ -46,9 +51,10 @@ deprecated_msg = (
#: If set to ``False``, Nginx buffering is disabled.
#: If set to ``True``, Nginx buffering is enabled.
DEFAULT_WITH_BUFFERING = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING'
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):
setattr(settings, setting_name, DEFAULT_WITH_BUFFERING)
@ -64,9 +70,10 @@ if not hasattr(settings, setting_name):
#: If set to ``False``, Nginx limit rate is disabled.
#: Else, it indicates the limit rate in bytes.
DEFAULT_LIMIT_RATE = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'
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):
setattr(settings, setting_name, DEFAULT_LIMIT_RATE)
@ -82,43 +89,49 @@ if not hasattr(settings, setting_name):
#: If set to ``False``, Nginx buffering is disabled.
#: Else, it indicates the expiration delay, in seconds.
DEFAULT_EXPIRES = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES'
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):
setattr(settings, setting_name, DEFAULT_EXPIRES)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR.
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):
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
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR'
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):
setattr(settings, setting_name, DEFAULT_SOURCE_DIR)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_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):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning)
warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_SOURCE_URL)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL.
DEFAULT_DESTINATION_URL = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL'
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
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL"
setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL'
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):
setattr(settings, setting_name, DEFAULT_DESTINATION_URL)

View file

@ -1,13 +1,13 @@
from six import iteritems
from django_downloadview.nginx.response import XAccelRedirectResponse
class XAccelRedirectValidator:
class XAccelRedirectValidator(object):
"""Utility class to validate XAccelRedirectResponse instances.
See also :py:func:`assert_x_accel_redirect` shortcut function.
"""
def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid X-Accel-Redirect response.
@ -36,8 +36,8 @@ class XAccelRedirectValidator:
"""
self.assert_x_accel_redirect_response(test_case, response)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
for key, value in iteritems(assertions):
assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value)
def assert_x_accel_redirect_response(self, test_case, response):
@ -47,45 +47,45 @@ class XAccelRedirectValidator:
test_case.assertEqual(response.basename, 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):
test_case.assertEqual(response["X-Accel-Redirect"], value)
test_case.assertEqual(response['X-Accel-Redirect'], 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):
header = "X-Accel-Buffering"
header = 'X-Accel-Buffering'
if value is None:
test_case.assertFalse(header in response)
elif value:
test_case.assertEqual(header, "yes")
test_case.assertEqual(header, 'yes')
else:
test_case.assertEqual(header, "no")
test_case.assertEqual(header, 'no')
def assert_expires(self, test_case, response, value):
header = "X-Accel-Expires"
header = 'X-Accel-Expires'
if value is None:
test_case.assertFalse(header in response)
elif not value:
test_case.assertEqual(header, "off")
test_case.assertEqual(header, 'off')
else:
test_case.assertEqual(header, value)
def assert_limit_rate(self, test_case, response, value):
header = "X-Accel-Limit-Rate"
header = 'X-Accel-Limit-Rate'
if value is None:
test_case.assertFalse(header in response)
elif not value:
test_case.assertEqual(header, "off")
test_case.assertEqual(header, 'off')
else:
test_case.assertEqual(header, value)
def assert_attachment(self, test_case, response, value):
header = "Content-Disposition"
header = 'Content-Disposition'
if value:
test_case.assertTrue(response[header].startswith("attachment"))
test_case.assertTrue(response[header].startswith('attachment'))
else:
test_case.assertFalse(header in response)

View file

@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
""":py:class:`django.http.HttpResponse` subclasses."""
import mimetypes
import os
import mimetypes
import re
import unicodedata
from urllib.parse import quote
import six
from six.moves import urllib
from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse
@ -12,7 +13,7 @@ from django.utils.encoding import force_str
def encode_basename_ascii(value):
"""Return US-ASCII encoded ``value`` for Content-Disposition header.
u"""Return US-ASCII encoded ``value`` for Content-Disposition header.
>>> print(encode_basename_ascii(u'éà'))
ea
@ -30,18 +31,18 @@ def encode_basename_ascii(value):
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)
if isinstance(value, six.binary_type):
value = value.decode('utf-8')
ascii_basename = six.text_type(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.
u"""Return UTF-8 encoded ``value`` for use in Content-Disposition header.
>>> print(encode_basename_utf8(u' .txt'))
%20.txt
@ -50,11 +51,11 @@ def encode_basename_utf8(value):
%C3%A9%C3%A0
"""
return quote(force_str(value))
return urllib.parse.quote(force_str(value))
def content_disposition(filename):
"""Return value of ``Content-Disposition`` header with 'attachment'.
u"""Return value of ``Content-Disposition`` header with 'attachment'.
>>> print(content_disposition('demo.txt'))
attachment; filename="demo.txt"
@ -72,22 +73,15 @@ def content_disposition(filename):
"""
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"\"")
)
return 'attachment'
ascii_filename = encode_basename_ascii(filename)
utf8_filename = encode_basename_utf8(filename)
if ascii_filename == utf8_filename: # ASCII only.
return f'attachment; filename="{ascii_filename}"'
return "attachment; filename=\"{ascii}\"".format(ascii=ascii_filename)
else:
return (
f'attachment; filename="{ascii_filename}"; '
f"filename*=UTF-8''{utf8_filename}"
)
return "attachment; filename=\"{ascii}\"; filename*=UTF-8''{utf8}" \
.format(ascii=ascii_filename,
utf8=utf8_filename)
class DownloadResponse(StreamingHttpResponse):
@ -123,17 +117,9 @@ class DownloadResponse(StreamingHttpResponse):
attributes (size, name, ...).
"""
def __init__(
self,
file_instance,
attachment=True,
basename=None,
status=200,
content_type=None,
file_mimetype=None,
file_encoding=None,
):
def __init__(self, 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.
@ -145,9 +131,9 @@ class DownloadResponse(StreamingHttpResponse):
#: A :doc:`file wrapper instance </files>`, such as
#: :class:`~django.core.files.base.File`.
self.file = file_instance
super().__init__(
streaming_content=self.file, status=status, content_type=content_type
)
super(DownloadResponse, self).__init__(streaming_content=self.file,
status=status,
content_type=content_type)
#: Client-side name of the file to stream.
#: Only used if ``attachment`` is ``True``.
@ -158,7 +144,7 @@ class DownloadResponse(StreamingHttpResponse):
#: Affects ``Content-Disposition`` header.
self.attachment = attachment
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
@ -191,17 +177,27 @@ class DownloadResponse(StreamingHttpResponse):
return self._default_headers
except AttributeError:
headers = {}
headers["Content-Type"] = self.get_content_type()
headers['Content-Type'] = self.get_content_type()
try:
headers["Content-Length"] = self.file.size
headers['Content-Length'] = self.file.size
except (AttributeError, NotImplementedError):
pass # Generated files.
if self.attachment:
basename = self.get_basename()
headers["Content-Disposition"] = content_disposition(basename)
headers['Content-Disposition'] = content_disposition(basename)
self._default_headers = 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):
"""Return basename."""
if self.basename:
@ -214,13 +210,15 @@ class DownloadResponse(StreamingHttpResponse):
try:
return self.file.content_type
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):
"""Return mime-type of the file."""
if self.file_mimetype is not None:
return self.file_mimetype
default_mime_type = "application/octet-stream"
default_mime_type = 'application/octet-stream'
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return mime_type or default_mime_type

View file

@ -1,27 +1,19 @@
# -*- coding: utf-8 -*-
"""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,
):
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,
)
view = PathDownloadView.as_view(path=filename,
attachment=attachment,
basename=attachment_filename,
mimetype=mimetype,
encoding=encoding)
return view(request)

View file

@ -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.
"""

View file

@ -1,4 +1,6 @@
"""Testing utilities."""
import shutil
from six import iteritems
import tempfile
from django.conf import settings
@ -6,7 +8,8 @@ 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.response import encode_basename_ascii, encode_basename_utf8
from django_downloadview.response import (encode_basename_ascii,
encode_basename_utf8)
def setup_view(view, request, *args, **kwargs):
@ -65,24 +68,22 @@ class temporary_media_root(override_settings):
True
"""
def enable(self):
"""Create a temporary directory and use it to override
settings.MEDIA_ROOT."""
tmp_dir = tempfile.mkdtemp()
self.options["MEDIA_ROOT"] = tmp_dir
super().enable()
self.options['MEDIA_ROOT'] = tmp_dir
super(temporary_media_root, self).enable()
def disable(self):
"""Remove directory settings.MEDIA_ROOT then restore original
setting."""
shutil.rmtree(settings.MEDIA_ROOT)
super().disable()
super(temporary_media_root, self).disable()
class DownloadResponseValidator(object):
"""Utility class to validate DownloadResponse instances."""
def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid DownloadResponse instance.
@ -102,8 +103,8 @@ class DownloadResponseValidator(object):
"""
self.assert_download_response(test_case, response)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
for key, value in iteritems(assertions):
assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value)
def assert_download_response(self, test_case, response):
@ -117,42 +118,40 @@ class DownloadResponseValidator(object):
check_ascii = False
if ascii_name == utf8_name: # Only ASCII characters.
check_ascii = True
if "filename*=" in response["Content-Disposition"]:
if "filename*=" in response['Content-Disposition']:
check_utf8 = True
else:
check_utf8 = True
if "filename=" in response["Content-Disposition"]:
if "filename=" in response['Content-Disposition']:
check_ascii = True
if check_ascii:
test_case.assertIn(
f'filename="{ascii_name}"',
response["Content-Disposition"],
)
test_case.assertIn('filename="{name}"'.format(
name=ascii_name),
response['Content-Disposition'])
if check_utf8:
test_case.assertIn(
f"filename*=UTF-8''{utf8_name}",
response["Content-Disposition"],
)
"filename*=UTF-8''{name}".format(name=utf8_name),
response['Content-Disposition'])
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):
test_case.assertTrue(response["Content-Type"].startswith(value))
test_case.assertTrue(response['Content-Type'].startswith(value))
def assert_content(self, test_case, response, value):
"""Assert value equals response's content (byte comparison)."""
parts = [force_bytes(s) for s in response.streaming_content]
test_case.assertEqual(b"".join(parts), force_bytes(value))
test_case.assertEqual(b''.join(parts), force_bytes(value))
def assert_attachment(self, test_case, response, value):
if value:
test_case.assertTrue("attachment;" in response["Content-Disposition"])
test_case.assertTrue(
'attachment;' in response['Content-Disposition'])
else:
test_case.assertTrue(
"Content-Disposition" not in response
or "attachment;" not in response["Content-Disposition"]
)
'Content-Disposition' not in response or
'attachment;' not in response['Content-Disposition'])
def assert_download_response(test_case, response, **assertions):

View file

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""Utility functions that may be implemented in external packages."""
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):
@ -15,7 +16,7 @@ def content_type_to_charset(content_type):
"""
match = re.search(charset_pattern, content_type)
if match:
return match.group("charset")
return match.group('charset')
def url_basename(url, content_type):
@ -29,7 +30,7 @@ def url_basename(url, content_type):
somefile.rst
"""
return url.split("/")[-1]
return url.split('/')[-1]
def import_member(import_string):
@ -41,6 +42,6 @@ def import_member(import_string):
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)
return getattr(module, factory_name)

View file

@ -1,9 +1,12 @@
# coding=utf-8
"""Views."""
# -*- coding: utf-8 -*-
"""Views to stream files."""
# API shortcuts.
from django_downloadview.views.base import BaseDownloadView, DownloadMixin # NoQA
from django_downloadview.views.http import HTTPDownloadView # NoQA
from django_downloadview.views.object import ObjectDownloadView # NoQA
from django_downloadview.views.base import (DownloadMixin, # NoQA
BaseDownloadView)
from django_downloadview.views.path import PathDownloadView # 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

View file

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""Base material for download views: :class:`DownloadMixin` and
:class:`BaseDownloadView`"""
import calendar
from django.http import Http404, HttpResponseNotModified
from django.http import HttpResponseNotModified, Http404
from django.views.generic.base import View
from django.views.static import was_modified_since
@ -26,7 +26,6 @@ class DownloadMixin(object):
returned by :py:meth:`get_file`.
"""
#: Response class, to be used in :py:meth:`render_to_response`.
response_class = DownloadResponse
@ -103,9 +102,9 @@ class DownloadMixin(object):
Else, fallbacks to default implementation, which uses
:py:func:`django.views.static.was_modified_since`.
Django's ``was_modified_since`` function needs a datetime.
It is passed the ``modified_time`` attribute from file
wrapper. If file wrapper does not support this attribute
Django's ``was_modified_since`` function needs a datetime and a size.
It is passed ``modified_time`` and ``size`` attributes from file
wrapper. If file wrapper does not support these attributes
(``AttributeError`` or ``NotImplementedError`` is raised), then
the file is considered as modified and ``True`` is returned.
@ -115,13 +114,13 @@ class DownloadMixin(object):
except (AttributeError, NotImplementedError):
try:
modification_time = calendar.timegm(
file_instance.modified_time.utctimetuple()
)
file_instance.modified_time.utctimetuple())
size = file_instance.size
except (AttributeError, NotImplementedError) as e:
print("!=======!", e)
return True
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):
"""Return :class:`django.http.HttpResponseNotModified` instance."""
@ -129,11 +128,11 @@ class DownloadMixin(object):
def download_response(self, *response_args, **response_kwargs):
"""Return :class:`~django_downloadview.response.DownloadResponse`."""
response_kwargs.setdefault("file_instance", self.file_instance)
response_kwargs.setdefault("attachment", self.attachment)
response_kwargs.setdefault("basename", self.get_basename())
response_kwargs.setdefault("file_mimetype", self.get_mimetype())
response_kwargs.setdefault("file_encoding", self.get_encoding())
response_kwargs.setdefault('file_instance', self.file_instance)
response_kwargs.setdefault('attachment', self.attachment)
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)
return response
@ -157,7 +156,7 @@ class DownloadMixin(object):
except exceptions.FileNotFound:
return self.file_not_found_response()
# 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 not self.was_modified_since(self.file_instance, since):
return self.not_modified_response(**response_kwargs)
@ -167,7 +166,6 @@ class DownloadMixin(object):
class BaseDownloadView(DownloadMixin, View):
"""A base :class:`DownloadMixin` that implements :meth:`get`."""
def get(self, request, *args, **kwargs):
"""Handle GET requests: stream a file."""
return self.render_to_response()

View file

@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
"""Stream files given an URL, i.e. files you want to proxy."""
import requests
from django_downloadview.files import HTTPFile
from django_downloadview.views.base import BaseDownloadView
import requests
class HTTPDownloadView(BaseDownloadView):
"""Proxy files that live on remote servers."""
#: URL to download (the one we are proxying).
url = ""
url = u''
#: Additional keyword arguments for request handler.
request_kwargs = {}
@ -41,9 +40,7 @@ class HTTPDownloadView(BaseDownloadView):
def get_file(self):
"""Return wrapper which has an ``url`` attribute."""
return HTTPFile(
request_factory=self.get_request_factory(),
name=self.get_basename(),
url=self.get_url(),
**self.get_request_kwargs(),
)
return HTTPFile(request_factory=self.get_request_factory(),
name=self.get_basename(),
url=self.get_url(),
**self.get_request_kwargs())

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Stream files that live in models."""
from django.views.generic.detail import SingleObjectMixin
from django_downloadview.exceptions import FileNotFound
@ -31,10 +31,9 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
local filesystem.
"""
#: Name of the model's attribute which contains the file to be streamed.
#: Typically the name of a FileField.
file_field = "file"
file_field = 'file'
#: Optional name of the model's attribute which contains the basename.
basename_field = None
@ -72,11 +71,13 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
"""
file_instance = getattr(self.object, self.file_field)
if not file_instance:
raise FileNotFound(
f'Field="{self.file_field}" on object="{self.object}" is empty'
)
for field in ("encoding", "mime_type", "charset", "modification_time", "size"):
model_field = getattr(self, "%s_field" % field, False)
raise FileNotFound('Field="{field}" on object="{object}" is '
'empty'.format(
field=self.file_field,
object=self.object))
for field in ('encoding', 'mime_type', 'charset', 'modification_time',
'size'):
model_field = getattr(self, '%s_field' % field, False)
if model_field:
value = getattr(self.object, model_field)
setattr(file_instance, field, value)
@ -84,14 +85,14 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
def get_basename(self):
"""Return client-side filename."""
basename = super().get_basename()
basename = super(ObjectDownloadView, self).get_basename()
if basename is None:
field = "basename"
model_field = getattr(self, "%s_field" % field, False)
field = 'basename'
model_field = getattr(self, '%s_field' % field, False)
if model_field:
basename = getattr(self.object, model_field)
return basename
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
return super(ObjectDownloadView, self).get(request, *args, **kwargs)

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
""":class:`PathDownloadView`."""
import os
from django.core.files import File
@ -10,7 +10,6 @@ from django_downloadview.views.base import BaseDownloadView
class PathDownloadView(BaseDownloadView):
"""Serve a file using filename."""
#: Server-side name (including path) of the file to serve.
#:
#: Filename is supposed to be an absolute filename of a file located on the
@ -18,7 +17,7 @@ class PathDownloadView(BaseDownloadView):
path = None
#: Name of the URL argument that contains path.
path_url_kwarg = "path"
path_url_kwarg = 'path'
def get_path(self):
"""Return actual path of the file to serve.
@ -36,5 +35,5 @@ class PathDownloadView(BaseDownloadView):
"""Use path to return wrapper around file to serve."""
filename = self.get_path()
if not os.path.isfile(filename):
raise FileNotFound(f'File "{filename}" does not exists')
return File(open(filename, "rb"))
raise FileNotFound('File "{0}" does not exists'.format(filename))
return File(open(filename, 'rb'))

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Stream files from storage."""
from django.core.files.storage import DefaultStorage
from django_downloadview.files import StorageFile
@ -8,13 +8,22 @@ from django_downloadview.views.path import PathDownloadView
class StorageDownloadView(PathDownloadView):
"""Serve a file using storage and filename."""
#: Storage the file to serve belongs to.
storage = DefaultStorage()
#: Path to the file to serve relative to storage.
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):
"""Return :class:`~django_downloadview.files.StorageFile` instance."""
return StorageFile(self.storage, self.get_path())

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Stream files that you generate or that live in memory."""
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.
"""
def was_modified_since(self, file_instance, since):
"""Delegate to file wrapper's was_modified_since, or return True.

View file

@ -19,8 +19,8 @@ optimizations.
* :doc:`/about/alternatives`
* `roadmap
<https://github.com/jazzband/django-downloadview/milestones>`_
<https://github.com/benoitbryon/django-downloadview/milestones>`_
.. target-notes::
.. _`Django`: https://www.djangoproject.com
.. _`Django`: https://djangoproject.com

View file

@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""django-downloadview documentation build configuration file."""
import os
import re
import importlib.metadata
# Minimal Django settings. Required to use sphinx.ext.autodoc, because
# django-downloadview depends on Django...
from django.conf import settings
settings.configure(
DATABASES={}, # Required to load ``django.views.generic``.
)
@ -18,58 +18,63 @@ settings.configure(
# Extensions.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.doctest",
"sphinx.ext.coverage",
"sphinx.ext.intersphinx",
'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.
templates_path = ["_templates"]
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = ".txt"
source_suffix = '.txt'
# The encoding of source files.
source_encoding = "utf-8"
source_encoding = 'utf-8'
# The master toctree document.
master_doc = "index"
master_doc = 'index'
# General information about the project.
project = "django-downloadview"
project_slug = re.sub(r"([\w_.-]+)", "-", project)
copyright = "2012-2015, Benoît Bryon"
author = "Benoît Bryon"
author_slug = re.sub(r"([\w_.-]+)", "-", author)
project = u'django-downloadview'
project_slug = re.sub(r'([\w_.-]+)', u'-', project)
copyright = u'2012-2015, Benoît Bryon'
author = u'Benoît Bryon'
author_slug = re.sub(r'([\w_.-]+)', u'-', author)
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
configuration_dir = os.path.dirname(__file__)
documentation_dir = configuration_dir
version_file = os.path.normpath(os.path.join(
documentation_dir,
'../VERSION'))
# The full version, including alpha/beta/rc tags.
release = importlib.metadata.version("django-downloadview")
release = open(version_file).read().strip()
# The short X.Y version.
version = ".".join(release.split(".")[:2])
version = '.'.join(release.split('.')[0:1])
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
language = "en"
language = 'en'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ["_build"]
exclude_patterns = ['_build']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx"
pygments_style = 'sphinx'
# -- Options for HTML output --------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = "alabaster"
html_theme = 'alabaster'
# 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,
@ -78,22 +83,23 @@ html_static_path = []
# Custom sidebar templates, maps document names to template names.
html_sidebars = {
"**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"],
'**': ['globaltoc.html',
'relations.html',
'sourcelink.html',
'searchbox.html'],
}
# Output file base name for HTML help builder.
htmlhelp_basename = "{project}doc".format(project=project_slug)
htmlhelp_basename = u'{project}doc'.format(project=project_slug)
# -- Options for sphinx.ext.intersphinx ---------------------------------------
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"django": (
"https://docs.djangoproject.com/en/3.1/",
"https://docs.djangoproject.com/en/3.1/_objects/",
),
"requests": ("https://requests.readthedocs.io/en/master/", None),
'python': ('http://docs.python.org/2.7', None),
'django': ('http://docs.djangoproject.com/en/1.8/',
'http://docs.djangoproject.com/en/1.8/_objects/'),
'requests': ('http://docs.python-requests.org/en/latest/', None),
}
@ -105,13 +111,11 @@ latex_elements = {}
# (source start file, target name, title, author, documentclass
# [howto/manual]).
latex_documents = [
(
"index",
"{project}.tex".format(project=project_slug),
"{project} Documentation".format(project=project),
author,
"manual",
),
('index',
u'{project}.tex'.format(project=project_slug),
u'{project} Documentation'.format(project=project),
author,
'manual'),
]
@ -120,7 +124,11 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
("index", project, "{project} Documentation".format(project=project), [author], 1)
('index',
project,
u'{project} Documentation'.format(project=project),
[author],
1)
]
@ -130,13 +138,11 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(
"index",
project_slug,
"{project} Documentation".format(project=project),
author,
project,
"One line description of project.",
"Miscellaneous",
),
('index',
project_slug,
u'{project} Documentation'.format(project=project),
author,
project,
'One line description of project.',
'Miscellaneous'),
]

View file

@ -31,7 +31,7 @@ Here are tips to migrate from `django-sendfile` to `django-downloadview`...
* setup ``DOWNLOADVIEW_RULES``. It replaces ``SENDFILE_ROOT`` and can do
more.
* register ``django_downloadview.SmartDownloadMiddleware`` in
``MIDDLEWARE``.
``MIDDLEWARE_CLASSES``.
4. Change your tests if any. You can no longer use `django-senfile`'s
``development`` backend. See :doc:`/testing` for `django-downloadview`'s
@ -54,4 +54,4 @@ API reference
.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile
.. _`django-downloadview's bugtracker`:
https://github.com/jazzband/django-downloadview/issues
https://github.com/benoitbryon/django-downloadview/issues

View file

@ -21,7 +21,7 @@ Django's builtins
wrapper in :doc:`/views/path`.
* :class:`django.db.models.fields.files.FieldFile` wraps a file that is
managed in a model. ``django-downloadview`` uses this wrapper in
managed in a model. ``django-downloadview`` uses this wrapper in
:doc:`/views/object`.
* :class:`django.core.files.base.ContentFile` wraps a bytes, string or

View file

@ -45,7 +45,7 @@ Setup XSendfile middlewares
***************************
Make sure ``django_downloadview.SmartDownloadMiddleware`` is in
``MIDDLEWARE`` of your `Django` settings.
``MIDDLEWARE_CLASSES`` of your `Django` settings.
Example:
@ -128,4 +128,4 @@ setup.
.. target-notes::
.. _`Apache mod_xsendfile documentation`: https://tn123.org/mod_xsendfile/
.. _`Apache mod_xsendfile documentation`: https://tn123.org/mod_xsendfile/

View file

@ -87,6 +87,6 @@ Here are optimizations builtin `django_downloadview`:
.. target-notes::
.. _`tell us`:
https://github.com/jazzband/django-downloadview/issues?labels=optimizations
https://github.com/benoitbryon/django-downloadview/issues?labels=optimizations
.. _`a feature request about "local cache" for streamed files`:
https://github.com/jazzband/django-downloadview/issues/70
https://github.com/benoitbryon/django-downloadview/issues/70

View file

@ -51,7 +51,7 @@ Setup XSendfile middlewares
***************************
Make sure ``django_downloadview.SmartDownloadMiddleware`` is in
``MIDDLEWARE`` of your `Django` settings.
``MIDDLEWARE_CLASSES`` of your `Django` settings.
Example:
@ -137,4 +137,4 @@ setup.
.. _`Lighttpd X-Sendfile documentation`:
http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file
.. _`X-Sendfile2 feature request on django_downloadview's bugtracker`:
https://github.com/jazzband/django-downloadview/issues/67
https://github.com/benoitbryon/django-downloadview/issues/67

View file

@ -28,7 +28,7 @@ Let's consider the following view:
.. literalinclude:: /../demo/demoproject/nginx/views.py
:language: python
:lines: 1-6, 8-17
:lines: 1-6, 8-16
What is important here is that the files will have an ``url`` property
implemented by storage. Let's setup an optimization rule based on that URL.
@ -46,26 +46,26 @@ Setup XAccelRedirect middlewares
********************************
Make sure ``django_downloadview.SmartDownloadMiddleware`` is in
``MIDDLEWARE`` of your `Django` settings.
``MIDDLEWARE_CLASSES`` of your `Django` settings.
Example:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 62-69
:lines: 63-70
Then set ``django_downloadview.nginx.XAccelRedirectMiddleware`` as
``DOWNLOADVIEW_BACKEND``:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 75
:lines: 76
Then register as many ``DOWNLOADVIEW_RULES`` as you wish:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 83-88
:lines: 84-89
Each item in ``DOWNLOADVIEW_RULES`` is a dictionary of keyword arguments passed
to the middleware factory. In the example above, we capture responses by
@ -142,7 +142,7 @@ Here is what you could have in :file:`/etc/nginx/sites-available/default`:
internal;
# Location to files on disk.
alias /var/www/files/;
}
}
# Proxy to Django-powered frontend.
location / {
@ -154,7 +154,7 @@ Here is what you could have in :file:`/etc/nginx/sites-available/default`:
}
... where specific configuration is the ``location /optimized-download``
section.
section.
.. note::
@ -183,7 +183,7 @@ Add ``charset utf-8;`` in your nginx configuration file.
``open() "path/to/something" failed (2: No such file or directory)``
====================================================================
Check your ``settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR`` in Django
Check your ``settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT`` in Django
configuration VS ``alias`` in nginx configuration: in a standard configuration,
they should be equal.
@ -192,4 +192,4 @@ they should be equal.
.. target-notes::
.. _`Nginx X-accel documentation`: http://wiki.nginx.org/X-accel
.. _`Nginx X-accel documentation`: http://wiki.nginx.org/X-accel

View file

@ -61,7 +61,7 @@ possible, file wrappers do not embed file data, in order to save memory.
Learn more about available file wrappers in :doc:`files`.
*****************************************************************
Middlewares convert DownloadResponse into ProxiedDownloadResponse
*****************************************************************

View file

@ -13,11 +13,11 @@ There is no need to register this application in ``INSTALLED_APPS``.
******************
MIDDLEWARE
MIDDLEWARE_CLASSES
******************
If you plan to setup :doc:`reverse-proxy optimizations </optimizations/index>`,
add ``django_downloadview.SmartDownloadMiddleware`` to ``MIDDLEWARE``.
add ``django_downloadview.SmartDownloadMiddleware`` to ``MIDDLEWARE_CLASSES``.
It is a response middleware. Move it after middlewares that compute the
response content such as gzip middleware.
@ -29,48 +29,6 @@ Example:
:end-before: END middlewares
********************
DEFAULT_FILE_STORAGE
********************
django-downloadview offers a built-in signed file storage, which cryptographically
signs requested file URLs with the Django's built-in TimeStampSigner.
To utilize the signed storage views you can configure
.. code:: python
DEFAULT_FILE_STORAGE='django_downloadview.storage.SignedStorage'
The signed file storage system inserts a ``X-Signature`` header to the requested file
URLs, and they can then be verified with the supplied ``signature_required`` wrapper function:
.. code:: python
from django.conf.urls import url, url_patterns
from django_downloadview import ObjectDownloadView
from django_downloadview.decorators import signature_required
from demoproject.download.models import Document # A model with a FileField
# ObjectDownloadView inherits from django.views.generic.BaseDetailView.
download = ObjectDownloadView.as_view(model=Document, file_field='file')
urlpatterns = [
path('download/<str:slug>/', signature_required(download)),
]
Make sure to test the desired functionality after configuration.
***************************
DOWNLOADVIEW_URL_EXPIRATION
***************************
Number of seconds signed download URLs are valid before expiring.
Default value for this flag is None and URLs never expire.
********************
DOWNLOADVIEW_BACKEND
********************
@ -91,7 +49,7 @@ Example:
See :doc:`/optimizations/index` for a list of available backends (middlewares).
When ``django_downloadview.SmartDownloadMiddleware`` is in your
``MIDDLEWARE``, this setting must be explicitely configured (no default
``MIDDLEWARE_CLASSES``, this setting must be explicitely configured (no default
value). Else, you can ignore this setting.
@ -119,5 +77,5 @@ See :doc:`/optimizations/index` for details about builtin backends
(middlewares) and their options.
When ``django_downloadview.SmartDownloadMiddleware`` is in your
``MIDDLEWARE``, this setting must be explicitely configured (no default
``MIDDLEWARE_CLASSES``, this setting must be explicitely configured (no default
value). Else, you can ignore this setting.

View file

@ -41,3 +41,4 @@ Example, related to :doc:`StorageDownloadView demo </views/storage>`:
.. literalinclude:: /../demo/demoproject/storage/tests.py
:language: python
:lines: 1-2, 8-12, 59-

View file

@ -55,7 +55,7 @@ See details in :attr:`attachment API documentation
.. literalinclude:: /../demo/demoproject/object/views.py
:language: python
:lines: 1-2, 19
:lines: 1-5, 20-23
************************************
@ -87,7 +87,7 @@ Modified" response:
class TextDownloadView(VirtualDownloadView):
def get_file(self):
"""Return :class:`django.core.files.base.ContentFile` object."""
return ContentFile("Hello world!", name='hello-world.txt')
return ContentFile(u"Hello world!", name='hello-world.txt')
def was_modified_since(self, file_instance, since):
return False # Never modified, always "Hello world!".
return False # Never modified, always u"Hello world!".

View file

@ -30,7 +30,7 @@ Setup a view to stream the ``file`` attribute:
.. literalinclude:: /../demo/demoproject/object/views.py
:language: python
:lines: 1-6
:lines: 1-5, 7
:class:`~django_downloadview.views.object.ObjectDownloadView` inherits from
:class:`~django.views.generic.detail.BaseDetailView`, i.e. it expects either
@ -38,7 +38,7 @@ Setup a view to stream the ``file`` attribute:
.. literalinclude:: /../demo/demoproject/object/urls.py
:language: python
:lines: 1-7, 8-11, 27
:lines: 1-7, 8-10, 20
************
@ -69,7 +69,7 @@ Then here is the code to serve "another_file" instead of the default "file":
.. literalinclude:: /../demo/demoproject/object/views.py
:language: python
:lines: 1-4, 8-11
:lines: 1-5, 10-12
**********************************
@ -90,7 +90,7 @@ Then you can configure the :attr:`ObjectDownloadView.basename_field` option:
.. literalinclude:: /../demo/demoproject/object/views.py
:language: python
:lines: 1-4, 13-17
:lines: 1-5, 16-18
.. note::

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