Skip to content

Commit d87863e

Browse files
authored
Merge pull request #66 from HackSoftware/file-uploads
File uploads - locally and on s3
2 parents 40d1532 + bc83158 commit d87863e

File tree

28 files changed

+705
-46
lines changed

28 files changed

+705
-46
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,10 @@
11
PYTHONBREAKPOINT=ipdb.set_trace
22
SENTRY_DSN=""
3+
4+
FILE_UPLOAD_STRATEGY="direct" # pass-thru
5+
FILE_UPLOAD_STORAGE="local" # s3
6+
7+
AWS_S3_ACCESS_KEY_ID=""
8+
AWS_S3_SECRET_ACCESS_KEY=""
9+
AWS_STORAGE_BUCKET_NAME="django-styleguide-example"
10+
AWS_S3_REGION_NAME="eu-central-1"

.github/workflows/django.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
- name: Build docker
99
run: docker-compose build
1010
- name: Type check
11-
run: docker-compose run django mypy styleguide_example/
11+
run: docker-compose run django mypy --config mypy.ini styleguide_example/
1212
- name: Run migrations
1313
run: docker-compose run django python manage.py migrate
1414
- name: Run tests
@@ -38,7 +38,9 @@ jobs:
3838
python -m pip install --upgrade pip
3939
pip install -r requirements/local.txt
4040
- name: Type check
41-
run: mypy styleguide_example/
41+
run: |
42+
mypy --version
43+
mypy --config mypy.ini styleguide_example/
4244
- name: Run migrations
4345
run: python manage.py migrate
4446
- name: Run tests

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,7 @@ dmypy.json
127127

128128
# Pyre type checker
129129
.pyre/
130+
131+
132+
# media files
133+
/media

config/django/base.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@
1212

1313
import os
1414

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

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

@@ -41,6 +38,8 @@
4138
'styleguide_example.users.apps.UsersConfig',
4239
'styleguide_example.errors.apps.ErrorsConfig',
4340
'styleguide_example.testing_examples.apps.TestingExamplesConfig',
41+
'styleguide_example.integrations.apps.IntegrationsConfig',
42+
'styleguide_example.files.apps.FilesConfig',
4443
]
4544

4645
THIRD_PARTY_APPS = [
@@ -171,8 +170,12 @@
171170
'DEFAULT_AUTHENTICATION_CLASSES': []
172171
}
173172

173+
APP_DOMAIN = env("APP_DOMAIN", default="http://localhost:8000")
174+
174175
from config.settings.cors import * # noqa
175176
from config.settings.jwt import * # noqa
176177
from config.settings.sessions import * # noqa
177178
from config.settings.celery import * # noqa
178179
from config.settings.sentry import * # noqa
180+
181+
from config.settings.files_and_storages import * # noqa

config/env.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
from django.core.exceptions import ImproperlyConfigured
2+
13
import environ
24

35
env = environ.Env()
6+
7+
BASE_DIR = environ.Path(__file__) - 2
8+
9+
10+
def env_to_enum(enum_cls, value):
11+
for x in enum_cls:
12+
if x.value == value:
13+
return x
14+
15+
raise ImproperlyConfigured(f"Env value {repr(value)} could not be found in {repr(enum_cls)}")

