Skip to content

File uploads - locally and on s3 #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
Apr 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f76e4fc
Draft uploading files locally and on s3
kbadova Dec 6, 2021
c4149f9
Drop duplication of "/files" in files urls
kbadova Dec 6, 2021
421cb5d
Add file.uploaded_at field
kbadova Dec 6, 2021
7fe1d22
Add an api for verifying a file upload
kbadova Dec 6, 2021
a13a9b3
Add media folder to .gitignore
kbadova Dec 6, 2021
29b870e
Setup media root and media dir. Make sure django sees aws settings
kbadova Dec 6, 2021
4d39c2e
Import timezone from django.utils
kbadova Dec 6, 2021
7068073
Send session key as auth headers when uploading files locally
kbadova Dec 6, 2021
3a54369
MAke file field optional
kbadova Dec 6, 2021
d3a2349
Accept integers in url params instead of uuids
kbadova Dec 6, 2021
31b9ea2
Fix generating an upload url
kbadova Dec 6, 2021
c42ef6e
Make sure uploaded files can be served by the BE
kbadova Dec 6, 2021
a15ac6c
Iteration 1: Local file upload via Django admin
RadoRado Apr 3, 2022
94e72c9
Iteration 2: S3 file upload via Django admin
RadoRado Apr 3, 2022
356537e
Iteration 3: API for direct upload
RadoRado Apr 4, 2022
d315a7b
Iteration 4: API for pass-thru upload + s3
RadoRado Apr 4, 2022
0b2b392
Update comment on why are we doing this
RadoRado Apr 4, 2022
f558837
Iteration 5: API + Pass-thru + local upload
RadoRado Apr 4, 2022
11e525f
fixup! Iteration 5: API + Pass-thru + local upload
RadoRado Apr 4, 2022
e8a9bff
fixup! fixup! Iteration 5: API + Pass-thru + local upload
RadoRado Apr 4, 2022
05f0bc8
Introduce `FileDirectUploadService` to serve as an example
RadoRado Apr 4, 2022
d328ad0
Partially fix `mypy`
RadoRado Apr 4, 2022
50cd1a0
Move stubs to local file
RadoRado Apr 4, 2022
a337a92
Add `mypy.ini`
RadoRado Apr 4, 2022
e0383bd
Explicitly specify mypy config
RadoRado Apr 4, 2022
e652bb7
Check mypy version
RadoRado Apr 4, 2022
315db5f
Introduce `S3Credentials`
RadoRado Apr 5, 2022
079561e
Introduce `FilePassThruUploadService`
RadoRado Apr 5, 2022
f00ebbe
Introduce enums for settings
RadoRado Apr 5, 2022
c5bcd1e
fixup! Introduce enums for settings
RadoRado Apr 5, 2022
f5427b9
Move `BASE_DIR` to `config.env`
RadoRado Apr 5, 2022
cf7817d
Rename `SERVER_HOST_DOMAIN` to `APP_DOMAIN`
RadoRado Apr 5, 2022
52cc46b
Properly calculate `BASE_DIR`
RadoRado Apr 5, 2022
1bf7f2f
Fix enum/string checks
RadoRado Apr 5, 2022
a9fa38d
Add more settings to `.env.example`
RadoRado Apr 5, 2022
bc83158
Add the shape of the presigned data
RadoRado Apr 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
PYTHONBREAKPOINT=ipdb.set_trace
SENTRY_DSN=""

FILE_UPLOAD_STRATEGY="direct" # pass-thru
FILE_UPLOAD_STORAGE="local" # s3

AWS_S3_ACCESS_KEY_ID=""
AWS_S3_SECRET_ACCESS_KEY=""
AWS_STORAGE_BUCKET_NAME="django-styleguide-example"
AWS_S3_REGION_NAME="eu-central-1"
6 changes: 4 additions & 2 deletions .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
- name: Build docker
run: docker-compose build
- name: Type check
run: docker-compose run django mypy styleguide_example/
run: docker-compose run django mypy --config mypy.ini styleguide_example/
- name: Run migrations
run: docker-compose run django python manage.py migrate
- name: Run tests
Expand Down Expand Up @@ -38,7 +38,9 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements/local.txt
- name: Type check
run: mypy styleguide_example/
run: |
mypy --version
mypy --config mypy.ini styleguide_example/
- name: Run migrations
run: python manage.py migrate
- name: Run tests
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,7 @@ dmypy.json

