Skip to content

Commit bfda4e1

Browse files
authored
[25.09.20 / TASK-255] Feature - admin mail template 업데이트 및 주간 배치 cron (#45)
* feature: 주간 배치 관련 어드민, 메일 템플릿 활용해서 상세보기시 최종적으로 만들어진 메일 볼 수 있게 * feature: 어드민 업데이트에 따른 test code update * modify: 주간 매일링 배치 cron update * modify: 코드 리뷰 반영 * modify: 더 이상 안쓰는 것들 정리 및 테코 업데이트 * modify: mocking 경로 수정 * modify: 다른 test 도 조금 수정
1 parent 78c5d5a commit bfda4e1

13 files changed

+846
-297
lines changed

.github/workflows/run-weekly-analysis.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ name: Weekly Analysis Batch
22

33
on:
44
workflow_dispatch:
5-
# schedule:
6-
# - cron: "*/50 * * * *"
5+
schedule:
6+
# 매주 월요일 오전 9시 30분(KST, UTC+9) 실행 -> UTC 기준 00:30 월요일
7+
- cron: "30 0 * * 1"
78

89
jobs:
910
weekly-trend-analysis-batch:
@@ -93,7 +94,7 @@ jobs:
9394
else
9495
echo "RESULT=No output file found" >> $GITHUB_OUTPUT
9596
fi
96-
97+
9798
- name: Send Slack Notification on Success
9899
if: success()
99100
uses: slackapi/[email protected]
@@ -114,4 +115,4 @@ jobs:
114115
"text": "*Weekly Analysis Batch*\n\n❌ *Status:* Failure\n📅 *Timestamp (KST):* ${{ env.KST_TIME }}\n📝 *Result:* \n```${{ steps.read-result.outputs.RESULT }}```\n🔗 *Workflow URL:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Workflow>"
115116
}
116117
env:
117-
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
118+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

.github/workflows/run-weekly-newsletter.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ name: Weekly Newsletter Batch
22

33
on:
44
workflow_dispatch:
5-
# schedule:
6-
# - cron: "*/50 * * * *"
5+
schedule:
6+
# 매주 월요일 오전 7시(KST, UTC+9) 실행 -> UTC 기준 일요일 22:00
7+
- cron: "0 22 * * 0"
78

