Skip to content

Commit cc05980

Browse files
authored
Merge pull request #16 from pythonkr/feature/write-created-updated-deleted-by-on-request
feat: System 계정 추가 및 생성/수정/삭제 시 해당 유저를 기록하도록 기능 추가
2 parents af11b54 + e4f0e56 commit cc05980

11 files changed

Lines changed: 167 additions & 28 deletions

File tree

app/cms/admin.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from cms.admin_mixins import RelatedReadonlyFieldsMixin
22
from cms.models import Page, Section, Sitemap
3+
from core.admin import BaseAbstractModelAdminMixin
34
from django import forms
45
from django.contrib import admin
56
from django.utils.html import format_html
@@ -151,7 +152,7 @@ class Meta:
151152

152153

153154
@admin.register(Sitemap)
154-
class SitemapAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin):
155+
class SitemapAdmin(BaseAbstractModelAdminMixin, RelatedReadonlyFieldsMixin, admin.ModelAdmin):
155156
fields = [
156157
"id",
157158
"parent_sitemap",
@@ -194,16 +195,20 @@ def get_fieldsets(self, request, obj=...):
194195
return original_fieldsets
195196

196197
def get_queryset(self, request):
197-
return super().get_queryset(request).select_related("page").select_related("parent_sitemap")
198+
return super().get_queryset(request).select_related("page", "parent_sitemap")
198199

199200

200-
class PageAdmin(admin.ModelAdmin):
201-
pass
201+
@admin.register(Page)
202+
class PageAdmin(BaseAbstractModelAdminMixin, admin.ModelAdmin):
203+
fields = ["id", "css", "title", "subtitle"]
204+
readonly_fields = ["id"]
205+
queryset = Page.objects.prefetch_related("sections")
202206

203207

204208
@admin.register(Section)
205-
class SectionAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin):
209+
class SectionAdmin(BaseAbstractModelAdminMixin, RelatedReadonlyFieldsMixin, admin.ModelAdmin):
206210
form = SectionAdminForm
211+
queryset = Section.objects.select_related("page")
207212
fields = ["id", "page", "order", "css", "body"]
208213
readonly_fields = ["id"]
209214
related_readonly_config = {"page": ["id", "is_active", "css", "title", "subtitle"]}
@@ -221,9 +226,3 @@ def get_fieldsets(self, request, obj=...):
221226
)
222227
)
223228
return original_fieldsets
224-
225-
def get_queryset(self, request):
226-
return super().get_queryset(request).select_related("page")
227-
228-
229-
admin.site.register(Page)

app/core/admin.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from core.models import BaseAbstractModel
2+
from django.contrib import admin
3+
from django.db import models
4+
from django.forms import ModelForm
5+
from django.http import HttpRequest
6+
7+
INITIAL_FIELDS = INITIAL_READONLY_FIELDS = [
8+
"id",
9+
"created_at",
10+
"created_by",
11+
"updated_at",
12+
"updated_by",
13+
"deleted_at",
14+
"deleted_by",
15+
]
16+
17+
18+
class AdminProtocol(admin.ModelAdmin):
19+
model: type[BaseAbstractModel]
20+
21+
22+
class BaseAbstractModelAdminMixin(AdminProtocol):
23+
def get_queryset(self, request: HttpRequest) -> models.QuerySet[BaseAbstractModel]:
24+
"""Override the default queryset to filter out soft-deleted objects."""
25+
return super().get_queryset(request).filter_active().select_related("created_by", "updated_by", "deleted_by")
26+
27+
def save_model(self, request: HttpRequest, obj: BaseAbstractModel, form: ModelForm, change: bool) -> None:
28+
"""Override save_model to set created_by and updated_by fields."""
29+
if not change:
30+
obj.created_by = request.user
31+
obj.updated_by = request.user
32+
super().save_model(request, obj, form, change)
33+
34+
def get_fields(self, request: HttpRequest, obj: models.Model | None = None) -> list[str]:
35+
fields = list(super().get_fields(request, obj))
36+
for field in INITIAL_FIELDS:
37+
if field not in fields:
38+
fields.append(field)
39+
return fields
40+
41+
def get_readonly_fields(self, request, obj: models.Model | None = None) -> list[str]:
42+
readonly_fields = list(super().get_readonly_fields(request, obj))
43+
for field in INITIAL_READONLY_FIELDS:
44+
if field not in readonly_fields:
45+
readonly_fields.append(field)
46+
return readonly_fields

app/core/const/system.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
SYSTEM_ID = 0
2+
SYSTEM_USERNAME = "system"
3+
SYSTEM_EMAIL = "system@python.or.kr"

app/core/middleware/request_response_logger.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
get_request_log_data,
99
get_response_log_data,
1010
)
11+
from core.middleware.type import GetResponseCallable
1112
from django.http.request import HttpRequest
1213
from django.http.response import HttpResponseBase
1314
from django.utils.deprecation import MiddlewareMixin
@@ -16,11 +17,6 @@
1617
slack_logger = logging.getLogger("slack_logger")
1718

1819