config/settings/files_and_storages.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import os
2+
3+
from config.env import BASE_DIR, env, env_to_enum
4+
5+
from styleguide_example.files.enums import FileUploadStrategy, FileUploadStorage
6+
7+
8+
FILE_UPLOAD_STRATEGY = env_to_enum(
9+
FileUploadStrategy,
10+
env("FILE_UPLOAD_STRATEGY", default="direct")
11+
)
12+
FILE_UPLOAD_STORAGE = env_to_enum(
13+
FileUploadStorage,
14+
env("FILE_UPLOAD_STORAGE", default="local")
15+
)
16+
17+
if FILE_UPLOAD_STORAGE == FileUploadStorage.LOCAL:
18+
MEDIA_ROOT_NAME = "media"
19+
MEDIA_ROOT = os.path.join(BASE_DIR, MEDIA_ROOT_NAME)
20+
MEDIA_URL = f"/{MEDIA_ROOT_NAME}/"
21+
22+
if FILE_UPLOAD_STORAGE == FileUploadStorage.S3:
23+
# Using django-storages
24+
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
25+
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
26+
27+
AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID")
28+
AWS_S3_SECRET_ACCESS_KEY = env("AWS_S3_SECRET_ACCESS_KEY")
29+
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
30+
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME")
31+
AWS_S3_SIGNATURE_VERSION = env("AWS_S3_SIGNATURE_VERSION", default="s3v4")
32+
33+
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
34+
AWS_DEFAULT_ACL = env("AWS_DEFAULT_ACL", default="private")
35+
36+
AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=10) # seconds

