Skip to content

Commit af11b54

Browse files
authored
Merge pull request #15 from pythonkr/feature/add-file-app
2 parents e7bb7f0 + b9b82e3 commit af11b54

File tree

9 files changed

+254
-7
lines changed

9 files changed

+254
-7
lines changed

app/core/permissions/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from rest_framework import permissions, request, views
2+
from user.models import UserExt
3+
4+
5+
class IsSuperUser(permissions.BasePermission):
6+
message = "You do not have permission to perform this action."
7+
8+
def has_permission(self, request: request.Request, view: views.APIView) -> bool:
9+
return isinstance(request.user, UserExt) and request.user.is_superuser

app/core/settings.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
"django_extensions",
156156
# django-app
157157
"user",
158+
"file",
158159
"cms",
159160
# django-constance
160161
"constance",
@@ -251,29 +252,47 @@
251252
# Static files (CSS, JavaScript, Images)
252253
# https://docs.djangoproject.com/en/5.2/howto/static-files/
253254
STATIC_ROOT = BASE_DIR / "static"
255+
MEDIA_ROOT = BASE_DIR / "media"
254256

255257
DEFAULT_STORAGE_BACKEND = env("DJANGO_DEFAULT_STORAGE_BACKEND", default="storages.backends.s3.S3Storage")
256258
STATIC_STORAGE_BACKEND = env("DJANGO_STATIC_STORAGE_BACKEND", default="storages.backends.s3.S3Storage")
257259

258-
STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}"
260+
PRIVATE_STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}"
261+
PUBLIC_STORAGE_BUCKET_NAME = f"pyconkr-backend-{API_STAGE}-public"
262+
259263
STATIC_URL = (
260-
f"https://s3.ap-northeast-2.amazonaws.com/{STORAGE_BUCKET_NAME}/"
264+
f"https://s3.ap-northeast-2.amazonaws.com/{PRIVATE_STORAGE_BUCKET_NAME}/"
261265
if STATIC_STORAGE_BACKEND == "storages.backends.s3.S3Storage"
262266
else "static/"
263267
)
268+
MEDIA_URL = (
269+
f"https://s3.ap-northeast-2.amazonaws.com/{PUBLIC_STORAGE_BUCKET_NAME}/"
270+
if DEFAULT_STORAGE_BACKEND == "storages.backends.s3.S3Storage"
271+
else "media/"
272+
)
264273

265-
STORAGE_OPTIONS = (
274+
STATIC_STORAGE_OPTIONS = (
275+
{
276+
"bucket_name": PRIVATE_STORAGE_BUCKET_NAME,
277+
"file_overwrite": False,
278+
"addressing_style": "path",
279+
}
280+
if DEFAULT_STORAGE_BACKEND == "storages.backends.s3.S3Storage"
281+
else {}
282+
)
283+
PUBLIC_STORAGE_OPTIONS = (
266284
{
267-
"bucket_name": STORAGE_BUCKET_NAME,
285+
"bucket_name": PRIVATE_STORAGE_BUCKET_NAME,
268286
"file_overwrite": False,
269287
"addressing_style": "path",
270288
}
271289
if DEFAULT_STORAGE_BACKEND == "storages.backends.s3.S3Storage"
272290
else {}
273291
)
274292
STORAGES = {
275-
"default": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": STORAGE_OPTIONS},
276-
"staticfiles": {"BACKEND": STATIC_STORAGE_BACKEND, "OPTIONS": STORAGE_OPTIONS},
293+
"default": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": STATIC_STORAGE_OPTIONS},
294+
"staticfiles": {"BACKEND": STATIC_STORAGE_BACKEND, "OPTIONS": STATIC_STORAGE_OPTIONS},
295+
"public": {"BACKEND": DEFAULT_STORAGE_BACKEND, "OPTIONS": PUBLIC_STORAGE_OPTIONS},
277296
}
278297

279298
# Default primary key field type

app/core/urls.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
path("admin/", admin.site.urls),
3434
# V1 API
3535
re_path("^v1/", include((v1_apis, "v1"), namespace="v1")),
36-
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
36+
] + [
37+
# Static files
38+
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
39+
*static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
40+
]
3741