89
jobs:
910
weekly-newsletter-batch:
@@ -89,7 +90,7 @@ jobs:
8990
else
9091
echo "RESULT=No output file found" >> $GITHUB_OUTPUT
9192
fi
92-
93+
9394
- name: Send Slack Notification on Success
9495
if: success()
9596
uses: slackapi/[email protected]
@@ -110,4 +111,4 @@ jobs:
110111
"text": "*Weekly Newsletter Batch*\n\n❌ *Status:* Failure\n📅 *Timestamp (KST):* ${{ env.KST_TIME }}\n📝 *Result:* \n```${{ steps.read-result.outputs.RESULT }}```\n🔗 *Workflow URL:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Workflow>"
111112
}
112113
env:
113-
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
114+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# CUSTOM
2-
**/timescale_data/**
2+
**/timescale_data*/**
33
*_result.txt
44

55
# Created by https://www.toptal.com/developers/gitignore/api/macos,python,pycharm,django

insight/admin/base_admin.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import json
2+
from html import escape
23

34
from django.contrib import admin
4-
from django.template.defaultfilters import truncatechars
5-
from django.template.loader import render_to_string
5+
from django.db.models import QuerySet
6+
from django.http import HttpRequest
67
from django.utils.html import format_html
78
from django.utils.safestring import mark_safe
89

910
from insight.models import UserWeeklyTrend, WeeklyTrend
11+
from utils.utils import get_local_now
1012

1113

1214
class BaseTrendAdminMixin:
@@ -21,11 +23,6 @@ def week_range(self, obj: WeeklyTrend | UserWeeklyTrend):
2123
obj.week_end_date.strftime("%Y-%m-%d"),
2224
)
2325

24-
@admin.display(description="인사이트 미리보기")
25-
def insight_preview(self, obj: WeeklyTrend | UserWeeklyTrend):
26-
"""인사이트 미리보기"""
27-
return self.get_json_preview(obj, "insight")
28-
2926
@admin.display(description="처리 완료")
3027
def is_processed_colored(self, obj: WeeklyTrend | UserWeeklyTrend):
3128
"""처리 상태를 색상으로 표시"""
@@ -44,29 +41,24 @@ def processed_at_formatted(self, obj: WeeklyTrend | UserWeeklyTrend):
4441
return obj.processed_at.strftime("%Y-%m-%d %H:%M")
4542
return "-"
4643

47-
48-
class JsonPreviewMixin:
49-
"""JSONField를 보기 좋게 표시하기 위한 Mixin"""
50-
51-
def get_json_preview(
52-
self, obj: WeeklyTrend | UserWeeklyTrend, field_name, max_length=150
53-
):
54-
"""JSONField 내용의 미리보기를 반환"""
55-
json_data = getattr(obj, field_name, {})
56-
if not json_data:
44+
@admin.display(description="Insight JSON")
45+
def formatted_insight_json(self, obj: WeeklyTrend | UserWeeklyTrend):
46+
if not obj.insight:
5747
return "-"
48+
json_str = json.dumps(obj.insight, indent=2, ensure_ascii=False)
49+
return mark_safe(f"<pre><code>{escape(json_str)}</code></pre>")
5850

59-
# JSON 문자열로 변환하여 일부만 표시
60-
json_str = json.dumps(json_data, ensure_ascii=False)
61-
return truncatechars(json_str, max_length)
51+
# ========================================================================
52+
# Actions
53+
# ========================================================================
6254

63-
@admin.display(description="인사이트 데이터")
64-
def formatted_insight(self, obj: WeeklyTrend | UserWeeklyTrend):
65-
"""인사이트 JSON을 보기 좋게 포맷팅하여 표시"""
66-
if not hasattr(obj, "insight") or not obj.insight:
67-
return "-"
68-
69-
context = {"insight": obj.insight, "user": getattr(obj, "user", None)}
70-
# render_to_string을 사용하여 템플릿 렌더링
71-
html = render_to_string("insights/insight_preview.html", context)
72-
return mark_safe(html)
55+
@admin.action(description="선택된 항목을 처리 완료로 표시하기")
56+
def mark_as_processed(
57+
self, request: HttpRequest, queryset: QuerySet[UserWeeklyTrend]
58+
):
59+
"""선택된 항목을 처리 완료로 표시"""
60+
queryset.update(is_processed=True, processed_at=get_local_now())
61+
self.message_user(
62+
request,
63+
f"{queryset.count()}개의 사용자 인사이트가 처리 완료로 표시되었습니다.",
64+
)

insight/admin/user_weekly_trend_admin.py

Lines changed: 121 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1+
from html import escape
2+
13
from django.contrib import admin
2-
from django.db.models import QuerySet
34
from django.http import HttpRequest
5+
from django.template.loader import render_to_string
46
from django.urls import reverse
57
from django.utils.html import format_html
8+
from django.utils.safestring import mark_safe
69

7-
from insight.admin import BaseTrendAdminMixin, JsonPreviewMixin
8-
from insight.models import UserWeeklyTrend
9-
from utils.utils import get_local_now
10+
from insight.admin.base_admin import BaseTrendAdminMixin
11+
from insight.models import (
12+
UserWeeklyTrend,
13+
WeeklyTrend,
14+
WeeklyTrendInsight,
15+
WeeklyUserTrendInsight,
16+
)
17+
from utils.utils import from_dict
1018

1119

1220
@admin.register(UserWeeklyTrend)
13-
class UserWeeklyTrendAdmin(
14-
admin.ModelAdmin, JsonPreviewMixin, BaseTrendAdminMixin
15-
):
21+
class UserWeeklyTrendAdmin(admin.ModelAdmin, BaseTrendAdminMixin):
1622
list_display = (
1723
"id",
1824
"user_info",
1925
"week_range",
20-
"insight_preview",
26+
"summarize_insight",
2127
"is_processed_colored",
2228
"processed_at_formatted",
2329
"created_at",
@@ -26,7 +32,8 @@ class UserWeeklyTrendAdmin(
2632
search_fields = ("user__username", "insight")
2733
readonly_fields = (
2834
"processed_at",
29-
"formatted_insight",
35+
"render_full_preview",
36+
"formatted_insight_json",
3037
)
3138
raw_id_fields = ("user",)
3239

@@ -38,10 +45,17 @@ class UserWeeklyTrendAdmin(
3845
},
3946
),
4047
(
41-
"인사이트 데이터",
48+
"뉴스레터 미리보기",
49+
{
50+
"fields": ("render_full_preview",),
51+
"classes": ("wide",),
52+
},
53+
),
54+
(
55+
"원본 데이터 (JSON)",
4256
{
43-
"fields": ("formatted_insight",),
44-
"classes": ("wide", "extrapretty"),
57+
"fields": ("formatted_insight_json",),
58+
"classes": ("wide", "collapse"),
4559
},
4660
),
4761
(
@@ -71,13 +85,98 @@ def user_info(self, obj: UserWeeklyTrend):
7185
obj.user.username or f"사용자 {obj.user.id}",
7286
)
7387

74-
@admin.action(description="선택된 항목을 처리 완료로 표시하기")
75-
def mark_as_processed(
76-
self, request: HttpRequest, queryset: QuerySet[UserWeeklyTrend]
77-
):
78-
"""선택된 항목을 처리 완료로 표시"""
79-
queryset.update(is_processed=True, processed_at=get_local_now())
80-
self.message_user(
81-
request,
82-
f"{queryset.count()}개의 사용자 인사이트가 처리 완료로 표시되었습니다.",
83-
)
88+
@admin.display(description="인사이트 요약")
89+
def summarize_insight(self, obj: UserWeeklyTrend):
90+
if not isinstance(obj.insight, dict):
91+
return "데이터 없음"
92+
93+
summary_parts = []
94+
stats = obj.insight.get("user_weekly_stats")
95+
if stats:
96+
summary_parts.append(
97+
f"조회수: {stats.get('views', 0)}, 새글: {stats.get('new_posts', 0)}"
98+
)
99+
100+
summary = obj.insight.get("trending_summary", [])
101+
if summary and isinstance(summary, list) and summary[0].get("title"):
102+
summary_parts.append(f"신규글: {summary[0]['title'][:20]}...")
103+
104+
return " | ".join(summary_parts) if summary_parts else "요약 정보 없음"
105+
106+
@admin.display(description="뉴스레터 템플릿")
107+
def render_full_preview(self, obj: UserWeeklyTrend):
108+
if not obj.insight:
109+
return "No insight data to preview."
110+
try:
111+
# 공통 주간 트렌드 데이터 조회
112+
weekly_trend = WeeklyTrend.objects.filter(
113+
week_start_date=obj.week_start_date,
114+
week_end_date=obj.week_end_date,
115+
).first()
116+
117+
if not weekly_trend or not weekly_trend.insight:
118+
weekly_trend_html = "<p><strong>경고:</strong> 해당 주차의 공통 WeeklyTrend를 찾을 수 없거나 데이터가 없습니다.</p>"
119+
else:
120+
weekly_trend_insight = from_dict(
121+
WeeklyTrendInsight, weekly_trend.insight
122+
)
123+
weekly_trend_html = render_to_string(
124+
"insights/weekly_trend.html",
125+
{"insight": weekly_trend_insight.to_dict()},
126+
)
127+
128+
# 사용자 주간 트렌드 렌더링
129+
user_weekly_insight = from_dict(
130+
WeeklyUserTrendInsight, obj.insight
131+
)
132+
user_weekly_trend_html = render_to_string(
133+
"insights/user_weekly_trend.html",
134+
{
135+
"user": {
136+
"username": obj.user.username if obj.user else "N/A"
137+
},
138+
"insight": user_weekly_insight.to_dict(),
139+
},
140+
)
141+
142+
# 최종 뉴스레터 렌더링
143+
final_html = render_to_string(
144+
"insights/index.html",
145+
{
146+
"s_date": obj.week_start_date,
147+
"e_date": obj.week_end_date,
148+
"is_expired_token_user": False,
149+
"weekly_trend_html": weekly_trend_html,
150+
"user_weekly_trend_html": user_weekly_trend_html,
151+
},
152+
)
153+
154+
# Admin 페이지 너비 확장을 위한 CSS
155+
style = """
156+
<style>
157+
/* iframe을 감싸는 필드의 너비 확장 */
158+
.field-render_full_preview {
159+
width: 100% !important;
160+
max-width: none !important;
161+
}
162+
163+
/* Django admin 전체 콘텐츠 영역 확장 */
164+
.app-insight.model-weeklytrend .form-row,
165+
.app-insight.model-weeklytrend .wide,
166+
.app-insight.model-weeklytrend #content-main {
167+
max-width: 1400px !important;
168+
width: 100% !important;
169+
}
170+
</style>
171+
"""
172+
173+
iframe = f"""
174+
<iframe
175+
srcdoc="{escape(final_html)}"
176+
style="width: 100%; min-width: 600px; height: 600px; border: 1px solid #ccc;"
177+
></iframe>
178+
"""
179+
180+
return mark_safe(style + iframe)
181+
except Exception as e:
182+
return f"Error rendering preview: {e}"

0 commit comments

Comments
 (0)