# Pyre type checker
.pyre/


# media files
/media
11 changes: 7 additions & 4 deletions config/django/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@

import os

from config.env import env, environ

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = environ.Path(__file__) - 3
from config.env import env, BASE_DIR

env.read_env(os.path.join(BASE_DIR, ".env"))

Expand All @@ -41,6 +38,8 @@
'styleguide_example.users.apps.UsersConfig',
'styleguide_example.errors.apps.ErrorsConfig',
'styleguide_example.testing_examples.apps.TestingExamplesConfig',
'styleguide_example.integrations.apps.IntegrationsConfig',
'styleguide_example.files.apps.FilesConfig',
]

THIRD_PARTY_APPS = [
Expand Down Expand Up @@ -171,8 +170,12 @@
'DEFAULT_AUTHENTICATION_CLASSES': []
}

APP_DOMAIN = env("APP_DOMAIN", default="http://localhost:8000")

from config.settings.cors import * # noqa
from config.settings.jwt import * # noqa
from config.settings.sessions import * # noqa
from config.settings.celery import * # noqa
from config.settings.sentry import * # noqa

from config.settings.files_and_storages import * # noqa
12 changes: 12 additions & 0 deletions config/env.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
from django.core.exceptions import ImproperlyConfigured

import environ

env = environ.Env()

BASE_DIR = environ.Path(__file__) - 2


def env_to_enum(enum_cls, value):
for x in enum_cls:
if x.value == value:
return x

raise ImproperlyConfigured(f"Env value {repr(value)} could not be found in {repr(enum_cls)}")
36 changes: 36 additions & 0 deletions config/settings/files_and_storages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import os

from config.env import BASE_DIR, env, env_to_enum

from styleguide_example.files.enums import FileUploadStrategy, FileUploadStorage


FILE_UPLOAD_STRATEGY = env_to_enum(
FileUploadStrategy,
env("FILE_UPLOAD_STRATEGY", default="direct")
)
FILE_UPLOAD_STORAGE = env_to_enum(
FileUploadStorage,
env("FILE_UPLOAD_STORAGE", default="local")
)

if FILE_UPLOAD_STORAGE == FileUploadStorage.LOCAL:
MEDIA_ROOT_NAME = "media"
MEDIA_ROOT = os.path.join(BASE_DIR, MEDIA_ROOT_NAME)
MEDIA_URL = f"/{MEDIA_ROOT_NAME}/"

if FILE_UPLOAD_STORAGE == FileUploadStorage.S3:
# Using django-storages
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'

AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID")
AWS_S3_SECRET_ACCESS_KEY = env("AWS_S3_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME")
AWS_S3_SIGNATURE_VERSION = env("AWS_S3_SIGNATURE_VERSION", default="s3v4")

# https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
AWS_DEFAULT_ACL = env("AWS_DEFAULT_ACL", default="private")

AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=10) # seconds
4 changes: 3 additions & 1 deletion config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.conf import settings
from django.urls import path, include
from django.conf.urls.static import static

urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(('styleguide_example.api.urls', 'api'))),
]
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
35 changes: 35 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[mypy]
plugins =
mypy_django_plugin.main,
mypy_drf_plugin.main

[mypy.plugins.django-stubs]
django_settings_module = "config.django.base"

[mypy-config.*]
# Ignore everything related to Django config
ignore_errors = true

[mypy-styleguide_example.*.migrations.*]
# Ignore Django migrations
ignore_errors = true

[mypy-celery.*]
# Remove this when celery stubs are present
ignore_missing_imports = True

[mypy-django_celery_beat.*]
# Remove this when django_celery_beat stubs are present
ignore_missing_imports = True

[mypy-django_filters.*]
# Remove this when django_filters stubs are present
ignore_missing_imports = True

[mypy-factory.*]
# Remove this when factory stubs are present
ignore_missing_imports = True

[mypy-rest_framework_jwt.*]
# Remove this when rest_framework_jwt stubs are present
ignore_missing_imports = True
6 changes: 5 additions & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ django-celery-beat==2.2.1
whitenoise==6.0.0

django-filter==21.1
django-cors-headers==3.11.0
django-extensions==3.1.5
django-cors-headers==3.10.0
django-storages==1.12.3

