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/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/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 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 [ 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)]