diff --git a/.env.example b/.env.example index cb64cc06..1ac077c3 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 56d1b3c0..5ad021b2 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index b6e47617..395352a1 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + + +# media files +/media \ No newline at end of file diff --git a/config/django/base.py b/config/django/base.py index b93df957..4d037d40 100644 --- a/config/django/base.py +++ b/config/django/base.py @@ -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")) @@ -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 = [ @@ -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 diff --git a/config/env.py b/config/env.py index 463cfb87..ac9a08b1 100644 --- a/config/env.py +++ b/config/env.py @@ -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)}") diff --git a/config/settings/files_and_storages.py b/config/settings/files_and_storages.py new file mode 100644 index 00000000..f9fe62b6 --- /dev/null +++ b/config/settings/files_and_storages.py @@ -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 diff --git a/config/urls.py b/config/urls.py index 105b2bd1..a72499ea 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..759f4e23 --- /dev/null +++ b/mypy.ini @@ -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 diff --git a/requirements/base.txt b/requirements/base.txt index 87b5fce3..b374e384 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -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 diff --git a/requirements/local.txt b/requirements/local.txt index f9a04974..4986bc3f 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -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 diff --git a/setup.cfg b/setup.cfg index 230cafbd..3f7883e1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/styleguide_example/api/urls.py b/styleguide_example/api/urls.py index 0f659849..f960f19a 100644 --- a/styleguide_example/api/urls.py +++ b/styleguide_example/api/urls.py @@ -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'))), ] diff --git a/styleguide_example/common/utils.py b/styleguide_example/common/utils.py index 89849a77..88ce46b3 100644 --- a/styleguide_example/common/utils.py +++ b/styleguide_example/common/utils.py @@ -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): @@ -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 diff --git a/styleguide_example/files/__init__.py b/styleguide_example/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/files/admin.py b/styleguide_example/files/admin.py new file mode 100644 index 00000000..a77e46bc --- /dev/null +++ b/styleguide_example/files/admin.py @@ -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) diff --git a/styleguide_example/files/apis.py b/styleguide_example/files/apis.py new file mode 100644 index 00000000..75311423 --- /dev/null +++ b/styleguide_example/files/apis.py @@ -0,0 +1,69 @@ +from django.shortcuts import get_object_or_404 + +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from styleguide_example.files.models import File +from styleguide_example.files.services import ( + FileDirectUploadService, + FilePassThruUploadService +) + +from styleguide_example.api.mixins import ApiAuthMixin + + +class FileDirectUploadApi(ApiAuthMixin, APIView): + def post(self, request): + service = FileDirectUploadService( + user=request.user, + file_obj=request.FILES["file"] + ) + file = service.create() + + return Response(data={"id": file.id}, status=status.HTTP_201_CREATED) + + +class FilePassThruUploadStartApi(ApiAuthMixin, APIView): + class InputSerializer(serializers.Serializer): + file_name = serializers.CharField() + file_type = serializers.CharField() + + def post(self, request, *args, **kwargs): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + service = FilePassThruUploadService(request.user) + presigned_data = service.start(**serializer.validated_data) + + return Response(data=presigned_data) + + +class FilePassThruUploadLocalApi(ApiAuthMixin, APIView): + def post(self, request, file_id): + file = get_object_or_404(File, id=file_id) + + file_object = request.FILES["file"] + + service = FilePassThruUploadService(request.user) + file = service.upload_local(file=file, file_object=file_object) + + return Response({"id": file.id}) + + +class FilePassThruUploadFinishApi(ApiAuthMixin, APIView): + class InputSerializer(serializers.Serializer): + file_id = serializers.CharField() + + def post(self, request): + serializer = self.InputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + file_id = serializer.validated_data["file_id"] + + file = get_object_or_404(File, id=file_id) + + service = FilePassThruUploadService(request.user) + service.finish(file=file) + + return Response({"id": file.id}) diff --git a/styleguide_example/files/apps.py b/styleguide_example/files/apps.py new file mode 100644 index 00000000..63f97e04 --- /dev/null +++ b/styleguide_example/files/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + name = 'styleguide_example.files' diff --git a/styleguide_example/files/enums.py b/styleguide_example/files/enums.py new file mode 100644 index 00000000..a123137a --- /dev/null +++ b/styleguide_example/files/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class FileUploadStrategy(Enum): + DIRECT = "direct" + PASS_THRU = "pass-thru" + + +class FileUploadStorage(Enum): + LOCAL = "local" + S3 = "s3" diff --git a/styleguide_example/files/migrations/0001_initial.py b/styleguide_example/files/migrations/0001_initial.py new file mode 100644 index 00000000..c4a54155 --- /dev/null +++ b/styleguide_example/files/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.12 on 2022-04-03 13:31 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import styleguide_example.files.utils + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('file', models.FileField(blank=True, null=True, upload_to=styleguide_example.files.utils.file_generate_upload_path)), + ('original_file_name', models.TextField()), + ('file_name', models.CharField(max_length=255, unique=True)), + ('file_type', models.CharField(max_length=255)), + ('upload_finished_at', models.DateTimeField(blank=True, null=True)), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/styleguide_example/files/migrations/__init__.py b/styleguide_example/files/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/files/models.py b/styleguide_example/files/models.py new file mode 100644 index 00000000..dd11b896 --- /dev/null +++ b/styleguide_example/files/models.py @@ -0,0 +1,46 @@ + +from django.db import models +from django.conf import settings + +from styleguide_example.common.models import BaseModel + +from styleguide_example.users.models import BaseUser + +from styleguide_example.files.utils import ( + file_generate_upload_path +) +from styleguide_example.files.enums import FileUploadStorage + + +class File(BaseModel): + file = models.FileField(upload_to=file_generate_upload_path, null=True, blank=True) + + original_file_name = models.TextField() + + file_name = models.CharField(max_length=255, unique=True) + file_type = models.CharField(max_length=255) + + # As a specific behavior, + # We might want to preserve files after the uploader has been deleted. + # In case you want to delete the files too, use models.CASCADE & drop the null=True + uploaded_by = models.ForeignKey( + BaseUser, + null=True, + on_delete=models.SET_NULL + ) + + upload_finished_at = models.DateTimeField(blank=True, null=True) + + @property + def is_valid(self): + """ + We consider a file "valid" if the the datetime flag has value. + """ + return bool(self.upload_finished_at) + + @property + def url(self): + if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3: + return self.file.url + + return f"{settings.APP_DOMAIN}{self.file.url}" diff --git a/styleguide_example/files/services.py b/styleguide_example/files/services.py new file mode 100644 index 00000000..e055f485 --- /dev/null +++ b/styleguide_example/files/services.py @@ -0,0 +1,147 @@ +import mimetypes + +from typing import Tuple, Dict, Any + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from styleguide_example.files.models import File +from styleguide_example.files.utils import ( + file_generate_upload_path, + file_generate_local_upload_url, + file_generate_name +) +from styleguide_example.files.enums import FileUploadStorage + +from styleguide_example.integrations.aws.client import s3_generate_presigned_post + +from styleguide_example.users.models import BaseUser + + +class FileDirectUploadService: + """ + This also serves as an example of a service class, + which encapsulates 2 different behaviors (create & update) under a namespace. + + Meaning, we use the class here for: + + 1. The namespace + 2. The ability to reuse `_infer_file_name_and_type` (which can also be an util) + """ + def __init__(self, user: BaseUser, file_obj): + self.user = user + self.file_obj = file_obj + + def _infer_file_name_and_type(self, file_name: str = "", file_type: str = "") -> Tuple[str, str]: + if not file_name: + file_name = self.file_obj.name + + if not file_type: + guessed_file_type, encoding = mimetypes.guess_type(file_name) + + if guessed_file_type is None: + file_type = "" + else: + file_type = guessed_file_type + + return file_name, file_type + + @transaction.atomic + def create(self, file_name: str = "", file_type: str = "") -> File: + file_name, file_type = self._infer_file_name_and_type(file_name, file_type) + + obj = File( + file=self.file_obj, + original_file_name=file_name, + file_name=file_generate_name(file_name), + file_type=file_type, + uploaded_by=self.user, + upload_finished_at=timezone.now() + ) + + obj.full_clean() + obj.save() + + return obj + + @transaction.atomic + def update(self, file: File, file_name: str = "", file_type: str = "") -> File: + file_name, file_type = self._infer_file_name_and_type(file_name, file_type) + + file.file = self.file_obj + file.original_file_name = file_name + file.file_name = file_generate_name(file_name) + file.file_type = file_type + file.uploaded_by = self.user + file.upload_finished_at = timezone.now() + + file.full_clean() + file.save() + + return file + + +class FilePassThruUploadService: + """ + This also serves as an example of a service class, + which encapsulates a flow (start & finish) + one-off action (upload_local) into a namespace. + + Meaning, we use the class here for: + + 1. The namespace + """ + def __init__(self, user: BaseUser): + self.user = user + + @transaction.atomic + def start(self, *, file_name: str, file_type: str) -> Dict[str, Any]: + file = File( + original_file_name=file_name, + file_name=file_generate_name(file_name), + file_type=file_type, + uploaded_by=self.user, + file=None + ) + file.full_clean() + file.save() + + upload_path = file_generate_upload_path(file, file.file_name) + + """ + We are doing this in order to have an associated file for the field. + """ + file.file = file.file.field.attr_class(file, file.file.field, upload_path) + file.save() + + presigned_data: Dict[str, Any] = {} + + if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3: + presigned_data = s3_generate_presigned_post( + file_path=upload_path, file_type=file.file_type + ) + + else: + presigned_data = { + "url": file_generate_local_upload_url(file_id=str(file.id)), + } + + return {"id": file.id, **presigned_data} + + @transaction.atomic + def finish(self, *, file: File) -> File: + # Potentially, check against user + file.upload_finished_at = timezone.now() + file.full_clean() + file.save() + + return file + + @transaction.atomic + def upload_local(self, *, file: File, file_object) -> File: + # Potentially, check against user + file.file = file_object + file.full_clean() + file.save() + + return file diff --git a/styleguide_example/files/urls.py b/styleguide_example/files/urls.py new file mode 100644 index 00000000..05fa699a --- /dev/null +++ b/styleguide_example/files/urls.py @@ -0,0 +1,43 @@ +from django.urls import path, include + +from styleguide_example.files.apis import ( + FileDirectUploadApi, + + FilePassThruUploadStartApi, + FilePassThruUploadFinishApi, + FilePassThruUploadLocalApi, +) + + +urlpatterns = [ + path( + "upload/", + include(([ + path( + "direct/", + FileDirectUploadApi.as_view(), + name="direct" + ), + path( + "pass-thru/", + include(([ + path( + "start/", + FilePassThruUploadStartApi.as_view(), + name="start" + ), + path( + "finish/", + FilePassThruUploadFinishApi.as_view(), + name="finish" + ), + path( + "local//", + FilePassThruUploadLocalApi.as_view(), + name="local" + ) + ], "pass-thru")) + ) + ], "upload")) + ) +] diff --git a/styleguide_example/files/utils.py b/styleguide_example/files/utils.py new file mode 100644 index 00000000..992bf515 --- /dev/null +++ b/styleguide_example/files/utils.py @@ -0,0 +1,25 @@ +import pathlib + +from uuid import uuid4 + +from django.urls import reverse +from django.conf import settings + + +def file_generate_name(original_file_name): + extension = pathlib.Path(original_file_name).suffix + + return f"{uuid4().hex}{extension}" + + +def file_generate_upload_path(instance, filename): + return f"files/{instance.file_name}" + + +def file_generate_local_upload_url(*, file_id: str): + url = reverse( + "api:files:upload:pass-thru:local", + kwargs={"file_id": file_id} + ) + + return f"{settings.APP_DOMAIN}{url}" diff --git a/styleguide_example/integrations/__init__.py b/styleguide_example/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/integrations/apps.py b/styleguide_example/integrations/apps.py new file mode 100644 index 00000000..2dddf743 --- /dev/null +++ b/styleguide_example/integrations/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class IntegrationsConfig(AppConfig): + name = 'styleguide_example.integrations' diff --git a/styleguide_example/integrations/aws/__init__.py b/styleguide_example/integrations/aws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/integrations/aws/client.py b/styleguide_example/integrations/aws/client.py new file mode 100644 index 00000000..e12c7c02 --- /dev/null +++ b/styleguide_example/integrations/aws/client.py @@ -0,0 +1,96 @@ +from typing import Dict, Any + +from functools import lru_cache + +from attrs import define + +import boto3 + +from styleguide_example.common.utils import assert_settings + + +@define +class S3Credentials: + access_key_id: str + secret_access_key: str + region_name: str + bucket_name: str + default_acl: str + presigned_expiry: int + + +@lru_cache +def s3_get_credentials() -> S3Credentials: + required_config = assert_settings( + [ + "AWS_S3_ACCESS_KEY_ID", + "AWS_S3_SECRET_ACCESS_KEY", + "AWS_S3_REGION_NAME", + "AWS_STORAGE_BUCKET_NAME", + "AWS_DEFAULT_ACL", + "AWS_PRESIGNED_EXPIRY" + ], + "S3 credentials not found." + ) + + return S3Credentials( + access_key_id=required_config["AWS_S3_ACCESS_KEY_ID"], + secret_access_key=required_config["AWS_S3_SECRET_ACCESS_KEY"], + region_name=required_config["AWS_S3_REGION_NAME"], + bucket_name=required_config["AWS_STORAGE_BUCKET_NAME"], + default_acl=required_config["AWS_DEFAULT_ACL"], + presigned_expiry=required_config["AWS_PRESIGNED_EXPIRY"] + ) + + +def s3_get_client(): + credentials = s3_get_credentials() + + return boto3.client( + service_name="s3", + aws_access_key_id=credentials.access_key_id, + aws_secret_access_key=credentials.secret_access_key, + region_name=credentials.region_name + ) + + +def s3_generate_presigned_post(*, file_path: str, file_type: str) -> Dict[str, Any]: + credentials = s3_get_credentials() + s3_client = s3_get_client() + + acl = credentials.default_acl + expires_in = credentials.presigned_expiry + + """ + TODO: Create a type for the presigned_data + It looks like this: + + { + 'fields': { + 'Content-Type': 'image/png', + 'acl': 'private', + 'key': 'files/bafdccb665a447468e237781154883b5.png', + 'policy': 'some-long-base64-string', + 'x-amz-algorithm': 'AWS4-HMAC-SHA256', + 'x-amz-credential': 'AKIASOZLZI5FJDJ6XTSZ/20220405/eu-central-1/s3/aws4_request', + 'x-amz-date': '20220405T114912Z', + 'x-amz-signature': '7d8be89aabec12b781d44b5b3f099d07be319b9a41d9a9c804bd1075e1ef5735' + }, + 'url': 'https://django-styleguide-example.s3.amazonaws.com/' + } + """ + presigned_data = s3_client.generate_presigned_post( + credentials.bucket_name, + file_path, + Fields={ + "acl": acl, + "Content-Type": file_type + }, + Conditions=[ + {"acl": acl}, + {"Content-Type": file_type} + ], + ExpiresIn=expires_in, + ) + + return presigned_data