19-
# From django-stubs
20-
class _GetResponseCallable(typing.Protocol):
21-
def __call__(self, request: HttpRequest, /) -> HttpResponseBase: ...
22-
23-
2420
class LoggerExtraDataType(typing.TypedDict):
2521
request: dict[str, typing.Any]
2622
response: dict[str, typing.Any]
@@ -41,10 +37,7 @@ class LoggerExtraType(typing.TypedDict):
4137
class RequestResponseLogger(MiddlewareMixin):
4238
sync_capable = True
4339
async_capable = False
44-
get_response: _GetResponseCallable
45-
46-
def __init__(self, get_response: _GetResponseCallable) -> None:
47-
self.get_response = get_response
40+
get_response: GetResponseCallable
4841

4942
def __call__(self, request: HttpRequest) -> HttpResponseBase:
5043
before_session_data = dict(request.session.items()) if config.DEBUG_COLLECT_SESSION_DATA else {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from core.middleware.type import GetResponseCallable
2+
from core.util.thread_local import thread_local
3+
from django.http.request import HttpRequest
4+
from django.http.response import HttpResponseBase
5+
from django.utils.deprecation import MiddlewareMixin
6+
7+
8+
class ThreadLocalMiddleware(MiddlewareMixin):
9+
sync_capable = True
10+
async_capable = False
11+
get_response: GetResponseCallable
12+
13+
def __call__(self, request: HttpRequest) -> HttpResponseBase:
14+
thread_local.current_request = request
15+
return self.get_response(request)

app/core/middleware/type.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import typing
2+
3+
from django.http.request import HttpRequest
4+
from django.http.response import HttpResponseBase
5+
6+
7+
# From django-stubs
8+
class GetResponseCallable(typing.Protocol):
9+
def __call__(self, request: HttpRequest, /) -> HttpResponseBase: ...

app/core/models.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import typing
33
import uuid
44

5+
from core.util.thread_local import get_current_user
56
from django.contrib.auth import get_user_model
67
from django.db import models
78
from django.db.models.functions import Now
@@ -13,8 +14,15 @@
1314

1415

1516
class BaseAbstractModelQuerySet(models.QuerySet):
17+
def create(self, **kwargs: dict) -> typing.Self:
18+
current_user = get_current_user()
19+
return super().create(**(kwargs | {"created_by": current_user, "updated_by": current_user}))
20+
21+
def update(self, **kwargs: dict) -> typing.Self:
22+
return super().update(**(kwargs | {"updated_by": get_current_user()}))
23+
1624
def delete(self) -> int: # type: ignore[override]
17-
return super().update(deleted_at=Now(), updated_at=Now())
25+
return super().update(deleted_by=get_current_user(), deleted_at=Now())
1826

1927
def hard_delete(self) -> tuple[int, dict[str, int]]:
2028
return super().delete()
@@ -54,6 +62,11 @@ def save( # type: ignore[override]
5462
update_fields: collections.abc.Iterable[str] | None = None,
5563
) -> None:
5664
if update_fields:
57-
update_fields = set(update_fields) | {"updated_at"}
58-
65+
update_fields = set(update_fields) | {"updated_at", "updated_by"}
66+
self.updated_by = get_current_user()
5967
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
68+
69+
def delete(self, using: str | None = None) -> None:
70+
self.deleted_at = Now()
71+
self.deleted_by = get_current_user()
72+
super().save(using=using, update_fields={"deleted_by", "deleted_at"})

app/core/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@
175175
"corsheaders.middleware.CorsMiddleware",
176176
# simple-history
177177
"simple_history.middleware.HistoryRequestMiddleware",
178+
# Thread Local Middleware
179+
"core.middleware.thread_middleware.ThreadLocalMiddleware",
178180
# Request Response Logger
179181
"core.middleware.request_response_logger.RequestResponseLogger",
180182
]

app/core/util/thread_local.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import importlib
5+
import threading
6+
import typing
7+
8+
from core.const.system import SYSTEM_ID
9+
from django.http.request import HttpRequest
10+
11+
if typing.TYPE_CHECKING:
12+
from user.models import UserExt
13+
14+
thread_local = threading.local()
15+
16+
17+
def get_request() -> HttpRequest | None:
18+
with contextlib.suppress(AttributeError):
19+
return thread_local.current_request
20+
return None
21+
22+
23+
def get_current_user() -> "UserExt" | None:
24+
if request := get_request():
25+
return request.user
26+
27+
if UserExt := getattr(importlib.import_module("user.models"), "UserExt", None):
28+
return UserExt.objects.filter(id=SYSTEM_ID).first()
29+
30+
return None

app/file/admin.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from core.admin import BaseAbstractModelAdminMixin
12
from django.contrib import admin
23
from django.http.request import HttpRequest
34
from django.http.response import HttpResponseNotAllowed, JsonResponse
@@ -7,12 +8,12 @@
78

89

910
@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"]
11+
class PublicFileAdmin(BaseAbstractModelAdminMixin, admin.ModelAdmin):
12+
fields = ["file", "mimetype", "hash", "size"]
13+
readonly_fields = ["mimetype", "hash", "size"]
1314

14-
def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> list[str]:
15-
return self.readonly_fields + (["file"] if obj else [])
15+
def get_readonly_fields(self, request: HttpRequest, obj: PublicFile | None = None) -> set[str]:
16+
return super().get_readonly_fields(request, obj) + (["file"] if obj else [])
1617

1718
def get_urls(self) -> list[URLPattern]:
1819
return [

0 commit comments

Comments
 (0)