diff --git a/app/core/permissions/__init__.py b/app/core/permissions/__init__.py new file mode 100644 index 0000000..3a1545c --- /dev/null +++ b/app/core/permissions/__init__.py @@ -0,0 +1,9 @@ +from rest_framework import permissions, request, views +from user.models import UserExt + + +class IsSuperUser(permissions.BasePermission): + message = "You do not have permission to perform this action." + + def has_permission(self, request: request.Request, view: views.APIView) -> bool: + return isinstance(request.user, UserExt) and request.user.is_superuser diff --git a/app/core/settings.py b/app/core/settings.py index f480f40..c78c8e4 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -155,6 +155,7 @@ "django_extensions", # django-app "user", + "file", "cms", # django-constance "constance", @@ -251,20 +252,37 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_ROOT = BASE_DIR / "static" +MEDIA_ROOT = BASE_DIR / "media" DEFAULT_STORAGE_BACKEND = env("DJANGO_DEFAULT_STORAGE_BACKEND", default="storages.backends.s3.S3Storage") STATIC_STORAGE_BACKEND = env("DJANGO_STATIC_STORAGE_BACKEND", default="storages.backends.s3.S3Storage") -STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}" +PRIVATE_STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}" +PUBLIC_STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}-public" + STATIC_URL = ( - f"https://s3.ap-northeast-2.amazonaws.com/{STORAGE_BUCKET_NAME}/" + f"https://s3.ap-northeast-2.amazonaws.com/{PRIVATE_STORAGE_BUCKET_NAME}/" if STATIC_STORAGE_BACKEND == "storages.backends.s3.S3Storage" else "static/" ) +MEDIA_URL = ( + f"https://s3.ap-northeast-2.amazonaws.com/{PUBLIC_STORAGE_BUCKET_NAME}/" + if DEFAULT_STORAGE_BACKEND == "storages.backends.s3.S3Storage" + else "media/" +) -STORAGE_OPTIONS = ( +STATIC_STORAGE_OPTIONS = ( + { + "bucket_name": PRIVATE_STORAGE_BUCKET_NAME, + "file_overwrite": False, + "addressing_style": "path", + } + if DEFAULT_STORAGE_BACKEND == "storages.backends.s3.S3Storage" + else {} +) +PUBLIC_STORAGE_OPTIONS = ( { - "bucket_name": STORAGE_BUCKET_NAME, + "bucket_name": PRIVATE_STORAGE_BUCKET_NAME, "file_overwrite": False, "addressing_style": "path", } @@ -272,8 +290,9 @@ else {} ) STORAGES = { - "default": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": STORAGE_OPTIONS}, - "staticfiles": {"BACKEND": STATIC_STORAGE_BACKEND, "OPTIONS": STORAGE_OPTIONS}, + "default": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": STATIC_STORAGE_OPTIONS}, + "staticfiles": {"BACKEND": STATIC_STORAGE_BACKEND, "OPTIONS": STATIC_STORAGE_OPTIONS}, + "public": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": PUBLIC_STORAGE_OPTIONS}, } # Default primary key field type diff --git a/app/core/urls.py b/app/core/urls.py index 942f940..d347ee9 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -33,7 +33,11 @@ path("admin/", admin.site.urls), # V1 API re_path("^v1/", include((v1_apis, "v1"), namespace="v1")), -] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +] + [ + # Static files + *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), + *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), +] if settings.DEBUG: urlpatterns += [ diff --git a/app/file/__init__.py b/app/file/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/file/admin.py b/app/file/admin.py new file mode 100644 index 0000000..62e618e --- /dev/null +++ b/app/file/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from django.http.request import HttpRequest +from django.http.response import HttpResponseNotAllowed, JsonResponse +from django.urls import re_path +from django.urls.resolvers import URLPattern +from file.models import PublicFile + + +@admin.register(PublicFile) +class PublicFileAdmin(admin.ModelAdmin): + fields = ["id", "file", "mimetype", "hash", "size", "created_at", "updated_at", "deleted_at"] + readonly_fields = ["id", "mimetype", "hash", "size", "created_at", "updated_at", "deleted_at"] + + def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> list[str]: + return self.readonly_fields + (["file"] if obj else []) + + def get_urls(self) -> list[URLPattern]: + return [ + re_path(route=r"^list/$", view=self.admin_site.admin_view(self.list_public_files)), + ] + super().get_urls() + + def list_public_files(self, request: HttpRequest) -> JsonResponse | HttpResponseNotAllowed: + if request.method == "GET": + data = list(PublicFile.objects.filter_active().values(*self.fields)) + return JsonResponse(data=data, safe=False, json_dumps_params={"ensure_ascii": False}) + return HttpResponseNotAllowed(permitted_methods=["GET"]) diff --git a/app/file/apps.py b/app/file/apps.py new file mode 100644 index 0000000..81ed857 --- /dev/null +++ b/app/file/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class FileConfig(AppConfig): + name = "file" + + def ready(self): + from file.models import PublicFile + from simple_history import register + + register(PublicFile) diff --git a/app/file/migrations/0001_initial.py b/app/file/migrations/0001_initial.py new file mode 100644 index 0000000..52d560c --- /dev/null +++ b/app/file/migrations/0001_initial.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2 on 2025-05-20 04:57 + +import uuid + +import django.core.files.storage +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalPublicFile", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("file", models.TextField(db_index=True, max_length=100)), + ("mimetype", models.CharField(max_length=256, null=True)), + ("hash", models.CharField(max_length=256)), + ("size", models.BigIntegerField()), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical public file", + "verbose_name_plural": "historical public files", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="PublicFile", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ( + "file", + models.FileField( + storage=django.core.files.storage.FileSystemStorage(), unique=True, upload_to="public/" + ), + ), + ("mimetype", models.CharField(max_length=256, null=True)), + ("hash", models.CharField(max_length=256)), + ("size", models.BigIntegerField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="publicfile", + index=models.Index(fields=["file"], name="file_public_file_3d3996_idx"), + ), + migrations.AddIndex( + model_name="publicfile", + index=models.Index(fields=["mimetype"], name="file_public_mimetyp_da163f_idx"), + ), + migrations.AddIndex( + model_name="publicfile", + index=models.Index(fields=["hash"], name="file_public_hash_669533_idx"), + ), + ] diff --git a/app/file/migrations/__init__.py b/app/file/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/file/models.py b/app/file/models.py new file mode 100644 index 0000000..3463299 --- /dev/null +++ b/app/file/models.py @@ -0,0 +1,29 @@ +import hashlib +import mimetypes + +from core.models import BaseAbstractModel +from django.core.files.storage import storages +from django.db import models + + +class PublicFile(BaseAbstractModel): + file = models.FileField(unique=True, null=False, blank=False, upload_to="public/", storage=storages["public"]) + mimetype = models.CharField(max_length=256, null=True, blank=False) + hash = models.CharField(max_length=256, null=False, blank=False) + size = models.BigIntegerField(null=False, blank=False) + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["file"]), models.Index(fields=["mimetype"]), models.Index(fields=["hash"])] + + def clean(self) -> None: + # 파일의 해시값, 크기, mimetype을 계산하여 저장합니다. + hash_md5 = hashlib.md5(usedforsecurity=False) + file_pointer = self.file.open("rb") + + for chunk in iter(lambda: file_pointer.read(4096), b""): + hash_md5.update(chunk) + + self.hash = hash_md5.hexdigest() + self.size = self.file.size + self.mimetype = mimetypes.guess_type(self.file.name)[0]