Compare commits

..

267 commits
1.4 ... master

Author SHA1 Message Date
Rémy HUBSCHER
969fb7931a
Merge pull request #228 from jazzband/pre-commit-ci-update-config 2025-11-17 21:38:55 +01:00
Rémy HUBSCHER
fe610b9c9f
Apply suggestion from @Natim 2025-11-17 21:38:36 +01:00
pre-commit-ci[bot]
187787d083
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-eslint: v9.39.1 → v10.0.0-alpha.0](https://github.com/pre-commit/mirrors-eslint/compare/v9.39.1...v10.0.0-alpha.0)
- [github.com/astral-sh/ruff-pre-commit: v0.14.4 → v0.14.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.4...v0.14.5)
2025-11-17 17:30:11 +00:00
Rémy HUBSCHER
614bc522d3
Merge pull request #227 from jazzband/pre-commit-ci-update-config 2025-11-12 11:29:07 +01:00
pre-commit-ci[bot]
34a83fd09b
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-eslint: v9.38.0 → v9.39.1](https://github.com/pre-commit/mirrors-eslint/compare/v9.38.0...v9.39.1)
- [github.com/astral-sh/ruff-pre-commit: v0.14.2 → v0.14.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.2...v0.14.4)
- [github.com/tox-dev/pyproject-fmt: v2.11.0 → v2.11.1](https://github.com/tox-dev/pyproject-fmt/compare/v2.11.0...v2.11.1)
2025-11-10 17:38:43 +00:00
Rémy Hubscher
415b76622e
Post release 2025-10-28 11:34:20 +01:00
Rémy HUBSCHER
982432a05b
Merge pull request #226 from jazzband/release/2.5.0 2025-10-28 11:31:52 +01:00
Rémy Hubscher
055ef690d2
Release 2.5.0 2025-10-28 11:23:58 +01:00
Rémy HUBSCHER
94b4a60917
Merge pull request #224 from jazzband/fix/upgrade-to-django-52 2025-10-28 11:19:59 +01:00
Rémy HUBSCHER
9194e6a11c
Merge pull request #225 from jazzband/pre-commit-ci-update-config 2025-10-28 11:19:37 +01:00
pre-commit-ci[bot]
140b631d5b
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/adamchainz/django-upgrade: 1.29.0 → 1.29.1](https://github.com/adamchainz/django-upgrade/compare/1.29.0...1.29.1)
- [github.com/astral-sh/ruff-pre-commit: v0.14.1 → v0.14.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.1...v0.14.2)
2025-10-27 17:25:13 +00:00
Rémy Hubscher
e3ebf67457
Upgrade test matrix 2025-10-21 10:36:39 +02:00
Rémy Hubscher
5f2ae9a9b0
Add test matrix 2025-10-21 09:53:13 +02:00
Rémy Hubscher
f1a07c226e
Upgrade to Django 5.2 and Python 3.14 2025-10-21 09:52:06 +02:00
Rémy HUBSCHER
9b90a3326d
Merge pull request #223 from jazzband/dependabot/github_actions/codecov/codecov-action-5 2025-10-21 09:51:50 +02:00
Rémy HUBSCHER
45fb86b52c
Merge pull request #222 from jazzband/dependabot/github_actions/actions/setup-python-6 2025-10-21 09:51:40 +02:00
Rémy HUBSCHER
5dc018f17b
Merge pull request #221 from jazzband/dependabot/github_actions/actions/cache-4 2025-10-21 09:51:28 +02:00
Rémy HUBSCHER
d515804708
Merge pull request #220 from jazzband/dependabot/github_actions/actions/checkout-5 2025-10-21 09:51:13 +02:00
dependabot[bot]
4e580980fe
Bump codecov/codecov-action from 1 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 1 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v1...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 07:46:40 +00:00
dependabot[bot]
9b77252326
Bump actions/setup-python from 2 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 07:46:37 +00:00
dependabot[bot]
f9cc2c5fab
Bump actions/cache from 2 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 2 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 07:46:33 +00:00
dependabot[bot]
ec0b34df5b
Bump actions/checkout from 2 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 07:46:30 +00:00
Rémy HUBSCHER
9e22eb8d8f
Merge pull request #219 from jazzband/pre-commit-ci-update-config 2025-10-21 09:45:49 +02:00
Rémy Hubscher
c239015d1a
Update github actions version. 2025-10-21 09:45:36 +02:00
pre-commit-ci[bot]
73f9c013cd [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-10-20 19:50:02 +00:00
pre-commit-ci[bot]
840827da1d
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0)
- [github.com/pycqa/doc8: v1.1.2 → v2.0.0](https://github.com/pycqa/doc8/compare/v1.1.2...v2.0.0)
- [github.com/adamchainz/django-upgrade: 1.22.2 → 1.29.0](https://github.com/adamchainz/django-upgrade/compare/1.22.2...1.29.0)
- [github.com/pre-commit/mirrors-eslint: v9.17.0 → v9.38.0](https://github.com/pre-commit/mirrors-eslint/compare/v9.17.0...v9.38.0)
- [github.com/astral-sh/ruff-pre-commit: v0.8.6 → v0.14.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.6...v0.14.1)
- [github.com/tox-dev/pyproject-fmt: v2.5.0 → v2.11.0](https://github.com/tox-dev/pyproject-fmt/compare/v2.5.0...v2.11.0)
- [github.com/abravalheri/validate-pyproject: v0.23 → v0.24.1](https://github.com/abravalheri/validate-pyproject/compare/v0.23...v0.24.1)
2025-10-20 17:24:32 +00:00
Rémy HUBSCHER
31e64b6a76
Merge pull request #218 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-01-07 08:50:48 +01:00
pre-commit-ci[bot]
71a1670703
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.4 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.4...v0.8.6)
2025-01-06 17:54:28 +00:00
Rémy HUBSCHER
dff487aca9
Merge pull request #217 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-12-24 14:42:55 +01:00
pre-commit-ci[bot]
7aa9b687aa
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.3 → v0.8.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.3...v0.8.4)
2024-12-23 17:46:59 +00:00
Rémy HUBSCHER
b02e2f13f9
Merge pull request #216 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-12-16 21:37:50 +01:00
pre-commit-ci[bot]
a7d182f0b4
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-eslint: v9.16.0 → v9.17.0](https://github.com/pre-commit/mirrors-eslint/compare/v9.16.0...v9.17.0)
- [github.com/astral-sh/ruff-pre-commit: v0.8.2 → v0.8.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.2...v0.8.3)
2024-12-16 17:45:43 +00:00
Rémy HUBSCHER
c8486417bb
Merge pull request #215 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-12-13 09:38:59 +01:00
pre-commit-ci[bot]
ebcd3a0028
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/pycqa/doc8: v1.1.1 → v1.1.2](https://github.com/pycqa/doc8/compare/v1.1.1...v1.1.2)
- [github.com/adamchainz/django-upgrade: 1.20.0 → 1.22.2](https://github.com/adamchainz/django-upgrade/compare/1.20.0...1.22.2)
- [github.com/pre-commit/mirrors-eslint: v9.8.0 → v9.16.0](https://github.com/pre-commit/mirrors-eslint/compare/v9.8.0...v9.16.0)
- [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.8.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.8.2)
- [github.com/tox-dev/pyproject-fmt: 2.2.1 → v2.5.0](https://github.com/tox-dev/pyproject-fmt/compare/2.2.1...v2.5.0)
- [github.com/abravalheri/validate-pyproject: v0.18 → v0.23](https://github.com/abravalheri/validate-pyproject/compare/v0.18...v0.23)
2024-12-09 17:44:37 +00:00
Rémy HUBSCHER
6c0e0a8c82
Merge pull request #214 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2024-08-06 09:26:57 +02:00
pre-commit-ci[bot]
d3a8f6b725
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.5 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.5...v0.5.6)
- [github.com/tox-dev/pyproject-fmt: 2.1.4 → 2.2.1](https://github.com/tox-dev/pyproject-fmt/compare/2.1.4...2.2.1)
2024-08-05 17:54:35 +00:00
Rémy HUBSCHER
13a502bc4a
Post release 2.4.0 2024-08-05 14:51:35 +02:00
Rémy HUBSCHER
9f42cde8cb
Merge pull request #213 from jazzband/prepare-2.4
Prepare 2.4 release
2024-08-05 14:49:32 +02:00
Rémy HUBSCHER
5c8bbda4b3
Update AUTHORS 2024-08-05 14:03:55 +02:00
Rémy HUBSCHER
eab4fa7abf
Deduplicate names 2024-08-05 11:00:37 +02:00
Rémy HUBSCHER
711b2e50b5
Run pre-commit on all files 2024-08-05 10:53:19 +02:00
Rémy HUBSCHER
dd35f867e0
Add .pre-commit-config.yaml file 2024-08-05 10:51:09 +02:00
Rémy HUBSCHER
402c77b332
Add readthedocs config 2024-08-05 10:50:24 +02:00
Rémy HUBSCHER
347f0202ab
Prepare 2.4 release 2024-08-05 10:45:59 +02:00
Rémy HUBSCHER
18cb41f760
Merge pull request #211 from tari/196-rfd
Guard against reflected file download
2024-08-05 10:26:15 +02:00
Rémy HUBSCHER
1b294f00fa
Merge pull request #212 from sevdog/xaccel-headers
Allow XResponses to keep original headers provided to base response
2024-08-05 10:23:48 +02:00
Davide
51deef0a7e
Allow XResponses to keep original headers provided to base response 2024-08-01 08:35:53 +02:00
Peter Marheine
e7e25e68dd Add missing import in packaging test
This test was broken when changed to begin using importlib,
but that wasn't evident because the tests directory
wasn't being automatically tested.
2024-08-01 06:28:06 +00:00
Peter Marheine
0568c3c559 Prevent reflected file downloads on specially-named files
This fixes #196, where it was observed that django_downloadview
was vulnerable to reflected file download attacks with
specially-named files, similar to CVE-2022-36359 in Django.
This change adopts the same replacement rules as used in Django's fix
in commit b3e4494d759202a3b6bf247fd34455bf13be5b80.
2024-08-01 06:24:00 +00:00
Peter Marheine
e2b4470c5b Ensure tests are actually run
pytest by default only discovers tests in files named test_*.py,
so none of the tests were actually being executed. Set the appropriate
pytest option
to discover the tests so they are automatically run.
2024-08-01 06:24:00 +00:00
Peter Marheine
71488c49c4
Merge pull request #204 from sevdog/fix-realdownload-check
Use safer check in RealDownloadMiddleware
2024-08-01 09:53:07 +10:00
Peter Marheine
6c7b7f8a31
Merge pull request #209 from tari/pkg-resources
Replace use of pkg_resources (setuptools)
2024-08-01 09:50:07 +10:00
Davide
d385cbba6f
Use hasattr to check if any of required attribute is present 2024-07-31 16:18:20 +02:00
Peter Marheine
60c1839bf5 Replace use of pkg_resources (setuptools)
Since Python 3.12, setuptools isn't included with Python
and importlib is the recommended replacement, available
since Python 3.8.
2024-07-31 11:41:25 +00:00
Peter Marheine
ba6be8c3cd
Merge pull request #210 from tari/django-4.2
Upgrade support matrix to maintained versions of Django
2024-07-31 21:05:14 +10:00
Peter Marheine
16b241d9b5
Merge pull request #206 from sevdog/upgrade-middleware-doc
Update usage of middleware settings
2024-07-30 22:07:22 +10:00
Davide
c51720296a
Update references to middleware settings 2024-07-30 13:01:56 +02:00
Peter Marheine
fba10f7b1b
Merge pull request #207 from sevdog/improve-py3-code
Improve codebase to python3
2024-07-30 20:20:14 +10:00
Peter Marheine
41caa79f46 Upgrade support matrix to maintained versions of Django
Currently, only Django versions 4.2 and 5.0 are maintained and
collectively only support Python down to version 3.8. This change updates the support matrix
for this package to match.
2024-07-30 10:03:32 +00:00
Davide
ff5073d00b
Use python3 super and remove useless method re-definitions 2023-09-26 11:26:43 +02:00
Rémy HUBSCHER
338e17195f
Merge pull request #194 from felixxm/was_modified_since_size
Removed passing unused size parameter to was_modified_since().
2022-08-05 09:38:56 +02:00
Rémy HUBSCHER
df439fbd4f
Merge pull request #199 from jazzband/johnthagen-patch-1
Enforce minimum Python version
2022-08-05 09:37:00 +02:00
johnthagen
dd2e148b05
Enforce minimum Python version 2022-08-04 13:23:07 -04:00
Rémy HUBSCHER
64e36826ff
Merge pull request #197 from johnthagen/drop-py3.6
Drop Python 3.6 support
2022-08-04 16:10:02 +02:00
johnthagen
d19e4bee50
Update GitHub Actions 2022-08-04 09:23:23 -04:00
johnthagen
8c74a77ebe Drop Python 3.6 support 2022-08-04 09:22:28 -04:00
Rémy HUBSCHER
a6975d9669
Merge pull request #195 from devidw/patch-1
Add missing `)` to settings docs sample
2022-08-04 14:26:22 +02:00
David Wolf
f715f72032
Add missing ) to settings docs sample 2022-04-14 17:02:49 +02:00
Mariusz Felisiak
293403b807 Removed passing unused size parameter to was_modified_since().
The size parameter is unused because we pass timestamp and not the
If-Modified-Since HTML header.
2022-03-14 20:38:28 +01:00
jazzband-bot
b64b1ad21a Jazzband: Created local 'CODE_OF_CONDUCT.md' from remote 'CODE_OF_CONDUCT.md' 2022-02-17 11:46:17 +01:00
Rémy HUBSCHER
3c2951ceac
Merge pull request #192 from jazzband/prepare-2.3-release
Prepare release of django-downloadview 2.3
2022-01-11 10:41:15 +01:00
Rémy HUBSCHER
aa003ed6bf
Prepare 2.3 release. 2022-01-11 10:37:35 +01:00
Rémy HUBSCHER
6dbf06c4ea
Merge pull request #190 from johnthagen/patch-2
Document Python 3.10 support
2022-01-05 14:11:53 +01:00
johnthagen
7c2af759c8
Document Python 3.10 support 2022-01-05 07:45:22 -05:00
Rémy HUBSCHER
7e9f81d758
Merge pull request #188 from tari/django-4.0-compat
Update compatibility for Django 4.0
2022-01-05 13:38:34 +01:00
Peter Marheine
e9fbb74b2c Uncap Github Actions parallelism
There seems to be no reason to limit tests to 5 jobs at a time; removing
the limit should allow tests to finish faster.
2021-12-23 20:27:29 +11:00
Peter Marheine
6381dc94f1 Include Django 4.0 in the test matrix 2021-12-23 13:05:46 +11:00
Peter Marheine
198f6a3295 Update compatibility for Django 4.0
The only meaningful change is removing use of `force_text` (which was
deprecated in Django 3.0) in favor of `force_str` (which has existed
since before Django 1.11). On Python 3 there is no functional difference
between the two.
2021-12-23 13:05:46 +11:00
Peter Marheine
a64a0e8c33 Split DownloadDispatcherMiddleware into two classes
Instantiating a middleware but not using it as a middleware was a
strange behavior, so this change splits the dispatching out to another
class with a more specialized API and uses that middleware.
2021-12-23 13:05:45 +11:00
Peter Marheine
0ab8aa3e8f Stop using django.util.deprecation.MiddlewareMixin
That class is intended primarily for compatibility with Pre-1.10
middleware, and recently gained a check that get_response is not None.
This package ensures an unexpecified `get_response` function is never
called on its own, so it's simplest to manually implement the middleware
API.
2021-12-23 13:05:45 +11:00
Peter Marheine
2524668e86 Test on Python 3.10 2021-12-23 11:53:26 +11:00
Peter Marheine
cb3ec3a091 Stop using nosetests
Nose is no longer maintained and is incompatible with Python 3.10, so
can no longer be used. This change runs `coverage` manually to collect
coverage and uses `pytest` to run doctests, collectively covering what
was tested using django_nose.
2021-12-23 11:51:53 +11:00
Peter Marheine
95b36fc843 Import ABCs from collections.abc, not collections
The types in collections.abc were moved from just collections in Python
3.3, and Python 3.10 removed the old aliases. We no longer support
Python versions earlier than 3.3 and need to support 3.10, so update the
import.
2021-12-23 10:27:14 +11:00
Rémy HUBSCHER
0578729a66
Merge pull request #183 from johnthagen/patch-1
Remove duplicate trove classifier
2021-04-30 08:09:51 +02:00
johnthagen
051f6116f8
Remove duplicate trove classifier 2021-04-27 19:09:12 -04:00
Hasan Ramezani
ca8331e2de Add Django supported version badge to README.rst. 2021-04-16 14:30:02 +02:00
Hasan Ramezani
a8d231cbed Add Django 3.2 support. 2021-04-16 14:30:02 +02:00
Hasan Ramezani
006d2a288d Drop Django 3.0 support. 2021-04-16 14:30:02 +02:00
Rémy HUBSCHER
cbd53d813e
Merge pull request #179 from johnthagen/storage-download-docs
Fix StorageDownloadView code snippets
2021-04-10 18:53:18 +02:00
Rémy HUBSCHER
3543a3b8fc
Merge pull request #180 from johnthagen/path-doc-fixes
Fix code snippet in PathDownloadView docs
2021-04-10 18:53:04 +02:00
Rémy HUBSCHER
465123b6e5
Merge pull request #178 from johnthagen/nginx-docs
Replace mention of deprecated NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR
2021-04-10 18:52:44 +02:00
Jannis Leidel
45e1219275
Rename Django's dev branch to main. (#181)
* Rename Django's dev branch to main.

More information: https://groups.google.com/g/django-developers/c/tctDuKUGosc/
Refs: https://github.com/django/django/pull/14048

* Don't run Django main test on Python < 3.8.
2021-03-09 13:49:08 +01:00
johnthagen
a06c511d8f Fix code snippet in PathDownloadView docs 2021-02-02 10:30:34 -05:00
johnthagen
a67f3dc41e Fix StorageDownloadView code snippets 2021-02-02 10:27:38 -05:00
johnthagen
9c01be6cc8 Replace mention of deprecated NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR 2021-02-02 10:10:59 -05:00
Rémy HUBSCHER
563b2a4f7b
Merge pull request #176 from johnthagen/fix-nginx-code-snippets
Fix the code snippets in the NGINX documentation page
2021-01-27 10:06:57 +01:00
johnthagen
707c392f6e Fix the code snippets in the NGINX documentation page 2021-01-26 16:00:11 -05:00
Rémy HUBSCHER
9b5fee2687
Merge pull request #174 from johnthagen/doc-code-fix
Fix doc code include in ObjectDownloadView
2021-01-21 19:12:34 +01:00
johnthagen
ed3d470908 Fix url example code 2021-01-21 13:07:36 -05:00
johnthagen
eb223f169b Fix "Mapping file attributes to model's" example code 2021-01-21 12:59:36 -05:00
johnthagen
700b1246df Fix "serving a specific file field" example code 2021-01-21 12:56:36 -05:00
johnthagen
2586cc5a97 Fix doc code include in ObjectDownloadView 2021-01-21 12:50:16 -05:00
Rémy HUBSCHER
c602c32d69
Merge pull request #173 from johnthagen/remove-setuptool-dep
Remove setuptools dependency
2021-01-21 18:03:34 +01:00
johnthagen
8fd75de224 Remove setuptools dependency 2021-01-21 12:01:38 -05:00
Rémy HUBSCHER
87daf8e92e
Merge pull request #172 from johnthagen/require-django-2.2
Require supported Django version 2.2
2021-01-21 18:01:34 +01:00
johnthagen
4dae54179b Require supported Django version 2.2 2021-01-21 11:59:37 -05:00
Rémy HUBSCHER
d1972d6460
Merge pull request #170 from johnthagen/update-doc-links
Update external documentation links to point to latest Django and Python releases
2021-01-21 17:46:06 +01:00
Rémy HUBSCHER
cee7810cfd
Fix links. 2021-01-21 17:45:42 +01:00
Rémy HUBSCHER
8df2615a29
Merge pull request #171 from johnthagen/demo-python-version
Update demo README to reference Python 3
2021-01-21 17:39:06 +01:00
Rémy HUBSCHER
252e6f127c
Merge pull request #169 from johnthagen/demo-settings-drop-old-django
Drop support for end of life Django settings in demo project
2021-01-21 17:38:17 +01:00
johnthagen
cc4636098b
Remove extra newline
Co-authored-by: Rémy HUBSCHER <hubscher.remy@gmail.com>
2021-01-21 11:37:57 -05:00
johnthagen
f225d87b85 Update demo README to reference Python 3 2021-01-21 11:37:12 -05:00
johnthagen
deddd2fd2d Update external documentation links to point to latest Django and Python releases 2021-01-21 11:35:00 -05:00
johnthagen
6338f61767 Drop support for end of life Django settings in demo project 2021-01-21 11:28:40 -05:00
Jannis Leidel
9f42e65986
Migrate to GitHub Actions. (#165)
* Add GitHub Actions test workflow.

* Add django version env var handling to tox config.

* Update badges.

* Add release workflow.

* Remove Travis.

* Fix typo.

* Fix more typos.

* Write coverage.xml.

* Remove the need for the VERSION file.

* Simplify demo setup.py.

* Remove VERSION file.

* Update demo/setup.py

* Update setup.py
2020-12-23 11:12:35 +01:00
Rémy HUBSCHER
384e7c5b13
Back to development: 2.3 2020-10-08 19:01:09 +02:00
Rémy HUBSCHER
0c59d6d261
Preparing release 2.2 2020-10-05 11:19:05 +02:00
Jannis Leidel
13d2b3ae58
Add release config.
Fix https://github.com/jazzband-roadies/help/issues/201.
2020-10-02 10:45:07 +02:00
Rémy HUBSCHER
f663fa4b03
Merge pull request #158 from Natim/jazzband
Be ready for Jazzband move.
2020-09-18 17:49:34 +02:00
Rémy HUBSCHER
7ec465d74d
Be ready for Jazzband move. 2020-09-18 16:50:33 +02:00
Rémy HUBSCHER
3bb1a73e6b
Merge pull request #160 from jazzband/add-python3.9-support
Add Python 3.9 support.
2020-09-18 16:50:11 +02:00
Rémy HUBSCHER
c0acf77f73
Merge branch 'master' into add-python3.9-support 2020-09-18 16:21:08 +02:00
Rémy HUBSCHER
d74e094fe7
Merge pull request #159 from Natim/new-url-syntax
Remove obsolete URL syntax.
2020-09-18 16:20:32 +02:00
Rémy HUBSCHER
3da22537df
Fix doc. 2020-09-18 11:27:42 +02:00
Rémy HUBSCHER
91be5c38a8
Add Python 3.9 support. 2020-09-18 11:26:25 +02:00
Rémy HUBSCHER
8e439d3485
Remove obsolete URL syntax. 2020-09-18 11:10:08 +02:00
Rémy HUBSCHER
6cd9ec3dd0
Fix test. 2020-09-18 10:50:26 +02:00
Rémy HUBSCHER
bd41e6f7bf
Merge pull request #156 from timgates42/bugfix_typo_instantiate
docs: Fix simple typo, instanciate -> instantiate
2020-03-08 20:12:05 +01:00
Tim Gates
e2da75ec9d
docs: Fix simple typo, instanciate -> instantiate
There is a small typo in django_downloadview/middlewares.py.

Should read `instantiate` rather than `instanciate`.
2020-03-08 18:03:42 +11:00
Rémy HUBSCHER
cd37fd5084
Back to development: 2.2 2020-01-14 10:26:51 +01:00
Rémy HUBSCHER
bc145254ea
Preparing release 2.1.1 2020-01-14 10:24:28 +01:00
Rémy HUBSCHER
79da070b14
Merge pull request #152 from aleksihakli/patch-1
Fix missing function parameter
2020-01-14 10:23:20 +01:00
Aleksi Häkli
aacb5c7a16
Fix missing function parameter 2020-01-13 17:23:54 +02:00
Rémy HUBSCHER
965218aafb
Back to development: 2.2 2020-01-13 10:51:20 +01:00
Rémy HUBSCHER
17afa754e9
Preparing release 2.1 2020-01-13 10:51:07 +01:00
Rémy HUBSCHER
48d2a622a7
Update changelog. 2020-01-13 10:50:51 +01:00
Rémy HUBSCHER
ee402dbcb8
Merge pull request #151 from aleksihakli/master
Add signed file system storage
2020-01-13 10:49:12 +01:00
Rémy HUBSCHER
bfb9c3cedb
Fix flake8 errors. 2020-01-13 10:46:14 +01:00
Aleksi Häkli
839b7bda38
Add signed file system storage
Fixes #138
2020-01-13 11:25:40 +02:00
Rémy HUBSCHER
27d72b058f
Back to development: 2.1 2020-01-07 16:14:06 +01:00
Rémy HUBSCHER
f511ab3602
Preparing release 2.0 2020-01-07 16:14:01 +01:00
Rémy HUBSCHER
bdf0ba2188
Merge pull request #150 from benoitbryon/add-black
Add black support.
2020-01-07 16:00:46 +01:00
Rémy HUBSCHER
c59cc37a0f
Validate black and isort on travis. 2020-01-07 15:55:44 +01:00
Rémy HUBSCHER
56e419bfc7
Add isort config. 2020-01-07 15:51:55 +01:00
Rémy HUBSCHER
8dad97c92d
Fix flake8 2020-01-07 15:42:04 +01:00
Rémy HUBSCHER
586a50aa5a
Fix sphinx lines. 2020-01-07 15:28:26 +01:00
Rémy HUBSCHER
af30524bd2
Remove u'' in front of strings. 2020-01-07 15:21:34 +01:00
Rémy HUBSCHER
6c7c8d9a60
Add isort support. 2020-01-07 15:19:22 +01:00
Rémy HUBSCHER
b094ed6cec
Add black support. 2020-01-07 15:12:51 +01:00
Rémy HUBSCHER
fd3c463a46
Merge pull request #149 from benoitbryon/148-drop-python2.7-support
Remove support for Python 2.7
2020-01-07 15:12:31 +01:00
Rémy HUBSCHER
b893e52eba
Remove support for Python 2.7 2020-01-07 15:01:26 +01:00
Rémy HUBSCHER
2773a2b158
Back to development: 2.0 2020-01-07 14:45:29 +01:00
Rémy HUBSCHER
9014f292e7
Preparing release 1.10 2020-01-07 14:45:15 +01:00
Rémy HUBSCHER
31bb096a77
Merge pull request #147 from joehybird/master
Add Django 2.2, Django 3.0 support & Drop Django 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 2.0 and 2.1 as well as Python 3.4 and 3.5
2020-01-07 14:39:30 +01:00
Rémy HUBSCHER
28a077b1c3
Another broken link. 2020-01-07 14:32:17 +01:00
Rémy HUBSCHER
1ce8aedfd1
Update documentation link. 2020-01-07 14:17:08 +01:00
Rémy HUBSCHER
cc1e77c405
Update setup.py 2020-01-07 14:13:49 +01:00
Rémy HUBSCHER
2988aa65e7
Simplify travis config. 2020-01-07 14:11:59 +01:00
Rémy HUBSCHER
9a82b76faf
@natim review. 2020-01-07 14:03:49 +01:00
Fabre Florian
433b2d5b80 Fix travis matrix 2020-01-07 13:51:30 +01:00
Fabre Florian
256974ab80 Update changelog 2020-01-07 11:55:19 +01:00
Fabre Florian
ac1e4dc9ce Fix StorageFile compatibility & some lint issues 2020-01-07 11:34:51 +01:00
Fabre Florian
0ad5e1238a Fix middleware compatibility issues 2020-01-07 11:09:25 +01:00
Fabre Florian
4aa44b9dbe Fix django.conf.urls compatibility issues 2020-01-07 09:57:48 +01:00
Fabre Florian
69bb9d864d Add Django 2.x targets to tox & travis scripts 2020-01-07 09:54:28 +01:00
Rémy HUBSCHER
1c829c5413 Merge pull request #140 from joehybird/master
Fix compatibility from django1.5 to django1.11 with python 2.7 and 3.x
2017-09-14 19:26:43 +07:00
Fabre Florian
9aaa069e23 Fix flake8 issue 2017-09-14 11:40:27 +02:00
Fabre Florian
5306a54222 Fix travis config. 2017-09-14 11:37:13 +02:00
Fabre Florian
ade4c06670 Fix travis config. 2017-09-14 11:15:24 +02:00
Fabre Florian
24afd546c6 Fix travis config. 2017-09-14 11:08:27 +02:00
Fabre Florian
235f95b967 Fix django 1.10 deprecation of django.conf.urls.patterns. Add django 1.11 to tox targets. Downgrade django-nose because versions >= 1.4.3 no longer supports django 1.5. 2017-09-14 10:09:59 +02:00
Rémy HUBSCHER
414c083aa3 Merge pull request #124 from adamchainz/readthedocs.io
Convert readthedocs links for their .org -> .io migration for hosted projects
2016-06-28 08:38:45 +02:00
Adam Chainz
a4cc84d758 Convert readthedocs links for their .org -> .io migration for hosted projects
As per [their blog post of the 27th April](https://blog.readthedocs.com/securing-subdomains/) ‘Securing subdomains’:

> Starting today, Read the Docs will start hosting projects from subdomains on the domain readthedocs.io, instead of on readthedocs.org. This change addresses some security concerns around site cookies while hosting user generated data on the same domain as our dashboard.

Test Plan: Manually visited all the links I’ve modified.
2016-06-27 22:42:16 +01:00
Rémy HUBSCHER
6166783914 Merge pull request #119 from mbaechtold/fix-documentation-optimization-examples
Fix code examples in the optimization chapter of the documentation.
2016-05-13 13:05:13 +02:00
Martin Bächtold
e0dc0b0c41
Fix code examples in the optimization chapter of the documentation. 2016-05-06 10:15:50 +02:00
Benoît Bryon
af67e0036f Back to development: 1.10 2016-03-15 12:18:35 +01:00
Benoît Bryon
2862d2f24e Preparing release 1.9 2016-03-15 12:16:24 +01:00
Benoît Bryon
0c445389f7 HTTPFile has 'content_type' property. It makes HTTPDownloadView proxy Content-Type header. Closes #116 2016-03-15 12:13:31 +01:00
Benoît Bryon
e37612f2b9 Make sure HTTPDownloadView proxies Content-Type header. Refs #116 2016-03-14 17:42:30 +01:00
Benoît Bryon
56214264f1 HTTPFile has 'content_type' property. Allows HTTPDownloadView to proxy Content-Type header. Refs #116 2016-03-14 17:05:32 +01:00
Benoît Bryon
18fd9abcc8 Introduced support of Python-3.5 and Django-1.9. Refs #112, refs #113. 2016-03-14 16:59:30 +01:00
Benoît Bryon
5fecce8017 Improved support of Python-3.5 and Django-1.9. Refs #112, refs #113. 2016-03-14 16:24:16 +01:00
Benoît Bryon
ec969efa7e Improved support of Python-3.5 and Django-1.9. Refs #112, refs #113. 2016-03-14 15:54:56 +01:00
Benoît Bryon
08031dab21 Improved support of Python-3.5 and Django-1.9. Refs #112, refs #113. 2016-03-14 15:40:13 +01:00
Benoît Bryon
a3203f2298 Added Python-3.5 and Django-1.9 to test suites. Refs #112, refs #113. 2016-03-14 15:22:48 +01:00
Benoît Bryon
1ec890fb20 Back to development: 1.9 2015-07-20 18:55:22 +02:00
Benoît
ae54019a27 Preparing release 1.8 2015-07-20 18:53:00 +02:00
Benoît Bryon
c18e57037b Pass numeric timestamp to Django's was_modified_since() (was receiving datetime). Thanks @benesch and @zerc. 2015-07-20 18:43:46 +02:00
Benoît Bryon
3a3d3ebb9f Refs #104 - Assert timestamp is passed to was_modified_since(). 2015-07-20 18:28:28 +02:00
Benoît Bryon
bdf8e6e368 Psycho-rebased work of @benesch and @zerc on top of master. 2015-07-20 18:14:08 +02:00
Benoît Bryon
150e50653f Refs #104 - Merged work of @benesch and @zerc. 2015-07-20 18:12:44 +02:00
Benoît Bryon
ea033b8eb2 PathDownloadView.get_file() makes a single call to PathDownloadView.get_path() (was doing it twice). Thanks @rleonhardt. Closes #103 2015-07-20 17:43:18 +02:00
zero13cool
bf7b0f23fd Refs #104 - Convert datetime to unix timestamp 2015-07-16 22:00:27 +03:00
René Leonhardt
5e4ae2fdc9 Changed assertCalledOnceWith() to assert_called_once_with()
When I change assertCalledOnceWith() to assert_called_once_with(), make test succeeds but shows another failure:
```
AssertionError: Expected call: mock(sentinel.size, sentinel.modified_time)
Actual call: mock(sentinel.since, sentinel.modified_time, sentinel.size)
```
After changing the call accordingly, all 59 tests succeed.
2015-07-14 14:53:14 +02:00
René Leonhardt
75b51ce214 Avoid calling get_path() twice inside get_file
Overridden PathDownloadView.get_path() may contain database lookups and logging which should not be called twice if not necessary, as it was in my case.
Because the acquired filename does not change inside get_file(), I replaced the duplicate call.
2015-07-14 12:03:52 +02:00
Benoît Bryon
142d8de7fa Declared project as production/stable. 2015-06-24 14:10:33 +02:00
Benoît
2596bc60e1 Back to development: 1.8 2015-06-13 01:46:40 +02:00
Benoît
652e5de854 Preparing release 1.7 2015-06-13 01:45:31 +02:00
Benoît Bryon
cfc57c19f0 Run demo with 'make demo runserver' (was broken). Closes #92 2015-06-13 01:39:05 +02:00
Benoît Bryon
0c282664ca Filenames with commas are now supported. In download responses, filename is now surrounded by double quotes. Closes #87 2015-06-13 01:37:33 +02:00
Benoît Bryon
5fa0a6caea Psycho-rebased branch 87-double-quoted-filename on top of master 2015-06-13 00:57:44 +02:00
Benoît Bryon
8a0883d461 Refs #92 - 'make demo' installs demo, 'make runserver' runs demo server. 2015-06-13 00:56:57 +02:00
Benoît Bryon
211fd5461b Refreshed development environment. Added tests around Python 3.4 and Django 1.8. Fixed content proxied by HTTPFile. Closes #97, closes #98, closes #99. 2015-06-13 00:39:58 +02:00
Benoît Bryon
2d04d30ba4 Refs #97 - Code style. 2015-06-12 20:51:51 +02:00
Benoît Bryon
6f9a79c1df Refs #98 - Assert tests follow PEP8. 2015-06-12 20:39:00 +02:00
Benoît Bryon
62c0c01304 Refs #98 - Workaround travis and test requirements. 2015-06-12 20:36:17 +02:00
Benoît Bryon
0160c00df1 Refs #98 - Workaround documentation. 2015-06-12 19:30:57 +02:00
Benoît Bryon
ec96afda11 Refs #98 - Added __init__ script in tests/ folder. 2015-06-12 16:52:33 +02:00
Benoît Bryon
d122c68455 Refs #97 - Splitted StringIteratorIO into TextIteratorIO and BytesIteratorIO. StringIteratorIO remains for backward compatibility. 2015-06-12 16:47:46 +02:00
Benoît Bryon
c54131db6e Refs #98 - Moved tests outside django-downloadview package, i.e. in tests/ folder at project root. 2015-06-12 15:42:50 +02:00
Benoît Bryon
618aa78455 Merge branch '97-httpfile-raw' into 98-dev-refresh 2015-06-12 11:16:14 +02:00
Benoît Bryon
f04a4b4cd4 Refs #98 - Starting refresh of development environment. Work in progress. 2015-06-12 11:15:39 +02:00
Benoît Bryon
047745f084 Refs #97 - HTTPFile proxies decoded file wrapper (was undecoded urllib3 file wrapper). 2015-06-11 17:02:18 +02:00
Rémy HUBSCHER
b2fa7754cc Merge pull request #90 from thedrow/patch-1
Fixed broken links.
2015-06-01 09:05:20 +01:00
Rémy HUBSCHER
6a3bb9affb Merge pull request #93 from benoitbryon/91-demo-data-folder
Makefile creates 'var' folder to store database
2015-06-01 10:04:54 +02:00
Nikhil Benesch
e685f9909d properly compute modified since with StaticFile wrapper 2015-05-29 16:16:05 -04:00
Nikhil Benesch
9b87f181bf add failing test cases for StaticFile IF_MODIFIED_SINCE handling 2015-05-29 16:15:19 -04:00
Benoît Bryon
ca9c9b3caf Refs #91 - Makefile creates 'var' folder to store database (was failing to create sqlite file). 2015-01-23 14:50:08 +01:00
Omer Katz
8a6b04c5a4 Fixed broken link. 2014-10-28 17:14:46 +02:00
Omer Katz
3717696dda Fixed broken link. 2014-10-28 17:14:08 +02:00
CJ
6ad1ae9704 refs #87 github url changed to githubsecurecontent
Hoping this fixes the error in 'simple_url' serves 'hello-world.txt' from Github
2014-06-06 13:37:11 -04:00
CJ
7d30adb802 refs #87 Spelling issue on Hello 2014-06-06 12:36:18 -04:00
CJ
def8df90ee refs #87 resaving hello-world.txt
Test is failing on loading this file with:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1: invalid start byte

Not really sure what the issue is, seeing if this will fix it...
2014-06-06 12:30:27 -04:00
CJ
fbb0f30f2a refs #87 quotes for ascii filename to fix doctest 2014-06-06 12:07:12 -04:00
CJ
3b124f0cdb refs #87 quotes for ascii filename to fix doctest 2014-06-06 11:59:50 -04:00
CJ
d7073be658 refs #87 assert_basename test failing
Added quotes to assert_basename test.
2014-06-06 11:49:30 -04:00
CJ
616c822b6c refs #87 Quotes around ascii filename, not utf-8
Updated the test to go with my change.
2014-06-06 10:37:54 -04:00
CJ
77166d2969 refs #87 Quotes around ascii filename, not utf-8 2014-06-06 10:37:04 -04:00
CJ
1407c0cc4e Tests updated for double quotes around filename 2014-06-06 09:58:37 -04:00
CJ
7b2d8215e6 Filename surrounded by double quotes.
Chrome will give a Duplicate Header error if a file name has a comma in it, and the file name is not surrounded by  double quotes.
2014-06-06 09:55:20 -04:00
Benoît Bryon
5c262dfa52 Fixed compliance to latest flake8. 2014-03-31 16:53:17 +02:00
Benoît Bryon
5c92f5ac87 Fixed typo in README. 2014-03-31 16:42:35 +02:00
Benoît Bryon
df6c52b571 Back to development: 1.7 2014-03-03 14:44:17 +01:00
Benoît Bryon
7aca2833bd Preparing release 1.6 2014-03-03 14:43:28 +01:00
Benoît Bryon
abd6d5daff Reviewed Makefile, development environment and contributor guide. 2014-03-03 14:38:33 +01:00
Benoît Bryon
8bfbbb36fc Improved documentation around 'DownloadMixin.attachment': how to serve files inline. Closes #80. 2014-03-03 11:58:37 +01:00
Benoît Bryon
d29e3532e8 Refs #80 - Updated changelog. 2014-03-03 08:19:43 +01:00
Benoît Bryon
eeaabd2a37 Refs #80 - Added a test around demo's 'object:inline_file' URL. 2014-03-03 08:15:46 +01:00
Benoît Bryon
ffef9ce703 Refs #80 - Referenced options inherited from DownloadMixin in each view's documentation. 2014-03-03 07:58:43 +01:00
Benoît Bryon
7875999fe2 Refs #80 - Improved documentation about 'DownnloadMixin.attachment' attribute. 2014-03-03 07:49:30 +01:00
Benoît Bryon
8e83ec559f Refs #80 - Changed wording in 'serve file inline' documentation. 2014-03-03 07:11:38 +01:00
Benoît Bryon
af0ffa3e2c Psycho-rebased branch 80-no-attachment-docs on top of master 2014-03-02 19:42:17 +01:00
Benoît Bryon
3b725c2ab4 Introduced support of Python >= 3.3. Closes #46. 2014-03-02 19:35:20 +01:00
Benoît Bryon
5850ffac81 Fixed indentation in tox.ini file. 2014-02-25 01:07:59 +01:00
Benoît Bryon
5cde2b0d2d Revert "Minor changes in tox configuration file."
This reverts commit cfb6964af3.
2014-02-25 01:06:59 +01:00
Benoît Bryon
2d4f8c9773 Refs #46 - Updated changelog. 2014-02-25 00:57:07 +01:00
Benoît Bryon
cfb6964af3 Minor changes in tox configuration file. 2014-02-25 00:56:50 +01:00
Benoît Bryon
70c6b00442 Refs #46 - Fixed Python 2 and Python 3 compatibility in doctests related to string/unicode. 2014-02-25 00:47:20 +01:00
Benoît Bryon
546bdc2461 Test of README build to HTML is now done with 'rst2html' command via tox. 2014-02-25 00:43:09 +01:00
Rémy HUBSCHER
1ea2790ba9 Refs #46 -- Add Py3 support. 2014-02-16 19:13:44 +01:00
Erik Dykema
21cb8f6409 Add documentation for serving a file inline.
Responsive to issue #80
2014-02-10 14:30:41 -05:00
Erik Dykema
558bd757a3 Add non-downloading inline_file_view
To be used in documentation for issue #80
2014-02-10 14:29:48 -05:00
Benoît Bryon
6232fcb736 Using tox in development environment. Makefile no longer uses buildout or virtualenv. Dropped tests for Python 2.6. Closes #74. 2014-02-10 01:38:28 +01:00
Benoît Bryon
d587c515f0 Refs #74 - Switched to tox as test-environment manager. Added flake8 to the test suite. Dropped python 2.6 tests. Makefile no longer creates a virtualenv. 2014-02-10 01:27:14 +01:00
Benoît Bryon
2962b84135 Refs #74 - Removing zc.buildout for DEV/TEST environments. WORK IN PROGRESS. 2014-01-24 15:26:16 +01:00
Benoît Bryon
e28951d0a1 Back to development: 1.6 2013-11-29 09:23:54 +01:00
Benoît Bryon
32322c1163 Preparing release 1.5 2013-11-29 09:23:32 +01:00
Benoît Bryon
40f407b627 Upgraded versions in development environment. 2013-11-29 09:21:26 +01:00
Benoît Bryon
9b2a745383 Merge pull request #73 from benoitbryon/41-django-sendfile
Closes #41 - django_downloadview.sendfile is a port of django-sendfile's sendfile function
2013-11-28 16:01:40 -08:00
Benoît Bryon
6dd090757a Refs #41 - Added 'mimetype' and 'encoding' arguments to 'DownloadMixin' => supported all arguments of original sendfile() function. Added documentation about migrating from django-sendfile to django-downloadview. 2013-11-29 00:55:46 +01:00
Benoît Bryon
59d9b4966e Refs #41 - django_downloadview.sendfile is a PathDownloadView (was StorageDownloadView). PathDownloadView.get_file() raises FileNotFound if file does not exists. 2013-11-28 23:29:50 +01:00
Benoît Bryon
194699045c Merge pull request #72 from benoitbryon/2-lighttpd-x-sendfile
Closes #2 - Introduced support of Lighttpd's X-Sendfile
2013-11-28 14:05:38 -08:00
Benoît Bryon
e33a8165ef Refs #2 - Introduced support of Lighttpd's X-Sendfile (mostly copied from django_downloadview.apache). 2013-11-28 23:02:40 +01:00
Benoît Bryon
b5191c6a6f Merge pull request #71 from benoitbryon/36-apache-x-sendfile
Closes #36 - Introduced support of Apache X-Sendfile
2013-11-28 13:23:23 -08:00
Benoît Bryon
ce31392efb Refs #36 - Updated changelog. 2013-11-28 22:21:11 +01:00
Benoît Bryon
8b44724c90 Refs #36 - Introduced support of Apache X-Sendfile. 2013-11-26 09:30:19 +01:00
Benoît Bryon
13c0f8b103 Back to development: 1.5 2013-11-24 11:18:03 +01:00
133 changed files with 3842 additions and 1799 deletions

8
.github/dependabot.yml vendored Normal file
View file

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

40
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,40 @@
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

61
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,61 @@
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 }}

15
.gitignore vendored
View file

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

16
.isort.cfg Normal file
View file

@ -0,0 +1,16 @@
[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

59
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,59 @@
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

18
.readthedocs.yaml Normal file
View file

@ -0,0 +1,18 @@
# .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: .

View file

@ -1,5 +0,0 @@
language: python
python:
- "2.7"
install: make configure develop
script: make test

27
AUTHORS
View file

@ -4,12 +4,25 @@ Authors & contributors
Maintainer: Benoît Bryon <benoit@marmelune.net>
Original code by `Novapost <http://www.novapost.fr>`_ team:
Original code by `PeopleDoc <https://www.people-doc.com/>`_ team:
* 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>
* Adam Chainz <adam@adamj.eu>
* Aleksi Häkli <aleksi.hakli@iki.fi>
* Benoît Bryon <benoit@marmelune.net>
Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors
* 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>

137
CHANGELOG
View file

@ -4,6 +4,141 @@ 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)
-----------------
- Introduced support from Django 1.11, 2.2 and 3.0.
- Drop support of Django 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 2.0 and 2.1
1.9 (2016-03-15)
----------------
- Feature #112 - Introduced support of Django 1.9.
- Feature #113 - Introduced support of Python 3.5.
- Feature #116 - ``HTTPFile`` has ``content_type`` property. It makes
``HTTPDownloadView`` proxy ``Content-Type`` header from remote location.
1.8 (2015-07-20)
----------------
Bugfixes.
- Bugfix #103 - ``PathDownloadView.get_file()`` makes a single call to
``PathDownloadView.get_file()`` (was doing it twice).
- Bugfix #104 - Pass numeric timestamp to Django's ``was_modified_since()``
(was passing a datetime).
1.7 (2015-06-13)
----------------
Bugfixes.
- Bugfix #87 - Filenames with commas are now supported. In download responses,
filename is now surrounded by double quotes.
- Bugfix #97 - ``HTTPFile`` proxies bytes as ``BytesIteratorIO`` (was undecoded
urllib3 file object). ``StringIteratorIO`` has been split into
``TextIteratorIO`` and ``BytesIteratorIO``. ``StringIteratorIO`` is
deprecated but kept for backward compatibility as an alias for
``TextIteratorIO``.
- Bugfix #92 - Run demo using ``make demo runserver`` (was broken).
- Feature #99 - Tox runs project's tests with Python 2.7, 3.3 and 3.4, and with
Django 1.5 to 1.8.
- Refactoring #98 - Refreshed development environment: packaging, Tox and
Sphinx.
1.6 (2014-03-03)
----------------
Python 3 support, development environment refactoring.
- Feature #46: introduced support for Python>=3.3.
- Feature #80: added documentation about "how to serve a file inline VS how to
serve a file as attachment". Improved documentation of views' base options
inherited from ``DownloadMixin``.
- Feature #74: the Makefile in project's repository no longer creates a
virtualenv. Developers setup the environment as they like, i.e. using
virtualenv, virtualenvwrapper or whatever. Tests are run with tox.
1.5 (2013-11-29)
----------------
X-Sendfile support and helpers to migrate for `django-sendfile`.
- Feature #2 - Introduced support of Lighttpd's x-Sendfile.
- Feature #36 - Introduced support of Apache's mod_xsendfile.
- Feature #41 - ``django_downloadview.sendfile`` is a port of
`django-sendfile`'s ``sendfile`` function. The documentation contains notes
about migrating from `django-sendfile` to `django-downloadview`.
1.4 (2013-11-24)
----------------
@ -131,4 +266,4 @@ Contains **backward incompatible changes.**
.. target-notes::
.. _`milestones`: https://github.com/benoitbryon/django-downloadview/issues/milestones
.. _`milestones`: https://github.com/jazzband/django-downloadview/milestones

46
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,46 @@
# Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

102
CONTRIBUTING.rst Normal file
View file

@ -0,0 +1,102 @@
############
Contributing
############
.. image:: https://jazzband.co/static/img/jazzband.svg
:target: https://jazzband.co/
:alt: Jazzband
This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
This document provides guidelines for people who want to contribute to
``django-downloadview``.
**************
Create tickets
**************
Please use the `bugtracker`_ **before** starting some work:
* check if the bug or feature request has already been filed. It may have been
answered too!
* else create a new ticket.
* if you plan to contribute, tell us, so that we are given an opportunity to
give feedback as soon as possible.
* Then, in your commit messages, reference the ticket with some
``refs #TICKET-ID`` syntax.
******************
Use topic branches
******************
* Work in branches.
* Prefix your branch with the ticket ID corresponding to the issue. As an
example, if you are working on ticket #23 which is about contribute
documentation, name your branch like ``23-contribute-doc``.
* If you work in a development branch and want to refresh it with changes from
master, please `rebase`_ or `merge-based rebase`_, i.e. do not merge master.
***********
Fork, clone
***********
Clone ``django-downloadview`` repository (adapt to use your own fork):
.. code:: sh
git clone git@github.com:jazzband/django-downloadview.git
cd django-downloadview/
*************
Usual actions
*************
The ``Makefile`` is the reference card for usual actions in development
environment:
* Install development toolkit with `pip`_: ``make develop``.
* Run tests with `tox`_: ``make test``.
* Build documentation: ``make documentation``. It builds `Sphinx`_
documentation in ``var/docs/html/index.html``.
* Release project with `zest.releaser`_: ``make release``.
* Cleanup local repository: ``make clean``, ``make distclean`` and
``make maintainer-clean``.
See also ``make help``.
*********************
Demo project included
*********************
The ``demo`` included in project's repository is part of the tests and
documentation. Maintain it along with code and documentation.
.. rubric:: Notes & references
.. target-notes::
.. _`bugtracker`:
https://github.com/jazzband/django-downloadview/issues
.. _`rebase`: http://git-scm.com/book/en/Git-Branching-Rebasing
.. _`merge-based rebase`: https://tech.people-doc.com/psycho-rebasing.html
.. _`pip`: https://pypi.python.org/pypi/pip/
.. _`tox`: https://tox.readthedocs.io/
.. _`Sphinx`: https://pypi.python.org/pypi/Sphinx/
.. _`zest.releaser`: https://pypi.python.org/pypi/zest.releaser/

83
INSTALL
View file

@ -4,43 +4,74 @@ Install
.. note::
If you want to install a development environment, please see :doc:`/dev`.
If you want to install a development environment, please see
:doc:`/contributing`.
System requirements:
* Python 2.7
************
Requirements
************
Install the package with your favorite Python installer. As an example, with
pip:
.. code:: sh
pip install django-downloadview
`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.
Installing `django-downloadview` will automatically trigger the installation of
the following requirements:
.. literalinclude:: /../setup.py
:language: python
:lines: 35
.. note::
Since version 1.1, django-downloadview requires Django>=1.5, which provides
:py:class:`~django.http.StreamingHttpResponse`.
:start-after: BEGIN requirements
:end-before: END requirements
**************************
Known good set of versions
**************************
************
As a library
************
`django-downloadview` has been tested in an environment with the following set
of versions. If something is going wrong with other versions, please report it
in `django-downloadview's bugtracker`_.
In most cases, you will use `django-downloadview` as a dependency of another
project. In such a case, you should add `django-downloadview` in your main
project's requirements. Typically in :file:`setup.py`:
.. literalinclude:: /../etc/buildout.cfg
:language: ini
:lines: 67-
.. code:: python
from setuptools import setup
setup(
install_requires=[
'django-downloadview',
#...
]
# ...
)
Then when you install your main project with your favorite package manager
(like `pip`_), `django-downloadview` and its recursive dependencies will
automatically be installed.
**********
Standalone
**********
You can install `django-downloadview` with your favorite Python package
manager. As an example with `pip`_:
.. code:: sh
pip install django-downloadview
*****
Check
*****
Check `django-downloadview` has been installed:
.. code:: sh
python -c "import django_downloadview;print(django_downloadview.__version__)"
You should get installed `django-downloadview`'s version.
.. rubric:: Notes & references
@ -53,5 +84,5 @@ in `django-downloadview's bugtracker`_.
.. target-notes::
.. _`django-downloadview's bugtracker`:
https://github.com/benoitbryon/django-downloadview/issues
.. _`Python`: https://www.python.org/
.. _`pip`: https://pip.pypa.io/

View file

@ -2,7 +2,7 @@
License
#######
Copyright (c) 2012-2013, Benoît Bryon.
Copyright (c) 2012-2014, Benoît Bryon.
All rights reserved.
Redistribution and use in source and binary forms, with or without

View file

@ -1,4 +1,8 @@
recursive-include django_downloadview *
global-exclude *.pyc .*.swp
include *.txt
include AUTHORS CHANGELOG INSTALL LICENSE README VERSION
global-exclude *.pyc
include AUTHORS
include CHANGELOG
include CONTRIBUTING.rst
include INSTALL
include LICENSE
include README.rst

142
Makefile
View file

@ -1,95 +1,101 @@
# Makefile for development.
# See INSTALL and docs/dev.txt for details.
SHELL = /bin/bash
ROOT_DIR = $(shell pwd)
BIN_DIR = $(ROOT_DIR)/bin
DATA_DIR = $(ROOT_DIR)/var
VIRTUALENV_DIR = $(ROOT_DIR)/lib/virtualenv
PIP = $(VIRTUALENV_DIR)/bin/pip
WGET = wget
PYTHON = $(VIRTUALENV_DIR)/bin/python
PROJECT = $(shell $(PYTHON) -c "import setup; print setup.NAME")
PACKAGE = $(shell $(PYTHON) -c "import setup; print setup.PACKAGES[0]")
BUILDOUT_CFG = $(ROOT_DIR)/etc/buildout.cfg
BUILDOUT_DIR = $(ROOT_DIR)/lib/buildout
BUILDOUT_VERSION = 2.2.1
BUILDOUT_BOOTSTRAP_URL = https://raw.github.com/buildout/buildout/$(BUILDOUT_VERSION)/bootstrap/bootstrap.py
BUILDOUT_BOOTSTRAP = $(BUILDOUT_DIR)/bootstrap.py
BUILDOUT_BOOTSTRAP_ARGS = -c $(BUILDOUT_CFG) --version=$(BUILDOUT_VERSION) buildout:directory=$(ROOT_DIR)
BUILDOUT = $(BIN_DIR)/buildout
BUILDOUT_ARGS = -N -c $(BUILDOUT_CFG) buildout:directory=$(ROOT_DIR)
NOSE = $(BIN_DIR)/nosetests
# Reference card for usual actions in development environment.
#
# For standard installation of django-downloadview as a library, see INSTALL.
#
# For details about django-downloadview's development environment, see
# CONTRIBUTING.rst.
#
PIP = pip
TOX = tox
BLACK = black
ISORT = isort
#: help - Display callable targets.
.PHONY: help
help:
@echo "Reference card for usual actions in development environment."
@echo "Here are available targets:"
@egrep -o "^#: (.+)" [Mm]akefile | sed 's/#: /* /'
configure:
# Configuration is stored in etc/ folder. Not generated yet.
develop: buildout
virtualenv:
if [ ! -d $(VIRTUALENV_DIR)/bin/ ]; then virtualenv --no-site-packages $(VIRTUALENV_DIR); fi
$(PIP) install -r $(ROOT_DIR)/etc/virtualenv.cfg
buildout: virtualenv
if [ ! -d $(BUILDOUT_DIR) ]; then mkdir -p $(BUILDOUT_DIR); fi
if [ ! -f $(BUILDOUT_BOOTSTRAP) ]; then wget -O $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_URL); fi
if [ ! -x $(BUILDOUT) ]; then $(PYTHON) $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_ARGS); fi
$(BUILDOUT) $(BUILDOUT_ARGS)
#: develop - Install minimal development utilities.
.PHONY: develop
develop:
$(PIP) install -e .
#: clean - Basic cleanup, mostly temporary files.
.PHONY: clean
clean:
find $(ROOT_DIR)/ -name "*.pyc" -delete
find $(ROOT_DIR)/ -name ".noseids" -delete
find . -name "*.pyc" -delete
find . -name '*.pyo' -delete
find . -name "__pycache__" -delete
#: distclean - Remove local builds, such as *.egg-info.
.PHONY: distclean
distclean: clean
rm -rf $(ROOT_DIR)/*.egg-info
rm -rf $(ROOT_DIR)/demo/*.egg-info
rm -rf *.egg
rm -rf *.egg-info
rm -rf demo/*.egg-info
#: maintainer-clean - Remove almost everything that can be re-generated.
.PHONY: maintainer-clean
maintainer-clean: distclean
rm -rf $(BIN_DIR)/
rm -rf $(ROOT_DIR)/lib/
rm -rf build/
rm -rf dist/
rm -rf .tox/
test: test-app test-demo test-documentation
#: test - Run test suites.
.PHONY: test
test:
mkdir -p var
$(PIP) install -e .[test]
$(TOX)
test-app:
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg -c $(ROOT_DIR)/etc/nose/$(PACKAGE).cfg
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/app.coverage
test-demo:
$(BIN_DIR)/demo test --nose-verbosity=2
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/demo.coverage
test-documentation:
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg sphinxcontrib.testbuild.tests
#: documentation - Build documentation (Sphinx, README, ...)
.PHONY: documentation
documentation: sphinx readme
#: sphinx - Build Sphinx documentation (docs).
.PHONY: sphinx
sphinx:
make --directory=docs clean html doctest
$(TOX) -e sphinx
documentation: sphinx
#: readme - Build standalone documentation files (README, CONTRIBUTING...).
.PHONY: readme
readme:
$(TOX) -e readme
demo: develop
$(BIN_DIR)/demo syncdb --noinput
#: demo - Setup demo project.
.PHONY: demo
demo:
pip install -e .
pip install -e demo
demo migrate --noinput
# Install fixtures.
mkdir -p var/media
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object-other
cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/nginx
$(BIN_DIR)/demo loaddata demo.json
mkdir -p var/media/object var/media/object-other/ var/media/nginx
cp -r demo/demoproject/fixtures/* var/media/object/
cp -r demo/demoproject/fixtures/* var/media/object-other/
cp -r demo/demoproject/fixtures/* var/media/nginx/
demo loaddata demo.json
#: runserver - Run demo server.
.PHONY: runserver
runserver: demo
$(BIN_DIR)/demo runserver
demo runserver
.PHONY: black
black:
$(BLACK) demo tests django_downloadview
release:
$(BIN_DIR)/fullrelease
.PHONY: isort
isort:
$(ISORT) --recursive django_downloadview tests demo

View file

@ -2,9 +2,33 @@
django-downloadview
###################
``django-downloadview`` makes it easy to serve files with Django:
.. image:: https://jazzband.co/static/img/badge.svg
:target: https://jazzband.co/
:alt: Jazzband
* you manage files with Django (permissions, search, generation, ...);
.. 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`_:
* you manage files with Django (permissions, filters, generation, ...);
* files are stored somewhere or generated somehow (local filesystem, remote
storage, memory...);
@ -12,14 +36,14 @@ django-downloadview
* ``django-downloadview`` helps you stream the files with very little code;
* ``django-downloadview`` helps you improve performances with reverse proxies,
via mechanisms such as Nginx's X-Accel.
via mechanisms such as Nginx's X-Accel or Apache's X-Sendfile.
*******
Example
*******
Let's serve a file stored in a FileField of some model:
Let's serve a file stored in a file field of some model:
.. code:: python
@ -35,13 +59,15 @@ Let's serve a file stored in a FileField of some model:
)
**********
Ressources
**********
*********
Resources
*********
* Documentation: http://django-downloadview.readthedocs.org
* Documentation: https://django-downloadview.readthedocs.io
* PyPI page: http://pypi.python.org/pypi/django-downloadview
* 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/issues/milestones
* 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
.. _`Django`: https://djangoproject.com

View file

@ -1 +0,0 @@
1.4

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 2.7, available as ``python`` command.
* `Python`_ version 3.7+, available as ``python`` command.
.. note::
You may use `Virtualenv`_ to make sure the active ``python`` is the right
@ -44,12 +44,12 @@ Execute:
.. code-block:: sh
git clone git@github.com:benoitbryon/django-downloadview.git
git clone git@github.com:jazzband/django-downloadview.git
cd django-downloadview/
make runserver
It installs and runs the demo server on localhost, port 8000. So have a look
at http://localhost:8000/
at ``http://localhost:8000/``.
.. note::
@ -66,7 +66,7 @@ References
.. target-notes::
.. _`demo folder in project's repository`:
https://github.com/benoitbryon/django-downloadview/tree/master/demo/demoproject/
https://github.com/jazzband/django-downloadview/tree/master/demo/demoproject/
.. _`Python`: http://python.org
.. _`Virtualenv`: http://virtualenv.org

View file

@ -0,0 +1 @@
"""Apache optimizations."""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -0,0 +1,61 @@
import os
from django.core.files.base import ContentFile
import django.test
from django.urls import reverse
from django_downloadview.apache import assert_x_sendfile
from demoproject.apache.views import storage, storage_dir
def setup_file():
if not os.path.exists(storage_dir):
os.makedirs(storage_dir)
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self):
"""'apache:optimized_by_middleware' returns X-Sendfile response."""
setup_file()
url = reverse("apache:optimized_by_middleware")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-optimized-by-middleware/hello-world.txt",
)
class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self):
"""'apache:optimized_by_decorator' returns X-Sendfile response."""
setup_file()
url = reverse("apache:optimized_by_decorator")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-optimized-by-decorator/hello-world.txt",
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'apache:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("apache:modified_headers")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-modified-headers/hello-world.txt",
)
self.assertEqual(response["X-Test"], "header")

View file

@ -0,0 +1,24 @@
"""URL mapping."""
from django.urls import path
from demoproject.apache import views
app_name = "apache"
urlpatterns = [
path(
"optimized-by-middleware/",
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]

View file

@ -0,0 +1,38 @@
import os
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
from django_downloadview.apache import x_sendfile
storage_dir = os.path.join(settings.MEDIA_ROOT, "apache")
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "apache/"])
)
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
optimized_by_decorator = x_sendfile(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
source_url=storage.base_url,
destination_dir="/apache-optimized-by-decorator/",
)
def _modified_headers(request):
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
response = view(request)
response["X-Test"] = "header"
return response
modified_headers = x_sendfile(
_modified_headers,
source_url=storage.base_url,
destination_dir="/apache-modified-headers/",
)

View file

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

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
@ -7,10 +7,20 @@ from django_downloadview import assert_download_response
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")
response = self.client.get(url)
assert_download_response(self, response, mime_type="image/png")

View file

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

View file

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

View file

@ -0,0 +1 @@
"""Lighttpd optimizations."""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -0,0 +1,61 @@
import os
from django.core.files.base import ContentFile
import django.test
from django.urls import reverse
from django_downloadview.lighttpd import assert_x_sendfile
from demoproject.lighttpd.views import storage, storage_dir
def setup_file():
if not os.path.exists(storage_dir):
os.makedirs(storage_dir)
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:optimized_by_middleware' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:optimized_by_middleware")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-optimized-by-middleware/hello-world.txt",
)
class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:optimized_by_decorator' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:optimized_by_decorator")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-optimized-by-decorator/hello-world.txt",
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:modified_headers")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-modified-headers/hello-world.txt",
)
self.assertEqual(response["X-Test"], "header")

View file

@ -0,0 +1,24 @@
"""URL mapping."""
from django.urls import path
from demoproject.lighttpd import views
app_name = "lighttpd"
urlpatterns = [
path(
"optimized-by-middleware/",
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]

View file

@ -0,0 +1,38 @@
import os
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
from django_downloadview.lighttpd import x_sendfile
storage_dir = os.path.join(settings.MEDIA_ROOT, "lighttpd")
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "lighttpd/"])
)
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
optimized_by_decorator = x_sendfile(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
source_url=storage.base_url,
destination_dir="/lighttpd-optimized-by-decorator/",
)
def _modified_headers(request):
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
response = view(request)
response["X-Test"] = "header"
return response
modified_headers = x_sendfile(
_modified_headers,
source_url=storage.base_url,
destination_dir="/lighttpd-modified-headers/",
)

View file

@ -4,8 +4,9 @@ import sys
from django.core.management import execute_from_command_line
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"{__package__}.settings")
execute_from_command_line(sys.argv)

View file

@ -1,8 +1,8 @@
import os
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
import django.test
from django.urls import reverse
from django_downloadview.nginx import assert_x_accel_redirect
@ -12,14 +12,14 @@ 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(u'Hello world!\n'))
storage.save("hello-world.txt", ContentFile("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,14 +30,15 @@ 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,
@ -48,4 +49,25 @@ class OptimizedByDecoratorTestCase(django.test.TestCase):
redirect_url="/nginx-optimized-by-decorator/hello-world.txt",
expires=None,
with_buffering=None,
limit_rate=None)
limit_rate=None,
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'nginx:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("nginx:modified_headers")
response = self.client.get(url)
assert_x_accel_redirect(
self,
response,
content_type="text/plain; charset=utf-8",
charset="utf-8",
basename="hello-world.txt",
redirect_url="/nginx-modified-headers/hello-world.txt",
expires=None,
with_buffering=None,
limit_rate=None,
)
self.assertEqual(response["X-Test"], "header")

View file

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

View file

@ -6,17 +6,33 @@ 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/')
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/",
)

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,29 +1,24 @@
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
import django.test
from django.urls import reverse
from django_downloadview import temporary_media_root, assert_download_response
from django_downloadview import assert_download_response, temporary_media_root
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
@ -33,13 +28,15 @@ 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):
@ -47,13 +44,15 @@ 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):
@ -61,10 +60,28 @@ 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):
@temporary_media_root()
def test_download_response(self):
"'inline_file_view' streams Document.file inline."
setup_document()
url = reverse("object:inline_file", kwargs={"slug": slug})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
mime_type="text/plain",
attachment=False,
)

View file

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

View file

@ -2,17 +2,19 @@ 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)

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
@ -7,22 +7,26 @@ from django_downloadview import assert_download_response
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,14 +1,13 @@
from django.conf.urls import patterns, url
from django.urls import path, re_path
from demoproject.path import views
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})$',
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})$",
views.dynamic_path,
name='dynamic_path'),
)
name="dynamic_path",
),
]

View file

@ -2,13 +2,12 @@ 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)
@ -27,10 +26,11 @@ 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(DynamicPathDownloadView, self).get_path()
relative_path = super().get_path()
# Make it absolute.
absolute_path = os.path.join(fixtures_dir, relative_path)
return absolute_path

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

@ -1,26 +1,26 @@
# -*- coding: utf-8 -*-
"""Django settings for Django-DownloadView demo project."""
from os.path import abspath, dirname, join
"""Django settings for django-downloadview demo project."""
import os
# Configure some relative directories.
demoproject_dir = dirname(abspath(__file__))
demo_dir = dirname(demoproject_dir)
root_dir = dirname(demo_dir)
data_dir = join(root_dir, 'var')
cfg_dir = join(root_dir, 'etc')
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")
# 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': join(data_dir, 'db.sqlite'),
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(data_dir, "db.sqlite"),
}
}
@ -29,59 +29,106 @@ DATABASES = {
SECRET_KEY = "This is a secret made public on project's repository."
# Media and static files.
MEDIA_ROOT = join(data_dir, 'media')
MEDIA_URL = '/media/'
STATIC_ROOT = 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 = (
# Standard Django applications.
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
# The actual django-downloadview demo.
'demoproject',
'demoproject.object', # Demo around ObjectDownloadView
'demoproject.storage', # Demo around StorageDownloadView
'demoproject.path', # Demo around PathDownloadView
'demoproject.http', # Demo around HTTPDownloadView
'demoproject.virtual', # Demo around VirtualDownloadView
'demoproject.nginx', # Sample optimizations for Nginx X-Accel.
# For test purposes. The demo project is part of django-downloadview
# test suite.
'django_nose',
"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",
)
# Middlewares.
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'
# 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",
]
# END middlewares
# Specific configuration for django_downloadview.SmartDownloadMiddleware.
DOWNLOADVIEW_BACKEND = 'django_downloadview.nginx.XAccelRedirectMiddleware'
# BEGIN backend
DOWNLOADVIEW_BACKEND = "django_downloadview.nginx.XAccelRedirectMiddleware"
# END backend
"""Could also be:
DOWNLOADVIEW_BACKEND = 'django_downloadview.apache.XSendfileMiddleware'
DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware'
"""
# BEGIN rules
DOWNLOADVIEW_RULES = [
{'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/",
# Bypass global default backend with additional argument "backend".
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
# enough. Here, the django_downloadview demo project needs to
# demonstrate usage of several backends.
"backend": "django_downloadview.apache.XSendfileMiddleware",
},
{
"source_url": "/media/lighttpd/",
"destination_dir": "/lighttpd-optimized-by-middleware/",
# Bypass global default backend with additional argument "backend".
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
# enough. Here, the django_downloadview demo project needs to
# demonstrate usage of several backends.
"backend": "django_downloadview.lighttpd.XSendfileMiddleware",
},
]
# Test/development settings.
DEBUG = True
TEMPLATE_DEBUG = DEBUG
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
nose_cfg_dir = join(cfg_dir, 'nose')
NOSE_ARGS = ['--config={etc}/base.cfg'.format(etc=nose_cfg_dir),
'--config={etc}/{package}.cfg'.format(etc=nose_cfg_dir,
package=__package__)]
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",
],
},
},
]

View file

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

View file

@ -1,17 +1,21 @@
import datetime
import unittest
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
from django.http.response import HttpResponseNotModified
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response, temporary_media_root
from django_downloadview import setup_view
from django_downloadview import (
assert_download_response,
setup_view,
temporary_media_root,
)
from demoproject.storage import views
# Fixtures.
file_content = 'Hello world!\n'
file_content = "Hello world!\n"
def setup_file(path):
@ -22,18 +26,48 @@ 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
response = self.client.get(
url, headers={"if-modified-since": f"Sat, 29 Oct {year} 19:43:31 GMT"}
)
self.assertTrue(isinstance(response, HttpResponseNotModified))
@temporary_media_root()
def test_modified_since_download_response(self):
"""'storage:static_path' streams file if modified."""
setup_file("1.txt")
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
response = self.client.get(
url, headers={"if-modified-since": "Sat, 29 Oct 1980 19:43:31 GMT"}
)
assert_download_response(
self,
response,
content=file_content,
basename="1.txt",
mime_type="text/plain",
)
class DynamicPathIntegrationTestCase(django.test.TestCase):
"""Integration tests around ``storage:dynamic_path`` URL."""
@temporary_media_root()
def test_download_response(self):
"""'dynamic_path' streams file by generated path.
@ -46,18 +80,21 @@ 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.
@ -69,8 +106,10 @@ 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,14 +1,17 @@
from django.conf.urls import patterns, url
from django.urls import re_path
from demoproject.storage import views
urlpatterns = patterns(
'',
url(r'^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
app_name = "storage"
urlpatterns = [
re_path(
r"^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$",
views.static_path,
name='static_path'),
url(r'^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
name="static_path",
),
re_path(
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,7 +2,6 @@ from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
storage = FileSystemStorage()
@ -12,9 +11,10 @@ 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(DynamicStorageDownloadView, self).get_path().upper()
return super().get_path().upper()
dynamic_path = DynamicStorageDownloadView.as_view(storage=storage)

View file

@ -1,4 +1,3 @@
{% load url from future %}
<html>
<head>
<title>django-downloadview demo</title>
@ -11,6 +10,9 @@
<p>In the following views, Django streams the files, no optimization
has been setup.</p>
<ul>
<li>
<a href="{% url 'object:default_file' 'hello-world' %}">object:default_file</a>
</li>
</ul>
<h2>Optimized downloads</h2>

View file

@ -1,13 +1,14 @@
# coding=utf8
"""Test suite for demoproject.download."""
from django.core.urlresolvers 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)

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

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

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test
from django.urls import reverse
from django_downloadview import assert_download_response
@ -7,34 +7,40 @@ from django_downloadview import assert_download_response
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,17 +1,10 @@
from django.conf.urls import patterns, url
from django.urls import path
from demoproject.virtual import views
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'),
)
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"),
]

View file

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

View file

@ -12,14 +12,16 @@ 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
# file. This includes Django's development server, if the WSGI_APPLICATION
# setting points here.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
# Apply WSGI middleware here.

View file

@ -1,47 +1,26 @@
# -*- coding: utf-8 -*-
"""Python packaging."""
import os
from setuptools import setup
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://{name}.readthedocs.org/'.format(name=NAME)
CLASSIFIERS = ['Development Status :: 4 - Beta',
'License :: OSI Approved :: BSD License',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 2.6',
'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)
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"]},
)

View file

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

View file

@ -0,0 +1,13 @@
"""Optimizations for Apache.
See also `documentation of mod_xsendfile for Apache
<https://tn123.org/mod_xsendfile/>`_ and :doc:`narrative documentation about
Apache optimizations </optimizations/apache>`.
"""
# API shortcuts.
from django_downloadview.apache.decorators import x_sendfile # NoQA
from django_downloadview.apache.middlewares import XSendfileMiddleware # NoQA
from django_downloadview.apache.response import XSendfileResponse # NoQA
from django_downloadview.apache.tests import assert_x_sendfile # NoQA

View file

@ -0,0 +1,16 @@
"""Decorators to apply Apache X-Sendfile on a specific view."""
from django_downloadview.apache.middlewares import XSendfileMiddleware
from django_downloadview.decorators import DownloadDecorator
def x_sendfile(view_func, *args, **kwargs):
"""Apply
:class:`~django_downloadview.apache.middlewares.XSendfileMiddleware` to
``view_func``.
Proxies (``*args``, ``**kwargs``) to middleware constructor.
"""
decorator = DownloadDecorator(XSendfileMiddleware)
return decorator(view_func, *args, **kwargs)

View file

@ -0,0 +1,36 @@
from django_downloadview.apache.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
"""Configurable middleware, for use in decorators or in global middlewares.
Standard Django middlewares are configured globally via settings. Instances
of this class are to be configured individually. It makes it possible to
use this class as the factory in
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
"""Constructor."""
super().__init__(get_response, source_dir, source_url, destination_dir)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
try:
redirect_url = self.get_redirect_url(response)
except NoRedirectionMatch:
return response
return XSendfileResponse(
file_path=redirect_url,
content_type=response["Content-Type"],
basename=response.basename,
attachment=response.attachment,
headers=response.headers,
)

View file

@ -0,0 +1,22 @@
"""Apache's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
class XSendfileResponse(ProxiedDownloadResponse):
"Delegates serving file to Apache via X-Sendfile header."
def __init__(
self, file_path, content_type, basename=None, attachment=True, headers=None
):
"""Return a HttpResponse with headers for Apache X-Sendfile."""
# content-type must be provided only as keyword argument to response
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path

View file

@ -0,0 +1,62 @@
from django_downloadview.apache.response import XSendfileResponse
class XSendfileValidator:
"""Utility class to validate XSendfileResponse instances.
See also :py:func:`assert_x_sendfile` shortcut function.
"""
def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid X-Sendfile response.
Optional ``assertions`` dictionary can be used to check additional
items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
self.assert_x_sendfile_response(test_case, response)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
assert_func(test_case, response, value)
def assert_x_sendfile_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XSendfileResponse))
def assert_basename(self, test_case, response, value):
test_case.assertEqual(response.basename, value)
def assert_content_type(self, test_case, response, value):
test_case.assertEqual(response["Content-Type"], value)
def assert_file_path(self, test_case, response, value):
test_case.assertEqual(response["X-Sendfile"], value)
def assert_attachment(self, test_case, response, value):
header = "Content-Disposition"
if value:
test_case.assertTrue(response[header].startswith("attachment"))
else:
test_case.assertFalse(header in response)
def assert_x_sendfile(test_case, response, **assertions):
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
Optional ``assertions`` dictionary can be used to check additional items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
validator = XSendfileValidator()
return validator(test_case, response, **assertions)

View file

@ -1,22 +1,29 @@
# -*- coding: utf-8 -*-
# flake8: noqa
"""Declaration of API shortcuts."""
from django_downloadview.io import StringIteratorIO # NoQA
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.sendfile 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,6 +5,12 @@ 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.
@ -17,16 +23,54 @@ 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,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Custom exceptions."""

View file

@ -1,12 +1,13 @@
# -*- coding: utf-8 -*-
"""File wrappers for use as exchange data between views and responses."""
from __future__ import absolute_import
from io import BytesIO
from urlparse import urlparse
from 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
@ -17,6 +18,7 @@ class StorageFile(File):
but unrelated to model instance.
"""
def __init__(self, storage, name, file=None):
"""Constructor.
@ -33,8 +35,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):
@ -48,7 +50,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).
@ -120,7 +122,10 @@ class StorageFile(File):
Proxy to self.storage.accessed_time(self.name).
"""
return self.storage.accessed(self.name)
try:
return self.storage.get_accessed_time(self.name)
except AttributeError:
return self.storage.accessed_time(self.name)
@property
def created_time(self):
@ -129,7 +134,10 @@ class StorageFile(File):
Proxy to self.storage.created_time(self.name).
"""
return self.storage.created_time(self.name)
try:
return self.storage.get_created_time(self.name)
except AttributeError:
return self.storage.created_time(self.name)
@property
def modified_time(self):
@ -138,12 +146,16 @@ class StorageFile(File):
Proxy to self.storage.modified_time(self.name).
"""
return self.storage.modified_time(self.name)
try:
return self.storage.get_modified_time(self.name)
except AttributeError:
return self.storage.modified_time(self.name)
class VirtualFile(File):
"""Wrapper for files that live in memory."""
def __init__(self, file=None, name=u'', url='', size=None):
def __init__(self, file=None, name="", url="", size=None):
"""Constructor.
file:
@ -156,7 +168,7 @@ class VirtualFile(File):
File URL.
"""
super(VirtualFile, self).__init__(file, name)
super().__init__(file, name)
self.url = url
if size is not None:
self._size = size
@ -172,7 +184,7 @@ class VirtualFile(File):
return self._size
def _set_size(self, value):
return super(VirtualFile, self)._set_size(value)
return super()._set_size(value)
size = property(_get_size, _set_size)
@ -194,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
@ -203,7 +215,6 @@ class VirtualFile(File):
yield buffer_
class HTTPFile(File):
"""Wrapper for files that live on remote HTTP servers.
@ -214,19 +225,19 @@ class HTTPFile(File):
Always sets "stream=True" in requests kwargs.
"""
def __init__(self, request_factory=requests.get, url='', name=u'',
**kwargs):
def __init__(self, request_factory=requests.get, url="", name="", **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
@ -234,13 +245,17 @@ 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
def file(self):
return self.request.raw
try:
return self._file
except AttributeError:
content = self.request.iter_content(decode_unicode=False)
self._file = BytesIteratorIO(content)
return self._file
@property
def size(self):
@ -249,4 +264,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"]

View file

@ -1,21 +1,26 @@
# -*- 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
class StringIteratorIO(io.TextIOBase):
"""A dynamically generated StringIO-like object.
class TextIteratorIO(io.TextIOBase):
"""A dynamically generated TextIO-like object.
Original code by Matt Joiner <anacrolix@gmail.com> from:
* http://stackoverflow.com/questions/12593576/adapt-an-iterator-to-behave-like-a-file-like-object-in-python
* http://stackoverflow.com/questions/12593576/
* https://gist.github.com/anacrolix/3788413
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
self._left = ''
#: Internal buffer.
self._left = ""
def readable(self):
return True
@ -26,40 +31,113 @@ class StringIteratorIO(io.TextIOBase):
self._left = next(self._iter)
except StopIteration:
break
else:
# Make sure we handle text.
self._left = force_str(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):
l = []
"""Return content up to ``n`` length."""
chunks = []
if n is None or n < 0:
while True:
m = self._read1()
if not m:
break
l.append(m)
chunks.append(m)
else:
while n > 0:
m = self._read1(n)
if not m:
break
n -= len(m)
l.append(m)
return ''.join(l)
chunks.append(m)
return "".join(chunks)
def readline(self):
l = []
chunks = []
while True:
i = self._left.find('\n')
i = self._left.find("\n")
if i == -1:
l.append(self._left)
chunks.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = ''
self._left = ""
break
else:
l.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(l)
return "".join(chunks)
class BytesIteratorIO(io.BytesIO):
"""A dynamically generated BytesIO-like object.
Original code by Matt Joiner <anacrolix@gmail.com> from:
* http://stackoverflow.com/questions/12593576/
* https://gist.github.com/anacrolix/3788413
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
#: Internal buffer.
self._left = b""
def readable(self):
return True
def _read1(self, n=None):
while not self._left:
try:
self._left = next(self._iter)
except StopIteration:
break
else:
# Make sure we handle text.
self._left = force_bytes(self._left)
ret = self._left[:n]
self._left = self._left[len(ret) :]
return ret
def read(self, n=None):
"""Return content up to ``n`` length."""
chunks = []
if n is None or n < 0:
while True:
m = self._read1()
if not m:
break
chunks.append(m)
else:
while n > 0:
m = self._read1(n)
if not m:
break
n -= len(m)
chunks.append(m)
return b"".join(chunks)
def readline(self):
chunks = []
while True:
i = self._left.find(b"\n")
if i == -1:
chunks.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = b""
break
else:
chunks.append(self._left[: i + 1])
self._left = self._left[i + 1 :]
break
return b"".join(chunks)

View file

@ -0,0 +1,14 @@
"""Optimizations for Lighttpd.
See also `documentation of X-Sendfile for Lighttpd
<http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file>`_ and
:doc:`narrative documentation about Lighttpd optimizations
</optimizations/lighttpd>`.
"""
# API shortcuts.
from django_downloadview.lighttpd.decorators import x_sendfile # NoQA
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA
from django_downloadview.lighttpd.response import XSendfileResponse # NoQA
from django_downloadview.lighttpd.tests import assert_x_sendfile # NoQA

View file

@ -0,0 +1,16 @@
"""Decorators to apply Lighttpd X-Sendfile on a specific view."""
from django_downloadview.decorators import DownloadDecorator
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware
def x_sendfile(view_func, *args, **kwargs):
"""Apply
:class:`~django_downloadview.lighttpd.middlewares.XSendfileMiddleware` to
``view_func``.
Proxies (``*args``, ``**kwargs``) to middleware constructor.
"""
decorator = DownloadDecorator(XSendfileMiddleware)
return decorator(view_func, *args, **kwargs)

View file

@ -0,0 +1,36 @@
from django_downloadview.lighttpd.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
"""Configurable middleware, for use in decorators or in global middlewares.
Standard Django middlewares are configured globally via settings. Instances
of this class are to be configured individually. It makes it possible to
use this class as the factory in
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
"""Constructor."""
super().__init__(get_response, source_dir, source_url, destination_dir)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
try:
redirect_url = self.get_redirect_url(response)
except NoRedirectionMatch:
return response
return XSendfileResponse(
file_path=redirect_url,
content_type=response["Content-Type"],
basename=response.basename,
attachment=response.attachment,
headers=response.headers,
)

View file

@ -0,0 +1,22 @@
"""Lighttpd's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
class XSendfileResponse(ProxiedDownloadResponse):
"Delegates serving file to Lighttpd via X-Sendfile header."
def __init__(
self, file_path, content_type, basename=None, attachment=True, headers=None
):
"""Return a HttpResponse with headers for Lighttpd X-Sendfile."""
# content-type must be porvided only as keyword argument to response
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path

View file

@ -0,0 +1,29 @@
import django_downloadview.apache.tests
from django_downloadview.lighttpd.response import XSendfileResponse
class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator):
"""Utility class to validate XSendfileResponse instances.
See also :py:func:`assert_x_sendfile` shortcut function.
"""
def assert_x_sendfile_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XSendfileResponse))
def assert_x_sendfile(test_case, response, **assertions):
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
Optional ``assertions`` dictionary can be used to check additional items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
validator = XSendfileValidator()
return validator(test_case, response, **assertions)

View file

@ -1,11 +1,12 @@
# -*- 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
import collections.abc
import copy
import os
from django.conf import settings
@ -30,12 +31,20 @@ def is_download_response(response):
return isinstance(response, DownloadResponse)
class BaseDownloadMiddleware(object):
class BaseDownloadMiddleware:
"""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.
@ -59,6 +68,7 @@ class BaseDownloadMiddleware(object):
class RealDownloadMiddleware(BaseDownloadMiddleware):
"""Download middleware that cannot handle virtual files."""
def is_download_response(self, response):
"""Return True for DownloadResponse, except for "virtual" files.
@ -67,18 +77,12 @@ class RealDownloadMiddleware(BaseDownloadMiddleware):
whose file attribute have either an URL or a file name.
"""
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
return super().is_download_response(response) and bool(
getattr(response.file, "url", None) or getattr(response.file, "name", None)
)
class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
"Download middleware that dispatches job to several middleware instances."
class DownloadDispatcher:
def __init__(self, middlewares=AUTO_CONFIGURE):
#: List of children middlewares.
self.middlewares = middlewares
@ -88,35 +92,48 @@ class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
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 process_download_response(self, request, response):
def dispatch(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 SmartDownloadMiddleware(BaseDownloadMiddleware):
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):
"""Easy to configure download middleware."""
def __init__(self,
backend_factory=AUTO_CONFIGURE,
backend_options=AUTO_CONFIGURE):
def __init__(
self,
get_response,
backend_factory=AUTO_CONFIGURE,
backend_options=AUTO_CONFIGURE,
):
"""Constructor."""
#: :class:`DownloadDispatcher` instance that can hold multiple
#: backend instances.
self.dispatcher = DownloadDispatcherMiddleware(middlewares=[])
#: Callable (typically a class) to instanciate backend (typically a
super().__init__(get_response, middlewares=[])
#: Callable (typically a class) to instantiate 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 instanciate backend
#: List of positional or keyword arguments to instantiate backend
#: instances.
self.backend_options = backend_options
if self.backend_options is AUTO_CONFIGURE:
@ -127,31 +144,34 @@ class SmartDownloadMiddleware(BaseDownloadMiddleware):
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
``settings.DOWNLOADVIEW_RULES``."""
try:
options_list = settings.DOWNLOADVIEW_RULES
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.Mapping): # Using kwargs.
if isinstance(options, collections.abc.Mapping): # Using kwargs.
kwargs = options
else:
args = options
middleware_instance = self.backend_factory(*args, **kwargs)
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."""
@ -159,8 +179,13 @@ class NoRedirectionMatch(Exception):
class ProxiedDownloadMiddleware(RealDownloadMiddleware):
"""Base class for middlewares that use optimizations of reverse proxies."""
def __init__(self, source_dir=None, source_url=None, destination_url=None):
def __init__(
self, get_response, source_dir=None, source_url=None, destination_url=None
):
"""Constructor."""
super().__init__(get_response)
self.source_dir = source_dir
self.source_url = source_url
self.destination_url = destination_url
@ -168,7 +193,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
@ -176,9 +201,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
@ -187,17 +212,21 @@ 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,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Optimizations for Nginx.
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
@ -6,9 +5,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,8 +3,10 @@ import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch)
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
from django_downloadview.nginx.response import XAccelRedirectResponse
@ -17,15 +19,26 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(self, 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:
@ -33,18 +46,20 @@ 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:
source_dir = source_dir
else:
source_dir = source_dir
super(XAccelRedirectMiddleware, self).__init__(source_dir,
source_url,
destination_url)
super().__init__(get_response, source_dir, source_url, destination_url)
self.expires = expires
self.with_buffering = with_buffering
self.limit_rate = limit_rate
@ -62,13 +77,16 @@ 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)
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,
)
class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
@ -105,16 +123,20 @@ class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
"""
def __init__(self):
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(SingleXAccelRedirectMiddleware, self).__init__(
"settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is "
"required by %s middleware" % self.__class__.__name__
)
super().__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,34 +1,43 @@
# -*- coding: utf-8 -*-
"""Nginx's specific responses."""
from datetime import timedelta
from django.utils.timezone import now
from django_downloadview.response import ProxiedDownloadResponse
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):
def __init__(
self,
redirect_url,
content_type,
basename=None,
expires=None,
with_buffering=None,
limit_rate=None,
attachment=True,
headers=None,
):
"""Return a HttpResponse with headers for Nginx X-Accel-Redirect."""
super(XAccelRedirectResponse, self).__init__(content_type=content_type)
# content-type must be porvided only as keyword argument to response
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment:
self.basename = basename or url_basename(redirect_url,
content_type)
self['Content-Disposition'] = 'attachment; filename={name}'.format(
name=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,4 +1,3 @@
# -*- coding: utf-8 -*-
"""Django settings around Nginx X-Accel.
.. warning::
@ -8,26 +7,31 @@
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.
middleware = 'django_downloadview.nginx.XAccelRedirectMiddleware'
if middleware in settings.MIDDLEWARE_CLASSES:
deprecated_middleware = "django_downloadview.nginx.XAccelRedirectMiddleware"
if deprecated_middleware in settings.MIDDLEWARE:
raise ImproperlyConfigured(
'{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.
@ -42,10 +46,9 @@ deprecated_msg = 'settings.{deprecated} is deprecated. You should combine ' \
#: 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)
@ -61,10 +64,9 @@ 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)
@ -80,49 +82,43 @@ 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,12 +1,13 @@
from django_downloadview.nginx.response import XAccelRedirectResponse
class XAccelRedirectValidator(object):
class XAccelRedirectValidator:
"""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.
@ -35,8 +36,8 @@ class XAccelRedirectValidator(object):
"""
self.assert_x_accel_redirect_response(test_case, response)
for key, value in assertions.iteritems():
assert_func = getattr(self, 'assert_%s' % key)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
assert_func(test_case, response, value)
def assert_x_accel_redirect_response(self, test_case, response):
@ -46,45 +47,45 @@ class XAccelRedirectValidator(object):
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,10 @@
# -*- coding: utf-8 -*-
""":py:class:`django.http.HttpResponse` subclasses."""
import os
import mimetypes
import os
import re
import unicodedata
import urllib
from urllib.parse import quote
from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse
@ -12,48 +12,82 @@ from django.utils.encoding import force_str
def encode_basename_ascii(value):
"""Return US-ASCII encoded ``value`` for use in Content-Disposition header.
"""Return US-ASCII encoded ``value`` for Content-Disposition header.
>>> encode_basename_ascii(unicode('éà', 'utf-8'))
u'ea'
>>> print(encode_basename_ascii(u'éà'))
ea
Spaces are converted to underscores.
>>> encode_basename_ascii(' ')
u'_'
Text with non US-ASCII characters is expected to be unicode.
>>> encode_basename_ascii('éà') # doctest: +ELLIPSIS
Traceback (most recent call last):
...
UnicodeDecodeError: \'ascii\' codec can\'t decode byte ...
>>> print(encode_basename_ascii(' '))
_
Of course, ASCII values are not modified.
>>> encode_basename_ascii('ea')
u'ea'
>>> print(encode_basename_ascii('ea'))
ea
>>> print(encode_basename_ascii(b'ea'))
ea
"""
ascii_basename = unicode(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, bytes):
value = value.decode("utf-8")
ascii_basename = str(value)
ascii_basename = unicodedata.normalize("NFKD", ascii_basename)
ascii_basename = ascii_basename.encode("ascii", "ignore")
ascii_basename = ascii_basename.decode("ascii")
ascii_basename = re.sub(r"[\s]", "_", ascii_basename)
return ascii_basename
def encode_basename_utf8(value):
"""Return UTF-8 encoded ``value`` for use in Content-Disposition header.
>>> encode_basename_utf8(u' .txt')
'%20.txt'
>>> print(encode_basename_utf8(u' .txt'))
%20.txt
>>> encode_basename_utf8(unicode('éà', 'utf-8'))
'%C3%A9%C3%A0'
>>> print(encode_basename_utf8(u'éà'))
%C3%A9%C3%A0
"""
return urllib.quote(force_str(value))
return quote(force_str(value))
def content_disposition(filename):
"""Return value of ``Content-Disposition`` header with 'attachment'.
>>> print(content_disposition('demo.txt'))
attachment; filename="demo.txt"
If filename is empty, only "attachment" is returned.
>>> print(content_disposition(''))
attachment
If filename contains non US-ASCII characters, the returned value contains
UTF-8 encoded filename and US-ASCII fallback.
>>> print(content_disposition(u'é.txt'))
attachment; filename="e.txt"; filename*=UTF-8''%C3%A9.txt
"""
if not filename:
return "attachment"
# ASCII filenames are quoted and must ensure escape sequences
# in the filename won't break out of the quoted header value
# which can permit a reflected file download attack. The UTF-8
# version is immune because it's not quoted.
ascii_filename = (
encode_basename_ascii(filename).replace("\\", "\\\\").replace('"', r"\"")
)
utf8_filename = encode_basename_utf8(filename)
if ascii_filename == utf8_filename: # ASCII only.
return f'attachment; filename="{ascii_filename}"'
else:
return (
f'attachment; filename="{ascii_filename}"; '
f"filename*=UTF-8''{utf8_filename}"
)
class DownloadResponse(StreamingHttpResponse):
@ -63,30 +97,7 @@ class DownloadResponse(StreamingHttpResponse):
where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a
file wrapper.
Constructor differs a bit from :class:`~django.http.response.HttpResponse`:
``file_instance``
A :doc:`file wrapper instance </files>`, such as
:class:`~django.core.files.base.File`.
``attachement``
Boolean. Whether to return the file as attachment or not.
Affects ``Content-Disposition`` header.
``basename``
Unicode. Client-side name of the file to stream.
Only used if ``attachment`` is ``True``.
Affects ``Content-Disposition`` header.
``status``
HTTP status code.
``content_type``
Value for ``Content-Type`` header.
If ``None``, then mime-type and encoding will be populated by the
response (default implementation uses mimetypes, based on file
name).
Constructor differs a bit from :class:`~django.http.response.HttpResponse`.
Here are some highlights to understand internal mechanisms and motivations:
@ -97,8 +108,8 @@ class DownloadResponse(StreamingHttpResponse):
generator-iterator) that produces the output in a block-by-block
fashion.
* `Django WSGI handler (application implementation) return response object
<https://github.com/django/django/blob/fd1279a44df3b9a837453cd79fd0fbcf81bae39d/django/core/handlers/wsgi.py#L268>`_.
* Django WSGI handler (application implementation) returns response object
(see :mod:`django.core.handlers.wsgi`).
* :class:`django.http.HttpResponse` and subclasses are iterators.
@ -112,20 +123,56 @@ class DownloadResponse(StreamingHttpResponse):
attributes (size, name, ...).
"""
def __init__(self, file_instance, attachment=True, basename=None,
status=200, content_type=None):
"""Constructor."""
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.
If ``None``, then mime-type and encoding will be
populated by the response (default implementation
uses :mod:`mimetypes`, based on file name).
"""
#: A :doc:`file wrapper instance </files>`, such as
#: :class:`~django.core.files.base.File`.
self.file = file_instance
super(DownloadResponse, self).__init__(streaming_content=self.file,
status=status,
content_type=content_type)
super().__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``.
#: Affects ``Content-Disposition`` header.
self.basename = basename
#: Whether to return the file as attachment or not.
#: 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
#: via Python's :mod:`mimetypes`. See :meth:`get_mime_type`.
self.file_mimetype = file_mimetype
#: Value for file's encoding. If ``None`` (the default), then the
#: file's encoding will be guessed via Python's :mod:`mimetypes`. See
#: :meth:`get_encoding`.
self.file_encoding = file_encoding
# Apply default headers.
for header, value in self.default_headers.items():
if not header in self:
if header not in self:
self[header] = value # Does self support setdefault?
@property
@ -137,37 +184,24 @@ class DownloadResponse(StreamingHttpResponse):
``Content-Disposition`` header is encoded according to `RFC 5987
<http://tools.ietf.org/html/rfc5987>`_. See also
http://stackoverflow.com/questions/93551/how-to-encode-the-filename-parameter-of-content-disposition-header-in-http.
http://stackoverflow.com/questions/93551/.
"""
try:
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'] = \
"attachment; filename={ascii}; filename*=UTF-8''{utf8}" \
.format(ascii=encode_basename_ascii(basename),
utf8=encode_basename_utf8(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:
@ -180,19 +214,21 @@ class DownloadResponse(StreamingHttpResponse):
try:
return self.file.content_type
except AttributeError:
content_type_template = '{mime_type}; charset={charset}'
return content_type_template.format(mime_type=self.get_mime_type(),
charset=self.get_charset())
return f"{self.get_mime_type()}; charset={self.get_charset()}"
def get_mime_type(self):
"""Return mime-type of the file."""
default_mime_type = 'application/octet-stream'
if self.file_mimetype is not None:
return self.file_mimetype
default_mime_type = "application/octet-stream"
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return mime_type or default_mime_type
def get_encoding(self):
"""Return encoding of the file to serve."""
if self.file_encoding is not None:
return self.file_encoding
basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename)
return encoding

View file

@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
"""Port of django-sendfile in django-downloadview."""
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview.views.storage import StorageDownloadView
def sendfile(request, filename, attachment=False, attachment_filename=None,
mimetype=None, encoding=None):
"""Port of django-sendfile's API in django-downloadview.
Instantiates a :class:`~django.core.files.storage.FileSystemStorage` with
``settings.SENDFILE_ROOT`` as root folder. Then uses
:class:`StorageDownloadView` to stream the file by ``filename``.
"""
storage = FileSystemStorage(location=settings.SENDFILE_ROOT)
view = StorageDownloadView().as_view(storage=storage,
path=filename,
attachment=attachment,
basename=attachment_filename)
return view(request)

View file

@ -0,0 +1,27 @@
"""Port of django-sendfile in django-downloadview."""
from django_downloadview.views.path import PathDownloadView
def sendfile(
request,
filename,
attachment=False,
attachment_filename=None,
mimetype=None,
encoding=None,
):
"""Port of django-sendfile's API in django-downloadview.
Instantiates a :class:`~django_downloadview.views.path.PathDownloadView` to
stream the file by ``filename``.
"""
view = PathDownloadView.as_view(
path=filename,
attachment=attachment,
basename=attachment_filename,
mimetype=mimetype,
encoding=encoding,
)
return view(request)

View file

@ -0,0 +1,22 @@
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,13 +1,12 @@
"""Testing utilities."""
import shutil
import tempfile
from django.conf import settings
from django.test.utils import override_settings
from django.utils.encoding import force_bytes
from django_downloadview.middlewares import is_download_response
from django_downloadview.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):
@ -46,7 +45,7 @@ class temporary_media_root(override_settings):
Use this function as a context manager:
>>> from django_downloadview.test import temporary_media_root
>>> from django.conf import settings
>>> from django.conf import settings # NoQA
>>> global_media_root = settings.MEDIA_ROOT
>>> with temporary_media_root():
... global_media_root == settings.MEDIA_ROOT
@ -66,22 +65,24 @@ 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(temporary_media_root, self).enable()
self.options["MEDIA_ROOT"] = tmp_dir
super().enable()
def disable(self):
"""Remove directory settings.MEDIA_ROOT then restore original
setting."""
shutil.rmtree(settings.MEDIA_ROOT)
super(temporary_media_root, self).disable()
super().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.
@ -101,8 +102,8 @@ class DownloadResponseValidator(object):
"""
self.assert_download_response(test_case, response)
for key, value in assertions.iteritems():
assert_func = getattr(self, 'assert_%s' % key)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
assert_func(test_case, response, value)
def assert_download_response(self, test_case, response):
@ -112,35 +113,46 @@ class DownloadResponseValidator(object):
"""Implies ``attachement is True``."""
ascii_name = encode_basename_ascii(value)
utf8_name = encode_basename_utf8(value)
check_utf8 = False
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('filename={name}'.format(
name=ascii_name),
response['Content-Disposition'])
test_case.assertIn(
f'filename="{ascii_name}"',
response["Content-Disposition"],
)
if check_utf8:
test_case.assertIn(
"filename*=UTF-8''{name}".format(name=utf8_name),
response['Content-Disposition'])
f"filename*=UTF-8''{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):
test_case.assertEqual(''.join(response.streaming_content), 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))
def assert_attachment(self, test_case, response, value):
test_case.assertEqual('attachment;' in response['Content-Disposition'],
value)
if value:
test_case.assertTrue("attachment;" in response["Content-Disposition"])
else:
test_case.assertTrue(
"Content-Disposition" not in response
or "attachment;" not in response["Content-Disposition"]
)
def assert_download_response(test_case, response, **assertions):

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests."""

View file

@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests around responses."""
import unittest
from django_downloadview.response import DownloadResponse
class DownloadResponseTestCase(unittest.TestCase):
"""Tests around :class:`django_downloadviews.response.DownloadResponse`."""
def test_content_disposition_encoding(self):
"""Content-Disposition header is encoded."""
response = DownloadResponse('fake file',
attachment=True,
basename=u'espacé .txt',)
headers = response.default_headers
self.assertIn("filename=espace_.txt",
headers['Content-Disposition'])
self.assertIn("filename*=UTF-8''espac%C3%A9%20.txt",
headers['Content-Disposition'])

View file

@ -1,232 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests around views."""
import unittest
try:
from unittest import mock
except ImportError:
import mock
from django.http import Http404
from django.http.response import HttpResponseNotModified
import django.test
from django_downloadview import exceptions
from django_downloadview.test import setup_view
from django_downloadview import views
class DownloadMixinTestCase(unittest.TestCase):
"""Tests around :class:`django_downloadviews.views.base.DownloadMixin`."""
def test_get_file(self):
"""DownloadMixin.get_file() raise NotImplementedError.
Subclasses must implement it!
"""
mixin = views.DownloadMixin()
with self.assertRaises(NotImplementedError):
mixin.get_file()
def test_get_basename(self):
"""DownloadMixin.get_basename() returns basename attribute."""
mixin = views.DownloadMixin()
self.assertEqual(mixin.get_basename(), None)
mixin.basename = 'fake'
self.assertEqual(mixin.get_basename(), 'fake')
def test_was_modified_since_file(self):
"""DownloadMixin.was_modified_since() tries (1) file's implementation.
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
first tries to delegate computations to file wrapper's implementation.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
return_value=mock.sentinel.was_modified)
mixin = views.DownloadMixin()
self.assertIs(
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
mock.sentinel.was_modified)
file_wrapper.was_modified_since.assertCalledOnceWith(
mock.sentinel.since)
def test_was_modified_since_django(self):
"""DownloadMixin.was_modified_since() tries (2) files attributes.
When calling file wrapper's ``was_modified_since()`` raises
``NotImplementedError`` or ``AttributeError``,
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
tries to pass file wrapper's ``size`` and ``modified_time`` to
:func:`django.views.static import was_modified_since`.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
side_effect=AttributeError)
file_wrapper.size = mock.sentinel.size
file_wrapper.modified_time = mock.sentinel.modified_time
was_modified_since_mock = mock.Mock(
return_value=mock.sentinel.was_modified)
mixin = views.DownloadMixin()
with mock.patch('django_downloadview.views.base.was_modified_since',
new=was_modified_since_mock):
self.assertIs(
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
mock.sentinel.was_modified)
was_modified_since_mock.assertCalledOnceWith(
mock.sentinel.size,
mock.sentinel.modified_time)
def test_was_modified_since_fallback(self):
"""DownloadMixin.was_modified_since() fallbacks to `True`.
When:
* calling file wrapper's ``was_modified_since()`` raises
``NotImplementedError`` or ``AttributeError``;
* and accessing ``size`` and ``modified_time`` from file wrapper raises
``NotImplementedError`` or ``AttributeError``...
... then
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
returns ``True``.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
side_effect=NotImplementedError)
type(file_wrapper).modified_time = mock.PropertyMock(
side_effect=NotImplementedError)
mixin = views.DownloadMixin()
self.assertIs(
mixin.was_modified_since(file_wrapper, 'fake since'),
True)
def test_not_modified_response(self):
"DownloadMixin.not_modified_response returns HttpResponseNotModified."
mixin = views.DownloadMixin()
response = mixin.not_modified_response()
self.assertTrue(isinstance(response, HttpResponseNotModified))
def test_download_response(self):
"DownloadMixin.download_response() returns download response instance."
mixin = views.DownloadMixin()
mixin.file_instance = mock.sentinel.file_wrapper
response_factory = mock.Mock(return_value=mock.sentinel.response)
mixin.response_class = response_factory
response_kwargs = {'dummy': 'value',
'file_instance': mock.sentinel.file_wrapper,
'attachment': True,
'basename': None}
response = mixin.download_response(**response_kwargs)
self.assertIs(response, mock.sentinel.response)
response_factory.assert_called_once_with(**response_kwargs) # Not args
def test_render_to_response_not_modified(self):
"""DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE
header (calls ``not_modified_response()``)."""
# Setup.
mixin = views.DownloadMixin()
mixin.request = django.test.RequestFactory().get(
'/dummy-url',
HTTP_IF_MODIFIED_SINCE=mock.sentinel.http_if_modified_since)
mixin.was_modified_since = mock.Mock(return_value=False)
mixin.not_modified_response = mock.Mock(
return_value=mock.sentinel.http_not_modified_response)
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
# Run.
response = mixin.render_to_response()
# Check.
self.assertIs(response, mock.sentinel.http_not_modified_response)
mixin.get_file.assert_called_once_with()
mixin.was_modified_since.assert_called_once_with(
mock.sentinel.file_wrapper,
mock.sentinel.http_if_modified_since)
mixin.not_modified_response.assert_called_once_with()
def test_render_to_response_modified(self):
"""DownloadMixin.render_to_response() calls download_response()."""
# Setup.
mixin = views.DownloadMixin()
mixin.request = django.test.RequestFactory().get(
'/dummy-url',
HTTP_IF_MODIFIED_SINCE=None)
mixin.was_modified_since = mock.Mock()
mixin.download_response = mock.Mock(
return_value=mock.sentinel.download_response)
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
# Run.
response = mixin.render_to_response()
# Check.
self.assertIs(response, mock.sentinel.download_response)
mixin.get_file.assert_called_once_with()
self.assertEqual(mixin.was_modified_since.call_count, 0)
mixin.download_response.assert_called_once_with()
def test_render_to_response_file_not_found(self):
"DownloadMixin.render_to_response() calls file_not_found_response()."
# Setup.
mixin = views.DownloadMixin()
mixin.request = django.test.RequestFactory().get('/dummy-url')
mixin.get_file = mock.Mock(side_effect=exceptions.FileNotFound)
mixin.file_not_found_response = mock.Mock()
# Run.
mixin.render_to_response()
# Check.
mixin.file_not_found_response.assert_called_once_with()
def test_file_not_found_response(self):
"""DownloadMixin.file_not_found_response() raises Http404."""
mixin = views.DownloadMixin()
with self.assertRaises(Http404):
mixin.file_not_found_response()
class BaseDownloadViewTestCase(unittest.TestCase):
"Tests around :class:`django_downloadviews.views.base.BaseDownloadView`."
def test_get(self):
"""BaseDownloadView.get() calls render_to_response()."""
request = django.test.RequestFactory().get('/dummy-url')
args = ['dummy-arg']
kwargs = {'dummy': 'kwarg'}
view = setup_view(views.BaseDownloadView(), request, *args, **kwargs)
view.render_to_response = mock.Mock(
return_value=mock.sentinel.response)
response = view.get(request, *args, **kwargs)
self.assertIs(response, mock.sentinel.response)
view.render_to_response.assert_called_once_with()
class ObjectDownloadViewTestCase(unittest.TestCase):
"Tests for :class:`django_downloadviews.views.object.ObjectDownloadView`."
def test_get_file_ok(self):
"ObjectDownloadView.get_file() returns ``file`` field by default."
view = setup_view(views.ObjectDownloadView(), 'fake request')
view.object = mock.Mock(spec=['file'])
view.get_file()
def test_get_file_wrong_field(self):
"""ObjectDownloadView.get_file() raises AttributeError if field does
not exist.
``AttributeError`` is expected because this is a configuration error,
i.e. it is related to Python code.
"""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock(spec=['file'])
with self.assertRaises(AttributeError):
view.get_file()
def test_get_file_empty_field(self):
"""ObjectDownloadView.get_file() raises FileNotFound if field does not
exist."""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock()
view.object.other_field = None
with self.assertRaises(exceptions.FileNotFound):
view.get_file()

View file

@ -1,9 +1,8 @@
# -*- 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):
@ -16,7 +15,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):
@ -26,11 +25,11 @@ def url_basename(url, content_type):
If URL contains extension, it is kept as-is.
>>> url_basename(u'/path/to/somefile.rst', 'text/plain')
u'somefile.rst'
>>> print(url_basename(u'/path/to/somefile.rst', 'text/plain'))
somefile.rst
"""
return url.split('/')[-1]
return url.split("/")[-1]
def import_member(import_string):
@ -42,6 +41,6 @@ def import_member(import_string):
True
"""
module_name, factory_name = str(import_string).rsplit('.', 1)
module = __import__(module_name, globals(), locals(), [factory_name], -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,12 +1,9 @@
# coding=utf-8
"""Views."""
# -*- coding: utf-8 -*-
"""Views to stream files."""
# API shortcuts.
from django_downloadview.views.base import (DownloadMixin, # NoQA
BaseDownloadView)
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.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,7 +1,9 @@
# -*- coding: utf-8 -*-
"""Base material for download views: :class:`DownloadMixin` and
:class:`BaseDownloadView`"""
from django.http import HttpResponseNotModified, Http404
import calendar
from django.http import Http404, HttpResponseNotModified
from django.views.generic.base import View
from django.views.static import was_modified_since
@ -24,15 +26,41 @@ class DownloadMixin(object):
returned by :py:meth:`get_file`.
"""
#: Response class, to be used in :py:meth:`render_to_response`.
response_class = DownloadResponse
#: Whether to return the response as attachment or not.
#:
#: When ``True`` (the default), the view returns file "as attachment",
#: which usually triggers a "Save the file as ..." prompt.
#:
#: When ``False``, the view returns file "inline", as if it was an element
#: of the current page.
#:
#: .. note::
#:
#: The actual behaviour client-side depends on the browser and its
#: configuration.
#:
#: In fact, affects the "Content-Disposition" header via :attr:`response's
#: attachment attribute
#: <django_downloadview.response.DownloadResponse.attachment>`.
attachment = True
#: Client-side filename, if only file is returned as attachment.
basename = None
#: File's mime type.
#: If ``None`` (the default), then the file's mime type will be guessed via
#: :mod:`mimetypes`.
mimetype = None
#: File's encoding.
#: If ``None`` (the default), then the file's encoding will be guessed via
#: :mod:`mimetypes`.
encoding = None
def get_file(self):
"""Return a file wrapper instance.
@ -43,8 +71,29 @@ class DownloadMixin(object):
raise NotImplementedError()
def get_basename(self):
"""Return :attr:`basename`.
Override this method if you need more dynamic basename.
"""
return self.basename
def get_mimetype(self):
"""Return :attr:`mimetype`.
Override this method if you need more dynamic mime type.
"""
return self.mimetype
def get_encoding(self):
"""Return :attr:`encoding`.
Override this method if you need more dynamic encoding.
"""
return self.encoding
def was_modified_since(self, file_instance, since):
"""Return True if ``file_instance`` was modified after ``since``.
@ -54,9 +103,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 and a size.
It is passed ``modified_time`` and ``size`` attributes from file
wrapper. If file wrapper does not support these attributes
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
(``AttributeError`` or ``NotImplementedError`` is raised), then
the file is considered as modified and ``True`` is returned.
@ -65,12 +114,14 @@ class DownloadMixin(object):
return file_instance.was_modified_since(since)
except (AttributeError, NotImplementedError):
try:
modification_time = file_instance.modified_time
size = file_instance.size
except (AttributeError, NotImplementedError):
modification_time = calendar.timegm(
file_instance.modified_time.utctimetuple()
)
except (AttributeError, NotImplementedError) as e:
print("!=======!", e)
return True
else:
return was_modified_since(since, modification_time, size)
return was_modified_since(since, modification_time)
def not_modified_response(self, *response_args, **response_kwargs):
"""Return :class:`django.http.HttpResponseNotModified` instance."""
@ -78,9 +129,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_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
@ -104,7 +157,7 @@ class DownloadMixin(object):
except exceptions.FileNotFound:
return self.file_not_found_response()
# Respect the If-Modified-Since header.
since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None)
since = self.request.headers.get("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)
@ -114,6 +167,7 @@ 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,15 +1,16 @@
# -*- 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 = u''
url = ""
#: Additional keyword arguments for request handler.
request_kwargs = {}
@ -40,7 +41,9 @@ 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,9 +31,10 @@ 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
@ -71,13 +72,11 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
"""
file_instance = getattr(self.object, self.file_field)
if not file_instance:
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)
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)
if model_field:
value = getattr(self.object, model_field)
setattr(file_instance, field, value)
@ -85,14 +84,14 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
def get_basename(self):
"""Return client-side filename."""
basename = super(ObjectDownloadView, self).get_basename()
basename = super().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(ObjectDownloadView, self).get(request, *args, **kwargs)
return super().get(request, *args, **kwargs)

View file

@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-
""":class:`PathDownloadView`."""
import os
from django.core.files import File
from django_downloadview.exceptions import FileNotFound
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
@ -14,7 +18,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.
@ -30,4 +34,7 @@ class PathDownloadView(BaseDownloadView):
def get_file(self):
"""Use path to return wrapper around file to serve."""
return File(open(self.get_path(), 'rb'))
filename = self.get_path()
if not os.path.isfile(filename):
raise FileNotFound(f'File "{filename}" does not exists')
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,22 +8,13 @@ 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,6 +9,7 @@ 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

@ -3,7 +3,7 @@
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = ../bin/sphinx-build
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = ../var/docs

View file

@ -30,6 +30,11 @@ django-sendfile
`django-sendfile`_ is a wrapper around web-server specific methods for sending
files to web clients.
.. note::
:func:`django_downloadview.shortcuts.sendfile` is a port of
`django-sendfile`'s main function. See :doc:`/django-sendfile` for details.
``django-senfile``'s main focus is simplicity: API is made of a single
``sendfile()`` function you call inside your views:
@ -64,18 +69,12 @@ Here are main differences between the two projects:
root folder. Whereas ``django-downloadview``'s
``DownloadDispatcherMiddleware`` supports multiple configurations.
As of 2012-04-11, ``django-sendfile`` (version 0.3.2) seems quite popular and
may be a good alternative **provided you serve files that live in a single
directory of local filesystem**.
:func:`django_downloadview.sendfile` is a port of django-sendfile's main function.
.. rubric:: References
.. target-notes::
.. _`django.contrib.staticfiles provides a view to serve files`:
https://docs.djangoproject.com/en/1.6/ref/contrib/staticfiles/#static-file-development-view
https://docs.djangoproject.com/en/3.0/ref/contrib/staticfiles/#static-file-development-view
.. _`Django ticket #2131`: https://code.djangoproject.com/ticket/2131
.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile

View file

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

View file

@ -1,275 +1,142 @@
# -*- coding: utf-8 -*-
#
# django-downloadview documentation build configuration file, created by
# sphinx-quickstart on Mon Aug 27 11:37:23 2012.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import os
"""django-downloadview documentation build configuration file."""
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``.
)
doc_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(doc_dir)
version_filename = os.path.join(project_dir, 'VERSION')
# -- General configuration ----------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc',
'sphinx.ext.autosummary',
'sphinx.ext.doctest',
'sphinx.ext.coverage',
'sphinx.ext.intersphinx']
# Extensions.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.doctest",
"sphinx.ext.coverage",
"sphinx.ext.intersphinx",
]
# Add any paths that contain templates here, relative to this directory.
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-sig'
source_encoding = "utf-8"
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# General information about the project.
project = u'django-downloadview'
copyright = u'2012, Benoît Bryon'
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)
# 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.
#
# The short X.Y version.
version = open(version_filename).read().strip()
# The full version, including alpha/beta/rc tags.
release = version
release = importlib.metadata.version("django-downloadview")
# The short X.Y version.
version = ".".join(release.split(".")[:2])
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
language = 'en'
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
language = "en"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
exclude_patterns = ["_build"]
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
pygments_style = "sphinx"
# -- Options for HTML output ---------------------------------------------------
# -- 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 = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
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,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
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"],
}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-downloadviewdoc'
htmlhelp_basename = "{project}doc".format(project=project_slug)
# -- Options for sphinx.ext.intersphinx ---------------------------------------
intersphinx_mapping = {
'python': ('http://docs.python.org/2.7', None),
'django': ('http://docs.djangoproject.com/en/1.5/',
'http://docs.djangoproject.com/en/1.5/_objects/'),
'requests': ('http://docs.python-requests.org/en/latest/', None),
"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),
}
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# -- Options for LaTeX output -------------------------------------------------
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
latex_elements = {}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
# (source start file, target name, title, author, documentclass
# [howto/manual]).
latex_documents = [
('index', 'django-downloadview.tex', u'django-downloadview Documentation',
u'Benoît Bryon', 'manual'),
(
"index",
"{project}.tex".format(project=project_slug),
"{project} Documentation".format(project=project),
author,
"manual",
),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# -- Options for manual page output -------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-downloadview', u'django-downloadview Documentation',
[u'Benoît Bryon'], 1)
("index", project, "{project} Documentation".format(project=project), [author], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# -- Options for Texinfo output -----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-downloadview', u'django-downloadview Documentation',
u'Benoît Bryon', 'django-downloadview', 'One line description of project.',
'Miscellaneous'),
(
"index",
project_slug,
"{project} Documentation".format(project=project),
author,
project,
"One line description of project.",
"Miscellaneous",
),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

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