config/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
1515
"""
1616
from django.contrib import admin
17+
from django.conf import settings
1718
from django.urls import path, include
19+
from django.conf.urls.static import static
1820

1921
urlpatterns = [
2022
path('admin/', admin.site.urls),
2123
path('api/', include(('styleguide_example.api.urls', 'api'))),
22-
]
24+
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

mypy.ini

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[mypy]
2+
plugins =
3+
mypy_django_plugin.main,
4+
mypy_drf_plugin.main
5+
6+
[mypy.plugins.django-stubs]
7+
django_settings_module = "config.django.base"
8+
9+
[mypy-config.*]
10+
# Ignore everything related to Django config
11+
ignore_errors = true
12+
13+
[mypy-styleguide_example.*.migrations.*]
14+
# Ignore Django migrations
15+
ignore_errors = true
16+
17+
[mypy-celery.*]
18+
# Remove this when celery stubs are present
19+
ignore_missing_imports = True
20+
21+
[mypy-django_celery_beat.*]
22+
# Remove this when django_celery_beat stubs are present
23+
ignore_missing_imports = True
24+
25+
[mypy-django_filters.*]
26+
# Remove this when django_filters stubs are present
27+
ignore_missing_imports = True
28+
29+
[mypy-factory.*]
30+
# Remove this when factory stubs are present
31+
ignore_missing_imports = True
32+
33+
[mypy-rest_framework_jwt.*]
34+
# Remove this when rest_framework_jwt stubs are present
35+
ignore_missing_imports = True

requirements/base.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ django-celery-beat==2.2.1
1010
whitenoise==6.0.0
1111

1212
django-filter==21.1
13-
django-cors-headers==3.11.0
1413
django-extensions==3.1.5
14+
django-cors-headers==3.10.0
15+
django-storages==1.12.3
1516

1617
drf-jwt==1.19.2
18+
19+
boto3==1.20.20
20+
attrs==21.4.0

requirements/local.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ ipython==8.2.0
1414
mypy==0.942
1515
django-stubs==1.9.0
1616
djangorestframework-stubs==1.4.0
17+
boto3-stubs==1.21.32

setup.cfg

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,3 @@ exclude =
44
.git,
55
__pycache__,
66
*/migrations/*
7-
8-
[mypy]
9-
plugins =
10-
mypy_django_plugin.main,
11-
mypy_drf_plugin.main
12-
13-
[mypy.plugins.django-stubs]
14-
django_settings_module = "config.django.base"
15-
16-
[mypy-config.*]
17-
# Ignore everything related to Django config
18-
ignore_errors = true
19-
20-
[mypy-styleguide_example.*.migrations.*]
21-
# Ignore Django migrations
22-
ignore_errors = true
23-
24-
[mypy-celery.*]
25-
# Remove this when celery stubs are present
26-
ignore_missing_imports = True
27-
28-
[mypy-django_celery_beat.*]
29-
# Remove this when django_celery_beat stubs are present
30-
ignore_missing_imports = True
31-
32-
[mypy-django_filters.*]
33-
# Remove this when django_filters stubs are present
34-
ignore_missing_imports = True
35-
36-
[mypy-factory.*]
37-
# Remove this when factory stubs are present
38-
ignore_missing_imports = True
39-
40-
[mypy-rest_framework_jwt.*]
41-
# Remove this when rest_framework_jwt stubs are present
42-
ignore_missing_imports = True

styleguide_example/api/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
),
77
path('users/', include(('styleguide_example.users.urls', 'users'))),
88
path('errors/', include(('styleguide_example.errors.urls', 'errors'))),
9+
path('files/', include(('styleguide_example.files.urls', 'files'))),
910
]

styleguide_example/common/utils.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from rest_framework import serializers
2-
1+
from django.conf import settings
32
from django.shortcuts import get_object_or_404
43
from django.http import Http404
4+
from django.core.exceptions import ImproperlyConfigured
5+
6+
from rest_framework import serializers
57

68

79
def make_mock_object(**kwargs):
@@ -30,3 +32,28 @@ def inline_serializer(*, fields, data=None, **kwargs):
3032
return serializer_class(data=data, **kwargs)
3133

3234
return serializer_class(**kwargs)
35+
36+
37+
def assert_settings(required_settings, error_message_prefix=""):
38+
"""
39+
Checks if each item from `required_settings` is present in Django settings
40+
"""
41+
not_present = []
42+
values = {}
43+
44+
for required_setting in required_settings:
45+
if not hasattr(settings, required_setting):
46+
not_present.append(required_setting)
47+
continue
48+
49+
values[required_setting] = getattr(settings, required_setting)
50+
51+
if not_present:
52+
if not error_message_prefix:
53+
error_message_prefix = "Required settings not found."
54+
55+
stringified_not_present = ", ".join(not_present)
56+
57+
raise ImproperlyConfigured(f"{error_message_prefix} Could not find: {stringified_not_present}")
58+
59+
return values

styleguide_example/files/__init__.py

Whitespace-only changes.

styleguide_example/files/admin.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from django import forms
2+
3+
from django.contrib import admin, messages
4+
from django.core.exceptions import ValidationError
5+
6+
from styleguide_example.files.models import File
7+
from styleguide_example.files.services import (
8+
FileDirectUploadService
9+
)
10+
11+
12+
class FileForm(forms.ModelForm):
13+
class Meta:
14+
model = File
15+
fields = ["file", "uploaded_by"]
16+
17+
18+
@admin.register(File)
19+
class FileAdmin(admin.ModelAdmin):
20+
list_display = [
21+
"id",
22+
"original_file_name",
23+
"file_name",
24+
"file_type",
25+
"url",
26+
"uploaded_by",
27+
"created_at",
28+
"upload_finished_at",
29+
"is_valid",
30+
]
31+
list_select_related = ["uploaded_by"]
32+
33+
ordering = ["-created_at"]
34+
35+
def get_form(self, request, obj=None, **kwargs):
36+
"""
37+
That's a bit of a hack
38+
Dynamically change self.form, before delegating to the actual ModelAdmin.get_form
39+
Proper kwargs are form, fields, exclude, formfield_callback
40+
"""
41+
if obj is None:
42+
self.form = FileForm
43+
44+
return super().get_form(request, obj, **kwargs)
45+
46+
def get_readonly_fields(self, request, obj=None):
47+
"""
48+
We want to show those fields only when we have an existing object.
49+
"""
50+
51+
if obj is not None:
52+
return [
53+
"original_file_name",
54+
"file_name",
55+
"file_type",
56+
"created_at",
57+
"updated_at",
58+
"upload_finished_at"
59+
]
60+
61+
return []
62+
63+
def save_model(self, request, obj, form, change):
64+
try:
65+
cleaned_data = form.cleaned_data
66+
67+
service = FileDirectUploadService(
68+
file_obj=cleaned_data["file"],
69+
user=cleaned_data["uploaded_by"]
70+
)
71+
72+
if change:
73+
service.update(file=obj)
74+
else:
75+
service.create()
76+
except ValidationError as exc:
77+
self.message_user(request, str(exc), messages.ERROR)

0 commit comments

Comments
 (0)