drf-jwt==1.19.2

boto3==1.20.20
attrs==21.4.0
1 change: 1 addition & 0 deletions requirements/local.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ ipython==8.2.0
mypy==0.942
django-stubs==1.9.0
djangorestframework-stubs==1.4.0
boto3-stubs==1.21.32
36 changes: 0 additions & 36 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,3 @@ exclude =
.git,
__pycache__,
*/migrations/*

[mypy]
plugins =
mypy_django_plugin.main,
mypy_drf_plugin.main

[mypy.plugins.django-stubs]
django_settings_module = "config.django.base"

[mypy-config.*]
# Ignore everything related to Django config
ignore_errors = true

[mypy-styleguide_example.*.migrations.*]
# Ignore Django migrations
ignore_errors = true

[mypy-celery.*]
# Remove this when celery stubs are present
ignore_missing_imports = True

[mypy-django_celery_beat.*]
# Remove this when django_celery_beat stubs are present
ignore_missing_imports = True

[mypy-django_filters.*]
# Remove this when django_filters stubs are present
ignore_missing_imports = True

[mypy-factory.*]
# Remove this when factory stubs are present
ignore_missing_imports = True

[mypy-rest_framework_jwt.*]
# Remove this when rest_framework_jwt stubs are present
ignore_missing_imports = True
1 change: 1 addition & 0 deletions styleguide_example/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
),
path('users/', include(('styleguide_example.users.urls', 'users'))),
path('errors/', include(('styleguide_example.errors.urls', 'errors'))),
path('files/', include(('styleguide_example.files.urls', 'files'))),
]
31 changes: 29 additions & 2 deletions styleguide_example/common/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from rest_framework import serializers

from django.conf import settings
from django.shortcuts import get_object_or_404
from django.http import Http404
from django.core.exceptions import ImproperlyConfigured

from rest_framework import serializers


def make_mock_object(**kwargs):
Expand Down Expand Up @@ -30,3 +32,28 @@ def inline_serializer(*, fields, data=None, **kwargs):
return serializer_class(data=data, **kwargs)

return serializer_class(**kwargs)


def assert_settings(required_settings, error_message_prefix=""):
"""
Checks if each item from `required_settings` is present in Django settings
"""
not_present = []
values = {}

for required_setting in required_settings:
if not hasattr(settings, required_setting):
not_present.append(required_setting)
continue

values[required_setting] = getattr(settings, required_setting)

if not_present:
if not error_message_prefix:
error_message_prefix = "Required settings not found."

stringified_not_present = ", ".join(not_present)

raise ImproperlyConfigured(f"{error_message_prefix} Could not find: {stringified_not_present}")

return values
Empty file.
77 changes: 77 additions & 0 deletions styleguide_example/files/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django import forms

from django.contrib import admin, messages
from django.core.exceptions import ValidationError

from styleguide_example.files.models import File
from styleguide_example.files.services import (
FileDirectUploadService
)


class FileForm(forms.ModelForm):
class Meta:
model = File
fields = ["file", "uploaded_by"]


@admin.register(File)
class FileAdmin(admin.ModelAdmin):
list_display = [
"id",
"original_file_name",
"file_name",
"file_type",
"url",
"uploaded_by",
"created_at",
"upload_finished_at",
"is_valid",
]
list_select_related = ["uploaded_by"]

ordering = ["-created_at"]

def get_form(self, request, obj=None, **kwargs):
"""
That's a bit of a hack
Dynamically change self.form, before delegating to the actual ModelAdmin.get_form
Proper kwargs are form, fields, exclude, formfield_callback
"""
if obj is None:
self.form = FileForm

return super().get_form(request, obj, **kwargs)

def get_readonly_fields(self, request, obj=None):
"""
We want to show those fields only when we have an existing object.
"""

if obj is not None:
return [
"original_file_name",
"file_name",
"file_type",
"created_at",
"updated_at",
"upload_finished_at"
]

return []

def save_model(self, request, obj, form, change):
try:
cleaned_data = form.cleaned_data

service = FileDirectUploadService(
file_obj=cleaned_data["file"],
user=cleaned_data["uploaded_by"]
)

if change:
service.update(file=obj)
else:
service.create()
except ValidationError as exc:
self.message_user(request, str(exc), messages.ERROR)
Loading