From 0396980f505a53145e96d2fd1b10a0f2acaa7538 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 24 May 2025 23:46:46 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20System=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/system.py | 3 +++ app/user/migrations/0002_create_superuser.py | 28 ++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 app/core/const/system.py create mode 100644 app/user/migrations/0002_create_superuser.py diff --git a/app/core/const/system.py b/app/core/const/system.py new file mode 100644 index 0000000..8a74df6 --- /dev/null +++ b/app/core/const/system.py @@ -0,0 +1,3 @@ +SYSTEM_ID = 0 +SYSTEM_USERNAME = "system" +SYSTEM_EMAIL = "system@python.or.kr" diff --git a/app/user/migrations/0002_create_superuser.py b/app/user/migrations/0002_create_superuser.py new file mode 100644 index 0000000..fb98154 --- /dev/null +++ b/app/user/migrations/0002_create_superuser.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-05-24 14:30 +import typing +import uuid + +from core.const.system import SYSTEM_EMAIL, SYSTEM_ID, SYSTEM_USERNAME +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps + +if typing.TYPE_CHECKING: + from user.models import UserExt as UserExtType + + +def create_superuser(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None: + UserExt: type["UserExtType"] = apps.get_model("user", "UserExt") + + if not UserExt.objects.filter(id=SYSTEM_ID).exists(): + UserExt.objects.create_superuser( + id=SYSTEM_ID, + username=SYSTEM_USERNAME, + email=SYSTEM_EMAIL, + password=uuid.uuid4().hex, + ) + + +class Migration(migrations.Migration): + dependencies = [("user", "0001_initial")] + operations = [migrations.RunPython(create_superuser, migrations.RunPython.noop)] From 038578afca7ae2a68106c996654a9d7ecf129af0 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 24 May 2025 23:58:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20row=20=EC=83=9D=EC=84=B1/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20created=5Fby/updated?= =?UTF-8?q?=5Fby/deleted=5Fby=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../middleware/request_response_logger.py | 11 ++----- app/core/middleware/thread_middleware.py | 15 ++++++++++ app/core/middleware/type.py | 9 ++++++ app/core/models.py | 19 ++++++++++-- app/core/settings.py | 2 ++ app/core/util/thread_local.py | 30 +++++++++++++++++++ 6 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 app/core/middleware/thread_middleware.py create mode 100644 app/core/middleware/type.py create mode 100644 app/core/util/thread_local.py diff --git a/app/core/middleware/request_response_logger.py b/app/core/middleware/request_response_logger.py index b2545a7..a912bcf 100644 --- a/app/core/middleware/request_response_logger.py +++ b/app/core/middleware/request_response_logger.py @@ -8,6 +8,7 @@ get_request_log_data, get_response_log_data, ) +from core.middleware.type import GetResponseCallable from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.deprecation import MiddlewareMixin @@ -16,11 +17,6 @@ slack_logger = logging.getLogger("slack_logger") -# From django-stubs -class _GetResponseCallable(typing.Protocol): - def __call__(self, request: HttpRequest, /) -> HttpResponseBase: ... - - class LoggerExtraDataType(typing.TypedDict): request: dict[str, typing.Any] response: dict[str, typing.Any] @@ -41,10 +37,7 @@ class LoggerExtraType(typing.TypedDict): class RequestResponseLogger(MiddlewareMixin): sync_capable = True async_capable = False - get_response: _GetResponseCallable - - def __init__(self, get_response: _GetResponseCallable) -> None: - self.get_response = get_response + get_response: GetResponseCallable def __call__(self, request: HttpRequest) -> HttpResponseBase: before_session_data = dict(request.session.items()) if config.DEBUG_COLLECT_SESSION_DATA else {} diff --git a/app/core/middleware/thread_middleware.py b/app/core/middleware/thread_middleware.py new file mode 100644 index 0000000..8be9ee3 --- /dev/null +++ b/app/core/middleware/thread_middleware.py @@ -0,0 +1,15 @@ +from core.middleware.type import GetResponseCallable +from core.util.thread_local import thread_local +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase +from django.utils.deprecation import MiddlewareMixin + + +class ThreadLocalMiddleware(MiddlewareMixin): + sync_capable = True + async_capable = False + get_response: GetResponseCallable + + def __call__(self, request: HttpRequest) -> HttpResponseBase: + thread_local.current_request = request + return self.get_response(request) diff --git a/app/core/middleware/type.py b/app/core/middleware/type.py new file mode 100644 index 0000000..f3095cb --- /dev/null +++ b/app/core/middleware/type.py @@ -0,0 +1,9 @@ +import typing + +from django.http.request import HttpRequest +from django.http.response import HttpResponseBase + + +# From django-stubs +class GetResponseCallable(typing.Protocol): + def __call__(self, request: HttpRequest, /) -> HttpResponseBase: ... diff --git a/app/core/models.py b/app/core/models.py index cb834a1..2fb47b7 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -2,6 +2,7 @@ import typing import uuid +from core.util.thread_local import get_current_user from django.contrib.auth import get_user_model from django.db import models from django.db.models.functions import Now @@ -13,8 +14,15 @@ class BaseAbstractModelQuerySet(models.QuerySet): + def create(self, **kwargs: dict) -> typing.Self: + current_user = get_current_user() + return super().create(**(kwargs | {"created_by": current_user, "updated_by": current_user})) + + def update(self, **kwargs: dict) -> typing.Self: + return super().update(**(kwargs | {"updated_by": get_current_user()})) + def delete(self) -> int: # type: ignore[override] - return super().update(deleted_at=Now(), updated_at=Now()) + return super().update(deleted_by=get_current_user(), deleted_at=Now()) def hard_delete(self) -> tuple[int, dict[str, int]]: return super().delete() @@ -54,6 +62,11 @@ def save( # type: ignore[override] update_fields: collections.abc.Iterable[str] | None = None, ) -> None: if update_fields: - update_fields = set(update_fields) | {"updated_at"} - + update_fields = set(update_fields) | {"updated_at", "updated_by"} + self.updated_by = get_current_user() super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + + def delete(self, using: str | None = None) -> None: + self.deleted_at = Now() + self.deleted_by = get_current_user() + super().save(using=using, update_fields={"deleted_by", "deleted_at"}) diff --git a/app/core/settings.py b/app/core/settings.py index c78c8e4..1490fea 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -175,6 +175,8 @@ "corsheaders.middleware.CorsMiddleware", # simple-history "simple_history.middleware.HistoryRequestMiddleware", + # Thread Local Middleware + "core.middleware.thread_middleware.ThreadLocalMiddleware", # Request Response Logger "core.middleware.request_response_logger.RequestResponseLogger", ] diff --git a/app/core/util/thread_local.py b/app/core/util/thread_local.py new file mode 100644 index 0000000..655e590 --- /dev/null +++ b/app/core/util/thread_local.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import contextlib +import importlib +import threading +import typing + +from core.const.system import SYSTEM_ID +from django.http.request import HttpRequest + +if typing.TYPE_CHECKING: + from user.models import UserExt + +thread_local = threading.local() + + +def get_request() -> HttpRequest | None: + with contextlib.suppress(AttributeError): + return thread_local.current_request + return None + + +def get_current_user() -> "UserExt" | None: + if request := get_request(): + return request.user + + if UserExt := getattr(importlib.import_module("user.models"), "UserExt", None): + return UserExt.objects.filter(id=SYSTEM_ID).first() + + return None From e4f0e565202cf06f102dcf2b91c90a8bfd800a0c Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 24 May 2025 23:58:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20created=5Fby/updated=5Fby/deleted=5Fby?= =?UTF-8?q?=EB=A5=BC=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cms/admin.py | 21 ++++++++++----------- app/core/admin.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ app/file/admin.py | 11 ++++++----- 3 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 app/core/admin.py diff --git a/app/cms/admin.py b/app/cms/admin.py index c5e713f..049c6da 100644 --- a/app/cms/admin.py +++ b/app/cms/admin.py @@ -1,5 +1,6 @@ from cms.admin_mixins import RelatedReadonlyFieldsMixin from cms.models import Page, Section, Sitemap +from core.admin import BaseAbstractModelAdminMixin from django import forms from django.contrib import admin from django.utils.html import format_html @@ -151,7 +152,7 @@ class Meta: @admin.register(Sitemap) -class SitemapAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin): +class SitemapAdmin(BaseAbstractModelAdminMixin, RelatedReadonlyFieldsMixin, admin.ModelAdmin): fields = [ "id", "parent_sitemap", @@ -194,16 +195,20 @@ def get_fieldsets(self, request, obj=...): return original_fieldsets def get_queryset(self, request): - return super().get_queryset(request).select_related("page").select_related("parent_sitemap") + return super().get_queryset(request).select_related("page", "parent_sitemap") -class PageAdmin(admin.ModelAdmin): - pass +@admin.register(Page) +class PageAdmin(BaseAbstractModelAdminMixin, admin.ModelAdmin): + fields = ["id", "css", "title", "subtitle"] + readonly_fields = ["id"] + queryset = Page.objects.prefetch_related("sections") @admin.register(Section) -class SectionAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin): +class SectionAdmin(BaseAbstractModelAdminMixin, RelatedReadonlyFieldsMixin, admin.ModelAdmin): form = SectionAdminForm + queryset = Section.objects.select_related("page") fields = ["id", "page", "order", "css", "body"] readonly_fields = ["id"] related_readonly_config = {"page": ["id", "is_active", "css", "title", "subtitle"]} @@ -221,9 +226,3 @@ def get_fieldsets(self, request, obj=...): ) ) return original_fieldsets - - def get_queryset(self, request): - return super().get_queryset(request).select_related("page") - - -admin.site.register(Page) diff --git a/app/core/admin.py b/app/core/admin.py new file mode 100644 index 0000000..5be4607 --- /dev/null +++ b/app/core/admin.py @@ -0,0 +1,46 @@ +from core.models import BaseAbstractModel +from django.contrib import admin +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest + +INITIAL_FIELDS = INITIAL_READONLY_FIELDS = [ + "id", + "created_at", + "created_by", + "updated_at", + "updated_by", + "deleted_at", + "deleted_by", +] + + +class AdminProtocol(admin.ModelAdmin): + model: type[BaseAbstractModel] + + +class BaseAbstractModelAdminMixin(AdminProtocol): + def get_queryset(self, request: HttpRequest) -> models.QuerySet[BaseAbstractModel]: + """Override the default queryset to filter out soft-deleted objects.""" + return super().get_queryset(request).filter_active().select_related("created_by", "updated_by", "deleted_by") + + def save_model(self, request: HttpRequest, obj: BaseAbstractModel, form: ModelForm, change: bool) -> None: + """Override save_model to set created_by and updated_by fields.""" + if not change: + obj.created_by = request.user + obj.updated_by = request.user + super().save_model(request, obj, form, change) + + def get_fields(self, request: HttpRequest, obj: models.Model | None = None) -> list[str]: + fields = list(super().get_fields(request, obj)) + for field in INITIAL_FIELDS: + if field not in fields: + fields.append(field) + return fields + + def get_readonly_fields(self, request, obj: models.Model | None = None) -> list[str]: + readonly_fields = list(super().get_readonly_fields(request, obj)) + for field in INITIAL_READONLY_FIELDS: + if field not in readonly_fields: + readonly_fields.append(field) + return readonly_fields diff --git a/app/file/admin.py b/app/file/admin.py index 62e618e..8957291 100644 --- a/app/file/admin.py +++ b/app/file/admin.py @@ -1,3 +1,4 @@ +from core.admin import BaseAbstractModelAdminMixin from django.contrib import admin from django.http.request import HttpRequest from django.http.response import HttpResponseNotAllowed, JsonResponse @@ -7,12 +8,12 @@ @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"] +class PublicFileAdmin(BaseAbstractModelAdminMixin, admin.ModelAdmin): + fields = ["file", "mimetype", "hash", "size"] + readonly_fields = ["mimetype", "hash", "size"] - def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> list[str]: - return self.readonly_fields + (["file"] if obj else []) + def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> set[str]: + return super().get_readonly_fields(request, obj) + (["file"] if obj else []) def get_urls(self) -> list[URLPattern]: return [