3842
if settings.DEBUG:
3943
urlpatterns += [

app/file/__init__.py

Whitespace-only changes.

app/file/admin.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.contrib import admin
2+
from django.http.request import HttpRequest
3+
from django.http.response import HttpResponseNotAllowed, JsonResponse
4+
from django.urls import re_path
5+
from django.urls.resolvers import URLPattern
6+
from file.models import PublicFile
7+
8+
9+
@admin.register(PublicFile)
10+
class PublicFileAdmin(admin.ModelAdmin):
11+
fields = ["id", "file", "mimetype", "hash", "size", "created_at", "updated_at", "deleted_at"]
12+
readonly_fields = ["id", "mimetype", "hash", "size", "created_at", "updated_at", "deleted_at"]
13+
14+
def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> list[str]:
15+
return self.readonly_fields + (["file"] if obj else [])
16+
17+
def get_urls(self) -> list[URLPattern]:
18+
return [
19+
re_path(route=r"^list/$", view=self.admin_site.admin_view(self.list_public_files)),
20+
] + super().get_urls()
21+
22+
def list_public_files(self, request: HttpRequest) -> JsonResponse | HttpResponseNotAllowed:
23+
if request.method == "GET":
24+
data = list(PublicFile.objects.filter_active().values(*self.fields))
25+
return JsonResponse(data=data, safe=False, json_dumps_params={"ensure_ascii": False})
26+
return HttpResponseNotAllowed(permitted_methods=["GET"])

app/file/apps.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.apps import AppConfig
2+
3+
4+
class FileConfig(AppConfig):
5+
name = "file"
6+
7+
def ready(self):
8+
from file.models import PublicFile
9+
from simple_history import register
10+
11+
register(PublicFile)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Generated by Django 5.2 on 2025-05-20 04:57
2+
3+
import uuid
4+
5+
import django.core.files.storage
6+
import django.db.models.deletion
7+
import simple_history.models
8+
from django.conf import settings
9+
from django.db import migrations, models
10+
11+
12+
class Migration(migrations.Migration):
13+
initial = True
14+
15+
dependencies = [
16+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17+
]
18+
19+
operations = [
20+
migrations.CreateModel(
21+
name="HistoricalPublicFile",
22+
fields=[
23+
("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)),
24+
("created_at", models.DateTimeField(blank=True, editable=False)),
25+
("updated_at", models.DateTimeField(blank=True, editable=False)),
26+
("deleted_at", models.DateTimeField(blank=True, null=True)),
27+
("file", models.TextField(db_index=True, max_length=100)),
28+
("mimetype", models.CharField(max_length=256, null=True)),
29+
("hash", models.CharField(max_length=256)),
30+
("size", models.BigIntegerField()),
31+
("history_id", models.AutoField(primary_key=True, serialize=False)),
32+
("history_date", models.DateTimeField(db_index=True)),
33+
("history_change_reason", models.CharField(max_length=100, null=True)),
34+
(
35+
"history_type",
36+
models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1),
37+
),
38+
(
39+
"created_by",
40+
models.ForeignKey(
41+
blank=True,
42+
db_constraint=False,
43+
null=True,
44+
on_delete=django.db.models.deletion.DO_NOTHING,
45+
related_name="+",
46+
to=settings.AUTH_USER_MODEL,
47+
),
48+
),
49+
(
50+
"deleted_by",
51+
models.ForeignKey(
52+
blank=True,
53+
db_constraint=False,
54+
null=True,
55+
on_delete=django.db.models.deletion.DO_NOTHING,
56+
related_name="+",
57+
to=settings.AUTH_USER_MODEL,
58+
),
59+
),
60+
(
61+
"history_user",
62+
models.ForeignKey(
63+
null=True,
64+
on_delete=django.db.models.deletion.SET_NULL,
65+
related_name="+",
66+
to=settings.AUTH_USER_MODEL,
67+
),
68+
),
69+
(
70+
"updated_by",
71+
models.ForeignKey(
72+
blank=True,
73+
db_constraint=False,
74+
null=True,
75+
on_delete=django.db.models.deletion.DO_NOTHING,
76+
related_name="+",
77+
to=settings.AUTH_USER_MODEL,
78+
),
79+
),
80+
],
81+
options={
82+
"verbose_name": "historical public file",
83+
"verbose_name_plural": "historical public files",
84+
"ordering": ("-history_date", "-history_id"),
85+
"get_latest_by": ("history_date", "history_id"),
86+
},
87+
bases=(simple_history.models.HistoricalChanges, models.Model),
88+
),
89+
migrations.CreateModel(
90+
name="PublicFile",
91+
fields=[
92+
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
93+
("created_at", models.DateTimeField(auto_now_add=True)),
94+
("updated_at", models.DateTimeField(auto_now=True)),
95+
("deleted_at", models.DateTimeField(blank=True, null=True)),
96+
(
97+
"file",
98+
models.FileField(
99+
storage=django.core.files.storage.FileSystemStorage(), unique=True, upload_to="public/"
100+
),
101+
),
102+
("mimetype", models.CharField(max_length=256, null=True)),
103+
("hash", models.CharField(max_length=256)),
104+
("size", models.BigIntegerField()),
105+
(
106+
"created_by",
107+
models.ForeignKey(
108+
null=True,
109+
on_delete=django.db.models.deletion.PROTECT,
110+
related_name="%(class)s_created_by",
111+
to=settings.AUTH_USER_MODEL,
112+
),
113+
),
114+
(
115+
"deleted_by",
116+
models.ForeignKey(
117+
null=True,
118+
on_delete=django.db.models.deletion.PROTECT,
119+
related_name="%(class)s_deleted_by",
120+
to=settings.AUTH_USER_MODEL,
121+
),
122+
),
123+
(
124+
"updated_by",
125+
models.ForeignKey(
126+
null=True,
127+
on_delete=django.db.models.deletion.PROTECT,
128+
related_name="%(class)s_updated_by",
129+
to=settings.AUTH_USER_MODEL,
130+
),
131+
),
132+
],
133+
options={
134+
"ordering": ["-created_at"],
135+
},
136+
),
137+
migrations.AddIndex(
138+
model_name="publicfile",
139+
index=models.Index(fields=["file"], name="file_public_file_3d3996_idx"),
140+
),
141+
migrations.AddIndex(
142+
model_name="publicfile",
143+
index=models.Index(fields=["mimetype"], name="file_public_mimetyp_da163f_idx"),
144+
),
145+
migrations.AddIndex(
146+
model_name="publicfile",
147+
index=models.Index(fields=["hash"], name="file_public_hash_669533_idx"),
148+
),
149+
]

app/file/migrations/__init__.py

Whitespace-only changes.

app/file/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import hashlib
2+
import mimetypes
3+
4+
from core.models import BaseAbstractModel
5+
from django.core.files.storage import storages
6+
from django.db import models
7+
8+
9+
class PublicFile(BaseAbstractModel):
10+
file = models.FileField(unique=True, null=False, blank=False, upload_to="public/", storage=storages["public"])
11+
mimetype = models.CharField(max_length=256, null=True, blank=False)
12+
hash = models.CharField(max_length=256, null=False, blank=False)
13+
size = models.BigIntegerField(null=False, blank=False)
14+
15+
class Meta:
16+
ordering = ["-created_at"]
17+
indexes = [models.Index(fields=["file"]), models.Index(fields=["mimetype"]), models.Index(fields=["hash"])]
18+
19+
def clean(self) -> None:
20+
# 파일의 해시값, 크기, mimetype을 계산하여 저장합니다.
21+
hash_md5 = hashlib.md5(usedforsecurity=False)
22+
file_pointer = self.file.open("rb")
23+
24+
for chunk in iter(lambda: file_pointer.read(4096), b""):
25+
hash_md5.update(chunk)
26+
27+
self.hash = hash_md5.hexdigest()
28+
self.size = self.file.size
29+
self.mimetype = mimetypes.guess_type(self.file.name)[0]

0 commit comments

Comments
 (0)