Django file uploads
last modified November 13, 2026 Python 3.15
In this article we let users upload files to a Django application. We configure where the files are
stored and served, add a FileField and an ImageField to models, validate the size, the
extension, and the content of the files, handle an upload without a form, serve the files during development,
and delete them when their objects are deleted. The examples use Django 6.1, Python 3.15, and Pillow.
How uploads work
A browser sends a file in a request with the content type multipart/form-data, which is
selected by the attribute enctype of the form; a form without it sends only the names of the files. Django
parses such a request and puts the files into the dictionary request.FILES, while the other fields stay in
request.POST. Each file is an UploadedFile with the attributes name,
size, and content_type, and the method chunks() that reads it in pieces. Small
files are kept in memory (InMemoryUploadedFile); larger ones are streamed to a temporary file on the disk
(TemporaryUploadedFile), so a big upload does not use much memory. The files are saved with a
storage: the default FileSystemStorage writes them below a directory on the
disk, and other storages, for example for Amazon S3, can be plugged in without changing the models.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django and pillow, generate the project with the configuration package
config, start the app gallery, and create the folder demos for demo scripts.
$ mkdir fileupload $ cd fileupload $ uv init --bare $ uv add django pillow $ uv run django-admin startproject config . $ uv run python manage.py startapp gallery $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'gallery',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The field ImageField needs the Pillow library, which Django uses to check that a file is
an image and to read its dimensions; the command uv add django pillow above installs it.
The settings MEDIA_ROOT and MEDIA_URL say where the uploaded files are stored and
under which URL they are served. They are different from the settings of the static files (see
Django static files): static files are a part of the application, and media files are
the data that the users upload.
MEDIA_URL = 'media/' MEDIA_ROOT = BASE_DIR / 'media'
The directory media is created in the project directory when the first file is saved. The next script
prints the settings that influence uploads together with their defaults.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.conf import settings
print('MEDIA_URL:', settings.MEDIA_URL)
print('MEDIA_ROOT:', settings.MEDIA_ROOT)
print('FILE_UPLOAD_MAX_MEMORY_SIZE:', settings.FILE_UPLOAD_MAX_MEMORY_SIZE)
print('DATA_UPLOAD_MAX_MEMORY_SIZE:', settings.DATA_UPLOAD_MAX_MEMORY_SIZE)
print('DATA_UPLOAD_MAX_NUMBER_FILES:', settings.DATA_UPLOAD_MAX_NUMBER_FILES)
print('FILE_UPLOAD_PERMISSIONS:', oct(settings.FILE_UPLOAD_PERMISSIONS))
print('FILE_UPLOAD_HANDLERS:')
for handler in settings.FILE_UPLOAD_HANDLERS:
print(' ', handler)
Files up to FILE_UPLOAD_MAX_MEMORY_SIZE, 2.5 MB, are kept in memory, and larger ones go to a temporary file;
this is the job of the two upload handlers. The setting DATA_UPLOAD_MAX_MEMORY_SIZE limits the size of the request
body without the files; it does not limit the uploads. The setting DATA_UPLOAD_MAX_NUMBER_FILES limits the number of files
in one request, and the new files get the permissions 0o644. A limit of the total size of a request is best set in the web
server, for example with client_max_body_size in nginx, because it can refuse a request before Django sees it.
$ uv run python -m demos.settings MEDIA_URL: /media/ MEDIA_ROOT: ./media FILE_UPLOAD_MAX_MEMORY_SIZE: 2621440 DATA_UPLOAD_MAX_MEMORY_SIZE: 2621440 DATA_UPLOAD_MAX_NUMBER_FILES: 100 FILE_UPLOAD_PERMISSIONS: 0o644 FILE_UPLOAD_HANDLERS: django.core.files.uploadhandler.MemoryFileUploadHandler django.core.files.uploadhandler.TemporaryFileUploadHandler
Models with files
The fields FileField and ImageField store only the path of the file, relative to
MEDIA_ROOT, in a database column; the file itself goes to the storage. The argument
upload_to chooses the directory and the name. It is either a string, which may contain
strftime() codes such as %Y/%m to spread the files over directories by date, or a function that
receives the instance and the file name of the client and returns the path. We should not keep the name that the client
sent: it can collide with another file, contain strange characters, or reveal something. Our function for the photos
generates a random name and keeps only the extension in the lower case. The documents keep their names, and when a
file with that name exists, Django appends a random suffix to the new one.
import uuid
from pathlib import Path
from django.core.validators import FileExtensionValidator
from django.db import models
from .validators import validate_file_size, validate_pdf_signature
def photo_path(instance, filename):
return f'photos/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
class Photo(models.Model):
title = models.CharField(max_length=100)
image = models.ImageField(upload_to=photo_path, validators=[validate_file_size])
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
class Document(models.Model):
title = models.CharField(max_length=100)
file = models.FileField(
upload_to='documents/%Y/%m/',
validators=[FileExtensionValidator(['pdf']), validate_file_size, validate_pdf_signature])
def __str__(self):
return self.title
The image field checks, when it is validated, that Pillow can open the file. The field of the document allows only
the extension pdf, with the built-in FileExtensionValidator. Both fields use our validators from the
module validators.py, which we write next. The tables are created with makemigrations and
migrate.
$ uv run python manage.py makemigrations gallery
Migrations for 'gallery':
gallery/migrations/0001_initial.py
+ Create model Document
+ Create model Photo
$ uv run python manage.py migrate gallery
Operations to perform:
Apply all migrations: gallery
Running migrations:
Applying gallery.0001_initial... OK
Validating uploads
The user decides everything about an uploaded file: its name, its size, its content, and the content type that
is sent with it. None of them can be trusted. We validate in layers: the size, the extension, and the content. A validator for a
file field is a function that takes the file object and raises ValidationError, like the validators described in
Django model validation and signals. The extension is only a hint, and it is easy to give a
text file the name report.pdf, so for documents we also look at the content. The
header Content-Type that the browser sent is chosen by the client and tells us nothing.
from django.core.exceptions import ValidationError
from django.template.defaultfilters import filesizeformat
MAX_SIZE = 1024 * 1024
def validate_file_size(file):
if file.size > MAX_SIZE:
raise ValidationError(
'The file is too large (%(size)s); the limit is %(limit)s.',
code='too_large',
params={'size': filesizeformat(file.size), 'limit': filesizeformat(MAX_SIZE)})
def validate_pdf_signature(file):
head = file.read(5)
file.seek(0)
if head != b'%PDF-':
raise ValidationError('The file is not a PDF document.', code='not_pdf')
The first validator compares the size of the upload with the limit of 1 MB and uses the
params of the error to put the sizes into the message. The second reads the first five bytes, which are the
signature of a PDF file, and moves back to the start with seek(0), so that the next reader gets the whole
file. This check recognizes only the type of a file, not whether it is a safe or complete document. For images, the ImageField
does the job, because Pillow has to decode the file.
Forms and views
Model forms (see Django ModelForm) turn the file fields into
FileField and ImageField form fields, whose widget is a file input.
from django import forms
from .models import Document, Photo
class PhotoForm(forms.ModelForm):
class Meta:
model = Photo
fields = ['title', 'image']
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['title', 'file']
The view differs from the view of an ordinary form in one detail: the form gets request.FILES as the second argument,
because the files are not in request.POST. One function handles the upload for both models, and the two
views call it with their own form and the model of the list.
from django.shortcuts import redirect, render
from .forms import DocumentForm, PhotoForm
from .models import Document, Photo
def upload(request, form_class, model, heading):
if request.method == 'POST':
form = form_class(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect(request.path)
else:
form = form_class()
context = {'form': form, 'items': model.objects.all(), 'heading': heading}
return render(request, 'gallery/upload.html', context)
def photos(request):
return upload(request, PhotoForm, Photo, 'Photos')
def documents(request):
return upload(request, DocumentForm, Document, 'Documents')
After a successful save, the view redirects to the same page (see Django forms). The URLs of the app follow.
from django.urls import path
from . import views
app_name = 'gallery'
urlpatterns = [
path('photos/', views.photos, name='photos'),
path('documents/', views.documents, name='documents'),
path('raw/', views.raw_upload, name='raw'),
]
The template renders the form and lists the saved objects. The important attribute is
enctype="multipart/form-data": without it the browser sends only the file names and the form reports that the
file field is required.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ heading }}</title>
</head>
<body>
<h1>{{ heading }}</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form }}
<button type="submit">Upload</button>
</form>
<ul>
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</body>
</html>
The same template serves both pages; the heading and the form class come from the view.
Uploading images
The script uses the test client to post to the photo page. It creates the images with Pillow in memory and wraps them
in SimpleUploadedFile, which behaves like an uploaded file. The name Sunset.PNG has an upper
case extension. The script then reads the saved object: the field has the name, the url, the
path on the disk, and the size; the image field also gives the width and the
height. The other uploads are not valid: a text file that is named as a PNG image, an image
that is larger than the limit, a request without a file, and a request in which the file field is only a text value, as a
browser would send it without the attribute enctype.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import io
import os
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.test.utils import setup_test_environment
from PIL import Image
setup_test_environment()
client = Client()
def png(width=80, height=40, noise=False):
image = Image.new('RGB', (width, height), 'tomato')
if noise:
image = Image.frombytes('RGB', (width, height), os.urandom(width * height * 3))
buffer = io.BytesIO()
image.save(buffer, 'PNG')
return buffer.getvalue()
def show(response):
form = response.context['form'] if response.context else None
if response.status_code == 302:
return f'302 -> {response["Location"]}'
return f'{response.status_code} errors: {dict(form.errors) if form else response.content[:60]}'
from gallery.models import Photo
Photo.objects.all().delete()
good = SimpleUploadedFile('Sunset.PNG', png(), content_type='image/png')
response = client.post('/photos/', {'title': 'Sunset', 'image': good})
print('valid upload:', show(response))
photo = Photo.objects.get()
print('name:', photo.image.name)
print('url:', photo.image.url)
print('size:', photo.image.size, 'bytes; dimensions:', photo.image.width, 'x', photo.image.height)
print('on disk:', os.path.exists(photo.image.path))
uploads = {
'not an image': SimpleUploadedFile('notes.png', b'plain text', content_type='image/png'),
'too large': SimpleUploadedFile('big.png', png(700, 700, noise=True), content_type='image/png'),
}
for title, upload in uploads.items():
response = client.post('/photos/', {'title': title, 'image': upload})
print(f'{title}:', show(response))
print('no file:', show(client.post('/photos/', {'title': 'No file'})))
print('without multipart:', show(client.post(
'/photos/', {'title': 'Sunset', 'image': 'sunset.png'})))
print('photos stored:', Photo.objects.count())
The valid upload is stored under a generated name, with the extension in lower case, and the dimensions are read from the file. The text file is rejected by Pillow, with the standard message of the image field, and the large image by our size validator, with the sizes in the message. A request without a file, or with the file as a plain text, gets the required error. Nothing but the first photo is saved.
$ uv run python -m demos.photo
valid upload: 302 -> /photos/
name: photos/f0cec64e8a35432ea8d44c68146b021d.png
url: /media/photos/f0cec64e8a35432ea8d44c68146b021d.png
size: 135 bytes; dimensions: 80 x 40
on disk: True
not an image: 200 errors: {'image': ['Upload a valid image. The file you uploaded was either not an image or a corrupted image.']}
too large: 200 errors: {'image': ['The file is too large (1.4\xa0MB); the limit is 1.0\xa0MB.']}
no file: 200 errors: {'image': ['This field is required.']}
without multipart: 200 errors: {'image': ['This field is required.']}
photos stored: 1
Uploading documents
The documents page checks the extension and the content of the file. We upload a valid PDF, a text file that has the name of a PDF file, and a real PDF with the wrong extension.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import io
import os
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.test.utils import setup_test_environment
from PIL import Image
setup_test_environment()
client = Client()
def png(width=80, height=40, noise=False):
image = Image.new('RGB', (width, height), 'tomato')
if noise:
image = Image.frombytes('RGB', (width, height), os.urandom(width * height * 3))
buffer = io.BytesIO()
image.save(buffer, 'PNG')
return buffer.getvalue()
def show(response):
form = response.context['form'] if response.context else None
if response.status_code == 302:
return f'302 -> {response["Location"]}'
return f'{response.status_code} errors: {dict(form.errors) if form else response.content[:60]}'
from gallery.models import Document
Document.objects.all().delete()
pdf = b'%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n'
uploads = {
'valid pdf': SimpleUploadedFile('report.pdf', pdf, content_type='application/pdf'),
'text renamed to pdf': SimpleUploadedFile('fake.pdf', b'just text', content_type='application/pdf'),
'wrong extension': SimpleUploadedFile('report.txt', pdf, content_type='text/plain'),
}
for title, upload in uploads.items():
response = client.post('/documents/', {'title': title, 'file': upload})
print(f'{title}:', show(response))
document = Document.objects.get()
print('stored as:', document.file.name)
The valid PDF is stored in the directory of the current year and month, taken from the
strftime() codes of upload_to, with its own name. The file that only pretends to be a PDF is stopped by the
signature check, and the file with the wrong extension by the extension validator, whose message lists the allowed extensions.
Both errors of the validators can occur in one upload, as the validators of a field run all.
$ uv run python -m demos.document
valid pdf: 302 -> /documents/
text renamed to pdf: 200 errors: {'file': ['The file is not a PDF document.']}
wrong extension: 200 errors: {'file': ['File extension “txt” is not allowed. Allowed extensions are: pdf.']}
stored as: documents/2026/11/report.pdf
Reading an upload without a form
Sometimes a form is not needed, for example for an upload through JavaScript. The view reads the file from
request.FILES, validates it by hand, and saves it with the storage:
default_storage.save(name, file) writes the file and returns the name that it was actually saved
under, which can differ from the requested one if the name was taken. We use get_valid_filename(), which
turns a name into a safe one: spaces become underscores, and characters other than letters, digits, dashes,
underscores, and dots are removed.
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.utils.text import get_valid_filename
from django.views.decorators.http import require_POST
...
@require_POST
def raw_upload(request):
uploaded = request.FILES.get('file')
if uploaded is None:
return JsonResponse({'error': 'No file was uploaded.'}, status=400)
name = default_storage.save(f'raw/{get_valid_filename(uploaded.name)}', uploaded)
return JsonResponse({
'saved_as': name,
'client_name': uploaded.name,
'size': uploaded.size,
'content_type': uploaded.content_type,
'class': type(uploaded).__name__,
})
The view allows only the POST method with the decorator require_POST and returns the information that
Django has about the file as JSON. The script uploads a small file, a file of 3 MB, which is above the memory limit, and a file with a
name that tries to climb out of the upload directory; then it sends a request without a file, and a GET request.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import io
import os
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.test.utils import setup_test_environment
from PIL import Image
setup_test_environment()
client = Client()
def png(width=80, height=40, noise=False):
image = Image.new('RGB', (width, height), 'tomato')
if noise:
image = Image.frombytes('RGB', (width, height), os.urandom(width * height * 3))
buffer = io.BytesIO()
image.save(buffer, 'PNG')
return buffer.getvalue()
def show(response):
form = response.context['form'] if response.context else None
if response.status_code == 302:
return f'302 -> {response["Location"]}'
return f'{response.status_code} errors: {dict(form.errors) if form else response.content[:60]}'
import json
small = SimpleUploadedFile('data.csv', b'a,b\n1,2\n', content_type='text/csv')
large = SimpleUploadedFile('large.bin', os.urandom(3 * 1024 * 1024))
sneaky = SimpleUploadedFile('../../etc/cron.d/evil name.txt', b'x', content_type='text/plain')
for upload in (small, large, sneaky):
response = client.post('/raw/', {'file': upload})
print(json.dumps(response.json()))
print(client.post('/raw/', {}).json())
print(client.get('/raw/').status_code)
The small file is an InMemoryUploadedFile and the large one a TemporaryUploadedFile, as the setting
of the limit says. Django has removed the directories from the dangerous name, so uploaded.name is just
evil name.txt, and our function made it evil_name.txt. The last two requests are rejected with
the status codes 400 and 405; the lines "Bad Request" and "Method Not Allowed" were written to the standard error stream by Django. We should never
build a path from request.POST values or from names that did not go through these functions.
$ uv run python -m demos.raw
{"saved_as": "raw/data.csv", "client_name": "data.csv", "size": 8, "content_type": "text/csv", "class": "InMemoryUploadedFile"}
{"saved_as": "raw/large.bin", "client_name": "large.bin", "size": 3145728, "content_type": "text/plain", "class": "TemporaryUploadedFile"}
{"saved_as": "raw/evil_name.txt", "client_name": "evil name.txt", "size": 1, "content_type": "text/plain", "class": "InMemoryUploadedFile"}
{'error': 'No file was uploaded.'}
405
Bad Request: /raw/
Method Not Allowed (GET): /raw/
Serving uploaded files
The development server does not serve the media files by itself. The function static() from
django.conf.urls.static returns a URL pattern that serves the directory MEDIA_ROOT below
MEDIA_URL. It returns an empty list when DEBUG is false, so the line does no harm in production. It is
meant only for development: on a real server, the web server, such as nginx, serves the media files directly from the
directory, or they are served by the storage service, for example a bucket of S3 with the
django-storages package.
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
urlpatterns = [
path('', include('gallery.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
We list the files that were saved by the scripts so far and request one of the photos from the running development
server; the format option of curl prints the status, the content type, and the size of the response.
$ find media -type f | sort
media/documents/2026/11/report.pdf
media/photos/f0cec64e8a35432ea8d44c68146b021d.png
media/raw/data.csv
media/raw/evil_name.txt
media/raw/large.bin
$ curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' http://127.0.0.1:8000/media/$(cd media && find photos -type f | head -1)
200 image/png 135
Deleting files
When a model object is deleted, Django does not delete its files. This has been the behavior since Django 1.3, because
the deletion could be rolled back, or the file could be shared with another object. The files of the deleted objects stay on the disk
and accumulate. The usual cleanup is a post_delete receiver (see
Django model validation and signals) that deletes the file; the call
delete(save=False) removes the file from the storage without saving the model again. In a project, the receiver
belongs into the signals.py module of the app; here the script connects it in the middle to see
the difference. A queryset delete() sends the signal for each object as well, but bulk operations that
skip signals leave the files. Files that are replaced when an object is edited are not removed either; a periodic
cleanup that compares the files on the disk with the database is a more robust solution.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import io
import os
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.test.utils import setup_test_environment
from PIL import Image
setup_test_environment()
client = Client()
def png(width=80, height=40, noise=False):
image = Image.new('RGB', (width, height), 'tomato')
if noise:
image = Image.frombytes('RGB', (width, height), os.urandom(width * height * 3))
buffer = io.BytesIO()
image.save(buffer, 'PNG')
return buffer.getvalue()
def show(response):
form = response.context['form'] if response.context else None
if response.status_code == 302:
return f'302 -> {response["Location"]}'
return f'{response.status_code} errors: {dict(form.errors) if form else response.content[:60]}'
from django.db.models.signals import post_delete
from django.dispatch import receiver
from gallery.models import Photo
Photo.objects.all().delete()
client.post('/photos/', {'title': 'One', 'image': SimpleUploadedFile('one.png', png())})
client.post('/photos/', {'title': 'Two', 'image': SimpleUploadedFile('two.png', png())})
first, second = Photo.objects.order_by('title')
first_path, second_path = first.image.path, second.image.path
first.delete()
print('deleted "One", file still exists:', os.path.exists(first_path))
@receiver(post_delete, sender=Photo)
def delete_file(sender, instance, **kwargs):
instance.image.delete(save=False)
second.delete()
print('deleted "Two", file still exists:', os.path.exists(second_path))
The script saves two photos and deletes the first one, and the file stays. After the receiver is connected, the second deletion removes the file together with the row.
$ uv run python -m demos.cleanup deleted "One", file still exists: True deleted "Two", file still exists: False
Security of uploads
Uploads are one of the riskiest features of a web application. The rules that we have followed in the example are these.
- do not trust the name, the content type, or the extension from the client; generate the stored names or pass them through
get_valid_filename() - limit the size: the validators for the file,
client_max_body_sizeor the equivalent in the web server for the request - check the content:
ImageFieldfor images, and a signature or a parser for other types - store the files outside the directories from which code is run, and never serve user uploads as pages of our own site; the safest way is a separate domain or a storage service, or the header
Content-Disposition: attachmentfor the types that a browser could execute, such as HTML and SVG - let only the users who are allowed upload, and keep private files behind a view that checks the permission instead of a public
MEDIA_URL
Django helps by sending X-Content-Type-Options: nosniff through the
SecurityMiddleware, which stops browsers from guessing a type different from the declared one. Details are in the
documentation on security, which is linked below.
Testing uploads
The tests use SimpleUploadedFile and a temporary directory as MEDIA_ROOT, set with
override_settings, so that the tests never write into the project's media directory; the directory is removed
after the tests. The tests check that the stored name is generated and has the dimensions of the image, that text is not an image,
that a large file is rejected, that a PDF needs both the extension and the signature, and the behavior of the view without a form.
import io
import shutil
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from PIL import Image
from .models import Document, Photo
from .validators import MAX_SIZE
MEDIA = tempfile.mkdtemp()
def png(size=(20, 20)):
buffer = io.BytesIO()
Image.new('RGB', size, 'tomato').save(buffer, 'PNG')
return buffer.getvalue()
@override_settings(MEDIA_ROOT=MEDIA)
class UploadTests(TestCase):
@classmethod
def tearDownClass(cls):
super().tearDownClass()
shutil.rmtree(MEDIA, ignore_errors=True)
def post_photo(self, name='Sunset.PNG', content=None):
content = png() if content is None else content
return self.client.post(reverse('gallery:photos'), {
'title': 'Sunset', 'image': SimpleUploadedFile(name, content)})
def test_photo_is_stored_under_a_generated_name(self):
response = self.post_photo()
self.assertRedirects(response, reverse('gallery:photos'))
photo = Photo.objects.get()
self.assertRegex(photo.image.name, r'^photos/[0-9a-f]{32}\.png$')
self.assertEqual((photo.image.width, photo.image.height), (20, 20))
def test_text_is_not_an_image(self):
response = self.post_photo('notes.png', b'plain text')
self.assertEqual(response.status_code, 200)
self.assertIn('image', response.context['form'].errors)
self.assertEqual(Photo.objects.count(), 0)
def test_large_file_is_rejected(self):
response = self.post_photo(content=png() + b'0' * MAX_SIZE)
self.assertTrue(any('too large' in e for e in response.context['form'].errors['image']))
def test_pdf_needs_extension_and_signature(self):
url = reverse('gallery:documents')
fake = SimpleUploadedFile('fake.pdf', b'just text')
response = self.client.post(url, {'title': 'Fake', 'file': fake})
self.assertEqual(response.context['form'].errors['file'], ['The file is not a PDF document.'])
real = SimpleUploadedFile('real.pdf', b'%PDF-1.4 ...')
self.client.post(url, {'title': 'Real', 'file': real})
self.assertTrue(Document.objects.get().file.name.endswith('.pdf'))
def test_raw_upload_strips_the_path(self):
upload = SimpleUploadedFile('../../evil.txt', b'x')
data = self.client.post(reverse('gallery:raw'), {'file': upload}).json()
self.assertEqual(data['client_name'], 'evil.txt')
self.assertTrue(data['saved_as'].startswith('raw/evil'))
def test_raw_upload_without_a_file(self):
response = self.client.post(reverse('gallery:raw'), {})
self.assertEqual(response.status_code, 400)
The helper png() creates a valid image of the requested size, and the large file is the image with a megabyte
of zeros appended; a trailing garbage does not stop Pillow from reading the image, so only the size validator fails. The
tests are run with the test command.
$ uv run python manage.py test Found 6 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ...... ---------------------------------------------------------------------- Ran 6 tests in 0.050s OK Destroying test database for alias 'default'...
Source
Django documentation: File Uploads
Django documentation: FileField
Django documentation: Managing files
Django documentation: Security in Django
In this article we have uploaded files with FileField and ImageField, configured
MEDIA_ROOT and MEDIA_URL, validated the size, the extension, and the content, handled an upload without a
form, served the files in development, and cleaned up after deleted objects. Related articles are
Django ModelForm, Django forms, and
Django static files.
Author
List all Django tutorials.